kaafil-js 0.2.0 → 0.3.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.
@@ -126,6 +126,27 @@ interface OutboxEntityRef {
126
126
  readonly listName: string;
127
127
  readonly id?: string;
128
128
  readonly arrayKey?: string;
129
+ /**
130
+ * Set ONLY by a create, and set to the same value as `id`: the id this
131
+ * device minted locally because the server had not assigned one yet.
132
+ *
133
+ * A create is the one write whose optimistic row cannot be keyed on the
134
+ * server's identity, and without a local key there is nothing to key it on
135
+ * at all — so before this existed, creates simply had no optimistic row.
136
+ * The manager added a checklist item out of signal and watched it not
137
+ * appear: queued, durable, landing on reconnect, and invisible until it
138
+ * did. Indistinguishable, from where they were standing, from having lost
139
+ * it.
140
+ *
141
+ * Its presence is what lets `applySuccess` clean up after itself. The
142
+ * canonical row comes back under the SERVER's id, so writing it would leave
143
+ * the local row beside it and show the same item twice; the drainer purges
144
+ * this id in the same step, and the swap is one `snapshot.updated`.
145
+ *
146
+ * A temporary id is NEVER sent to the server — it is not in `body`, not in
147
+ * `path`, and no request carries it. It exists only as a snapshot key.
148
+ */
149
+ readonly temporaryId?: string;
129
150
  }
130
151
  /**
131
152
  * WHY an op is waiting out a backoff rung. Recorded because the two causes
@@ -460,8 +481,21 @@ interface SnapshotUpdatedEvent {
460
481
  * from the pull never appears here — absence is not a change, and it is
461
482
  * emphatically not a purge (`06 §4.1`). */
462
483
  readonly lists: readonly string[];
463
- /** The cursor now stored: the pull response's own `meta.serverTime`. */
464
- readonly serverTime: string;
484
+ /**
485
+ * The cursor now stored: the pull response's own `meta.serverTime`.
486
+ *
487
+ * ABSENT for a change that never involved the server — an optimistic create
488
+ * painted while offline, or a queued op the host discarded. Those move rows
489
+ * and must repaint, but they carry no server reading, and putting a device
490
+ * clock in this field would be exactly the lie `../delta/cursor.ts` exists
491
+ * to prevent: a subscriber deriving "now" from it would age every other row
492
+ * against a phone's wall clock.
493
+ *
494
+ * A subscriber that only needs "something changed" ignores this field. One
495
+ * that shows an "as of" time, or derives the current server time, must skip
496
+ * an event without it and keep the last reading it had.
497
+ */
498
+ readonly serverTime?: string;
465
499
  }
466
500
  interface ShareInvalidatedEvent {
467
501
  /** The server-authored code when one arrived; absent when local
@@ -1308,6 +1342,22 @@ interface EnqueueWriteOptions {
1308
1342
  /** The row to paint immediately, rebased later from the server's canonical
1309
1343
  * entity. Omit for a write with nothing to show optimistically. */
1310
1344
  readonly optimistic?: SnapshotRow;
1345
+ /**
1346
+ * Set on a CREATE, where there is no server id yet to key the optimistic
1347
+ * row on. This engine mints a temporary one, writes `optimistic` under it,
1348
+ * and records it on the op so the drainer can swap it for the canonical row
1349
+ * the moment the write lands (`OutboxEntityRef.temporaryId`).
1350
+ *
1351
+ * The caller does not mint the id and cannot see it before this resolves:
1352
+ * identity comes from the engine's own `newId`, which keeps it injectable
1353
+ * and keeps a UI layer out of the business of inventing identifiers. Read
1354
+ * it back off the returned op's `entity.temporaryId` if you need it.
1355
+ *
1356
+ * `entity.listName` is required alongside it — without a list there is
1357
+ * nowhere to paint — and any `entity.id` passed here is ignored, because a
1358
+ * create has no server identity to pass.
1359
+ */
1360
+ readonly creates?: boolean;
1311
1361
  readonly idempotencyKey?: string;
1312
1362
  readonly enqueuedAt?: string;
1313
1363
  }
@@ -1342,6 +1392,21 @@ interface OfflineEngine {
1342
1392
  open(): Promise<void>;
1343
1393
  /** Write-ahead, optimistic apply, kick — in that order. */
1344
1394
  enqueue(options: EnqueueWriteOptions): Promise<OutboxOp>;
1395
+ /**
1396
+ * Abandons a queued op AND undoes its optimistic paint — the host-initiated
1397
+ * "discard my change" that a conflict surface or a sync centre offers.
1398
+ *
1399
+ * Use this rather than `outbox.drop()` directly. `drop()` is a queue
1400
+ * operation and knows nothing about the snapshot, so dropping a CREATE
1401
+ * through it leaves the locally-keyed row on screen forever: an item the
1402
+ * manager chose to discard, which no longer exists anywhere, and which no
1403
+ * pull can ever remove because the server never had it.
1404
+ *
1405
+ * Only a create's own temporary row is purged. An update's optimistic row
1406
+ * belongs to a server row that still exists, and the next pull rebases it —
1407
+ * removing it here would blank a row that is really there.
1408
+ */
1409
+ discard(opId: string): Promise<void>;
1345
1410
  /** The structured op enqueues NOW with `receiptPending: true`; the bytes
1346
1411
  * upload opportunistically and back-fill later (`06 §3.1`). */
1347
1412
  enqueueWithBlob(options: EnqueueWithBlobOptions): Promise<OutboxOp>;
@@ -126,6 +126,27 @@ interface OutboxEntityRef {
126
126
  readonly listName: string;
127
127
  readonly id?: string;
128
128
  readonly arrayKey?: string;
129
+ /**
130
+ * Set ONLY by a create, and set to the same value as `id`: the id this
131
+ * device minted locally because the server had not assigned one yet.
132
+ *
133
+ * A create is the one write whose optimistic row cannot be keyed on the
134
+ * server's identity, and without a local key there is nothing to key it on
135
+ * at all — so before this existed, creates simply had no optimistic row.
136
+ * The manager added a checklist item out of signal and watched it not
137
+ * appear: queued, durable, landing on reconnect, and invisible until it
138
+ * did. Indistinguishable, from where they were standing, from having lost
139
+ * it.
140
+ *
141
+ * Its presence is what lets `applySuccess` clean up after itself. The
142
+ * canonical row comes back under the SERVER's id, so writing it would leave
143
+ * the local row beside it and show the same item twice; the drainer purges
144
+ * this id in the same step, and the swap is one `snapshot.updated`.
145
+ *
146
+ * A temporary id is NEVER sent to the server — it is not in `body`, not in
147
+ * `path`, and no request carries it. It exists only as a snapshot key.
148
+ */
149
+ readonly temporaryId?: string;
129
150
  }
130
151
  /**
131
152
  * WHY an op is waiting out a backoff rung. Recorded because the two causes
@@ -460,8 +481,21 @@ interface SnapshotUpdatedEvent {
460
481
  * from the pull never appears here — absence is not a change, and it is
461
482
  * emphatically not a purge (`06 §4.1`). */
462
483
  readonly lists: readonly string[];
463
- /** The cursor now stored: the pull response's own `meta.serverTime`. */
464
- readonly serverTime: string;
484
+ /**
485
+ * The cursor now stored: the pull response's own `meta.serverTime`.
486
+ *
487
+ * ABSENT for a change that never involved the server — an optimistic create
488
+ * painted while offline, or a queued op the host discarded. Those move rows
489
+ * and must repaint, but they carry no server reading, and putting a device
490
+ * clock in this field would be exactly the lie `../delta/cursor.ts` exists
491
+ * to prevent: a subscriber deriving "now" from it would age every other row
492
+ * against a phone's wall clock.
493
+ *
494
+ * A subscriber that only needs "something changed" ignores this field. One
495
+ * that shows an "as of" time, or derives the current server time, must skip
496
+ * an event without it and keep the last reading it had.
497
+ */
498
+ readonly serverTime?: string;
465
499
  }
466
500
  interface ShareInvalidatedEvent {
467
501
  /** The server-authored code when one arrived; absent when local
@@ -1308,6 +1342,22 @@ interface EnqueueWriteOptions {
1308
1342
  /** The row to paint immediately, rebased later from the server's canonical
1309
1343
  * entity. Omit for a write with nothing to show optimistically. */
1310
1344
  readonly optimistic?: SnapshotRow;
1345
+ /**
1346
+ * Set on a CREATE, where there is no server id yet to key the optimistic
1347
+ * row on. This engine mints a temporary one, writes `optimistic` under it,
1348
+ * and records it on the op so the drainer can swap it for the canonical row
1349
+ * the moment the write lands (`OutboxEntityRef.temporaryId`).
1350
+ *
1351
+ * The caller does not mint the id and cannot see it before this resolves:
1352
+ * identity comes from the engine's own `newId`, which keeps it injectable
1353
+ * and keeps a UI layer out of the business of inventing identifiers. Read
1354
+ * it back off the returned op's `entity.temporaryId` if you need it.
1355
+ *
1356
+ * `entity.listName` is required alongside it — without a list there is
1357
+ * nowhere to paint — and any `entity.id` passed here is ignored, because a
1358
+ * create has no server identity to pass.
1359
+ */
1360
+ readonly creates?: boolean;
1311
1361
  readonly idempotencyKey?: string;
1312
1362
  readonly enqueuedAt?: string;
1313
1363
  }
@@ -1342,6 +1392,21 @@ interface OfflineEngine {
1342
1392
  open(): Promise<void>;
1343
1393
  /** Write-ahead, optimistic apply, kick — in that order. */
1344
1394
  enqueue(options: EnqueueWriteOptions): Promise<OutboxOp>;
1395
+ /**
1396
+ * Abandons a queued op AND undoes its optimistic paint — the host-initiated
1397
+ * "discard my change" that a conflict surface or a sync centre offers.
1398
+ *
1399
+ * Use this rather than `outbox.drop()` directly. `drop()` is a queue
1400
+ * operation and knows nothing about the snapshot, so dropping a CREATE
1401
+ * through it leaves the locally-keyed row on screen forever: an item the
1402
+ * manager chose to discard, which no longer exists anywhere, and which no
1403
+ * pull can ever remove because the server never had it.
1404
+ *
1405
+ * Only a create's own temporary row is purged. An update's optimistic row
1406
+ * belongs to a server row that still exists, and the next pull rebases it —
1407
+ * removing it here would blank a row that is really there.
1408
+ */
1409
+ discard(opId: string): Promise<void>;
1345
1410
  /** The structured op enqueues NOW with `receiptPending: true`; the bytes
1346
1411
  * upload opportunistically and back-fill later (`06 §3.1`). */
1347
1412
  enqueueWithBlob(options: EnqueueWithBlobOptions): Promise<OutboxOp>;
@@ -2583,6 +2583,11 @@ function createDrainer(options) {
2583
2583
  row: outcome.entity,
2584
2584
  ...outcome.serverTime !== void 0 ? { syncedAt: outcome.serverTime } : {}
2585
2585
  });
2586
+ const temporaryId = entity.temporaryId;
2587
+ if (temporaryId !== void 0 && temporaryId !== outcome.entity.id) {
2588
+ await snapshot.purgeEntity(op.tripRef, entity.listName, temporaryId);
2589
+ changed = true;
2590
+ }
2586
2591
  }
2587
2592
  if (changed && outcome.serverTime !== void 0) {
2588
2593
  events.emit("snapshot.updated", {
@@ -2682,6 +2687,16 @@ function createDrainer(options) {
2682
2687
  switch (result.action) {
2683
2688
  case "drop":
2684
2689
  await outbox.drop(op.id);
2690
+ if (op.entity?.temporaryId !== void 0) {
2691
+ await snapshot.purgeEntity(op.tripRef, op.entity.listName, op.entity.temporaryId);
2692
+ if (outcome.serverTime !== void 0) {
2693
+ events.emit("snapshot.updated", {
2694
+ tripRef: op.tripRef,
2695
+ lists: [op.entity.listName],
2696
+ serverTime: outcome.serverTime
2697
+ });
2698
+ }
2699
+ }
2685
2700
  return "terminal";
2686
2701
  case "reissue":
2687
2702
  await outbox.reissue(op.id, {
@@ -3515,6 +3530,7 @@ function createOfflineEngine(options) {
3515
3530
  });
3516
3531
  const snapshot = createSnapshotStore({ storage: options.storage, keyPrefix });
3517
3532
  const reads = createReadCacheStore({ storage: options.storage, keyPrefix });
3533
+ const mintEntityId = () => `tmp_${options.newId?.() ?? newIdempotencyKey()}`;
3518
3534
  const outbox = createOutbox({
3519
3535
  storage: options.storage,
3520
3536
  keyPrefix,
@@ -3563,6 +3579,13 @@ function createOfflineEngine(options) {
3563
3579
  let unbind;
3564
3580
  async function enqueue(input) {
3565
3581
  share?.assertWritable();
3582
+ let entity = input.entity;
3583
+ let temporaryId;
3584
+ if (input.creates === true && input.entity !== void 0) {
3585
+ const { temporaryId: _ignored, ...ref } = input.entity;
3586
+ temporaryId = mintEntityId();
3587
+ entity = { ...ref, id: temporaryId, temporaryId };
3588
+ }
3566
3589
  const op = await outbox.enqueue({
3567
3590
  tripRef: input.tripRef,
3568
3591
  method: input.method,
@@ -3570,16 +3593,25 @@ function createOfflineEngine(options) {
3570
3593
  operationId: input.operationId,
3571
3594
  ...input.body !== void 0 ? { body: input.body } : {},
3572
3595
  ...input.ifMatch !== void 0 ? { ifMatch: input.ifMatch } : {},
3573
- ...input.entity !== void 0 ? { entity: input.entity } : {},
3596
+ ...entity !== void 0 ? { entity } : {},
3574
3597
  ...input.idempotencyKey !== void 0 ? { idempotencyKey: input.idempotencyKey } : {},
3575
3598
  ...input.enqueuedAt !== void 0 ? { enqueuedAt: input.enqueuedAt } : {}
3576
3599
  });
3577
- if (input.optimistic !== void 0 && input.entity !== void 0) {
3600
+ if (input.optimistic !== void 0 && entity !== void 0) {
3578
3601
  await snapshot.upsert({
3579
3602
  tripRef: input.tripRef,
3580
- listName: input.entity.listName,
3581
- row: input.optimistic
3603
+ listName: entity.listName,
3604
+ // A create's row is keyed on the minted id rather than on whatever
3605
+ // `id` the caller happened to put in the optimistic row — those two
3606
+ // disagreeing is what would leave an orphan the drainer cannot find.
3607
+ row: temporaryId === void 0 ? input.optimistic : { ...input.optimistic, id: temporaryId }
3582
3608
  });
3609
+ if (temporaryId !== void 0) {
3610
+ events.emit("snapshot.updated", {
3611
+ tripRef: input.tripRef,
3612
+ lists: [entity.listName]
3613
+ });
3614
+ }
3583
3615
  }
3584
3616
  drainer.kick();
3585
3617
  return op;
@@ -3604,6 +3636,22 @@ function createOfflineEngine(options) {
3604
3636
  drainer.kick();
3605
3637
  },
3606
3638
  enqueue,
3639
+ async discard(opId) {
3640
+ const op = outbox.get(opId);
3641
+ if (op === void 0) {
3642
+ return;
3643
+ }
3644
+ await outbox.drop(opId);
3645
+ const temporaryId = op.entity?.temporaryId;
3646
+ if (op.entity === void 0 || temporaryId === void 0) {
3647
+ return;
3648
+ }
3649
+ await snapshot.purgeEntity(op.tripRef, op.entity.listName, temporaryId);
3650
+ events.emit("snapshot.updated", {
3651
+ tripRef: op.tripRef,
3652
+ lists: [op.entity.listName]
3653
+ });
3654
+ },
3607
3655
  async enqueueWithBlob(input) {
3608
3656
  const { blob, ...rest } = input;
3609
3657
  share?.assertWritable();