@lunora/do 1.0.0-alpha.6 → 1.0.0-alpha.7

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.mts CHANGED
@@ -3666,6 +3666,44 @@ declare class SessionDO {
3666
3666
  private handleRevoke;
3667
3667
  }
3668
3668
  /**
3669
+ * Diff the previously-sent list snapshot (`previousJson`, the memo's
3670
+ * `lastJson`) against the new query result and produce per-row
3671
+ * {@link MutationDelta}s the client can merge in place via `applyDelta` —
3672
+ * Convex-parity live-pagination deltas (server half of gap #20).
3673
+ *
3674
+ * Returns `undefined` (caller falls back to a full `{type:"data"}` snapshot)
3675
+ * unless ALL of these hold:
3676
+ *
3677
+ * 1. `previousJson` parses to an array (there IS a previous list to diff against).
3678
+ * 2. `nextResult` is also an array.
3679
+ * 3. Every row in both arrays is a plain object carrying a string `_id`.
3680
+ * 4. Order preservation — rows present in BOTH arrays appear in the same relative order.
3681
+ * 5. Chattiness cap — the number of deltas does not exceed the new array length (a near-total change is cheaper as a snapshot).
3682
+ *
3683
+ * Diff is keyed by `_id`: rows only in prev → `delete`; rows only in next →
3684
+ * `insert`; rows in both whose JSON differs → `update`. Insert/update carry the
3685
+ * full new `row`; delete omits it (matching the wire contract `@lunora/client`
3686
+ * parses). Deltas are ordered deletes-then-inserts/updates so the client never
3687
+ * sees a transient over-length page.
3688
+ *
3689
+ * Per-row serialization is done exactly **once** per refresh (finding #6). Each
3690
+ * row is stringified a single time into a fingerprint reused for both the
3691
+ * `prev !== next` change-detection compare and — when the caller passes the
3692
+ * optional `frames` sink — the pre-serialized delta frame body. The returned
3693
+ * `MutationDelta[]` shape is unchanged; `frames`, when supplied, receives the
3694
+ * exact `JSON.stringify(delta)` string for each returned delta, in the same
3695
+ * order, so the caller can splice it straight into the `{type:"delta"}` frame
3696
+ * without serializing the delta (and the row inside it) a second time.
3697
+ * @returns the per-row deltas to send, or `undefined` when any precondition fails and a full snapshot should be sent instead
3698
+ */
3699
+ declare const subscriptionListDeltas: (previousJson: string, nextResult: unknown, table: string, frames?: string[]) => MutationDelta[] | undefined;
3700
+ /**
3701
+ * Send one WebSocket frame, reporting whether it left the socket. A throw from
3702
+ * `ws.send` (socket closed mid-flush, outbound buffer gone) is the only
3703
+ * delivery-failure signal the runtime exposes; callers use the boolean to decide
3704
+ * whether to advance a subscription's delivered-diff baseline.
3705
+ */
3706
+ /**
3669
3707
  * Optional programmatic log sink, resolved from `createShardDO({ observability })`.
3670
3708
  * Structurally a subset of `@lunora/runtime`'s `ObservabilitySink`, so a user can
3671
3709
  * pass the SAME sink object to `createWorker` (which drives `onRpc`) and
@@ -3918,38 +3956,6 @@ interface RunShardRankPageArgs {
3918
3956
  take?: number;
3919
3957
  }
3920
3958
  /**
3921
- * Diff the previously-sent list snapshot (`previousJson`, the memo's
3922
- * `lastJson`) against the new query result and produce per-row
3923
- * {@link MutationDelta}s the client can merge in place via `applyDelta` —
3924
- * Convex-parity live-pagination deltas (server half of gap #20).
3925
- *
3926
- * Returns `undefined` (caller falls back to a full `{type:"data"}` snapshot)
3927
- * unless ALL of these hold:
3928
- *
3929
- * 1. `previousJson` parses to an array (there IS a previous list to diff against).
3930
- * 2. `nextResult` is also an array.
3931
- * 3. Every row in both arrays is a plain object carrying a string `_id`.
3932
- * 4. Order preservation — rows present in BOTH arrays appear in the same relative order.
3933
- * 5. Chattiness cap — the number of deltas does not exceed the new array length (a near-total change is cheaper as a snapshot).
3934
- *
3935
- * Diff is keyed by `_id`: rows only in prev → `delete`; rows only in next →
3936
- * `insert`; rows in both whose JSON differs → `update`. Insert/update carry the
3937
- * full new `row`; delete omits it (matching the wire contract `@lunora/client`
3938
- * parses). Deltas are ordered deletes-then-inserts/updates so the client never
3939
- * sees a transient over-length page.
3940
- *
3941
- * Per-row serialization is done exactly **once** per refresh (finding #6). Each
3942
- * row is stringified a single time into a fingerprint reused for both the
3943
- * `prev !== next` change-detection compare and — when the caller passes the
3944
- * optional `frames` sink — the pre-serialized delta frame body. The returned
3945
- * `MutationDelta[]` shape is unchanged; `frames`, when supplied, receives the
3946
- * exact `JSON.stringify(delta)` string for each returned delta, in the same
3947
- * order, so the caller can splice it straight into the `{type:"delta"}` frame
3948
- * without serializing the delta (and the row inside it) a second time.
3949
- * @returns the per-row deltas to send, or `undefined` when any precondition fails and a full snapshot should be sent instead
3950
- */
3951
- declare const subscriptionListDeltas: (previousJson: string, nextResult: unknown, table: string, frames?: string[]) => MutationDelta[] | undefined;
3952
- /**
3953
3959
  * Threshold at which a `__root__` DO triggers the size warning. 1 GiB —
3954
3960
  * exactly 10% of the 10 GiB per-DO SQLite ceiling, leaving plenty of runway
3955
3961
  * to plan a `.shardBy()` migration before the wall hits.
@@ -4131,6 +4137,18 @@ declare abstract class ShardDO {
4131
4137
  */
4132
4138
  private pendingChangedTables;
4133
4139
  /**
4140
+ * Coalesced set of tables awaiting a subscription-refresh pass, merged
4141
+ * across every {@link ShardDO.flushChangedTables} call that lands while a
4142
+ * pass is already draining. The single drain loop
4143
+ * ({@link ShardDO.drainSubscriptionRefreshes}) owns this set; a burst of N
4144
+ * writes to the same table therefore collapses into one (or two) refresh
4145
+ * passes instead of N, so each affected subscription's handler re-runs once
4146
+ * per burst rather than once per write. `undefined` when nothing is pending.
4147
+ */
4148
+ private pendingRefreshTables;
4149
+ /** True while {@link ShardDO.drainSubscriptionRefreshes} is running; the single-waiter gate that coalesces concurrent flushes. */
4150
+ private refreshInFlight;
4151
+ /**
4134
4152
  * Last pushed result per `(socket, subId)`, keyed by socket. Lets
4135
4153
  * `refreshSubscriptions` skip re-running queries whose tables were
4136
4154
  * untouched and suppress pushes when the re-run result is unchanged. Held
@@ -5303,6 +5321,17 @@ declare abstract class ShardDO {
5303
5321
  */
5304
5322
  private flushChangedTables;
5305
5323
  /**
5324
+ * Drain {@link ShardDO.pendingRefreshTables} one coalesced batch at a time
5325
+ * until it is empty, then release the {@link ShardDO.refreshInFlight} gate.
5326
+ * Tables merged by a `flushChangedTables` that lands mid-pass are picked up
5327
+ * by the next loop iteration, so every committed write is observed by a
5328
+ * refresh that runs after it — bursts simply share a pass. The post-write
5329
+ * high-watermark and live-socket set are re-read inside each
5330
+ * `refreshSubscriptions` call, so a later batch always reflects the latest
5331
+ * committed state.
5332
+ */
5333
+ private drainSubscriptionRefreshes;
5334
+ /**
5306
5335
  * For every live subscription whose query reads one of `changed`, re-run
5307
5336
  * the query and push a fresh `{ type: "data" }` frame when the result
5308
5337
  * differs from the last one sent. Subscriptions with no `functionPath`
package/dist/index.d.ts CHANGED
@@ -3666,6 +3666,44 @@ declare class SessionDO {
3666
3666
  private handleRevoke;
3667
3667
  }
3668
3668
  /**
3669
+ * Diff the previously-sent list snapshot (`previousJson`, the memo's
3670
+ * `lastJson`) against the new query result and produce per-row
3671
+ * {@link MutationDelta}s the client can merge in place via `applyDelta` —
3672
+ * Convex-parity live-pagination deltas (server half of gap #20).
3673
+ *
3674
+ * Returns `undefined` (caller falls back to a full `{type:"data"}` snapshot)
3675
+ * unless ALL of these hold:
3676
+ *
3677
+ * 1. `previousJson` parses to an array (there IS a previous list to diff against).
3678
+ * 2. `nextResult` is also an array.
3679
+ * 3. Every row in both arrays is a plain object carrying a string `_id`.
3680
+ * 4. Order preservation — rows present in BOTH arrays appear in the same relative order.
3681
+ * 5. Chattiness cap — the number of deltas does not exceed the new array length (a near-total change is cheaper as a snapshot).
3682
+ *
3683
+ * Diff is keyed by `_id`: rows only in prev → `delete`; rows only in next →
3684
+ * `insert`; rows in both whose JSON differs → `update`. Insert/update carry the
3685
+ * full new `row`; delete omits it (matching the wire contract `@lunora/client`
3686
+ * parses). Deltas are ordered deletes-then-inserts/updates so the client never
3687
+ * sees a transient over-length page.
3688
+ *
3689
+ * Per-row serialization is done exactly **once** per refresh (finding #6). Each
3690
+ * row is stringified a single time into a fingerprint reused for both the
3691
+ * `prev !== next` change-detection compare and — when the caller passes the
3692
+ * optional `frames` sink — the pre-serialized delta frame body. The returned
3693
+ * `MutationDelta[]` shape is unchanged; `frames`, when supplied, receives the
3694
+ * exact `JSON.stringify(delta)` string for each returned delta, in the same
3695
+ * order, so the caller can splice it straight into the `{type:"delta"}` frame
3696
+ * without serializing the delta (and the row inside it) a second time.
3697
+ * @returns the per-row deltas to send, or `undefined` when any precondition fails and a full snapshot should be sent instead
3698
+ */
3699
+ declare const subscriptionListDeltas: (previousJson: string, nextResult: unknown, table: string, frames?: string[]) => MutationDelta[] | undefined;
3700
+ /**
3701
+ * Send one WebSocket frame, reporting whether it left the socket. A throw from
3702
+ * `ws.send` (socket closed mid-flush, outbound buffer gone) is the only
3703
+ * delivery-failure signal the runtime exposes; callers use the boolean to decide
3704
+ * whether to advance a subscription's delivered-diff baseline.
3705
+ */
3706
+ /**
3669
3707
  * Optional programmatic log sink, resolved from `createShardDO({ observability })`.
3670
3708
  * Structurally a subset of `@lunora/runtime`'s `ObservabilitySink`, so a user can
3671
3709
  * pass the SAME sink object to `createWorker` (which drives `onRpc`) and
@@ -3918,38 +3956,6 @@ interface RunShardRankPageArgs {
3918
3956
  take?: number;
3919
3957
  }
3920
3958
  /**
3921
- * Diff the previously-sent list snapshot (`previousJson`, the memo's
3922
- * `lastJson`) against the new query result and produce per-row
3923
- * {@link MutationDelta}s the client can merge in place via `applyDelta` —
3924
- * Convex-parity live-pagination deltas (server half of gap #20).
3925
- *
3926
- * Returns `undefined` (caller falls back to a full `{type:"data"}` snapshot)
3927
- * unless ALL of these hold:
3928
- *
3929
- * 1. `previousJson` parses to an array (there IS a previous list to diff against).
3930
- * 2. `nextResult` is also an array.
3931
- * 3. Every row in both arrays is a plain object carrying a string `_id`.
3932
- * 4. Order preservation — rows present in BOTH arrays appear in the same relative order.
3933
- * 5. Chattiness cap — the number of deltas does not exceed the new array length (a near-total change is cheaper as a snapshot).
3934
- *
3935
- * Diff is keyed by `_id`: rows only in prev → `delete`; rows only in next →
3936
- * `insert`; rows in both whose JSON differs → `update`. Insert/update carry the
3937
- * full new `row`; delete omits it (matching the wire contract `@lunora/client`
3938
- * parses). Deltas are ordered deletes-then-inserts/updates so the client never
3939
- * sees a transient over-length page.
3940
- *
3941
- * Per-row serialization is done exactly **once** per refresh (finding #6). Each
3942
- * row is stringified a single time into a fingerprint reused for both the
3943
- * `prev !== next` change-detection compare and — when the caller passes the
3944
- * optional `frames` sink — the pre-serialized delta frame body. The returned
3945
- * `MutationDelta[]` shape is unchanged; `frames`, when supplied, receives the
3946
- * exact `JSON.stringify(delta)` string for each returned delta, in the same
3947
- * order, so the caller can splice it straight into the `{type:"delta"}` frame
3948
- * without serializing the delta (and the row inside it) a second time.
3949
- * @returns the per-row deltas to send, or `undefined` when any precondition fails and a full snapshot should be sent instead
3950
- */
3951
- declare const subscriptionListDeltas: (previousJson: string, nextResult: unknown, table: string, frames?: string[]) => MutationDelta[] | undefined;
3952
- /**
3953
3959
  * Threshold at which a `__root__` DO triggers the size warning. 1 GiB —
3954
3960
  * exactly 10% of the 10 GiB per-DO SQLite ceiling, leaving plenty of runway
3955
3961
  * to plan a `.shardBy()` migration before the wall hits.
@@ -4131,6 +4137,18 @@ declare abstract class ShardDO {
4131
4137
  */
4132
4138
  private pendingChangedTables;
4133
4139
  /**
4140
+ * Coalesced set of tables awaiting a subscription-refresh pass, merged
4141
+ * across every {@link ShardDO.flushChangedTables} call that lands while a
4142
+ * pass is already draining. The single drain loop
4143
+ * ({@link ShardDO.drainSubscriptionRefreshes}) owns this set; a burst of N
4144
+ * writes to the same table therefore collapses into one (or two) refresh
4145
+ * passes instead of N, so each affected subscription's handler re-runs once
4146
+ * per burst rather than once per write. `undefined` when nothing is pending.
4147
+ */
4148
+ private pendingRefreshTables;
4149
+ /** True while {@link ShardDO.drainSubscriptionRefreshes} is running; the single-waiter gate that coalesces concurrent flushes. */
4150
+ private refreshInFlight;
4151
+ /**
4134
4152
  * Last pushed result per `(socket, subId)`, keyed by socket. Lets
4135
4153
  * `refreshSubscriptions` skip re-running queries whose tables were
4136
4154
  * untouched and suppress pushes when the re-run result is unchanged. Held
@@ -5303,6 +5321,17 @@ declare abstract class ShardDO {
5303
5321
  */
5304
5322
  private flushChangedTables;
5305
5323
  /**
5324
+ * Drain {@link ShardDO.pendingRefreshTables} one coalesced batch at a time
5325
+ * until it is empty, then release the {@link ShardDO.refreshInFlight} gate.
5326
+ * Tables merged by a `flushChangedTables` that lands mid-pass are picked up
5327
+ * by the next loop iteration, so every committed write is observed by a
5328
+ * refresh that runs after it — bursts simply share a pass. The post-write
5329
+ * high-watermark and live-socket set are re-read inside each
5330
+ * `refreshSubscriptions` call, so a later batch always reflects the latest
5331
+ * committed state.
5332
+ */
5333
+ private drainSubscriptionRefreshes;
5334
+ /**
5306
5335
  * For every live subscription whose query reads one of `changed`, re-run
5307
5336
  * the query and push a fresh `{ type: "data" }` frame when the result
5308
5337
  * differs from the last one sent. Subscriptions with no `functionPath`
package/dist/index.mjs CHANGED
@@ -23,7 +23,7 @@ export { RLS_UNWRAP_SYMBOL, RlsRequiredError, guardWriter } from './packem_share
23
23
  export { buildFtsMatch, ftsTableName, scoreDocument, stringifySearchText, tokenizeSearch } from './packem_shared/buildFtsMatch-BLEMawrp.mjs';
24
24
  export { M as MIN_ADMIN_TOKEN_LENGTH, a as MIN_AUTH_SECRET_LENGTH, b as buildSecurityAudit } from './packem_shared/security-audit-CucgBice.mjs';
25
25
  export { SESSION_DO_TTL_DEFAULT, SessionDO } from './packem_shared/SESSION_DO_TTL_DEFAULT-ilPZsVwu.mjs';
26
- export { ROOT_DO_SIZE_WARN_BYTES, ROOT_SHARD_NAME, ShardDO, subscriptionListDeltas } from './packem_shared/ROOT_DO_SIZE_WARN_BYTES-DfwcxW8F.mjs';
26
+ export { ROOT_DO_SIZE_WARN_BYTES, ROOT_SHARD_NAME, ShardDO } from './packem_shared/ROOT_DO_SIZE_WARN_BYTES-2DxWrdla.mjs';
27
27
  export { SHARD_REGISTRY_DO_NAME, ShardRegistryDO } from './packem_shared/SHARD_REGISTRY_DO_NAME-BsAbi5Mn.mjs';
28
28
  export { MAX_SQL_ROWS, assertReadonly, runReadonlySql } from './packem_shared/MAX_SQL_ROWS-dDcFE1YZ.mjs';
29
29
  export { createSystemReader } from './packem_shared/createSystemReader-8CzSZP9V.mjs';
@@ -33,3 +33,4 @@ export { compileWhereSql } from './packem_shared/compileWhereSql-CXrhFA3G.mjs';
33
33
  export { CDC_LOG_TABLE, applyCdcChanges, readCdcChanges, trimCdcChanges } from './packem_shared/CDC_LOG_TABLE-Ctdmxmrv.mjs';
34
34
  export { backfillAggregateIndexes, backfillRankIndexes } from './packem_shared/backfillAggregateIndexes-BbVPvciS.mjs';
35
35
  export { runShardMigrations } from './packem_shared/runShardMigrations-PabobOjF.mjs';
36
+ export { subscriptionListDeltas } from './packem_shared/subscriptionListDeltas-ce84gpwL.mjs';
@@ -12,6 +12,7 @@ import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-ByVzgH3d.mjs';
12
12
  import { redact, standardRules } from '@visulima/redact';
13
13
  import { i as isDevEnvironment, c as buildSettings, b as buildSecurityAudit } from './security-audit-CucgBice.mjs';
14
14
  import { runReadonlySql } from './MAX_SQL_ROWS-dDcFE1YZ.mjs';
15
+ import { trySendFrame, subscriptionListDeltas, sendDeltaFrames } from './subscriptionListDeltas-ce84gpwL.mjs';
15
16
  import { ConflictError } from './ConflictError-C0STs6bU.mjs';
16
17
  import { CDC_LOG_TABLE, readCdcChanges, readCdcCursor, readCdcEpoch, minCdcSeq, bumpCdcEpoch } from './CDC_LOG_TABLE-Ctdmxmrv.mjs';
17
18
  import { r as readIdempotent, w as writeIdempotent, t as trimIdempotent } from './ctx-db-idempotency-DkC9rP91.mjs';
@@ -413,97 +414,7 @@ const findDanglingReferences = (sql, storageColumns, liveKeys) => {
413
414
 
414
415
  const WS_KEEPALIVE_PING = "lunora-ping";
415
416
  const WS_KEEPALIVE_PONG = "lunora-pong";
416
- const ROW_ID_FIELD = "_id";
417
- const DELTA_FALLBACK_TABLE = "__lunora__";
418
- const readRowId = (row) => {
419
- if (typeof row !== "object" || row === null || Array.isArray(row)) {
420
- return void 0;
421
- }
422
- const id = row[ROW_ID_FIELD];
423
- return typeof id === "string" ? id : void 0;
424
- };
425
- const indexRowsById = (rows) => {
426
- const byId = /* @__PURE__ */ new Map();
427
- const order = [];
428
- for (const row of rows) {
429
- const id = readRowId(row);
430
- if (id === void 0 || byId.has(id)) {
431
- return void 0;
432
- }
433
- byId.set(id, row);
434
- order.push(id);
435
- }
436
- return { byId, order };
437
- };
438
- const survivorsKeepOrder = (previous, next) => {
439
- const survivingPrevious = previous.order.filter((id) => next.byId.has(id));
440
- const survivingNext = next.order.filter((id) => previous.byId.has(id));
441
- if (survivingPrevious.length !== survivingNext.length) {
442
- return false;
443
- }
444
- return survivingPrevious.every((id, index) => survivingNext[index] === id);
445
- };
446
- const collectDeleteDeltas = (previous, next, deltaTable, tableJson) => {
447
- const out = [];
448
- for (const id of previous.order) {
449
- if (!next.byId.has(id)) {
450
- out.push({
451
- delta: { key: id, op: "delete", table: deltaTable },
452
- frame: `{"key":${JSON.stringify(id)},"op":"delete","table":${tableJson}}`
453
- });
454
- }
455
- }
456
- return out;
457
- };
458
- const collectUpsertDeltas = (previous, next, deltaTable, tableJson) => {
459
- const out = [];
460
- for (const id of next.order) {
461
- const nextRow = next.byId.get(id);
462
- const previousRow = previous.byId.get(id);
463
- const nextFingerprint = JSON.stringify(nextRow);
464
- const previousFingerprint = previousRow === void 0 ? void 0 : JSON.stringify(previousRow);
465
- if (previousFingerprint === nextFingerprint) {
466
- continue;
467
- }
468
- const op = previousFingerprint === void 0 ? "insert" : "update";
469
- out.push({
470
- delta: { key: id, op, row: nextRow, table: deltaTable },
471
- frame: `{"key":${JSON.stringify(id)},"op":"${op}","row":${nextFingerprint},"table":${tableJson}}`
472
- });
473
- }
474
- return out;
475
- };
476
- const subscriptionListDeltas = (previousJson, nextResult, table, frames) => {
477
- let parsed;
478
- try {
479
- parsed = JSON.parse(previousJson);
480
- } catch {
481
- return void 0;
482
- }
483
- if (!Array.isArray(parsed) || !Array.isArray(nextResult)) {
484
- return void 0;
485
- }
486
- const previous = indexRowsById(parsed);
487
- const next = indexRowsById(nextResult);
488
- if (previous === void 0 || next === void 0) {
489
- return void 0;
490
- }
491
- if (!survivorsKeepOrder(previous, next)) {
492
- return void 0;
493
- }
494
- const deltaTable = table === "" ? DELTA_FALLBACK_TABLE : table;
495
- const tableJson = JSON.stringify(deltaTable);
496
- const framed = [...collectDeleteDeltas(previous, next, deltaTable, tableJson), ...collectUpsertDeltas(previous, next, deltaTable, tableJson)];
497
- if (framed.length > next.order.length) {
498
- return void 0;
499
- }
500
- if (frames !== void 0) {
501
- for (const { frame } of framed) {
502
- frames.push(frame);
503
- }
504
- }
505
- return framed.map(({ delta }) => delta);
506
- };
417
+ const UNDELIVERED_BASELINE = "<undelivered>";
507
418
  const ROOT_DO_SIZE_WARN_BYTES = 1073741824;
508
419
  const CDC_RESUME_SCAN_LIMIT = 1e4;
509
420
  const IDEMPOTENCY_RETENTION_MS = 864e5;
@@ -1133,6 +1044,18 @@ class ShardDO {
1133
1044
  * the common read-only path allocates nothing.
1134
1045
  */
1135
1046
  pendingChangedTables = void 0;
1047
+ /**
1048
+ * Coalesced set of tables awaiting a subscription-refresh pass, merged
1049
+ * across every {@link ShardDO.flushChangedTables} call that lands while a
1050
+ * pass is already draining. The single drain loop
1051
+ * ({@link ShardDO.drainSubscriptionRefreshes}) owns this set; a burst of N
1052
+ * writes to the same table therefore collapses into one (or two) refresh
1053
+ * passes instead of N, so each affected subscription's handler re-runs once
1054
+ * per burst rather than once per write. `undefined` when nothing is pending.
1055
+ */
1056
+ pendingRefreshTables = void 0;
1057
+ /** True while {@link ShardDO.drainSubscriptionRefreshes} is running; the single-waiter gate that coalesces concurrent flushes. */
1058
+ refreshInFlight = false;
1136
1059
  /**
1137
1060
  * Last pushed result per `(socket, subId)`, keyed by socket. Lets
1138
1061
  * `refreshSubscriptions` skip re-running queries whose tables were
@@ -2249,10 +2172,7 @@ class ShardDO {
2249
2172
  if (!this.matchesSubscription(query, delta)) {
2250
2173
  continue;
2251
2174
  }
2252
- try {
2253
- ws.send(`{"type":"delta","id":${JSON.stringify(subId)},"delta":${deltaJson}}`);
2254
- } catch {
2255
- }
2175
+ trySendFrame(ws, `{"type":"delta","id":${JSON.stringify(subId)},"delta":${deltaJson}}`);
2256
2176
  }
2257
2177
  }
2258
2178
  }
@@ -3567,11 +3487,47 @@ class ShardDO {
3567
3487
  if (!changed || changed.size === 0) {
3568
3488
  return;
3569
3489
  }
3490
+ if (this.pendingRefreshTables) {
3491
+ for (const table of changed) {
3492
+ this.pendingRefreshTables.add(table);
3493
+ }
3494
+ } else {
3495
+ this.pendingRefreshTables = changed;
3496
+ }
3497
+ if (this.refreshInFlight) {
3498
+ return;
3499
+ }
3570
3500
  if (typeof this.state.waitUntil === "function") {
3571
- this.state.waitUntil(this.refreshSubscriptions(changed));
3501
+ this.state.waitUntil(this.drainSubscriptionRefreshes());
3502
+ return;
3503
+ }
3504
+ await this.drainSubscriptionRefreshes();
3505
+ }
3506
+ /**
3507
+ * Drain {@link ShardDO.pendingRefreshTables} one coalesced batch at a time
3508
+ * until it is empty, then release the {@link ShardDO.refreshInFlight} gate.
3509
+ * Tables merged by a `flushChangedTables` that lands mid-pass are picked up
3510
+ * by the next loop iteration, so every committed write is observed by a
3511
+ * refresh that runs after it — bursts simply share a pass. The post-write
3512
+ * high-watermark and live-socket set are re-read inside each
3513
+ * `refreshSubscriptions` call, so a later batch always reflects the latest
3514
+ * committed state.
3515
+ */
3516
+ async drainSubscriptionRefreshes() {
3517
+ if (this.refreshInFlight) {
3572
3518
  return;
3573
3519
  }
3574
- await this.refreshSubscriptions(changed);
3520
+ this.refreshInFlight = true;
3521
+ try {
3522
+ let batch = this.pendingRefreshTables;
3523
+ while (batch && batch.size > 0) {
3524
+ this.pendingRefreshTables = void 0;
3525
+ await this.refreshSubscriptions(batch);
3526
+ batch = this.pendingRefreshTables;
3527
+ }
3528
+ } finally {
3529
+ this.refreshInFlight = false;
3530
+ }
3575
3531
  }
3576
3532
  /**
3577
3533
  * For every live subscription whose query reads one of `changed`, re-run
@@ -3763,21 +3719,8 @@ class ShardDO {
3763
3719
  }
3764
3720
  const deltaFrames = [];
3765
3721
  const deltas = existing === void 0 ? void 0 : subscriptionListDeltas(existing.lastJson, outcome.result, outcome.tables.values().next().value ?? "", deltaFrames);
3766
- memos.set(subId, { lastJson: json, tables: outcome.tables });
3767
- if (deltas !== void 0) {
3768
- const idJson = JSON.stringify(subId);
3769
- for (const deltaBody of deltaFrames) {
3770
- try {
3771
- ws.send(`{"type":"delta","id":${idJson},"delta":${deltaBody}${cursorSuffix}}`);
3772
- } catch {
3773
- }
3774
- }
3775
- return;
3776
- }
3777
- try {
3778
- ws.send(`{"type":"data","id":${JSON.stringify(subId)},"data":${json}${cursorSuffix}}`);
3779
- } catch {
3780
- }
3722
+ const delivered = deltas === void 0 ? trySendFrame(ws, `{"type":"data","id":${JSON.stringify(subId)},"data":${json}${cursorSuffix}}`) : sendDeltaFrames(ws, subId, deltaFrames, cursorSuffix);
3723
+ memos.set(subId, { lastJson: delivered ? json : existing?.lastJson ?? UNDELIVERED_BASELINE, tables: outcome.tables });
3781
3724
  }
3782
3725
  /**
3783
3726
  * Gate the upgrade request against two complementary controls:
@@ -3999,10 +3942,7 @@ class ShardDO {
3999
3942
  if (ws === sender || this.readAttachment(ws).whispers?.includes(topic) !== true) {
4000
3943
  continue;
4001
3944
  }
4002
- try {
4003
- ws.send(frame);
4004
- } catch {
4005
- }
3945
+ trySendFrame(ws, frame);
4006
3946
  }
4007
3947
  }
4008
3948
  // eslint-disable-next-line class-methods-use-this -- cohesive DO instance method grouped with the hibernation/attachment helpers; reads only the socket
@@ -0,0 +1,111 @@
1
+ const ROW_ID_FIELD = "_id";
2
+ const DELTA_FALLBACK_TABLE = "__lunora__";
3
+ const readRowId = (row) => {
4
+ if (typeof row !== "object" || row === null || Array.isArray(row)) {
5
+ return void 0;
6
+ }
7
+ const id = row[ROW_ID_FIELD];
8
+ return typeof id === "string" ? id : void 0;
9
+ };
10
+ const indexRowsById = (rows) => {
11
+ const byId = /* @__PURE__ */ new Map();
12
+ const order = [];
13
+ for (const row of rows) {
14
+ const id = readRowId(row);
15
+ if (id === void 0 || byId.has(id)) {
16
+ return void 0;
17
+ }
18
+ byId.set(id, row);
19
+ order.push(id);
20
+ }
21
+ return { byId, order };
22
+ };
23
+ const survivorsKeepOrder = (previous, next) => {
24
+ const survivingPrevious = previous.order.filter((id) => next.byId.has(id));
25
+ const survivingNext = next.order.filter((id) => previous.byId.has(id));
26
+ if (survivingPrevious.length !== survivingNext.length) {
27
+ return false;
28
+ }
29
+ return survivingPrevious.every((id, index) => survivingNext[index] === id);
30
+ };
31
+ const collectDeleteDeltas = (previous, next, deltaTable, tableJson) => {
32
+ const out = [];
33
+ for (const id of previous.order) {
34
+ if (!next.byId.has(id)) {
35
+ out.push({
36
+ delta: { key: id, op: "delete", table: deltaTable },
37
+ frame: `{"key":${JSON.stringify(id)},"op":"delete","table":${tableJson}}`
38
+ });
39
+ }
40
+ }
41
+ return out;
42
+ };
43
+ const collectUpsertDeltas = (previous, next, deltaTable, tableJson) => {
44
+ const out = [];
45
+ for (const id of next.order) {
46
+ const nextRow = next.byId.get(id);
47
+ const previousRow = previous.byId.get(id);
48
+ const nextFingerprint = JSON.stringify(nextRow);
49
+ const previousFingerprint = previousRow === void 0 ? void 0 : JSON.stringify(previousRow);
50
+ if (previousFingerprint === nextFingerprint) {
51
+ continue;
52
+ }
53
+ const op = previousFingerprint === void 0 ? "insert" : "update";
54
+ out.push({
55
+ delta: { key: id, op, row: nextRow, table: deltaTable },
56
+ frame: `{"key":${JSON.stringify(id)},"op":"${op}","row":${nextFingerprint},"table":${tableJson}}`
57
+ });
58
+ }
59
+ return out;
60
+ };
61
+ const subscriptionListDeltas = (previousJson, nextResult, table, frames) => {
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(previousJson);
65
+ } catch {
66
+ return void 0;
67
+ }
68
+ if (!Array.isArray(parsed) || !Array.isArray(nextResult)) {
69
+ return void 0;
70
+ }
71
+ const previous = indexRowsById(parsed);
72
+ const next = indexRowsById(nextResult);
73
+ if (previous === void 0 || next === void 0) {
74
+ return void 0;
75
+ }
76
+ if (!survivorsKeepOrder(previous, next)) {
77
+ return void 0;
78
+ }
79
+ const deltaTable = table === "" ? DELTA_FALLBACK_TABLE : table;
80
+ const tableJson = JSON.stringify(deltaTable);
81
+ const framed = [...collectDeleteDeltas(previous, next, deltaTable, tableJson), ...collectUpsertDeltas(previous, next, deltaTable, tableJson)];
82
+ if (framed.length > next.order.length) {
83
+ return void 0;
84
+ }
85
+ if (frames !== void 0) {
86
+ for (const { frame } of framed) {
87
+ frames.push(frame);
88
+ }
89
+ }
90
+ return framed.map(({ delta }) => delta);
91
+ };
92
+ const trySendFrame = (ws, frame) => {
93
+ try {
94
+ ws.send(frame);
95
+ return true;
96
+ } catch {
97
+ return false;
98
+ }
99
+ };
100
+ const sendDeltaFrames = (ws, subId, deltaFrames, cursorSuffix) => {
101
+ const idJson = JSON.stringify(subId);
102
+ let delivered = true;
103
+ for (const deltaBody of deltaFrames) {
104
+ if (!trySendFrame(ws, `{"type":"delta","id":${idJson},"delta":${deltaBody}${cursorSuffix}}`)) {
105
+ delivered = false;
106
+ }
107
+ }
108
+ return delivered;
109
+ };
110
+
111
+ export { sendDeltaFrames, subscriptionListDeltas, trySendFrame };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/do",
3
- "version": "1.0.0-alpha.6",
3
+ "version": "1.0.0-alpha.7",
4
4
  "description": "Lunora Durable Objects: ShardDO (SQLite, OCC, hibernated WebSocket subscriptions) and SessionDO",
5
5
  "keywords": [
6
6
  "cloudflare",