fix(sync): simulate all outliner ops and stabilize bootstrap

This commit is contained in:
Tienson Qin
2026-04-14 00:16:36 +08:00
parent 0958e1f402
commit 08f70a7b9f
4 changed files with 1367 additions and 36 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {
parseArgs,
@@ -13,8 +15,64 @@ const {
createSeededRng,
shuffleOperationPlan,
extractReplayContext,
buildSimulationOperationPlan,
mergeOutlinerCoverageIntoRound,
ALL_OUTLINER_OP_COVERAGE_OPS,
} = require('../../sync-open-chrome-tab-simulate.cjs');
const OUTLINER_OP_SCHEMA_PATH = path.resolve(
__dirname,
'../../../deps/outliner/src/logseq/outliner/op.cljs'
);
const OUTLINER_OP_CONSTRUCT_PATH = path.resolve(
__dirname,
'../../../deps/outliner/src/logseq/outliner/op/construct.cljc'
);
function extractSection(sourceText, startToken, endToken) {
const start = sourceText.indexOf(startToken);
if (start < 0) {
throw new Error(`Missing start token: ${startToken}`);
}
const end = sourceText.indexOf(endToken, start);
if (end < 0) {
throw new Error(`Missing end token: ${endToken}`);
}
return sourceText.slice(start, end);
}
function parseOpSchemaOps(sourceText) {
const section = extractSection(
sourceText,
'(def ^:private ^:large-vars/data-var op-schema',
'(def ^:private ops-schema'
);
const ops = new Set();
for (const match of section.matchAll(/\[\:([a-z0-9-]+)\s*\n\s+\[:catn/g)) {
ops.add(match[1]);
}
return [...ops];
}
function parseSemanticOps(sourceText) {
const section = extractSection(
sourceText,
'(def ^:api semantic-outliner-ops',
'(def ^:private transient-block-keys'
);
const setStart = section.indexOf('#{');
const setEnd = section.indexOf('}', setStart);
if (setStart < 0 || setEnd < 0 || setEnd <= setStart) {
throw new Error('Failed to parse semantic-outliner-ops set');
}
const setText = section.slice(setStart + 2, setEnd);
const ops = new Set();
for (const match of setText.matchAll(/:([a-z0-9-]+)/g)) {
ops.add(match[1]);
}
return [...ops];
}
test('isRetryableAgentBrowserError treats transient CDP navigation closures as retryable', () => {
const navigationClosed = new Error(
'CDP error (Runtime.evaluate): Inspected target navigated or closed'
@@ -69,6 +127,14 @@ test('classifySimulationFailure detects tx-rejected failures', () => {
assert.equal(classifySimulationFailure(txRejectedError), 'tx_rejected');
});
test('classifySimulationFailure treats opfs access-handle lock errors as other', () => {
const opfsLockError = new Error(
"NoModificationAllowedError: Failed to execute 'createSyncAccessHandle' on 'FileSystemFileHandle'"
);
assert.equal(classifySimulationFailure(opfsLockError), 'other');
});
test('buildRejectedResultEntry marks peer as cancelled after checksum mismatch fail-fast', () => {
const failFastState = {
sourceIndex: 0,
@@ -121,6 +187,22 @@ test('buildRejectedResultEntry marks peer as cancelled after tx-rejected fail-fa
assert.equal(source.failureType, 'tx_rejected');
});
test('buildRejectedResultEntry does not cancel peer on opfs lock fail-fast reason', () => {
const failFastState = {
sourceIndex: 0,
reasonType: 'opfs_access_handle_lock',
};
const peer = buildRejectedResultEntry(
'logseq-op-sim-2',
1,
new Error('Command timed out'),
failFastState
);
assert.equal(peer.cancelled, undefined);
assert.equal(peer.failureType, 'other');
});
test('extractChecksumMismatchDetailsFromError parses rtc-log payload JSON', () => {
const errorText =
'Evaluation error: Error: checksum mismatch rtc-log detected: {"type":":rtc.log/checksum-mismatch","messageType":"tx/batch/ok","localTx":10,"remoteTx":10,"localChecksum":"aa","remoteChecksum":"bb"}';
@@ -230,3 +312,122 @@ test('extractReplayContext returns args override and fixed client plans', () =>
assert.deepEqual(replay.fixedPlansByInstance.get(1), ['add', 'move']);
assert.deepEqual(replay.fixedPlansByInstance.get(2), ['add', 'delete']);
});
test('buildSimulationOperationPlan full profile includes save, refs, templates, and multi-property ops', () => {
const plan = buildSimulationOperationPlan(20, 'full');
assert.deepEqual(plan, [
'add',
'save',
'inlineTag',
'emptyInlineTag',
'pageReference',
'blockReference',
'propertySet',
'batchSetProperty',
'propertyValueDelete',
'copyPaste',
'copyPasteTreeToEmptyTarget',
'templateApply',
'move',
'moveUpDown',
'indent',
'outdent',
'delete',
'propertyRemove',
'undo',
'redo',
]);
});
test('buildSimulationOperationPlan fast profile cycles through refs, templates, and property variants', () => {
const plan = buildSimulationOperationPlan(17, 'fast');
assert.deepEqual(plan, [
'add',
'save',
'inlineTag',
'emptyInlineTag',
'pageReference',
'blockReference',
'propertySet',
'batchSetProperty',
'move',
'delete',
'indent',
'outdent',
'moveUpDown',
'templateApply',
'propertyValueDelete',
'add',
'move',
]);
});
test('ALL_OUTLINER_OP_COVERAGE_OPS tracks canonical outliner-op definitions', () => {
const opSchemaSource = fs.readFileSync(OUTLINER_OP_SCHEMA_PATH, 'utf8');
const opConstructSource = fs.readFileSync(OUTLINER_OP_CONSTRUCT_PATH, 'utf8');
const expectedOps = [
...new Set([
...parseOpSchemaOps(opSchemaSource),
...parseSemanticOps(opConstructSource),
]),
].sort();
const actualOps = [...new Set(ALL_OUTLINER_OP_COVERAGE_OPS)].sort();
assert.equal(actualOps.length, ALL_OUTLINER_OP_COVERAGE_OPS.length);
assert.deepEqual(actualOps, expectedOps);
});
test('mergeOutlinerCoverageIntoRound prepends all outliner coverage ops to requested plan and op log', () => {
const round = {
requestedOps: 2,
executedOps: 2,
counts: { add: 1, move: 1 },
requestedPlan: ['add', 'move'],
opLog: [
{ index: 0, requested: 'add', executedAs: 'add', detail: { kind: 'add' } },
{ index: 1, requested: 'move', executedAs: 'move', detail: { kind: 'move' } },
],
outlinerOpCoverage: {
expectedOps: ['save-block', 'set-block-property'],
failedOps: [],
sample: [
{ op: 'save-block', ok: true, durationMs: 123 },
{ op: 'set-block-property', ok: true, durationMs: 88 },
],
},
};
const merged = mergeOutlinerCoverageIntoRound(round);
assert.equal(merged.requestedOps, 4);
assert.equal(merged.executedOps, 4);
assert.deepEqual(merged.requestedPlan, [
'outliner:save-block',
'outliner:set-block-property',
'add',
'move',
]);
assert.equal(merged.opLog.length, 4);
assert.equal(merged.opLog[0].requested, 'outliner:save-block');
assert.equal(merged.opLog[1].requested, 'outliner:set-block-property');
assert.equal(merged.opLog[2].index, 2);
assert.equal(merged.opLog[3].index, 3);
assert.equal(merged.counts.outlinerCoverage, 2);
assert.equal(merged.counts.outlinerCoverageFailed, 0);
});
test('mergeOutlinerCoverageIntoRound is idempotent once outliner ops are already merged', () => {
const round = {
requestedOps: 2,
executedOps: 2,
counts: {},
requestedPlan: ['outliner:save-block', 'add'],
opLog: [],
outlinerOpCoverage: {
expectedOps: ['save-block'],
failedOps: [],
},
};
const merged = mergeOutlinerCoverageIntoRound(round);
assert.deepEqual(merged, round);
});

View File

@@ -16,6 +16,7 @@
[frontend.worker.undo-redo :as worker-undo-redo]
[lambdaisland.glogi :as log]
[logseq.db :as ldb]
[logseq.db-sync.tx-sanitize :as tx-sanitize]
[logseq.db-sync.order :as sync-order]
[logseq.db.common.normalize :as db-normalize]
[logseq.db.sqlite.util :as sqlite-util]
@@ -462,9 +463,10 @@
results []]
(let [db @conn]
(if-let [remote-tx (first remaining)]
(let [tx-data (->> (:tx-data remote-tx)
(map (partial resolve-temp-id db))
seq)
(let [tx-data (some->> (:tx-data remote-tx)
(map (partial resolve-temp-id db))
(tx-sanitize/sanitize-tx db)
seq)
report (ldb/transact! conn tx-data {:transact-remote? true
:t (:t remote-tx)})
results' (cond-> results

View File

@@ -4110,8 +4110,31 @@
(when target'
(is (= "remote-restored" (:block/title target'))))))))))
(deftest apply-remote-txs-local-delete-parent-remote-move-then-delete-parent-repro-test
(testing "reproduces transact-remote failure when remote moves blocks under a locally deleted parent and then retracts that parent"
(deftest apply-remote-txs-delete-parent-with-child-without-local-changes-test
(testing "remote delete-blocks tx should retract descendant children on client"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page 1"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}]})
parent (db-test/find-block-by-content @conn "parent")
child (db-test/find-block-by-content @conn "child")
parent-uuid (:block/uuid parent)
child-uuid (:block/uuid child)]
(with-datascript-conns conn nil
(fn []
(#'sync-apply/apply-remote-txs!
test-repo
nil
[{:tx-data [[:db/retractEntity [:block/uuid parent-uuid]]]}])
(is (nil? (d/entity @conn [:block/uuid parent-uuid])))
(is (nil? (d/entity @conn [:block/uuid child-uuid])))
(let [validation (db-validate/validate-local-db! @conn)]
(is (empty? (non-recycle-validation-entities validation))
(str (:errors validation)))))))))
(deftest apply-remote-txs-local-delete-parent-remote-move-then-delete-parent-test
(testing "remote moves under parent then delete-parent should not fail when local delete is pending"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page 1"}
@@ -4147,15 +4170,13 @@
;; Local delete creates pending tx requiring reverse before remote apply.
(outliner-core/delete-blocks! conn [parent] {})
(is (seq (#'sync-apply/pending-txs test-repo)))
(let [result (try
(#'sync-apply/apply-remote-txs! test-repo client remote-txs)
nil
(catch :default e
e))]
(is (instance? js/Error result))
(is (string/includes? (or (ex-message result) "")
"DB write failed with invalid data")
(str "unexpected error: " (ex-message result)))))))))
(#'sync-apply/apply-remote-txs! test-repo client remote-txs)
(is (nil? (d/entity @conn [:block/uuid parent-uuid])))
(is (nil? (d/entity @conn [:block/uuid mover-1-uuid])))
(is (nil? (d/entity @conn [:block/uuid mover-2-uuid])))
(let [validation (db-validate/validate-local-db! @conn)]
(is (empty? (non-recycle-validation-entities validation))
(str (:errors validation)))))))))
(deftest apply-remote-txs-overlap-out-of-order-parent-delete-then-move-repro-test
(testing "reproduces missing-parent transact-remote failure when overlapping remote slices arrive out of order"