@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.
- package/dist/devtools.d.ts +12 -0
- package/dist/engine/index.js +38 -1
- package/dist/engine/index.js.map +3 -3
- package/dist/engine/syncEngine.d.ts +69 -0
- package/dist/index.js +152 -4
- package/dist/index.js.map +4 -4
- package/dist/testing.js +38 -1
- package/dist/testing.js.map +3 -3
- package/package.json +1 -1
|
@@ -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 & 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
|
-
|
|
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(
|
|
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=
|
|
3369
|
+
//# debugId=9662318FD91E917A64756E2164756E21
|
|
3222
3370
|
//# sourceMappingURL=index.js.map
|