@quereus/sync 3.2.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -7
- package/dist/src/clock/hlc.d.ts +43 -3
- package/dist/src/clock/hlc.d.ts.map +1 -1
- package/dist/src/clock/hlc.js +60 -10
- package/dist/src/clock/hlc.js.map +1 -1
- package/dist/src/create-sync-module.d.ts +46 -9
- package/dist/src/create-sync-module.d.ts.map +1 -1
- package/dist/src/create-sync-module.js +11 -8
- package/dist/src/create-sync-module.js.map +1 -1
- package/dist/src/index.d.ts +10 -8
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -5
- package/dist/src/index.js.map +1 -1
- package/dist/src/metadata/basis-lifecycle.d.ts +180 -0
- package/dist/src/metadata/basis-lifecycle.d.ts.map +1 -0
- package/dist/src/metadata/basis-lifecycle.js +211 -0
- package/dist/src/metadata/basis-lifecycle.js.map +1 -0
- package/dist/src/metadata/change-log.d.ts +1 -1
- package/dist/src/metadata/change-log.d.ts.map +1 -1
- package/dist/src/metadata/change-log.js +6 -6
- package/dist/src/metadata/change-log.js.map +1 -1
- package/dist/src/metadata/column-version.d.ts +14 -5
- package/dist/src/metadata/column-version.d.ts.map +1 -1
- package/dist/src/metadata/column-version.js +22 -12
- package/dist/src/metadata/column-version.js.map +1 -1
- package/dist/src/metadata/index.d.ts +2 -0
- package/dist/src/metadata/index.d.ts.map +1 -1
- package/dist/src/metadata/index.js +2 -0
- package/dist/src/metadata/index.js.map +1 -1
- package/dist/src/metadata/keys.d.ts +52 -2
- package/dist/src/metadata/keys.d.ts.map +1 -1
- package/dist/src/metadata/keys.js +99 -14
- package/dist/src/metadata/keys.js.map +1 -1
- package/dist/src/metadata/peer-state.d.ts +1 -1
- package/dist/src/metadata/peer-state.js +5 -5
- package/dist/src/metadata/quarantine.d.ts +90 -0
- package/dist/src/metadata/quarantine.d.ts.map +1 -0
- package/dist/src/metadata/quarantine.js +167 -0
- package/dist/src/metadata/quarantine.js.map +1 -0
- package/dist/src/metadata/schema-migration.d.ts +1 -1
- package/dist/src/metadata/schema-migration.js +7 -7
- package/dist/src/metadata/schema-version.d.ts +1 -1
- package/dist/src/metadata/schema-version.js +7 -7
- package/dist/src/metadata/tombstones.d.ts +26 -9
- package/dist/src/metadata/tombstones.d.ts.map +1 -1
- package/dist/src/metadata/tombstones.js +42 -19
- package/dist/src/metadata/tombstones.js.map +1 -1
- package/dist/src/sql/basis-lifecycle-tvf.d.ts +34 -0
- package/dist/src/sql/basis-lifecycle-tvf.d.ts.map +1 -0
- package/dist/src/sql/basis-lifecycle-tvf.js +101 -0
- package/dist/src/sql/basis-lifecycle-tvf.js.map +1 -0
- package/dist/src/sync/admission.d.ts +81 -0
- package/dist/src/sync/admission.d.ts.map +1 -0
- package/dist/src/sync/admission.js +94 -0
- package/dist/src/sync/admission.js.map +1 -0
- package/dist/src/sync/change-applicator.d.ts +65 -2
- package/dist/src/sync/change-applicator.d.ts.map +1 -1
- package/dist/src/sync/change-applicator.js +493 -68
- package/dist/src/sync/change-applicator.js.map +1 -1
- package/dist/src/sync/change-grouping.d.ts +43 -0
- package/dist/src/sync/change-grouping.d.ts.map +1 -0
- package/dist/src/sync/change-grouping.js +103 -0
- package/dist/src/sync/change-grouping.js.map +1 -0
- package/dist/src/sync/events.d.ts +159 -1
- package/dist/src/sync/events.d.ts.map +1 -1
- package/dist/src/sync/events.js +50 -0
- package/dist/src/sync/events.js.map +1 -1
- package/dist/src/sync/manager.d.ts +87 -0
- package/dist/src/sync/manager.d.ts.map +1 -1
- package/dist/src/sync/protocol.d.ts +174 -11
- package/dist/src/sync/protocol.d.ts.map +1 -1
- package/dist/src/sync/protocol.js +4 -1
- package/dist/src/sync/protocol.js.map +1 -1
- package/dist/src/sync/snapshot-stream.d.ts.map +1 -1
- package/dist/src/sync/snapshot-stream.js +89 -19
- package/dist/src/sync/snapshot-stream.js.map +1 -1
- package/dist/src/sync/snapshot.d.ts.map +1 -1
- package/dist/src/sync/snapshot.js +63 -44
- package/dist/src/sync/snapshot.js.map +1 -1
- package/dist/src/sync/store-adapter.d.ts +117 -15
- package/dist/src/sync/store-adapter.d.ts.map +1 -1
- package/dist/src/sync/store-adapter.js +247 -91
- package/dist/src/sync/store-adapter.js.map +1 -1
- package/dist/src/sync/sync-context.d.ts +44 -1
- package/dist/src/sync/sync-context.d.ts.map +1 -1
- package/dist/src/sync/sync-context.js +26 -0
- package/dist/src/sync/sync-context.js.map +1 -1
- package/dist/src/sync/sync-manager-impl.d.ts +247 -13
- package/dist/src/sync/sync-manager-impl.d.ts.map +1 -1
- package/dist/src/sync/sync-manager-impl.js +788 -193
- package/dist/src/sync/sync-manager-impl.js.map +1 -1
- package/package.json +6 -6
|
@@ -6,16 +6,52 @@
|
|
|
6
6
|
* 2. applyToStore callback — write data to store
|
|
7
7
|
* 3. commitChangeMetadata — persist CRDT metadata
|
|
8
8
|
*/
|
|
9
|
-
import { compareHLC } from '../clock/hlc.js';
|
|
9
|
+
import { compareHLC, maxHLC } from '../clock/hlc.js';
|
|
10
10
|
import { siteIdEquals } from '../clock/site.js';
|
|
11
|
-
import {
|
|
11
|
+
import { encodePK } from '../metadata/keys.js';
|
|
12
|
+
import { admitGroup } from './admission.js';
|
|
13
|
+
/** Stable `schema.table` map key for batch deltas and diversion groups. */
|
|
14
|
+
function tableKey(schema, table) {
|
|
15
|
+
return `${schema}.${table}`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* In-batch table delta from the batch's schema migrations: `create_table` adds a
|
|
19
|
+
* table, `drop_table` removes one. Detection runs at Phase 1 (before any DDL
|
|
20
|
+
* executes in Phase 2), so a referenced table is "known" if it is in the current
|
|
21
|
+
* basis OR created by this batch, AND not dropped by this batch. Computed over the
|
|
22
|
+
* WHOLE batch because admission applies all schema changes before all data.
|
|
23
|
+
*/
|
|
24
|
+
function computeBatchTableDelta(changes) {
|
|
25
|
+
const created = new Set();
|
|
26
|
+
const dropped = new Set();
|
|
27
|
+
for (const changeSet of changes) {
|
|
28
|
+
for (const migration of changeSet.schemaMigrations) {
|
|
29
|
+
const key = tableKey(migration.schema, migration.table);
|
|
30
|
+
if (migration.type === 'create_table') {
|
|
31
|
+
created.add(key);
|
|
32
|
+
}
|
|
33
|
+
else if (migration.type === 'drop_table') {
|
|
34
|
+
dropped.add(key);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { created, dropped };
|
|
39
|
+
}
|
|
12
40
|
/**
|
|
13
41
|
* Apply change sets from a remote peer.
|
|
14
42
|
*
|
|
15
43
|
* Three-phase process:
|
|
16
|
-
* 1. Resolve all changes (no writes)
|
|
44
|
+
* 1. Resolve all changes (no writes); divert out-of-basis straggler changes
|
|
17
45
|
* 2. Apply data to store via callback
|
|
18
|
-
* 3. Commit CRDT metadata
|
|
46
|
+
* 3. Commit CRDT metadata (+ durable quarantine of diverted changes)
|
|
47
|
+
*
|
|
48
|
+
* Unknown-table disposition: a change referencing a table outside the local basis
|
|
49
|
+
* (a retired-table straggler delta — see `docs/migration.md` § 4 Contract) is
|
|
50
|
+
* **diverted** during resolution — never resolved, applied, or recorded as CRDT
|
|
51
|
+
* metadata, so the change log stays clean (no survivor-HLC pollution). Diverted
|
|
52
|
+
* changes are held (durably, idempotently — `quarantine`, or `store-and-forward`
|
|
53
|
+
* which additionally marks them forwardable) or ignored per
|
|
54
|
+
* {@link SyncConfig.unknownTableDisposition}, and telemetered either way.
|
|
19
55
|
*/
|
|
20
56
|
export async function applyChanges(ctx, changes) {
|
|
21
57
|
let applied = 0;
|
|
@@ -26,9 +62,14 @@ export async function applyChanges(ctx, changes) {
|
|
|
26
62
|
const appliedChanges = [];
|
|
27
63
|
const resolvedDataChanges = [];
|
|
28
64
|
const pendingSchemaMigrations = [];
|
|
29
|
-
//
|
|
65
|
+
// Out-of-basis straggler changes diverted out of the resolve/apply/metadata
|
|
66
|
+
// path, grouped per table for disposition + telemetry.
|
|
67
|
+
const unknownByTable = new Map();
|
|
68
|
+
const { created: batchCreated, dropped: batchDropped } = computeBatchTableDelta(changes);
|
|
69
|
+
// PHASE 1: Resolve all changes (no writes yet). The clock watermark is merged
|
|
70
|
+
// once after a successful admission (see admitGroup below), not per changeset —
|
|
71
|
+
// resolution reads stored versions via compareHLC, never the live clock.
|
|
30
72
|
for (const changeSet of changes) {
|
|
31
|
-
ctx.hlcManager.receive(changeSet.hlc);
|
|
32
73
|
// Process schema migrations first (DDL before DML)
|
|
33
74
|
for (const migration of changeSet.schemaMigrations) {
|
|
34
75
|
const schemaVersion = migration.schemaVersion ??
|
|
@@ -51,6 +92,29 @@ export async function applyChanges(ctx, changes) {
|
|
|
51
92
|
}
|
|
52
93
|
// Resolve data changes
|
|
53
94
|
for (const change of changeSet.changes) {
|
|
95
|
+
// Self-origin echo skip BEFORE unknown-table detection, so a self-change
|
|
96
|
+
// to a retired table is skipped (counted), never quarantined. resolveChange
|
|
97
|
+
// re-checks this defensively for any other caller.
|
|
98
|
+
if (siteIdEquals(change.hlc.siteId, ctx.getSiteId())) {
|
|
99
|
+
skipped++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// Structural unknown-table detection: in the current basis OR created by
|
|
103
|
+
// this batch, AND not dropped by this batch. Diverted changes never reach
|
|
104
|
+
// resolveChange / dataChangesToApply / commitChangeMetadata, so no CRDT
|
|
105
|
+
// metadata is written for a table the receiver does not have.
|
|
106
|
+
const key = tableKey(change.schema, change.table);
|
|
107
|
+
const known = (ctx.isTableInBasis(change.schema, change.table) || batchCreated.has(key))
|
|
108
|
+
&& !batchDropped.has(key);
|
|
109
|
+
if (!known) {
|
|
110
|
+
let group = unknownByTable.get(key);
|
|
111
|
+
if (!group) {
|
|
112
|
+
group = { schema: change.schema, table: change.table, siteId: changeSet.siteId, changes: [] };
|
|
113
|
+
unknownByTable.set(key, group);
|
|
114
|
+
}
|
|
115
|
+
group.changes.push(change);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
54
118
|
const resolved = await resolveChange(ctx, change);
|
|
55
119
|
if (resolved.outcome === 'applied') {
|
|
56
120
|
applied++;
|
|
@@ -68,64 +132,289 @@ export async function applyChanges(ctx, changes) {
|
|
|
68
132
|
}
|
|
69
133
|
}
|
|
70
134
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
hlc: migration.hlc,
|
|
94
|
-
schemaVersion,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
// Emit remote change events
|
|
98
|
-
if (appliedChanges.length > 0) {
|
|
99
|
-
const changesBySite = new Map();
|
|
100
|
-
for (const { change, siteId } of appliedChanges) {
|
|
101
|
-
const siteKey = Array.from(siteId).join(',');
|
|
102
|
-
const siteChanges = changesBySite.get(siteKey);
|
|
103
|
-
if (siteChanges) {
|
|
104
|
-
siteChanges.push(change);
|
|
135
|
+
const disposition = ctx.config.unknownTableDisposition;
|
|
136
|
+
// Single receive timestamp for every quarantine entry this apply (GC horizon).
|
|
137
|
+
const receivedAt = Date.now();
|
|
138
|
+
// Admit the whole resolved batch as one all-or-nothing unit: data first (PHASE
|
|
139
|
+
// 2), then CRDT metadata (PHASE 3), then the merged clock watermark — aborting
|
|
140
|
+
// with no metadata committed on any data-apply failure. The batch is admitted
|
|
141
|
+
// once (not per ChangeSet): the single per-peer lastSyncHLC watermark cannot
|
|
142
|
+
// express a partial commit, so selective commit is intentionally not done.
|
|
143
|
+
await admitGroup(ctx, {
|
|
144
|
+
dataChanges: dataChangesToApply,
|
|
145
|
+
schemaChanges: schemaChangesToApply,
|
|
146
|
+
applyOptions: { remote: true },
|
|
147
|
+
commitMetadata: async () => {
|
|
148
|
+
await commitChangeMetadata(ctx, resolvedDataChanges);
|
|
149
|
+
// Commit schema migration metadata
|
|
150
|
+
for (const { migration, schemaVersion } of pendingSchemaMigrations) {
|
|
151
|
+
await ctx.schemaMigrations.recordMigration(migration.schema, migration.table, {
|
|
152
|
+
type: migration.type,
|
|
153
|
+
ddl: migration.ddl,
|
|
154
|
+
hlc: migration.hlc,
|
|
155
|
+
schemaVersion,
|
|
156
|
+
});
|
|
105
157
|
}
|
|
106
|
-
|
|
107
|
-
|
|
158
|
+
// Hold diverted changes as part of the admission unit — durable BEFORE
|
|
159
|
+
// the watermark advances below, so a crash never strands a straggler's
|
|
160
|
+
// change with no re-delivery (the batch re-resolves and re-holds
|
|
161
|
+
// idempotently, HLC-keyed). Both `quarantine` and `store-and-forward`
|
|
162
|
+
// hold identically; `store-and-forward` additionally marks each entry
|
|
163
|
+
// forwardable so the relay (sibling ticket) can re-offer it. `ignore`
|
|
164
|
+
// writes nothing.
|
|
165
|
+
if ((disposition === 'quarantine' || disposition === 'store-and-forward') && unknownByTable.size > 0) {
|
|
166
|
+
const forwardable = disposition === 'store-and-forward';
|
|
167
|
+
const qBatch = ctx.kv.batch();
|
|
168
|
+
for (const group of unknownByTable.values()) {
|
|
169
|
+
for (const change of group.changes) {
|
|
170
|
+
ctx.quarantine.put(qBatch, change, receivedAt, forwardable);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
await qBatch.write();
|
|
108
174
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
175
|
+
// Dynamic basis-retirement signal: a remote write to a non-directly-mapped
|
|
176
|
+
// tracked table bumps its `lastDirectlyMappedWriteAt`, deferring eviction
|
|
177
|
+
// (docs/migration.md § 2 Converge). Advisory — never aborts the apply.
|
|
178
|
+
await bumpLastDirectlyMappedWrites(ctx, appliedChanges);
|
|
179
|
+
},
|
|
180
|
+
// Merging the batch max once is equivalent to receiving each changeset's
|
|
181
|
+
// HLC (receive is a monotonic max-merge); on a mid-batch abort the clock
|
|
182
|
+
// does not advance and the batch re-resolves next sync.
|
|
183
|
+
watermarkHLC: maxHLC(changes.map(cs => cs.hlc)),
|
|
184
|
+
});
|
|
185
|
+
// Emit remote change events (grouped by the relaying changeset's siteId).
|
|
186
|
+
emitRemoteChanges(ctx, appliedChanges);
|
|
187
|
+
// Reactive low-latency drain (sync-drain-reappear-inbound-ddl): every APPLIED
|
|
188
|
+
// create_table may have revived a previously-retired table that has held
|
|
189
|
+
// out-of-basis changes waiting on it. Replay them NOW — as a SEPARATE post-commit
|
|
190
|
+
// apply unit, after the admitting batch above has fully committed (fresh data lands
|
|
191
|
+
// first, held changes LWW-resolve against it) — instead of waiting up to one
|
|
192
|
+
// periodic-sweep interval. Only applied migrations are in `pendingSchemaMigrations`
|
|
193
|
+
// (an HLC-dominated create_table `continue`s before being pushed), so a losing
|
|
194
|
+
// create never triggers a drain. A create+drop in the same batch leaves the table
|
|
195
|
+
// absent, so its key is skipped to avoid a wasted scoped `quarantine.list`.
|
|
196
|
+
// Advisory: `drainReappearedTables` logs + swallows any failure, so a drain throw
|
|
197
|
+
// never turns this successful apply into an error.
|
|
198
|
+
const reappeared = new Map();
|
|
199
|
+
for (const { migration } of pendingSchemaMigrations) {
|
|
200
|
+
if (migration.type !== 'create_table')
|
|
201
|
+
continue;
|
|
202
|
+
const key = tableKey(migration.schema, migration.table);
|
|
203
|
+
if (batchDropped.has(key))
|
|
204
|
+
continue;
|
|
205
|
+
if (!reappeared.has(key))
|
|
206
|
+
reappeared.set(key, { schema: migration.schema, table: migration.table });
|
|
207
|
+
}
|
|
208
|
+
await drainReappearedTables(ctx, [...reappeared.values()]);
|
|
209
|
+
// Unknown-table telemetry AFTER successful admission, regardless of disposition
|
|
210
|
+
// (the operator must see straggler traffic even when it is being dropped).
|
|
211
|
+
let unknownTableCount = 0;
|
|
212
|
+
for (const group of unknownByTable.values()) {
|
|
213
|
+
unknownTableCount += group.changes.length;
|
|
214
|
+
ctx.recordUnknownTable(disposition, group.schema, group.table, group.changes.length);
|
|
215
|
+
ctx.syncEvents.emitUnknownTable({
|
|
216
|
+
schema: group.schema,
|
|
217
|
+
table: group.table,
|
|
218
|
+
disposition,
|
|
219
|
+
changeCount: group.changes.length,
|
|
220
|
+
siteId: group.siteId,
|
|
221
|
+
// Group is non-empty by construction, so maxHLC is defined.
|
|
222
|
+
latestHLC: maxHLC(group.changes.map(c => c.hlc)),
|
|
223
|
+
});
|
|
120
224
|
}
|
|
121
|
-
await persistHLCState(ctx);
|
|
122
225
|
return {
|
|
123
226
|
applied,
|
|
124
227
|
skipped,
|
|
125
228
|
conflicts,
|
|
126
229
|
transactions: changes.length,
|
|
230
|
+
...(unknownTableCount > 0 ? { unknownTable: unknownTableCount } : {}),
|
|
127
231
|
};
|
|
128
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* Emit `onRemoteChange` for applied changes, grouped by a per-change origin site
|
|
235
|
+
* id. Shared by the wire apply (grouping by the relaying changeset's `siteId`) and
|
|
236
|
+
* the drain path (grouping by each held change's original origin `hlc.siteId`), so
|
|
237
|
+
* downstream reactivity — MV maintenance, `Database.watch`, UI — fires identically
|
|
238
|
+
* on a revival as on a fresh remote apply. No-op for an empty set.
|
|
239
|
+
*/
|
|
240
|
+
function emitRemoteChanges(ctx, entries) {
|
|
241
|
+
if (entries.length === 0)
|
|
242
|
+
return;
|
|
243
|
+
const changesBySite = new Map();
|
|
244
|
+
for (const { change, siteId } of entries) {
|
|
245
|
+
const siteKey = Array.from(siteId).join(',');
|
|
246
|
+
const siteChanges = changesBySite.get(siteKey);
|
|
247
|
+
if (siteChanges) {
|
|
248
|
+
siteChanges.push(change);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
changesBySite.set(siteKey, [change]);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const appliedAt = ctx.hlcManager.now();
|
|
255
|
+
for (const [siteKey, siteChanges] of changesBySite) {
|
|
256
|
+
const siteIdBytes = new Uint8Array(siteKey.split(',').map(Number));
|
|
257
|
+
ctx.syncEvents.emitRemoteChange({
|
|
258
|
+
siteId: siteIdBytes,
|
|
259
|
+
transactionId: crypto.randomUUID(),
|
|
260
|
+
changes: siteChanges,
|
|
261
|
+
appliedAt,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Replay held out-of-basis changes (`quarantine` + forwardable `store-and-forward`
|
|
267
|
+
* entries) into tables that have since reappeared in the local basis — the revival
|
|
268
|
+
* half of the unknown-table contract (`docs/migration.md` § 4 Contract). The
|
|
269
|
+
* sibling of {@link applyChanges}' data branch, minus the schema-migration and
|
|
270
|
+
* unknown-table-divert machinery: held changes have no DDL and their table is, by
|
|
271
|
+
* the basis gate, already present.
|
|
272
|
+
*
|
|
273
|
+
* Driven two ways, never interleaved: the host calls {@link SyncManager.drainHeldChanges}
|
|
274
|
+
* from its periodic maintenance sweep, and {@link drainReappearedTables} calls it
|
|
275
|
+
* reactively from {@link applyChanges} the moment an inbound `create_table` revives a
|
|
276
|
+
* held table (gated by {@link SyncConfig.drainOnReappear}). Either way drain runs as a
|
|
277
|
+
* SEPARATE apply, after any re-creating batch has fully committed — fresh data is
|
|
278
|
+
* already in storage and the older held changes simply LWW-resolve against it. The
|
|
279
|
+
* invariant is "never INTERLEAVE drain into the admitting batch", not "never drain
|
|
280
|
+
* inline": a reactive post-commit drain is its own admission unit, distinct from the
|
|
281
|
+
* batch it follows.
|
|
282
|
+
*
|
|
283
|
+
* Scope mirrors {@link QuarantineStore.list}: `(schema, table)` drains one table,
|
|
284
|
+
* `(schema)` drains a schema, `()` sweeps every held entry whose table is back.
|
|
285
|
+
* Bounded by the held set (itself bounded by the retention horizon); zero-cost when
|
|
286
|
+
* nothing is held. With NO basis oracle every group's table reports `undefined`
|
|
287
|
+
* column names ⇒ every group is skipped ⇒ this returns 0 (the relay-only no-op).
|
|
288
|
+
*
|
|
289
|
+
* @returns the number of held entries cleared from the hold (across present tables).
|
|
290
|
+
*/
|
|
291
|
+
export async function drainHeldChanges(ctx, schema, table) {
|
|
292
|
+
const held = await ctx.quarantine.list(schema, table);
|
|
293
|
+
if (held.length === 0)
|
|
294
|
+
return 0;
|
|
295
|
+
// Group held changes by (schema, table) — drain admits one table at a time.
|
|
296
|
+
const groups = new Map();
|
|
297
|
+
for (const entry of held) {
|
|
298
|
+
const change = entry.change;
|
|
299
|
+
const key = tableKey(change.schema, change.table);
|
|
300
|
+
let group = groups.get(key);
|
|
301
|
+
if (!group) {
|
|
302
|
+
group = { schema: change.schema, table: change.table, changes: [] };
|
|
303
|
+
groups.set(key, group);
|
|
304
|
+
}
|
|
305
|
+
group.changes.push(change);
|
|
306
|
+
}
|
|
307
|
+
let totalDrained = 0;
|
|
308
|
+
for (const group of groups.values()) {
|
|
309
|
+
totalDrained += await drainTableGroup(ctx, group);
|
|
310
|
+
}
|
|
311
|
+
return totalDrained;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Drain one `(schema, table)` group of held changes. Returns the number of held
|
|
315
|
+
* entries cleared for this table (0 when the table is still absent).
|
|
316
|
+
*/
|
|
317
|
+
async function drainTableGroup(ctx, group) {
|
|
318
|
+
// Basis gate: a table not back in the local basis (oracle returns undefined —
|
|
319
|
+
// also the no-oracle case) stays held; skip the whole group, drain nothing.
|
|
320
|
+
const columns = ctx.getTableColumnNames(group.schema, group.table);
|
|
321
|
+
if (columns === undefined)
|
|
322
|
+
return 0;
|
|
323
|
+
const columnSet = new Set(columns);
|
|
324
|
+
const dataChangesToApply = [];
|
|
325
|
+
const resolvedDataChanges = [];
|
|
326
|
+
const appliedEntries = [];
|
|
327
|
+
let applied = 0;
|
|
328
|
+
let skipped = 0;
|
|
329
|
+
for (const change of group.changes) {
|
|
330
|
+
// Schema-drift filter: a held column change for a column the re-created table
|
|
331
|
+
// no longer has is resolved-and-dropped (never sent to resolveChange or the
|
|
332
|
+
// store), so one stale entry cannot abort the table's whole drain admission.
|
|
333
|
+
// Its held entry is still cleared below. Deletes are never drift-filtered — a
|
|
334
|
+
// delete of an absent pk is a store no-op, not a poison.
|
|
335
|
+
if (change.type === 'column' && !columnSet.has(change.column)) {
|
|
336
|
+
skipped++;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
// Identical LWW / tombstone-blocking / allowResurrection semantics as a fresh
|
|
340
|
+
// receive — the held change is just an older inbound change resolved late.
|
|
341
|
+
const resolved = await resolveChange(ctx, change);
|
|
342
|
+
if (resolved.outcome === 'applied') {
|
|
343
|
+
applied++;
|
|
344
|
+
resolvedDataChanges.push(resolved);
|
|
345
|
+
if (resolved.dataChange)
|
|
346
|
+
dataChangesToApply.push(resolved.dataChange);
|
|
347
|
+
// Group revival events by the held change's ORIGINAL origin (its HLC
|
|
348
|
+
// siteId) — a held change carries no relaying changeset.
|
|
349
|
+
appliedEntries.push({ change, siteId: change.hlc.siteId });
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
skipped++;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// One admission unit: data first → CRDT metadata + held-entry deletes second.
|
|
356
|
+
// No watermarkHLC — these HLCs were already merged into the local clock at the
|
|
357
|
+
// original receive (applyChanges merges the batch maxHLC even for diverted
|
|
358
|
+
// changes), so re-merging would be a no-op; omitting it keeps drain a pure replay.
|
|
359
|
+
await admitGroup(ctx, {
|
|
360
|
+
dataChanges: dataChangesToApply,
|
|
361
|
+
schemaChanges: [],
|
|
362
|
+
applyOptions: { remote: true },
|
|
363
|
+
commitMetadata: async () => {
|
|
364
|
+
await commitChangeMetadata(ctx, resolvedDataChanges);
|
|
365
|
+
// Clear every held entry CONSIDERED this drain (applied, LWW-lost, blocked,
|
|
366
|
+
// or drift-dropped) in the SAME unit, so the hold clears atomically with the
|
|
367
|
+
// apply. Once a held change has resolved against the present table, holding
|
|
368
|
+
// it longer changes nothing — a future drain resolves it identically — so a
|
|
369
|
+
// non-applied outcome is cleared too. A data-apply failure aborts before
|
|
370
|
+
// this callback, so on a crash the entries stay held and re-drain next sweep.
|
|
371
|
+
const qBatch = ctx.kv.batch();
|
|
372
|
+
for (const change of group.changes) {
|
|
373
|
+
ctx.quarantine.delete(qBatch, change);
|
|
374
|
+
}
|
|
375
|
+
await qBatch.write();
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
// Revival events AFTER the unit commits (so a listener reading back sees the
|
|
379
|
+
// applied data): remote-change per origin, then one drained event for the table.
|
|
380
|
+
emitRemoteChanges(ctx, appliedEntries);
|
|
381
|
+
ctx.syncEvents.emitHeldChangesDrained({
|
|
382
|
+
schema: group.schema,
|
|
383
|
+
table: group.table,
|
|
384
|
+
drained: group.changes.length,
|
|
385
|
+
applied,
|
|
386
|
+
skipped,
|
|
387
|
+
});
|
|
388
|
+
return group.changes.length;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Best-effort scoped drain of tables that just reappeared in the local basis, run as
|
|
392
|
+
* SEPARATE post-commit apply unit(s) after the re-creating batch committed. Advisory:
|
|
393
|
+
* each table is drained independently and any failure is logged + swallowed (the held
|
|
394
|
+
* entries stay held for the periodic sweep). No-op when {@link SyncConfig.drainOnReappear}
|
|
395
|
+
* is disabled or the list is empty. Each {@link drainHeldChanges} call is cheap when
|
|
396
|
+
* nothing is held (a scoped `quarantine.list` returning `[]`) and a no-op when the table
|
|
397
|
+
* is still absent (the oracle gate in {@link drainTableGroup}).
|
|
398
|
+
*/
|
|
399
|
+
export async function drainReappearedTables(ctx, tables) {
|
|
400
|
+
if (!ctx.config.drainOnReappear || tables.length === 0)
|
|
401
|
+
return;
|
|
402
|
+
// Per-table try/catch so one table's drain failure never aborts the others — and,
|
|
403
|
+
// crucially, never propagates out of applyChanges to turn a committed apply into a
|
|
404
|
+
// throw. The create_table + data are already durable; a swallowed drain leaves the
|
|
405
|
+
// held entries for the next periodic sweep (drain is idempotent — a re-drain of an
|
|
406
|
+
// already-drained table returns 0).
|
|
407
|
+
for (const { schema, table } of tables) {
|
|
408
|
+
try {
|
|
409
|
+
await drainHeldChanges(ctx, schema, table);
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
console.warn(`[Sync] drainReappearedTables failed for ${schema}.${table} `
|
|
413
|
+
+ `(advisory; held entries stay held for the periodic sweep): `
|
|
414
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
129
418
|
/**
|
|
130
419
|
* Resolve CRDT conflicts for a single change WITHOUT writing metadata.
|
|
131
420
|
*
|
|
@@ -144,6 +433,7 @@ export async function resolveChange(ctx, change) {
|
|
|
144
433
|
return {
|
|
145
434
|
outcome: 'applied',
|
|
146
435
|
change,
|
|
436
|
+
oldTombstone: existingTombstone ?? undefined,
|
|
147
437
|
dataChange: {
|
|
148
438
|
type: 'delete',
|
|
149
439
|
schema: change.schema,
|
|
@@ -155,6 +445,11 @@ export async function resolveChange(ctx, change) {
|
|
|
155
445
|
else {
|
|
156
446
|
// Column change: single getColumnVersion read, then decide via resolver or HLC
|
|
157
447
|
const localVersion = await ctx.columnVersions.getColumnVersion(change.schema, change.table, change.pk, change.column);
|
|
448
|
+
// Surface the incoming change's before-image to the resolver and conflict
|
|
449
|
+
// events (spread only when present — keeps the no-prior fast path identical).
|
|
450
|
+
const remotePrior = change.priorHlc !== undefined
|
|
451
|
+
? { remotePriorHlc: change.priorHlc, remotePriorValue: change.priorValue }
|
|
452
|
+
: undefined;
|
|
158
453
|
if (localVersion) {
|
|
159
454
|
const remoteWins = ctx.config.conflictResolver
|
|
160
455
|
? ctx.config.conflictResolver({
|
|
@@ -166,6 +461,7 @@ export async function resolveChange(ctx, change) {
|
|
|
166
461
|
localHlc: localVersion.hlc,
|
|
167
462
|
remoteValue: change.value,
|
|
168
463
|
remoteHlc: change.hlc,
|
|
464
|
+
...remotePrior,
|
|
169
465
|
}) === 'remote'
|
|
170
466
|
: compareHLC(change.hlc, localVersion.hlc) > 0;
|
|
171
467
|
if (!remoteWins) {
|
|
@@ -178,6 +474,7 @@ export async function resolveChange(ctx, change) {
|
|
|
178
474
|
remoteValue: change.value,
|
|
179
475
|
winner: 'local',
|
|
180
476
|
winningHLC: localVersion.hlc,
|
|
477
|
+
...remotePrior,
|
|
181
478
|
});
|
|
182
479
|
return { outcome: 'conflict', change };
|
|
183
480
|
}
|
|
@@ -197,6 +494,7 @@ export async function resolveChange(ctx, change) {
|
|
|
197
494
|
remoteValue: change.value,
|
|
198
495
|
winner: 'remote',
|
|
199
496
|
winningHLC: change.hlc,
|
|
497
|
+
...remotePrior,
|
|
200
498
|
});
|
|
201
499
|
}
|
|
202
500
|
return {
|
|
@@ -217,36 +515,163 @@ export async function resolveChange(ctx, change) {
|
|
|
217
515
|
* Commit CRDT metadata for resolved changes.
|
|
218
516
|
*
|
|
219
517
|
* Phase 3 of the 3-phase apply pattern: called AFTER data is written to store.
|
|
518
|
+
*
|
|
519
|
+
* In-batch repeats of one key are collapsed to the max-HLC winner before any write.
|
|
520
|
+
* Two versions of the same key can land in a single `applyChanges` batch (e.g.
|
|
521
|
+
* concurrent deletes of the same pk relayed together), and Phase 1 resolved BOTH
|
|
522
|
+
* against the same pre-batch prior version — neither saw the other. Writing both would
|
|
523
|
+
* leave two change-log entries for one key, re-attributing the older entry to the later
|
|
524
|
+
* HLC and breaking {@link SyncManagerImpl}'s `collectChangesSince` LOAD-BEARING
|
|
525
|
+
* INVARIANT (survivor's log HLC == its current version's HLC). So only the winner's
|
|
526
|
+
* metadata + change-log entry are written; losers are never written and the single
|
|
527
|
+
* pre-batch prior entry is deleted once. Mirrors the local write-path dedup in
|
|
528
|
+
* `recordDataEvent` / `recordColumnVersions`, keeping the delete and column paths
|
|
529
|
+
* symmetric.
|
|
220
530
|
*/
|
|
221
531
|
export async function commitChangeMetadata(ctx, resolvedChanges) {
|
|
222
532
|
if (resolvedChanges.length === 0)
|
|
223
533
|
return;
|
|
224
|
-
|
|
534
|
+
// Collapse in-batch repeats per key, keeping the max-HLC change. Deletes key by
|
|
535
|
+
// (schema, table, pk); columns by (schema, table, pk, column) — distinct change-log
|
|
536
|
+
// entry types that never collide.
|
|
537
|
+
const deleteWinners = new Map();
|
|
538
|
+
const columnWinners = new Map();
|
|
225
539
|
for (const resolved of resolvedChanges) {
|
|
226
540
|
if (resolved.outcome !== 'applied')
|
|
227
541
|
continue;
|
|
228
542
|
const change = resolved.change;
|
|
229
543
|
if (change.type === 'delete') {
|
|
230
|
-
|
|
231
|
-
ctx.changeLog.recordDeletionBatch(batch, change.hlc, change.schema, change.table, change.pk);
|
|
544
|
+
keepMaxHLC(deleteWinners, deleteKey(change), resolved);
|
|
232
545
|
}
|
|
233
546
|
else {
|
|
234
|
-
|
|
235
|
-
if (resolved.oldColumnVersion) {
|
|
236
|
-
ctx.changeLog.deleteEntryBatch(batch, resolved.oldColumnVersion.hlc, 'column', change.schema, change.table, change.pk, change.column);
|
|
237
|
-
}
|
|
238
|
-
ctx.columnVersions.setColumnVersionBatch(batch, change.schema, change.table, change.pk, change.column, { hlc: change.hlc, value: change.value });
|
|
239
|
-
ctx.changeLog.recordColumnChangeBatch(batch, change.hlc, change.schema, change.table, change.pk, change.column);
|
|
547
|
+
keepMaxHLC(columnWinners, columnKey(change), resolved);
|
|
240
548
|
}
|
|
241
549
|
}
|
|
550
|
+
const batch = ctx.kv.batch();
|
|
551
|
+
for (const resolved of deleteWinners.values()) {
|
|
552
|
+
const change = resolved.change;
|
|
553
|
+
if (change.type !== 'delete')
|
|
554
|
+
continue; // homogeneous map; narrows the union
|
|
555
|
+
commitDeleteMetadata(ctx, batch, change, resolved.oldTombstone);
|
|
556
|
+
}
|
|
557
|
+
for (const resolved of columnWinners.values()) {
|
|
558
|
+
const change = resolved.change;
|
|
559
|
+
if (change.type !== 'column')
|
|
560
|
+
continue; // homogeneous map; narrows the union
|
|
561
|
+
commitColumnMetadata(ctx, batch, change, resolved.oldColumnVersion);
|
|
562
|
+
}
|
|
242
563
|
await batch.write();
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
564
|
+
// Column-version cleanup for deletes requires async iteration — once per winning
|
|
565
|
+
// delete (losers were never written, so there is nothing of theirs to clean).
|
|
566
|
+
for (const resolved of deleteWinners.values()) {
|
|
567
|
+
const change = resolved.change;
|
|
568
|
+
if (change.type !== 'delete')
|
|
246
569
|
continue;
|
|
247
|
-
|
|
248
|
-
|
|
570
|
+
await ctx.columnVersions.deleteRowVersions(change.schema, change.table, change.pk);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// Collapse keys reuse encodePK so in-batch grouping matches the canonical pk encoding of
|
|
574
|
+
// the actual KV keys (buildTombstoneKey / buildColumnVersionKey) — two pks collapse here
|
|
575
|
+
// iff they would collide on disk.
|
|
576
|
+
/** Stable per-pk key for collapsing repeated delete entries within one batch. */
|
|
577
|
+
function deleteKey(change) {
|
|
578
|
+
return `delete:${change.schema}.${change.table}:${encodePK(change.pk)}`;
|
|
579
|
+
}
|
|
580
|
+
/** Stable per-(pk, column) key for collapsing repeated column entries within one batch. */
|
|
581
|
+
function columnKey(change) {
|
|
582
|
+
return `column:${change.schema}.${change.table}:${encodePK(change.pk)}:${change.column}`;
|
|
583
|
+
}
|
|
584
|
+
/** Keep the max-HLC resolved change per key, collapsing in-batch repeats to one winner. */
|
|
585
|
+
function keepMaxHLC(winners, key, resolved) {
|
|
586
|
+
const prev = winners.get(key);
|
|
587
|
+
if (!prev || compareHLC(resolved.change.hlc, prev.change.hlc) > 0) {
|
|
588
|
+
winners.set(key, resolved);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/** Write a winning delete's tombstone + change-log entry, deleting any prior delete entry. */
|
|
592
|
+
function commitDeleteMetadata(ctx, batch, change, oldTombstone) {
|
|
593
|
+
// Dedupe: a newer tombstone overwrites the prior one, leaving its delete change-log
|
|
594
|
+
// entry stale — remove it so at most one survives per pk (mirrors the column dedup
|
|
595
|
+
// and the write path in recordDataEvent).
|
|
596
|
+
if (oldTombstone) {
|
|
597
|
+
ctx.changeLog.deleteEntryBatch(batch, oldTombstone.hlc, 'delete', change.schema, change.table, change.pk);
|
|
598
|
+
}
|
|
599
|
+
// Persist the incoming change's before-image (when present) onto the tombstone so
|
|
600
|
+
// a receiver relaying via getChangesSince re-emits it. Only the winning delete's
|
|
601
|
+
// metadata is written here (in-batch losers never persist), so the surviving
|
|
602
|
+
// priorRow is the latest delete's row image.
|
|
603
|
+
ctx.tombstones.setTombstoneBatch(batch, change.schema, change.table, change.pk, change.hlc, change.priorRow);
|
|
604
|
+
ctx.changeLog.recordDeletionBatch(batch, change.hlc, change.schema, change.table, change.pk);
|
|
605
|
+
}
|
|
606
|
+
/** Write a winning column's version + change-log entry, deleting any prior column entry. */
|
|
607
|
+
function commitColumnMetadata(ctx, batch, change, oldColumnVersion) {
|
|
608
|
+
// Delete the prior (pk, column) change-log entry if one exists.
|
|
609
|
+
if (oldColumnVersion) {
|
|
610
|
+
ctx.changeLog.deleteEntryBatch(batch, oldColumnVersion.hlc, 'column', change.schema, change.table, change.pk, change.column);
|
|
611
|
+
}
|
|
612
|
+
// Persist the before-image as this replica's local lineage: the version this
|
|
613
|
+
// write replaced here (`oldColumnVersion`). In causal-order delivery that equals
|
|
614
|
+
// the origin's prior, so re-relay forwards the origin's chain (the prior's own
|
|
615
|
+
// origin HLC, never reset to this receiver's clock). A first write here records
|
|
616
|
+
// no prior, matching the local write path in `recordColumnVersions`.
|
|
617
|
+
const prior = oldColumnVersion
|
|
618
|
+
? { priorHlc: oldColumnVersion.hlc, priorValue: oldColumnVersion.value }
|
|
619
|
+
: undefined;
|
|
620
|
+
ctx.columnVersions.setColumnVersionBatch(batch, change.schema, change.table, change.pk, change.column, { hlc: change.hlc, value: change.value, ...prior });
|
|
621
|
+
ctx.changeLog.recordColumnChangeBatch(batch, change.hlc, change.schema, change.table, change.pk, change.column);
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Bump `lastDirectlyMappedWriteAt` for each non-directly-mapped tracked basis
|
|
625
|
+
* table that received an inbound (remote) write this apply — the dynamic
|
|
626
|
+
* basis-retirement signal (docs/migration.md § 2 Converge). Every `appliedChange`
|
|
627
|
+
* is remote (self-origin is skipped before resolution), so any write to a legacy
|
|
628
|
+
* table is presumed to originate at a peer that still maps it directly: a
|
|
629
|
+
* deliberately conservative over-estimate that errs toward RETAINING storage
|
|
630
|
+
* (under-counting would risk a premature drop). A `directly-mapped` table (the
|
|
631
|
+
* local peer still maps it) is ordinary sync traffic, not a retirement signal —
|
|
632
|
+
* skipped, so the common pre-migration case touches nothing.
|
|
633
|
+
*
|
|
634
|
+
* Cheap in the common case: one bounded `getAll` scan, and an immediate return
|
|
635
|
+
* when no lifecycle records exist. Batched — one KV update per touched table, not
|
|
636
|
+
* per change. Advisory: a failure is logged and swallowed so it can never abort
|
|
637
|
+
* the apply (the next inbound write re-bumps the clock).
|
|
638
|
+
*/
|
|
639
|
+
async function bumpLastDirectlyMappedWrites(ctx, appliedChanges) {
|
|
640
|
+
if (appliedChanges.length === 0)
|
|
641
|
+
return;
|
|
642
|
+
try {
|
|
643
|
+
const stored = await ctx.basisLifecycle.getAll();
|
|
644
|
+
if (stored.size === 0)
|
|
645
|
+
return; // pre-migration: nothing tracked, zero overhead
|
|
646
|
+
// Per-table max inbound wall-time among writes to a NON-directly-mapped record.
|
|
647
|
+
const maxWall = new Map();
|
|
648
|
+
for (const { change } of appliedChanges) {
|
|
649
|
+
const key = `${change.schema}.${change.table}`.toLowerCase();
|
|
650
|
+
const record = stored.get(key);
|
|
651
|
+
if (!record || record.state === 'directly-mapped')
|
|
652
|
+
continue;
|
|
653
|
+
const cur = maxWall.get(key);
|
|
654
|
+
if (cur === undefined || change.hlc.wallTime > cur)
|
|
655
|
+
maxWall.set(key, change.hlc.wallTime);
|
|
249
656
|
}
|
|
657
|
+
if (maxWall.size === 0)
|
|
658
|
+
return;
|
|
659
|
+
const batch = ctx.kv.batch();
|
|
660
|
+
let wrote = false;
|
|
661
|
+
for (const [key, wall] of maxWall) {
|
|
662
|
+
const record = stored.get(key);
|
|
663
|
+
const next = Math.max(record.lastDirectlyMappedWriteAt ?? 0, Number(wall));
|
|
664
|
+
if (next !== (record.lastDirectlyMappedWriteAt ?? 0)) {
|
|
665
|
+
ctx.basisLifecycle.put(batch, { ...record, lastDirectlyMappedWriteAt: next });
|
|
666
|
+
wrote = true;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (wrote)
|
|
670
|
+
await batch.write();
|
|
671
|
+
}
|
|
672
|
+
catch (error) {
|
|
673
|
+
console.warn(`[Sync] bumpLastDirectlyMappedWrites failed (advisory; eviction clock not updated this apply): `
|
|
674
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
250
675
|
}
|
|
251
676
|
}
|
|
252
677
|
//# sourceMappingURL=change-applicator.js.map
|