@absolutejs/sync 1.23.0 → 1.24.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/dist/engine/index.d.ts +2 -0
- package/dist/engine/index.js +81 -1
- package/dist/engine/index.js.map +5 -4
- package/dist/engine/migrate.d.ts +131 -0
- package/dist/engine/syncEngine.d.ts +56 -0
- package/dist/index.js +80 -1
- package/dist/index.js.map +5 -4
- package/dist/testing.js +80 -1
- package/dist/testing.js.map +5 -4
- package/package.json +1 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant migration primitives. Closes G7 from the deep-research
|
|
3
|
+
* audit: "move a tenant from engine A to engine B."
|
|
4
|
+
*
|
|
5
|
+
* The substrate offers three composable verbs:
|
|
6
|
+
*
|
|
7
|
+
* - **`engine.fence({ reason })`** — pause new mutations on the
|
|
8
|
+
* source so its captured state stops drifting. Subscribers continue
|
|
9
|
+
* to read; only `runMutation` rejects (with
|
|
10
|
+
* {@link EngineFencedError}). Returns a {@link FenceHandle} with
|
|
11
|
+
* `lift()` to undo.
|
|
12
|
+
* - **`engine.exportSnapshot({ tables?, ctx? })`** — walk the
|
|
13
|
+
* registered readers and return a portable
|
|
14
|
+
* {@link EngineSnapshot} carrying the source `instanceId`,
|
|
15
|
+
* `version`, and current rows per table. Cheap and synchronous from
|
|
16
|
+
* the operator's POV.
|
|
17
|
+
* - **`engine.importSnapshot(snapshot, options?)`** — on the target,
|
|
18
|
+
* bulk-load the rows via each table's registered writer. Tracks
|
|
19
|
+
* per-table progress. Returns a {@link MigrationImportResult} with
|
|
20
|
+
* row counts.
|
|
21
|
+
*
|
|
22
|
+
* The intended choreography for a cross-region tenant move:
|
|
23
|
+
*
|
|
24
|
+
* ```ts
|
|
25
|
+
* // ── on the source ──
|
|
26
|
+
* const fence = source.fence({ reason: 'tenant-7 → us-east-2' });
|
|
27
|
+
* try {
|
|
28
|
+
* const snapshot = await source.exportSnapshot();
|
|
29
|
+
* await transport(snapshot); // S3, message bus, etc.
|
|
30
|
+
* // ── on the target ──
|
|
31
|
+
* await target.importSnapshot(snapshot, {
|
|
32
|
+
* onProgress: (table, done, total) =>
|
|
33
|
+
* console.log(`${table}: ${done}/${total}`)
|
|
34
|
+
* });
|
|
35
|
+
* await cutoverDns(); // direct clients at target
|
|
36
|
+
* } finally {
|
|
37
|
+
* fence.lift();
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* Out of scope: out-of-band writes (CDC drivers, raw SQL) — the
|
|
42
|
+
* caller is responsible for pausing those before fencing, otherwise
|
|
43
|
+
* the captured snapshot drifts.
|
|
44
|
+
*
|
|
45
|
+
* Added in 1.24.0.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* Portable per-tenant state captured by
|
|
49
|
+
* {@link SyncEngine.exportSnapshot}. Consumed by
|
|
50
|
+
* {@link SyncEngine.importSnapshot} on the target engine.
|
|
51
|
+
*/
|
|
52
|
+
export type EngineSnapshot = {
|
|
53
|
+
/** The exporting engine's `instanceId` (for audit / forensics). */
|
|
54
|
+
sourceInstanceId: string;
|
|
55
|
+
/** Source engine's monotonic version at snapshot time. */
|
|
56
|
+
version: number;
|
|
57
|
+
/** `Date.now()` at export — used by hosts for staleness checks. */
|
|
58
|
+
exportedAt: number;
|
|
59
|
+
/** Current rows per table, read from each table's registered reader. */
|
|
60
|
+
tables: Record<string, ReadonlyArray<unknown>>;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Returned by {@link SyncEngine.importSnapshot}.
|
|
64
|
+
*/
|
|
65
|
+
export type MigrationImportResult = {
|
|
66
|
+
/** Number of tables that had at least one row imported. */
|
|
67
|
+
tablesImported: number;
|
|
68
|
+
/** Total rows inserted across all tables. */
|
|
69
|
+
rowsImported: number;
|
|
70
|
+
/** Rows inserted per table. Tables with zero rows are still listed. */
|
|
71
|
+
perTable: Record<string, number>;
|
|
72
|
+
/**
|
|
73
|
+
* Tables present in the snapshot that the target engine has no
|
|
74
|
+
* registered writer for — skipped silently. Surface this to
|
|
75
|
+
* operators so they can catch "I forgot to register `tasks` on
|
|
76
|
+
* the new shard" cleanly.
|
|
77
|
+
*/
|
|
78
|
+
skipped: ReadonlyArray<string>;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Returned by {@link SyncEngine.fence}. Hold this and call `lift()`
|
|
82
|
+
* to re-enable mutations. Holding multiple fences is supported — the
|
|
83
|
+
* engine stays fenced until every handle has been lifted.
|
|
84
|
+
*/
|
|
85
|
+
export type FenceHandle = {
|
|
86
|
+
/** `Date.now()` at fence time. */
|
|
87
|
+
fencedAt: number;
|
|
88
|
+
/** Human-readable reason — surfaced on {@link EngineFencedError}. */
|
|
89
|
+
reason: string;
|
|
90
|
+
/** Re-enable mutations. Idempotent (later calls are no-ops). */
|
|
91
|
+
lift: () => void;
|
|
92
|
+
};
|
|
93
|
+
export type ExportSnapshotOptions = {
|
|
94
|
+
/**
|
|
95
|
+
* Narrow the export to a subset of registered tables. Useful for
|
|
96
|
+
* per-tenant cuts when readers expose `ctx`-scoped data.
|
|
97
|
+
*/
|
|
98
|
+
tables?: ReadonlyArray<string>;
|
|
99
|
+
/**
|
|
100
|
+
* Context passed to each reader's `all(ctx)`. The default `{}`
|
|
101
|
+
* works for engines whose readers ignore context.
|
|
102
|
+
*/
|
|
103
|
+
ctx?: unknown;
|
|
104
|
+
};
|
|
105
|
+
export type ImportSnapshotOptions = {
|
|
106
|
+
/**
|
|
107
|
+
* Narrow the import to a subset of tables in the snapshot.
|
|
108
|
+
* Tables outside the filter are skipped (NOT recorded in
|
|
109
|
+
* `skipped`; that field is for tables with no writer).
|
|
110
|
+
*/
|
|
111
|
+
tables?: ReadonlyArray<string>;
|
|
112
|
+
/**
|
|
113
|
+
* Called for each row insertion. Fires synchronously inside the
|
|
114
|
+
* import loop; keep it cheap or schedule heavy work elsewhere.
|
|
115
|
+
*/
|
|
116
|
+
onProgress?: (table: string, done: number, total: number) => void;
|
|
117
|
+
/**
|
|
118
|
+
* Context passed to each writer's `insert(data, ctx, tx)`. The
|
|
119
|
+
* default `{}` works for writers that ignore context.
|
|
120
|
+
*/
|
|
121
|
+
ctx?: unknown;
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Thrown by `runMutation` when the engine is fenced. The reason
|
|
125
|
+
* carries through so operators can correlate denied calls to the
|
|
126
|
+
* fence that caused them.
|
|
127
|
+
*/
|
|
128
|
+
export declare class EngineFencedError extends Error {
|
|
129
|
+
readonly reason: string;
|
|
130
|
+
constructor(reason: string);
|
|
131
|
+
}
|
|
@@ -10,6 +10,7 @@ import type { SearchCollectionDefinition } from './search';
|
|
|
10
10
|
import type { ScheduleDefinition } from './schedule';
|
|
11
11
|
import type { EngineActivity, EngineInspection, EngineMetrics } from './devtools';
|
|
12
12
|
import type { SchemaDefinition, TableSchema } from './schema';
|
|
13
|
+
import { type EngineSnapshot, type ExportSnapshotOptions, type FenceHandle, type ImportSnapshotOptions, type MigrationImportResult } from './migrate';
|
|
13
14
|
import type { CrdtMergeable } from '../crdt';
|
|
14
15
|
import type { ClusterBus } from './cluster';
|
|
15
16
|
import type { ChangeSource, RowChange, ViewDiff } from './types';
|
|
@@ -324,6 +325,61 @@ export type SyncEngine = {
|
|
|
324
325
|
* ```
|
|
325
326
|
*/
|
|
326
327
|
replayTo: (options: ReplayOptions) => Promise<ReplayResult>;
|
|
328
|
+
/**
|
|
329
|
+
* Pause new mutations on the engine — the source half of the G7 tenant
|
|
330
|
+
* migration contract. While at least one fence is held, `runMutation`
|
|
331
|
+
* rejects with {@link EngineFencedError}; subscribe/hydrate continue to
|
|
332
|
+
* work, so live readers stay served while the snapshot is in flight.
|
|
333
|
+
*
|
|
334
|
+
* Multiple fence handles compose — the engine stays fenced until every
|
|
335
|
+
* handle has been `lift()`-ed. Lifting is idempotent.
|
|
336
|
+
*
|
|
337
|
+
* Out of scope: out-of-band writes (CDC drivers, raw SQL). The caller
|
|
338
|
+
* is responsible for halting those before fencing, otherwise the
|
|
339
|
+
* snapshot will drift between `exportSnapshot` and import on the target.
|
|
340
|
+
*
|
|
341
|
+
* Added in 1.24.0.
|
|
342
|
+
*/
|
|
343
|
+
fence: (options: {
|
|
344
|
+
reason: string;
|
|
345
|
+
}) => FenceHandle;
|
|
346
|
+
/**
|
|
347
|
+
* Capture the engine's current per-table state into a portable
|
|
348
|
+
* {@link EngineSnapshot}. Walks every registered reader's `all(ctx)`
|
|
349
|
+
* and collects the rows. Used to ship a tenant between engines (G7).
|
|
350
|
+
*
|
|
351
|
+
* Pair with `fence()` on the source to stop drift, then
|
|
352
|
+
* `importSnapshot()` on the target. The shape is intentionally
|
|
353
|
+
* detached from `ChangeLogSnapshot` — snapshots carry live state, not
|
|
354
|
+
* history. Use `exportChangeLog()` separately if you need forensic
|
|
355
|
+
* continuity at the target instanceId.
|
|
356
|
+
*
|
|
357
|
+
* Added in 1.24.0.
|
|
358
|
+
*
|
|
359
|
+
* @example
|
|
360
|
+
* const fence = source.fence({ reason: 'tenant move' });
|
|
361
|
+
* try {
|
|
362
|
+
* const snapshot = await source.exportSnapshot();
|
|
363
|
+
* await target.importSnapshot(snapshot);
|
|
364
|
+
* } finally { fence.lift(); }
|
|
365
|
+
*/
|
|
366
|
+
exportSnapshot: (options?: ExportSnapshotOptions) => Promise<EngineSnapshot>;
|
|
367
|
+
/**
|
|
368
|
+
* Bulk-load an {@link EngineSnapshot} into this engine via each table's
|
|
369
|
+
* registered writer. Tables present in the snapshot but missing a
|
|
370
|
+
* writer here are surfaced in `result.skipped` so the operator can
|
|
371
|
+
* detect a misconfigured target. The target half of the G7 migration
|
|
372
|
+
* contract.
|
|
373
|
+
*
|
|
374
|
+
* Inserts do NOT emit change events to subscribers — the import is
|
|
375
|
+
* meant to land on a fresh target whose clients will re-hydrate after
|
|
376
|
+
* the DNS cutover. If you need to fan changes out (e.g. mid-flight
|
|
377
|
+
* cutover), drain the change log via `streamChanges()` and
|
|
378
|
+
* `applyChange()` separately.
|
|
379
|
+
*
|
|
380
|
+
* Added in 1.24.0.
|
|
381
|
+
*/
|
|
382
|
+
importSnapshot: (snapshot: EngineSnapshot, options?: ImportSnapshotOptions) => Promise<MigrationImportResult>;
|
|
327
383
|
/**
|
|
328
384
|
* Subscribe to the live engine activity stream (changes, mutation outcomes,
|
|
329
385
|
* subscribe/unsubscribe). Returns an unsubscribe. Powers the devtools feed.
|
package/dist/index.js
CHANGED
|
@@ -1143,6 +1143,16 @@ var defineSearchCollection = (definition) => ({
|
|
|
1143
1143
|
kind: "search"
|
|
1144
1144
|
});
|
|
1145
1145
|
|
|
1146
|
+
// src/engine/migrate.ts
|
|
1147
|
+
class EngineFencedError extends Error {
|
|
1148
|
+
reason;
|
|
1149
|
+
constructor(reason) {
|
|
1150
|
+
super(`[sync] Engine is fenced for migration: ${reason}`);
|
|
1151
|
+
this.name = "EngineFencedError";
|
|
1152
|
+
this.reason = reason;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1146
1156
|
// src/engine/syncEngine.ts
|
|
1147
1157
|
class UnauthorizedError extends Error {
|
|
1148
1158
|
constructor(subject) {
|
|
@@ -1345,6 +1355,7 @@ var createSyncEngine = (options = {}) => {
|
|
|
1345
1355
|
let mutationsInFlight = 0;
|
|
1346
1356
|
const mutationWaiters = [];
|
|
1347
1357
|
let mutationsQueued = 0;
|
|
1358
|
+
const activeFences = new Set;
|
|
1348
1359
|
const acquireMutationSlot = async () => {
|
|
1349
1360
|
const limit = options.mutationConcurrency;
|
|
1350
1361
|
if (limit === undefined) {
|
|
@@ -2463,6 +2474,10 @@ var createSyncEngine = (options = {}) => {
|
|
|
2463
2474
|
}
|
|
2464
2475
|
});
|
|
2465
2476
|
try {
|
|
2477
|
+
if (activeFences.size > 0) {
|
|
2478
|
+
const oldest = activeFences.values().next().value;
|
|
2479
|
+
throw new EngineFencedError(oldest.reason);
|
|
2480
|
+
}
|
|
2466
2481
|
const mutation = mutations.get(name);
|
|
2467
2482
|
if (mutation === undefined) {
|
|
2468
2483
|
throw new Error(`Unknown mutation "${name}"`);
|
|
@@ -2838,6 +2853,70 @@ var createSyncEngine = (options = {}) => {
|
|
|
2838
2853
|
}
|
|
2839
2854
|
return { asOfAt, asOfVersion, rows, truncated };
|
|
2840
2855
|
},
|
|
2856
|
+
fence: ({ reason }) => {
|
|
2857
|
+
const handle = {
|
|
2858
|
+
fencedAt: Date.now(),
|
|
2859
|
+
reason,
|
|
2860
|
+
lift: () => {
|
|
2861
|
+
activeFences.delete(handle);
|
|
2862
|
+
}
|
|
2863
|
+
};
|
|
2864
|
+
activeFences.add(handle);
|
|
2865
|
+
return handle;
|
|
2866
|
+
},
|
|
2867
|
+
exportSnapshot: async ({ tables, ctx = {} } = {}) => {
|
|
2868
|
+
const tableFilter = tables !== undefined ? new Set(tables) : undefined;
|
|
2869
|
+
const rows = {};
|
|
2870
|
+
for (const [table, reader] of readers) {
|
|
2871
|
+
if (tableFilter !== undefined && !tableFilter.has(table)) {
|
|
2872
|
+
continue;
|
|
2873
|
+
}
|
|
2874
|
+
const iterable = await reader.all(ctx);
|
|
2875
|
+
rows[table] = [...iterable];
|
|
2876
|
+
}
|
|
2877
|
+
return {
|
|
2878
|
+
exportedAt: Date.now(),
|
|
2879
|
+
sourceInstanceId: instanceId,
|
|
2880
|
+
tables: rows,
|
|
2881
|
+
version
|
|
2882
|
+
};
|
|
2883
|
+
},
|
|
2884
|
+
importSnapshot: async (snapshot, { tables, onProgress, ctx = {} } = {}) => {
|
|
2885
|
+
const tableFilter = tables !== undefined ? new Set(tables) : undefined;
|
|
2886
|
+
const perTable = {};
|
|
2887
|
+
const skipped = [];
|
|
2888
|
+
let tablesImported = 0;
|
|
2889
|
+
let rowsImported = 0;
|
|
2890
|
+
for (const [table, snapshotRows] of Object.entries(snapshot.tables)) {
|
|
2891
|
+
if (tableFilter !== undefined && !tableFilter.has(table)) {
|
|
2892
|
+
continue;
|
|
2893
|
+
}
|
|
2894
|
+
const writer = writers.get(table);
|
|
2895
|
+
if (writer === undefined) {
|
|
2896
|
+
skipped.push(table);
|
|
2897
|
+
continue;
|
|
2898
|
+
}
|
|
2899
|
+
const total = snapshotRows.length;
|
|
2900
|
+
let done = 0;
|
|
2901
|
+
for (const row of snapshotRows) {
|
|
2902
|
+
await writer.insert(row, ctx, undefined);
|
|
2903
|
+
done += 1;
|
|
2904
|
+
rowsImported += 1;
|
|
2905
|
+
if (onProgress !== undefined) {
|
|
2906
|
+
onProgress(table, done, total);
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
perTable[table] = done;
|
|
2910
|
+
if (done > 0)
|
|
2911
|
+
tablesImported += 1;
|
|
2912
|
+
}
|
|
2913
|
+
return {
|
|
2914
|
+
perTable,
|
|
2915
|
+
rowsImported,
|
|
2916
|
+
skipped,
|
|
2917
|
+
tablesImported
|
|
2918
|
+
};
|
|
2919
|
+
},
|
|
2841
2920
|
metrics: () => {
|
|
2842
2921
|
const now = Date.now();
|
|
2843
2922
|
const byCollection = {};
|
|
@@ -3366,5 +3445,5 @@ export {
|
|
|
3366
3445
|
createPresenceHub
|
|
3367
3446
|
};
|
|
3368
3447
|
|
|
3369
|
-
//# debugId=
|
|
3448
|
+
//# debugId=07C83ADFBDD0094F64756E2164756E21
|
|
3370
3449
|
//# sourceMappingURL=index.js.map
|