@absolutejs/sync 1.23.0 → 1.25.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
@@ -139,51 +139,54 @@ var sync = ({
139
139
  path = "/sync",
140
140
  resolveTopics = defaultResolveTopics,
141
141
  heartbeatMs = 25000
142
- }) => new Elysia({ name: "@absolutejs/sync" }).get(path, (context) => {
143
- const topics = resolveTopics({
144
- query: context.query,
145
- request: context.request
146
- });
147
- const encoder = new TextEncoder;
148
- const stream = new ReadableStream({
149
- start(controller) {
150
- const write = (chunk) => {
151
- try {
152
- controller.enqueue(encoder.encode(chunk));
153
- } catch {}
154
- };
155
- const send = (event) => {
156
- write(`data: ${JSON.stringify(event)}
142
+ }) => {
143
+ const app = new Elysia({ name: "@absolutejs/sync" }).get(path, (context) => {
144
+ const topics = resolveTopics({
145
+ query: context.query,
146
+ request: context.request
147
+ });
148
+ const encoder = new TextEncoder;
149
+ const stream = new ReadableStream({
150
+ start(controller) {
151
+ const write = (chunk) => {
152
+ try {
153
+ controller.enqueue(encoder.encode(chunk));
154
+ } catch {}
155
+ };
156
+ const send = (event) => {
157
+ write(`data: ${JSON.stringify(event)}
157
158
 
158
159
  `);
159
- };
160
- send({
161
- topic: SYNC_OPEN_TOPIC,
162
- at: Date.now(),
163
- payload: { topics }
164
- });
165
- const unsubscribe = topics.length > 0 ? hub.subscribe(topics, send) : () => {};
166
- const heartbeat = setInterval(() => write(`: ping
160
+ };
161
+ send({
162
+ topic: SYNC_OPEN_TOPIC,
163
+ at: Date.now(),
164
+ payload: { topics }
165
+ });
166
+ const unsubscribe = topics.length > 0 ? hub.subscribe(topics, send) : () => {};
167
+ const heartbeat = setInterval(() => write(`: ping
167
168
 
168
169
  `), heartbeatMs);
169
- context.request.signal.addEventListener("abort", () => {
170
- clearInterval(heartbeat);
171
- unsubscribe();
172
- try {
173
- controller.close();
174
- } catch {}
175
- }, { once: true });
176
- }
177
- });
178
- return new Response(stream, {
179
- headers: {
180
- "cache-control": "no-cache, no-transform",
181
- connection: "keep-alive",
182
- "content-type": "text/event-stream",
183
- "x-accel-buffering": "no"
184
- }
170
+ context.request.signal.addEventListener("abort", () => {
171
+ clearInterval(heartbeat);
172
+ unsubscribe();
173
+ try {
174
+ controller.close();
175
+ } catch {}
176
+ }, { once: true });
177
+ }
178
+ });
179
+ return new Response(stream, {
180
+ headers: {
181
+ "cache-control": "no-cache, no-transform",
182
+ connection: "keep-alive",
183
+ "content-type": "text/event-stream",
184
+ "x-accel-buffering": "no"
185
+ }
186
+ });
185
187
  });
186
- });
188
+ return app;
189
+ };
187
190
  // src/engine/socket.ts
188
191
  import { Elysia as Elysia2 } from "elysia";
189
192
 
@@ -1143,6 +1146,16 @@ var defineSearchCollection = (definition) => ({
1143
1146
  kind: "search"
1144
1147
  });
1145
1148
 
1149
+ // src/engine/migrate.ts
1150
+ class EngineFencedError extends Error {
1151
+ reason;
1152
+ constructor(reason) {
1153
+ super(`[sync] Engine is fenced for migration: ${reason}`);
1154
+ this.name = "EngineFencedError";
1155
+ this.reason = reason;
1156
+ }
1157
+ }
1158
+
1146
1159
  // src/engine/syncEngine.ts
1147
1160
  class UnauthorizedError extends Error {
1148
1161
  constructor(subject) {
@@ -1345,6 +1358,7 @@ var createSyncEngine = (options = {}) => {
1345
1358
  let mutationsInFlight = 0;
1346
1359
  const mutationWaiters = [];
1347
1360
  let mutationsQueued = 0;
1361
+ const activeFences = new Set;
1348
1362
  const acquireMutationSlot = async () => {
1349
1363
  const limit = options.mutationConcurrency;
1350
1364
  if (limit === undefined) {
@@ -1471,7 +1485,11 @@ var createSyncEngine = (options = {}) => {
1471
1485
  }
1472
1486
  const broadcast = (changes, originVersion) => {
1473
1487
  if (clusterBus !== undefined && changes.length > 0) {
1474
- clusterBus.publish({ changes, origin: instanceId, originVersion });
1488
+ clusterBus.publish({
1489
+ changes,
1490
+ origin: instanceId,
1491
+ originVersion
1492
+ });
1475
1493
  }
1476
1494
  };
1477
1495
  const subsFor = (collection) => {
@@ -2234,7 +2252,14 @@ var createSyncEngine = (options = {}) => {
2234
2252
  registerSearch: (collection) => {
2235
2253
  registry.set(collection.name, collection);
2236
2254
  },
2237
- subscribe: async ({ collection, params, ctx, onDiff, since, signal }) => {
2255
+ subscribe: async ({
2256
+ collection,
2257
+ params,
2258
+ ctx,
2259
+ onDiff,
2260
+ since,
2261
+ signal
2262
+ }) => {
2238
2263
  const subscribeSpan = tracer.startSpan("sync.subscribe", {
2239
2264
  attributes: {
2240
2265
  [ABS_ATTRS.engineId]: instanceId,
@@ -2263,7 +2288,10 @@ var createSyncEngine = (options = {}) => {
2263
2288
  releaseSubscriptionSlot(tenantSlot);
2264
2289
  innerUnsubscribe();
2265
2290
  };
2266
- const wrapped = { ...sub, unsubscribe: wrappedUnsubscribe };
2291
+ const wrapped = {
2292
+ ...sub,
2293
+ unsubscribe: wrappedUnsubscribe
2294
+ };
2267
2295
  linkAbortToUnsubscribe(signal, wrappedUnsubscribe);
2268
2296
  slotHandedOff = true;
2269
2297
  return wrapped;
@@ -2298,7 +2326,9 @@ var createSyncEngine = (options = {}) => {
2298
2326
  const scopedTable = tables.length === 1 ? tables[0] : undefined;
2299
2327
  const readRule = scopedTable !== undefined ? readRuleFor(scopedTable) : undefined;
2300
2328
  const rehydrate = async () => {
2301
- const raw = [...await definition.hydrate(params, ctx)];
2329
+ const raw = [
2330
+ ...await definition.hydrate(params, ctx)
2331
+ ];
2302
2332
  const rows = scopedTable !== undefined ? raw.map((row) => migrateRow(scopedTable, row)) : raw;
2303
2333
  return readRule ? rows.filter((row) => readRule(ctx, row)) : rows;
2304
2334
  };
@@ -2463,6 +2493,10 @@ var createSyncEngine = (options = {}) => {
2463
2493
  }
2464
2494
  });
2465
2495
  try {
2496
+ if (activeFences.size > 0) {
2497
+ const oldest = activeFences.values().next().value;
2498
+ throw new EngineFencedError(oldest.reason);
2499
+ }
2466
2500
  const mutation = mutations.get(name);
2467
2501
  if (mutation === undefined) {
2468
2502
  throw new Error(`Unknown mutation "${name}"`);
@@ -2838,6 +2872,73 @@ var createSyncEngine = (options = {}) => {
2838
2872
  }
2839
2873
  return { asOfAt, asOfVersion, rows, truncated };
2840
2874
  },
2875
+ fence: ({ reason }) => {
2876
+ const handle = {
2877
+ fencedAt: Date.now(),
2878
+ reason,
2879
+ lift: () => {
2880
+ activeFences.delete(handle);
2881
+ }
2882
+ };
2883
+ activeFences.add(handle);
2884
+ return handle;
2885
+ },
2886
+ exportSnapshot: async ({
2887
+ tables,
2888
+ ctx = {}
2889
+ } = {}) => {
2890
+ const tableFilter = tables !== undefined ? new Set(tables) : undefined;
2891
+ const rows = {};
2892
+ for (const [table, reader] of readers) {
2893
+ if (tableFilter !== undefined && !tableFilter.has(table)) {
2894
+ continue;
2895
+ }
2896
+ const iterable = await reader.all(ctx);
2897
+ rows[table] = [...iterable];
2898
+ }
2899
+ return {
2900
+ exportedAt: Date.now(),
2901
+ sourceInstanceId: instanceId,
2902
+ tables: rows,
2903
+ version
2904
+ };
2905
+ },
2906
+ importSnapshot: async (snapshot, { tables, onProgress, ctx = {} } = {}) => {
2907
+ const tableFilter = tables !== undefined ? new Set(tables) : undefined;
2908
+ const perTable = {};
2909
+ const skipped = [];
2910
+ let tablesImported = 0;
2911
+ let rowsImported = 0;
2912
+ for (const [table, snapshotRows] of Object.entries(snapshot.tables)) {
2913
+ if (tableFilter !== undefined && !tableFilter.has(table)) {
2914
+ continue;
2915
+ }
2916
+ const writer = writers.get(table);
2917
+ if (writer === undefined) {
2918
+ skipped.push(table);
2919
+ continue;
2920
+ }
2921
+ const total = snapshotRows.length;
2922
+ let done = 0;
2923
+ for (const row of snapshotRows) {
2924
+ await writer.insert(row, ctx, undefined);
2925
+ done += 1;
2926
+ rowsImported += 1;
2927
+ if (onProgress !== undefined) {
2928
+ onProgress(table, done, total);
2929
+ }
2930
+ }
2931
+ perTable[table] = done;
2932
+ if (done > 0)
2933
+ tablesImported += 1;
2934
+ }
2935
+ return {
2936
+ perTable,
2937
+ rowsImported,
2938
+ skipped,
2939
+ tablesImported
2940
+ };
2941
+ },
2841
2942
  metrics: () => {
2842
2943
  const now = Date.now();
2843
2944
  const byCollection = {};
@@ -3366,5 +3467,5 @@ export {
3366
3467
  createPresenceHub
3367
3468
  };
3368
3469
 
3369
- //# debugId=9662318FD91E917A64756E2164756E21
3470
+ //# debugId=62313B55CB7C7F6064756E2164756E21
3370
3471
  //# sourceMappingURL=index.js.map