@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.
@@ -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=9662318FD91E917A64756E2164756E21
3448
+ //# debugId=07C83ADFBDD0094F64756E2164756E21
3370
3449
  //# sourceMappingURL=index.js.map