@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.
@@ -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
  /**