@absolutejs/sync 1.22.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 = {};
@@ -3063,7 +3142,7 @@ var syncCdc = ({
3063
3142
  };
3064
3143
  // src/devtools.ts
3065
3144
  import { Elysia as Elysia3 } from "elysia";
3066
- var dashboardHtml = (streamPath) => `<!doctype html>
3145
+ var dashboardHtml = (streamPath, replayPath) => `<!doctype html>
3067
3146
  <html lang="en"><head><meta charset="utf-8" />
3068
3147
  <meta name="viewport" content="width=device-width, initial-scale=1" />
3069
3148
  <title>@absolutejs/sync devtools</title>
@@ -3086,12 +3165,37 @@ th{color:#7f849c;font-weight:600}
3086
3165
  .t-change{color:#94e2d5}.t-mutation{color:#cba6f7}.err{color:#f38ba8}
3087
3166
  .pill{padding:0 6px;border-radius:10px;background:#1c2230;flex:0 0 auto}
3088
3167
  .empty{color:#6c7086;padding:6px}
3168
+ .replay{grid-column:1/3}
3169
+ .replay-controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:8px}
3170
+ .replay-controls input{background:#0b0e14;border:1px solid #1c2230;color:#cdd6f4;border-radius:4px;padding:4px 6px;font:inherit}
3171
+ .replay-controls input[type="datetime-local"]{min-width:200px}
3172
+ .replay-controls input[type="text"]{min-width:180px}
3173
+ .replay-controls button{background:#89b4fa;border:none;color:#0b0e14;border-radius:4px;padding:5px 12px;font:inherit;font-weight:600;cursor:pointer}
3174
+ .replay-controls button:hover{background:#74c7ec}
3175
+ .replay-controls button:disabled{background:#1c2230;color:#6c7086;cursor:not-allowed}
3176
+ .replay-controls label{color:#7f849c;display:flex;align-items:center;gap:6px}
3177
+ .truncated{background:#311b1b;border:1px solid #f38ba8;color:#f38ba8;padding:6px 10px;border-radius:4px;margin-bottom:8px}
3178
+ .replay-meta{color:#a6adc8;margin-bottom:8px}
3179
+ .replay-meta b{color:#cdd6f4}
3180
+ .replay-table{margin-top:10px}
3181
+ .replay-table h3{margin:0 0 4px;color:#94e2d5;font-size:12px;font-weight:600}
3182
+ .replay-rows pre{margin:0;font-size:11px;color:#a6adc8;background:#0b0e14;border:1px solid #1c2230;border-radius:4px;padding:6px;max-height:200px;overflow:auto;white-space:pre-wrap;word-break:break-word}
3089
3183
  </style></head>
3090
3184
  <body>
3091
3185
  <header><b>@absolutejs/sync</b> devtools <span id="status" class="empty">connecting\u2026</span><span class="ver">v<span id="version">0</span></span></header>
3092
3186
  <main>
3093
3187
  <section><h2>Collections</h2><table><thead><tr><th>name</th><th>kind</th><th>tables</th><th class="subs">subs</th></tr></thead><tbody id="collections"></tbody></table></section>
3094
3188
  <section><h2>Mutations \xB7 Schedules</h2><div id="ops"></div></section>
3189
+ <section class="replay"><h2>Point-in-time replay</h2>
3190
+ <div class="replay-controls">
3191
+ <label>at <input type="datetime-local" id="replay-at" step="1" /></label>
3192
+ <label>tables <input type="text" id="replay-tables" placeholder="(all if blank \u2014 csv)" /></label>
3193
+ <label>max rows per table <input type="number" id="replay-max" value="10" min="1" max="500" style="width:64px" /></label>
3194
+ <button id="replay-go">Replay</button>
3195
+ <button id="replay-now" type="button" title="Set datetime to right now">Now</button>
3196
+ </div>
3197
+ <div id="replay-result"><div class="empty">Pick a date+time, optionally filter tables, then click Replay to reconstruct state at that point in the log window.</div></div>
3198
+ </section>
3095
3199
  <section class="log"><h2>Activity</h2><div id="activity"><div class="empty">waiting for changes &amp; mutations\u2026</div></div></section>
3096
3200
  </main>
3097
3201
  <script>
@@ -3120,6 +3224,57 @@ const logActivity=(a)=>{
3120
3224
  box.prepend(row);
3121
3225
  while(box.childNodes.length>200)box.removeChild(box.lastChild);
3122
3226
  };
3227
+ const localToMs=(value)=>{
3228
+ if(!value)return null;
3229
+ const ms=new Date(value).getTime();
3230
+ return Number.isFinite(ms)?ms:null;
3231
+ };
3232
+ const msToLocal=(ms)=>{
3233
+ const d=new Date(ms);
3234
+ const pad=(n)=>String(n).padStart(2,'0');
3235
+ return d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())+'T'+pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds());
3236
+ };
3237
+ const renderReplay=(result, maxRows)=>{
3238
+ const box=$('replay-result');
3239
+ const tables=Object.keys(result.rows).sort();
3240
+ const trunc=result.truncated?'<div class="truncated">\u26A0 Replay truncated \u2014 log retention window doesn\\'t cover this timestamp. Result is best-effort, walked forward from the oldest retained entry.</div>':'';
3241
+ const meta='<div class="replay-meta">As of <b>version '+result.asOfVersion+'</b> at <b>'+(result.asOfAt?new Date(result.asOfAt).toLocaleString():'(no entries folded)')+'</b></div>';
3242
+ if(tables.length===0){
3243
+ box.innerHTML=trunc+meta+'<div class="empty">No rows in any table at this timestamp.</div>';
3244
+ return;
3245
+ }
3246
+ const sections=tables.map((t)=>{
3247
+ const rows=result.rows[t];
3248
+ const total=rows.length;
3249
+ const shown=rows.slice(0,maxRows);
3250
+ const more=total>maxRows?'<div class="empty">\u2026 '+(total-maxRows)+' more rows omitted</div>':'';
3251
+ return '<div class="replay-table"><h3>'+esc(t)+' <span class="empty">('+total+' row'+(total===1?'':'s')+')</span></h3><div class="replay-rows"><pre>'+esc(JSON.stringify(shown,null,2))+'</pre>'+more+'</div></div>';
3252
+ });
3253
+ box.innerHTML=trunc+meta+sections.join('');
3254
+ };
3255
+ const doReplay=async()=>{
3256
+ const at=localToMs($('replay-at').value);
3257
+ if(at===null){alert('Please pick a valid date+time');return;}
3258
+ const tables=$('replay-tables').value.split(',').map((s)=>s.trim()).filter(Boolean);
3259
+ const maxRows=Math.max(1,Math.min(500,parseInt($('replay-max').value)||10));
3260
+ const btn=$('replay-go');btn.disabled=true;btn.textContent='Replaying\u2026';
3261
+ try{
3262
+ const params=new URLSearchParams();
3263
+ params.set('at',String(at));
3264
+ if(tables.length>0)params.set('tables',tables.join(','));
3265
+ const res=await fetch('${replayPath}?'+params.toString());
3266
+ if(!res.ok){throw new Error('HTTP '+res.status);}
3267
+ const result=await res.json();
3268
+ renderReplay(result,maxRows);
3269
+ }catch(e){
3270
+ $('replay-result').innerHTML='<div class="truncated">Replay failed: '+esc(e.message)+'</div>';
3271
+ }finally{
3272
+ btn.disabled=false;btn.textContent='Replay';
3273
+ }
3274
+ };
3275
+ $('replay-go').addEventListener('click',doReplay);
3276
+ $('replay-now').addEventListener('click',()=>{$('replay-at').value=msToLocal(Date.now());});
3277
+ $('replay-at').value=msToLocal(Date.now()-60*60*1000); // default: 1 hour ago
3123
3278
  const src=new EventSource('${streamPath}');
3124
3279
  src.addEventListener('open',()=>{$('status').textContent='live';$('status').className='';});
3125
3280
  src.addEventListener('error',()=>{$('status').textContent='reconnecting\u2026';$('status').className='empty';});
@@ -3132,9 +3287,44 @@ var syncDevtools = ({
3132
3287
  snapshotMs = 2000
3133
3288
  }) => {
3134
3289
  const streamPath = `${path}/stream`;
3135
- return new Elysia3({ name: "@absolutejs/sync/devtools" }).get(path, () => new Response(dashboardHtml(streamPath), {
3290
+ const replayPath = `${path}/replay`;
3291
+ return new Elysia3({ name: "@absolutejs/sync/devtools" }).get(path, () => new Response(dashboardHtml(streamPath, replayPath), {
3136
3292
  headers: { "content-type": "text/html; charset=utf-8" }
3137
- })).get(streamPath, (context) => {
3293
+ })).get(replayPath, async (context) => {
3294
+ const url = new URL(context.request.url);
3295
+ const atRaw = url.searchParams.get("at");
3296
+ const atMs = atRaw === null ? NaN : Number(atRaw);
3297
+ if (!Number.isFinite(atMs)) {
3298
+ return new Response(JSON.stringify({
3299
+ error: "invalid `at` \u2014 must be a numeric ms timestamp"
3300
+ }), {
3301
+ headers: {
3302
+ "content-type": "application/json; charset=utf-8"
3303
+ },
3304
+ status: 400
3305
+ });
3306
+ }
3307
+ const tablesRaw = url.searchParams.get("tables");
3308
+ const tables = tablesRaw === null || tablesRaw.length === 0 ? undefined : tablesRaw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
3309
+ try {
3310
+ const result = await engine.replayTo(tables === undefined ? { at: atMs } : { at: atMs, tables });
3311
+ return new Response(JSON.stringify(result), {
3312
+ headers: {
3313
+ "cache-control": "no-store",
3314
+ "content-type": "application/json; charset=utf-8"
3315
+ }
3316
+ });
3317
+ } catch (error) {
3318
+ return new Response(JSON.stringify({
3319
+ error: error instanceof Error ? error.message : String(error)
3320
+ }), {
3321
+ headers: {
3322
+ "content-type": "application/json; charset=utf-8"
3323
+ },
3324
+ status: 500
3325
+ });
3326
+ }
3327
+ }).get(streamPath, (context) => {
3138
3328
  const encoder = new TextEncoder;
3139
3329
  const stream = new ReadableStream({
3140
3330
  start(controller) {
@@ -3255,5 +3445,5 @@ export {
3255
3445
  createPresenceHub
3256
3446
  };
3257
3447
 
3258
- //# debugId=6DBA546C7A7B1DC564756E2164756E21
3448
+ //# debugId=07C83ADFBDD0094F64756E2164756E21
3259
3449
  //# sourceMappingURL=index.js.map