@absolutejs/sync 1.21.0 → 1.23.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.
@@ -297,6 +297,33 @@ export type SyncEngine = {
297
297
  * Added in 1.19.0.
298
298
  */
299
299
  importChangeLog: (snapshot: ChangeLogSnapshot) => number;
300
+ /**
301
+ * Reconstruct the state of registered tables as of a target
302
+ * timestamp by walking the change log forward and folding each op
303
+ * into a per-table view. Useful for forensic incident response
304
+ * ("what did the tenant see at 14:32?") and the "I deleted prod
305
+ * — restore us to 2h ago" recovery story.
306
+ *
307
+ * The reconstruction is exact when the log spans `targetAt` (i.e.
308
+ * the log's oldest entry is at version 1). When the log has been
309
+ * trimmed (`changeLogSize` / `changeLogRetainMs` evicted older
310
+ * entries) AND `targetAt` falls in the gap, the result is
311
+ * best-effort: state walked forward from the OLDEST retained
312
+ * entry, with `truncated: true` so the caller knows.
313
+ *
314
+ * Added in 1.22.0.
315
+ *
316
+ * @example
317
+ * ```ts
318
+ * const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
319
+ * const result = await engine.replayTo({ at: twoHoursAgo, tables: ['orders'] });
320
+ * if (result.truncated) {
321
+ * console.warn('Replay truncated — log retention window too short.');
322
+ * }
323
+ * console.log(result.rows.orders); // orders as of two hours ago
324
+ * ```
325
+ */
326
+ replayTo: (options: ReplayOptions) => Promise<ReplayResult>;
300
327
  /**
301
328
  * Subscribe to the live engine activity stream (changes, mutation outcomes,
302
329
  * subscribe/unsubscribe). Returns an unsubscribe. Powers the devtools feed.
@@ -467,6 +494,48 @@ export type ChangeLogSnapshot = {
467
494
  */
468
495
  exportedAt?: number;
469
496
  };
497
+ /**
498
+ * Options for {@link SyncEngine.replayTo}. Added in 1.22.0.
499
+ */
500
+ export type ReplayOptions = {
501
+ /**
502
+ * Target timestamp (`Date.now()`-shaped). The engine walks the
503
+ * change log forward, applying entries with `at <= targetAt`. The
504
+ * result is the state as-of `targetAt` (or as close as the log
505
+ * permits — see `truncated`).
506
+ */
507
+ at: number;
508
+ /**
509
+ * Optional table filter. When set, only entries whose `table` is
510
+ * in this list are folded into the result; entries for other
511
+ * tables are skipped. Useful for "show me what `tasks` looked
512
+ * like at T" without paying to reconstruct every table.
513
+ */
514
+ tables?: ReadonlyArray<string>;
515
+ };
516
+ /**
517
+ * Returned by {@link SyncEngine.replayTo}. Added in 1.22.0.
518
+ *
519
+ * - `rows` — per-table arrays of rows that existed as of `asOfAt`.
520
+ * Keys are table names; values are the row objects (in last-write
521
+ * order — last write wins for duplicate-keyed inserts).
522
+ * - `asOfVersion` / `asOfAt` — the version + wall-clock of the LAST
523
+ * entry folded into the result. May be earlier than `targetAt` if
524
+ * no entries existed between the last-included entry and the
525
+ * target.
526
+ * - `truncated` — `true` when the log has been trimmed past the
527
+ * target window (`changeLog[0].version > 1 && changeLog[0].at >
528
+ * targetAt`). In this case, `rows` represents the state walked
529
+ * forward from the OLDEST retained entry — NOT the actual state
530
+ * at `targetAt`. The caller should treat the result as
531
+ * "best-effort given retention window" and warn the operator.
532
+ */
533
+ export type ReplayResult = {
534
+ asOfVersion: number;
535
+ asOfAt: number;
536
+ rows: Record<string, ReadonlyArray<unknown>>;
537
+ truncated: boolean;
538
+ };
470
539
  export type SyncEngineOptions = {
471
540
  /**
472
541
  * Stable identifier for this engine instance. Defaults to a per-process
package/dist/index.js CHANGED
@@ -2801,6 +2801,43 @@ var createSyncEngine = (options = {}) => {
2801
2801
  version
2802
2802
  }),
2803
2803
  importChangeLog,
2804
+ replayTo: async ({ at, tables }) => {
2805
+ const filterTables = tables !== undefined ? new Set(tables) : undefined;
2806
+ const state = new Map;
2807
+ let asOfVersion = 0;
2808
+ let asOfAt = 0;
2809
+ const oldest = changeLog[0];
2810
+ const truncated = oldest !== undefined && oldest.version > 1 && oldest.at > at;
2811
+ for (const entry of changeLog) {
2812
+ if (entry.at > at)
2813
+ break;
2814
+ if (filterTables !== undefined && !filterTables.has(entry.table)) {
2815
+ continue;
2816
+ }
2817
+ let tableState = state.get(entry.table);
2818
+ if (tableState === undefined) {
2819
+ tableState = new Map;
2820
+ state.set(entry.table, tableState);
2821
+ }
2822
+ const reader = readers.get(entry.table);
2823
+ const key = reader?.key?.(entry.change.row) ?? entry.change.row?.id;
2824
+ if (key === undefined) {
2825
+ continue;
2826
+ }
2827
+ if (entry.change.op === "delete") {
2828
+ tableState.delete(key);
2829
+ } else {
2830
+ tableState.set(key, entry.change.row);
2831
+ }
2832
+ asOfVersion = entry.version;
2833
+ asOfAt = entry.at;
2834
+ }
2835
+ const rows = {};
2836
+ for (const [table, map] of state) {
2837
+ rows[table] = [...map.values()];
2838
+ }
2839
+ return { asOfAt, asOfVersion, rows, truncated };
2840
+ },
2804
2841
  metrics: () => {
2805
2842
  const now = Date.now();
2806
2843
  const byCollection = {};
@@ -3026,7 +3063,7 @@ var syncCdc = ({
3026
3063
  };
3027
3064
  // src/devtools.ts
3028
3065
  import { Elysia as Elysia3 } from "elysia";
3029
- var dashboardHtml = (streamPath) => `<!doctype html>
3066
+ var dashboardHtml = (streamPath, replayPath) => `<!doctype html>
3030
3067
  <html lang="en"><head><meta charset="utf-8" />
3031
3068
  <meta name="viewport" content="width=device-width, initial-scale=1" />
3032
3069
  <title>@absolutejs/sync devtools</title>
@@ -3049,12 +3086,37 @@ th{color:#7f849c;font-weight:600}
3049
3086
  .t-change{color:#94e2d5}.t-mutation{color:#cba6f7}.err{color:#f38ba8}
3050
3087
  .pill{padding:0 6px;border-radius:10px;background:#1c2230;flex:0 0 auto}
3051
3088
  .empty{color:#6c7086;padding:6px}
3089
+ .replay{grid-column:1/3}
3090
+ .replay-controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:8px}
3091
+ .replay-controls input{background:#0b0e14;border:1px solid #1c2230;color:#cdd6f4;border-radius:4px;padding:4px 6px;font:inherit}
3092
+ .replay-controls input[type="datetime-local"]{min-width:200px}
3093
+ .replay-controls input[type="text"]{min-width:180px}
3094
+ .replay-controls button{background:#89b4fa;border:none;color:#0b0e14;border-radius:4px;padding:5px 12px;font:inherit;font-weight:600;cursor:pointer}
3095
+ .replay-controls button:hover{background:#74c7ec}
3096
+ .replay-controls button:disabled{background:#1c2230;color:#6c7086;cursor:not-allowed}
3097
+ .replay-controls label{color:#7f849c;display:flex;align-items:center;gap:6px}
3098
+ .truncated{background:#311b1b;border:1px solid #f38ba8;color:#f38ba8;padding:6px 10px;border-radius:4px;margin-bottom:8px}
3099
+ .replay-meta{color:#a6adc8;margin-bottom:8px}
3100
+ .replay-meta b{color:#cdd6f4}
3101
+ .replay-table{margin-top:10px}
3102
+ .replay-table h3{margin:0 0 4px;color:#94e2d5;font-size:12px;font-weight:600}
3103
+ .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}
3052
3104
  </style></head>
3053
3105
  <body>
3054
3106
  <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>
3055
3107
  <main>
3056
3108
  <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>
3057
3109
  <section><h2>Mutations \xB7 Schedules</h2><div id="ops"></div></section>
3110
+ <section class="replay"><h2>Point-in-time replay</h2>
3111
+ <div class="replay-controls">
3112
+ <label>at <input type="datetime-local" id="replay-at" step="1" /></label>
3113
+ <label>tables <input type="text" id="replay-tables" placeholder="(all if blank \u2014 csv)" /></label>
3114
+ <label>max rows per table <input type="number" id="replay-max" value="10" min="1" max="500" style="width:64px" /></label>
3115
+ <button id="replay-go">Replay</button>
3116
+ <button id="replay-now" type="button" title="Set datetime to right now">Now</button>
3117
+ </div>
3118
+ <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>
3119
+ </section>
3058
3120
  <section class="log"><h2>Activity</h2><div id="activity"><div class="empty">waiting for changes &amp; mutations\u2026</div></div></section>
3059
3121
  </main>
3060
3122
  <script>
@@ -3083,6 +3145,57 @@ const logActivity=(a)=>{
3083
3145
  box.prepend(row);
3084
3146
  while(box.childNodes.length>200)box.removeChild(box.lastChild);
3085
3147
  };
3148
+ const localToMs=(value)=>{
3149
+ if(!value)return null;
3150
+ const ms=new Date(value).getTime();
3151
+ return Number.isFinite(ms)?ms:null;
3152
+ };
3153
+ const msToLocal=(ms)=>{
3154
+ const d=new Date(ms);
3155
+ const pad=(n)=>String(n).padStart(2,'0');
3156
+ return d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())+'T'+pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds());
3157
+ };
3158
+ const renderReplay=(result, maxRows)=>{
3159
+ const box=$('replay-result');
3160
+ const tables=Object.keys(result.rows).sort();
3161
+ 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>':'';
3162
+ 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>';
3163
+ if(tables.length===0){
3164
+ box.innerHTML=trunc+meta+'<div class="empty">No rows in any table at this timestamp.</div>';
3165
+ return;
3166
+ }
3167
+ const sections=tables.map((t)=>{
3168
+ const rows=result.rows[t];
3169
+ const total=rows.length;
3170
+ const shown=rows.slice(0,maxRows);
3171
+ const more=total>maxRows?'<div class="empty">\u2026 '+(total-maxRows)+' more rows omitted</div>':'';
3172
+ 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>';
3173
+ });
3174
+ box.innerHTML=trunc+meta+sections.join('');
3175
+ };
3176
+ const doReplay=async()=>{
3177
+ const at=localToMs($('replay-at').value);
3178
+ if(at===null){alert('Please pick a valid date+time');return;}
3179
+ const tables=$('replay-tables').value.split(',').map((s)=>s.trim()).filter(Boolean);
3180
+ const maxRows=Math.max(1,Math.min(500,parseInt($('replay-max').value)||10));
3181
+ const btn=$('replay-go');btn.disabled=true;btn.textContent='Replaying\u2026';
3182
+ try{
3183
+ const params=new URLSearchParams();
3184
+ params.set('at',String(at));
3185
+ if(tables.length>0)params.set('tables',tables.join(','));
3186
+ const res=await fetch('${replayPath}?'+params.toString());
3187
+ if(!res.ok){throw new Error('HTTP '+res.status);}
3188
+ const result=await res.json();
3189
+ renderReplay(result,maxRows);
3190
+ }catch(e){
3191
+ $('replay-result').innerHTML='<div class="truncated">Replay failed: '+esc(e.message)+'</div>';
3192
+ }finally{
3193
+ btn.disabled=false;btn.textContent='Replay';
3194
+ }
3195
+ };
3196
+ $('replay-go').addEventListener('click',doReplay);
3197
+ $('replay-now').addEventListener('click',()=>{$('replay-at').value=msToLocal(Date.now());});
3198
+ $('replay-at').value=msToLocal(Date.now()-60*60*1000); // default: 1 hour ago
3086
3199
  const src=new EventSource('${streamPath}');
3087
3200
  src.addEventListener('open',()=>{$('status').textContent='live';$('status').className='';});
3088
3201
  src.addEventListener('error',()=>{$('status').textContent='reconnecting\u2026';$('status').className='empty';});
@@ -3095,9 +3208,44 @@ var syncDevtools = ({
3095
3208
  snapshotMs = 2000
3096
3209
  }) => {
3097
3210
  const streamPath = `${path}/stream`;
3098
- return new Elysia3({ name: "@absolutejs/sync/devtools" }).get(path, () => new Response(dashboardHtml(streamPath), {
3211
+ const replayPath = `${path}/replay`;
3212
+ return new Elysia3({ name: "@absolutejs/sync/devtools" }).get(path, () => new Response(dashboardHtml(streamPath, replayPath), {
3099
3213
  headers: { "content-type": "text/html; charset=utf-8" }
3100
- })).get(streamPath, (context) => {
3214
+ })).get(replayPath, async (context) => {
3215
+ const url = new URL(context.request.url);
3216
+ const atRaw = url.searchParams.get("at");
3217
+ const atMs = atRaw === null ? NaN : Number(atRaw);
3218
+ if (!Number.isFinite(atMs)) {
3219
+ return new Response(JSON.stringify({
3220
+ error: "invalid `at` \u2014 must be a numeric ms timestamp"
3221
+ }), {
3222
+ headers: {
3223
+ "content-type": "application/json; charset=utf-8"
3224
+ },
3225
+ status: 400
3226
+ });
3227
+ }
3228
+ const tablesRaw = url.searchParams.get("tables");
3229
+ const tables = tablesRaw === null || tablesRaw.length === 0 ? undefined : tablesRaw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
3230
+ try {
3231
+ const result = await engine.replayTo(tables === undefined ? { at: atMs } : { at: atMs, tables });
3232
+ return new Response(JSON.stringify(result), {
3233
+ headers: {
3234
+ "cache-control": "no-store",
3235
+ "content-type": "application/json; charset=utf-8"
3236
+ }
3237
+ });
3238
+ } catch (error) {
3239
+ return new Response(JSON.stringify({
3240
+ error: error instanceof Error ? error.message : String(error)
3241
+ }), {
3242
+ headers: {
3243
+ "content-type": "application/json; charset=utf-8"
3244
+ },
3245
+ status: 500
3246
+ });
3247
+ }
3248
+ }).get(streamPath, (context) => {
3101
3249
  const encoder = new TextEncoder;
3102
3250
  const stream = new ReadableStream({
3103
3251
  start(controller) {
@@ -3218,5 +3366,5 @@ export {
3218
3366
  createPresenceHub
3219
3367
  };
3220
3368
 
3221
- //# debugId=06ECBF0C02EA0B8464756E2164756E21
3369
+ //# debugId=9662318FD91E917A64756E2164756E21
3222
3370
  //# sourceMappingURL=index.js.map