@pylonsync/sync 0.22.47 → 0.22.58
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/index.d.ts +171 -2
- package/dist/multi-tab-orchestrator.d.ts +4 -0
- package/dist/mutation-queue.d.ts +62 -1
- package/dist/test-harness/env.d.ts +2 -0
- package/dist/test-harness/server.d.ts +104 -0
- package/dist/types.d.ts +10 -0
- package/dist/upload-queue.d.ts +190 -0
- package/dist/upload.d.ts +37 -0
- package/package.json +1 -1
- package/src/first-sync-requests.test.ts +147 -0
- package/src/index.ts +598 -58
- package/src/multi-tab-orchestrator.test.ts +2 -0
- package/src/multi-tab-orchestrator.ts +13 -0
- package/src/mutation-queue.ts +162 -7
- package/src/offline-conflict.test.ts +110 -0
- package/src/outbox.test.ts +333 -0
- package/src/session-before-pull.test.ts +51 -0
- package/src/session-dedupe.test.ts +77 -0
- package/src/snapshot-catch-up.test.ts +101 -0
- package/src/test-harness/env.ts +3 -0
- package/src/test-harness/server.ts +145 -0
- package/src/test-harness/transport.ts +26 -0
- package/src/types.ts +10 -0
- package/src/upload-queue.ts +593 -0
- package/src/upload.ts +130 -0
- package/src/uploads.test.ts +222 -0
|
@@ -221,6 +221,9 @@ export class TestServer {
|
|
|
221
221
|
* answers. Lets a test queue a write while a push is in flight. */
|
|
222
222
|
pushGate: Promise<void> | null = null;
|
|
223
223
|
|
|
224
|
+
/** The file endpoints (`/api/files/init`, PUT, `/api/files/confirm`). */
|
|
225
|
+
readonly files = new TestFileServer(this);
|
|
226
|
+
|
|
224
227
|
/** Entities whose pushed writes are rejected per op, like a write
|
|
225
228
|
* policy denial on the real server (HTTP 200 with a per-op error). */
|
|
226
229
|
private deniedWrites = new Map<string, { code: string; message: string }>();
|
|
@@ -228,6 +231,22 @@ export class TestServer {
|
|
|
228
231
|
this.deniedWrites.set(entity, { code, message });
|
|
229
232
|
}
|
|
230
233
|
|
|
234
|
+
/** Reject a pushed insert/update of `entity` whose fields match
|
|
235
|
+
* `predicate`, like a write policy that checks a field value. */
|
|
236
|
+
private conditionalDenials: Array<{
|
|
237
|
+
entity: string;
|
|
238
|
+
predicate: (data: Row) => boolean;
|
|
239
|
+
error: { code: string; message: string };
|
|
240
|
+
}> = [];
|
|
241
|
+
rejectWritesWhere(
|
|
242
|
+
entity: string,
|
|
243
|
+
predicate: (data: Row) => boolean,
|
|
244
|
+
code = "POLICY_DENIED",
|
|
245
|
+
message = "write denied by policy",
|
|
246
|
+
): void {
|
|
247
|
+
this.conditionalDenials.push({ entity, predicate, error: { code, message } });
|
|
248
|
+
}
|
|
249
|
+
|
|
231
250
|
private nextPushOutcome:
|
|
232
251
|
| { kind: "network" }
|
|
233
252
|
| { kind: "status"; status: number }
|
|
@@ -390,6 +409,11 @@ export class TestServer {
|
|
|
390
409
|
if (denied) {
|
|
391
410
|
return { op_id, status: "error", error: denied };
|
|
392
411
|
}
|
|
412
|
+
if (kind !== "delete") {
|
|
413
|
+
const merged = { ...(this.rows.get(entity)?.get(row_id) ?? {}), ...(change.data ?? {}) } as Row;
|
|
414
|
+
const rule = this.conditionalDenials.find((d) => d.entity === entity && d.predicate(merged));
|
|
415
|
+
if (rule) return { op_id, status: "error", error: rule.error };
|
|
416
|
+
}
|
|
393
417
|
const exists = this.rows.get(entity)?.has(row_id) ?? false;
|
|
394
418
|
if (kind === "insert") {
|
|
395
419
|
this.insert(entity, { ...(change.data ?? {}), id: row_id } as Row);
|
|
@@ -428,6 +452,60 @@ export class TestServer {
|
|
|
428
452
|
return this.visible(entity, all, auth).map((r) => this.project(entity, r));
|
|
429
453
|
}
|
|
430
454
|
|
|
455
|
+
/** When true, /api/sync/pull answers like the real server's snapshot
|
|
456
|
+
* path: a pull from 0 (or a delta pull with `snapshot_ok=1` and a cursor
|
|
457
|
+
* `farBehind` accepts) gets the current visible rows as insert events,
|
|
458
|
+
* paged by `limit`, flagged `snapshot`, with `snapshot_entities` on the
|
|
459
|
+
* last page. Off by default: older tests expect the log replay. */
|
|
460
|
+
snapshotMode = false;
|
|
461
|
+
/** In `snapshotMode`: delta cursors answered with a snapshot. */
|
|
462
|
+
farBehind: (since: number) => boolean = () => false;
|
|
463
|
+
/** In `snapshotMode`: leave `snapshot_entities` off the last page, like a
|
|
464
|
+
* server from before it was added. */
|
|
465
|
+
omitSnapshotEntities = false;
|
|
466
|
+
|
|
467
|
+
/** One snapshot page for `snapshotMode`. `offset` is the continuation. */
|
|
468
|
+
snapshotPage(
|
|
469
|
+
token: string | undefined,
|
|
470
|
+
offset: number,
|
|
471
|
+
limit: number,
|
|
472
|
+
): {
|
|
473
|
+
changes: ChangeEvent[];
|
|
474
|
+
cursor: SyncCursor;
|
|
475
|
+
has_more: boolean;
|
|
476
|
+
snapshot: true;
|
|
477
|
+
snapshot_after: string | null;
|
|
478
|
+
snapshot_entities?: string[];
|
|
479
|
+
} {
|
|
480
|
+
if (offset === 0) this.snapshotPullCount += 1;
|
|
481
|
+
const auth = this.authContextFor(token);
|
|
482
|
+
const entities = [...this.rows.keys()].sort();
|
|
483
|
+
const all: ChangeEvent[] = [];
|
|
484
|
+
for (const entity of entities) {
|
|
485
|
+
const rows = this.visible(entity, Array.from(this.rows.get(entity)?.values() ?? []), auth);
|
|
486
|
+
for (const row of rows) {
|
|
487
|
+
all.push({
|
|
488
|
+
seq: this.nextSeq,
|
|
489
|
+
entity,
|
|
490
|
+
row_id: (row as { id: string }).id,
|
|
491
|
+
kind: "insert",
|
|
492
|
+
data: this.project(entity, row),
|
|
493
|
+
timestamp: "",
|
|
494
|
+
} as ChangeEvent);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const page = all.slice(offset, offset + limit);
|
|
498
|
+
const done = offset + limit >= all.length;
|
|
499
|
+
return {
|
|
500
|
+
changes: page,
|
|
501
|
+
cursor: { last_seq: done ? this.nextSeq : 0 },
|
|
502
|
+
has_more: !done,
|
|
503
|
+
snapshot: true,
|
|
504
|
+
snapshot_after: done ? null : String(offset + limit),
|
|
505
|
+
...(done && !this.omitSnapshotEntities ? { snapshot_entities: entities } : {}),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
431
509
|
/** /api/sync/pull — every visible change since `since`. */
|
|
432
510
|
async pull(token: string | undefined, since: number): Promise<{
|
|
433
511
|
changes: ChangeEvent[];
|
|
@@ -569,3 +647,70 @@ export class TestServer {
|
|
|
569
647
|
}
|
|
570
648
|
}
|
|
571
649
|
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* In-memory stand-in for the runtime's file routes. Records every request
|
|
653
|
+
* so a test can count uploads, and lets a test refuse a file (`rejectInit`)
|
|
654
|
+
* or drop the connection during the next PUT (`dropNextPut`).
|
|
655
|
+
*/
|
|
656
|
+
export class TestFileServer {
|
|
657
|
+
private nextId = 1;
|
|
658
|
+
private slots = new Map<string, { owner: string | null; filename: string; size: number; bytes?: number }>();
|
|
659
|
+
readonly stored = new Map<string, { filename: string; size: number; owner: string | null }>();
|
|
660
|
+
initCount = 0;
|
|
661
|
+
putCount = 0;
|
|
662
|
+
private rejection: { status: number; code: string; message: string } | null = null;
|
|
663
|
+
private dropPut = false;
|
|
664
|
+
|
|
665
|
+
constructor(private readonly server: TestServer) {}
|
|
666
|
+
|
|
667
|
+
/** Refuse every init with this status, like a type or size check. */
|
|
668
|
+
rejectInit(status: number, code: string, message: string): void {
|
|
669
|
+
this.rejection = { status, code, message };
|
|
670
|
+
}
|
|
671
|
+
clearRejection(): void {
|
|
672
|
+
this.rejection = null;
|
|
673
|
+
}
|
|
674
|
+
/** When set, every PUT waits for it before it answers. */
|
|
675
|
+
putGate: Promise<void> | null = null;
|
|
676
|
+
/** The next PUT fails like a dropped connection (no status). */
|
|
677
|
+
dropNextPut(): void {
|
|
678
|
+
this.dropPut = true;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
init(token: string | undefined, body: { filename?: string; size?: number }) {
|
|
682
|
+
this.initCount += 1;
|
|
683
|
+
if (this.rejection) {
|
|
684
|
+
const r = this.rejection;
|
|
685
|
+
return { status: r.status, body: { error: { code: r.code, message: r.message } } };
|
|
686
|
+
}
|
|
687
|
+
const id = `f_${this.nextId++}`;
|
|
688
|
+
const owner = this.server.authContextFor(token).userId;
|
|
689
|
+
this.slots.set(id, { owner, filename: body.filename ?? "upload", size: body.size ?? 0 });
|
|
690
|
+
return { status: 200, body: { uploadUrl: `/api/files/local-put/${id}`, assetId: id } };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
put(id: string, bytes: unknown) {
|
|
694
|
+
this.putCount += 1;
|
|
695
|
+
if (this.dropPut) {
|
|
696
|
+
this.dropPut = false;
|
|
697
|
+
throw new TypeError("simulated dropped connection during upload");
|
|
698
|
+
}
|
|
699
|
+
const slot = this.slots.get(id);
|
|
700
|
+
if (!slot) return { status: 404, body: { error: { code: "NOT_FOUND" } } };
|
|
701
|
+
slot.bytes = bytes instanceof ArrayBuffer ? bytes.byteLength : slot.size;
|
|
702
|
+
return { status: 200, body: null };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
confirm(token: string | undefined, id: string) {
|
|
706
|
+
const slot = this.slots.get(id);
|
|
707
|
+
if (!slot || slot.bytes === undefined) {
|
|
708
|
+
return { status: 400, body: { error: { code: "NOT_UPLOADED" } } };
|
|
709
|
+
}
|
|
710
|
+
if (slot.owner !== this.server.authContextFor(token).userId) {
|
|
711
|
+
return { status: 403, body: { error: { code: "FORBIDDEN" } } };
|
|
712
|
+
}
|
|
713
|
+
this.stored.set(id, { filename: slot.filename, size: slot.bytes, owner: slot.owner });
|
|
714
|
+
return { status: 200, body: { id, url: `/api/files/${id}`, size: slot.bytes } };
|
|
715
|
+
}
|
|
716
|
+
}
|
|
@@ -251,6 +251,15 @@ async function handle(
|
|
|
251
251
|
}
|
|
252
252
|
server.pullUrls.push(url);
|
|
253
253
|
const since = Number(new URL(url, "http://test").searchParams.get("since") ?? "0");
|
|
254
|
+
if (server.snapshotMode) {
|
|
255
|
+
const params = new URL(url, "http://test").searchParams;
|
|
256
|
+
const after = params.get("snapshot_after");
|
|
257
|
+
const limit = Number(params.get("limit") ?? "1000");
|
|
258
|
+
const snapshotOk = params.get("snapshot_ok") === "1";
|
|
259
|
+
if (since === 0 || after !== null || (snapshotOk && server.farBehind(since))) {
|
|
260
|
+
return { status: 200, body: server.snapshotPage(token, Number(after ?? "0"), limit) };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
254
263
|
// Cluster-divergence sim: a delta pull lands on an instance that
|
|
255
264
|
// can't serve our cursor → 410. A snapshot (since=0) still succeeds.
|
|
256
265
|
if (since > 0 && server.force410OnDelta) {
|
|
@@ -374,6 +383,23 @@ async function handle(
|
|
|
374
383
|
};
|
|
375
384
|
}
|
|
376
385
|
|
|
386
|
+
// File uploads: /api/files/init, the PUT to the returned URL, and
|
|
387
|
+
// /api/files/confirm (see TestServer.files).
|
|
388
|
+
if (url.endsWith("/api/files/init") && method === "POST") {
|
|
389
|
+
const body = typeof _init?.body === "string" ? JSON.parse(_init.body) : {};
|
|
390
|
+
return server.files.init(token, body);
|
|
391
|
+
}
|
|
392
|
+
const put = url.match(/\/api\/files\/local-put\/([^/?]+)$/);
|
|
393
|
+
if (put && method === "PUT") {
|
|
394
|
+
const bytes = _init?.body instanceof Blob ? await _init.body.arrayBuffer() : _init?.body;
|
|
395
|
+
if (server.files.putGate) await server.files.putGate;
|
|
396
|
+
return server.files.put(put[1]!, bytes);
|
|
397
|
+
}
|
|
398
|
+
if (url.endsWith("/api/files/confirm") && method === "POST") {
|
|
399
|
+
const body = typeof _init?.body === "string" ? JSON.parse(_init.body) : {};
|
|
400
|
+
return server.files.confirm(token, body.assetId);
|
|
401
|
+
}
|
|
402
|
+
|
|
377
403
|
// Anything else: 404 with a clear error so test failures point
|
|
378
404
|
// at the exact missing route rather than a generic JSON parse.
|
|
379
405
|
return {
|
package/src/types.ts
CHANGED
|
@@ -20,6 +20,16 @@ export interface PullResponse {
|
|
|
20
20
|
changes: ChangeEvent[];
|
|
21
21
|
cursor: SyncCursor;
|
|
22
22
|
has_more: boolean;
|
|
23
|
+
/** Set on every page of a snapshot (current rows, not the log): a pull
|
|
24
|
+
* from 0, or a far-behind catch-up the client accepted with
|
|
25
|
+
* `snapshot_ok=1`. */
|
|
26
|
+
snapshot?: boolean;
|
|
27
|
+
/** Continuation of a snapshot that spans pages. Opaque; send it back
|
|
28
|
+
* verbatim. */
|
|
29
|
+
snapshot_after?: string | null;
|
|
30
|
+
/** On a snapshot's last page: the entities it holds every visible row
|
|
31
|
+
* of. Rows of these entities that it did not send are gone. */
|
|
32
|
+
snapshot_entities?: string[];
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
/**
|