@syncular/server 0.15.44 → 0.15.46

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.
Files changed (93) hide show
  1. package/README.md +135 -5
  2. package/dist/admin.d.ts +12 -6
  3. package/dist/admin.js +11 -1
  4. package/dist/authoritative-query.d.ts +20 -0
  5. package/dist/authoritative-query.js +184 -0
  6. package/dist/blob-store.d.ts +2 -2
  7. package/dist/context.d.ts +11 -2
  8. package/dist/context.js +2 -0
  9. package/dist/d1-storage.d.ts +10 -1
  10. package/dist/d1-storage.js +219 -3
  11. package/dist/errors.d.ts +1 -1
  12. package/dist/errors.js +43 -1
  13. package/dist/events-ring.d.ts +1 -1
  14. package/dist/events-ring.js +1 -1
  15. package/dist/events.d.ts +52 -3
  16. package/dist/handler.js +6 -3
  17. package/dist/index.d.ts +5 -1
  18. package/dist/index.js +5 -1
  19. package/dist/operations-realtime.d.ts +16 -0
  20. package/dist/operations-realtime.js +196 -0
  21. package/dist/operations.d.ts +97 -0
  22. package/dist/operations.js +392 -0
  23. package/dist/pg-executor.d.ts +1 -1
  24. package/dist/pg-executor.js +1 -1
  25. package/dist/postgres-fanout.d.ts +1 -1
  26. package/dist/postgres-storage.d.ts +11 -2
  27. package/dist/postgres-storage.js +223 -3
  28. package/dist/pull.js +1 -1
  29. package/dist/push.d.ts +8 -2
  30. package/dist/push.js +75 -21
  31. package/dist/reactions.d.ts +167 -0
  32. package/dist/reactions.js +442 -0
  33. package/dist/realtime.js +4 -1
  34. package/dist/relational-rows.d.ts +9 -9
  35. package/dist/relational-rows.js +9 -9
  36. package/dist/s3-blob-store.js +1 -1
  37. package/dist/s3-segment-store.js +1 -1
  38. package/dist/schema.d.ts +4 -5
  39. package/dist/schema.js +3 -3
  40. package/dist/seed.js +1 -1
  41. package/dist/segment-store.d.ts +3 -3
  42. package/dist/signed-url.js +2 -2
  43. package/dist/sigv4.d.ts +2 -4
  44. package/dist/sigv4.js +3 -3
  45. package/dist/sqlite-blob-store.d.ts +1 -1
  46. package/dist/sqlite-blob-store.js +1 -1
  47. package/dist/sqlite-dialect.d.ts +3 -4
  48. package/dist/sqlite-dialect.js +21 -1
  49. package/dist/sqlite-image.d.ts +1 -1
  50. package/dist/sqlite-lease-store.d.ts +1 -1
  51. package/dist/sqlite-lease-store.js +1 -1
  52. package/dist/sqlite-segment-store.d.ts +2 -2
  53. package/dist/sqlite-segment-store.js +2 -2
  54. package/dist/sqlite-storage.d.ts +11 -2
  55. package/dist/sqlite-storage.js +219 -4
  56. package/dist/storage.d.ts +112 -3
  57. package/dist/validate.js +1 -0
  58. package/package.json +2 -2
  59. package/src/admin.ts +30 -6
  60. package/src/authoritative-query.ts +218 -0
  61. package/src/blob-store.ts +0 -0
  62. package/src/context.ts +12 -2
  63. package/src/d1-storage.ts +355 -3
  64. package/src/errors.ts +43 -1
  65. package/src/events-ring.ts +1 -1
  66. package/src/events.ts +64 -2
  67. package/src/handler.ts +15 -3
  68. package/src/index.ts +33 -1
  69. package/src/operations-realtime.ts +272 -0
  70. package/src/operations.ts +720 -0
  71. package/src/pg-executor.ts +1 -1
  72. package/src/postgres-fanout.ts +1 -1
  73. package/src/postgres-storage.ts +355 -4
  74. package/src/pull.ts +1 -1
  75. package/src/push.ts +97 -29
  76. package/src/reactions.ts +741 -0
  77. package/src/realtime.ts +7 -1
  78. package/src/relational-rows.ts +9 -9
  79. package/src/s3-blob-store.ts +1 -1
  80. package/src/s3-segment-store.ts +1 -1
  81. package/src/schema.ts +6 -7
  82. package/src/seed.ts +1 -1
  83. package/src/segment-store.ts +3 -3
  84. package/src/signed-url.ts +2 -2
  85. package/src/sigv4.ts +3 -5
  86. package/src/sqlite-blob-store.ts +1 -1
  87. package/src/sqlite-dialect.ts +22 -3
  88. package/src/sqlite-image.ts +1 -1
  89. package/src/sqlite-lease-store.ts +1 -1
  90. package/src/sqlite-segment-store.ts +2 -2
  91. package/src/sqlite-storage.ts +369 -4
  92. package/src/storage.ts +168 -3
  93. package/src/validate.ts +1 -0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The minimal Postgres executor seam (REVISE B2: zero runtime deps).
2
+ * The minimal Postgres executor seam has zero runtime dependencies.
3
3
  *
4
4
  * `PostgresServerStorage` is written entirely against this interface so the
5
5
  * server library never imports a specific driver. Production wires one of:
@@ -1,5 +1,5 @@
1
1
  /**
2
- * LISTEN/NOTIFY multi-instance fanout (TODO §4.1) — the primitive that lets
2
+ * LISTEN/NOTIFY multi-instance fanout lets
3
3
  * N server instances behind a load balancer wake each other's realtime
4
4
  * sessions when a commit lands on any one of them.
5
5
  *
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Postgres server storage the production database path (TODO §4.1).
2
+ * Postgres server storage: the production database path.
3
3
  *
4
4
  * Semantics mirror `SqliteServerStorage` exactly (both run the shared
5
5
  * storage contract in `test/storage-contract.ts`); the difference is that
@@ -36,6 +36,11 @@
36
36
  * does not tolerate). Cross-partition pushes never contend.
37
37
  */
38
38
  import type { PushOperationResult } from '@syncular/core';
39
+ import {
40
+ bindAuthoritativePartition,
41
+ postgresPlaceholders,
42
+ prepareAuthoritativeQuery,
43
+ } from './authoritative-query';
39
44
  import { syncError } from './errors';
40
45
  import {
41
46
  asBytes,
@@ -68,14 +73,24 @@ import {
68
73
  import type { CompiledSchema, CompiledTable } from './schema';
69
74
  import { matchesEffective } from './scopes';
70
75
  import type {
76
+ AuthoritativeQueryRequest,
77
+ AuthoritativeQueryResult,
71
78
  ClientCursorInfo,
72
79
  ClientRecord,
73
80
  ClientSubscription,
74
81
  CommitMetadata,
75
82
  CommitMetadataQuery,
76
83
  CommitWindowQuery,
84
+ DurableJsonValue,
77
85
  IndexRowScanQuery,
78
86
  NewCommit,
87
+ NewReaction,
88
+ PrunedReactionCounts,
89
+ ReactionClaimQuery,
90
+ ReactionFailure,
91
+ ReactionFailureUpdate,
92
+ ReactionListQuery,
93
+ ReactionPruneQuery,
79
94
  RowScanQuery,
80
95
  ScopeActivityQuery,
81
96
  ScopeCommitActivity,
@@ -84,6 +99,7 @@ import type {
84
99
  StoredChange,
85
100
  StoredCommit,
86
101
  StoredPushResult,
102
+ StoredReaction,
87
103
  StoredRow,
88
104
  } from './storage';
89
105
  import {
@@ -151,6 +167,26 @@ CREATE TABLE IF NOT EXISTS sync_push_results(
151
167
  client_commit_id TEXT NOT NULL, result JSONB NOT NULL,
152
168
  PRIMARY KEY(partition, client_id, client_commit_id)
153
169
  );
170
+ CREATE TABLE IF NOT EXISTS sync_reactions(
171
+ partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,
172
+ type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,
173
+ source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,
174
+ source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,
175
+ available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,
176
+ attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,
177
+ lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,
178
+ last_failure JSONB,
179
+ PRIMARY KEY(partition, idempotency_key),
180
+ CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))
181
+ );
182
+ CREATE INDEX IF NOT EXISTS sync_reactions_due
183
+ ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);
184
+ CREATE INDEX IF NOT EXISTS sync_reactions_lease
185
+ ON sync_reactions(partition, status, lease_expires_at_ms);
186
+ CREATE INDEX IF NOT EXISTS sync_reactions_completed
187
+ ON sync_reactions(partition, status, completed_at_ms, idempotency_key);
188
+ CREATE INDEX IF NOT EXISTS sync_reactions_dead_letter
189
+ ON sync_reactions(partition, status, available_at_ms, idempotency_key);
154
190
  CREATE TABLE IF NOT EXISTS sync_clients(
155
191
  partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,
156
192
  cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,
@@ -185,6 +221,52 @@ function fromBase64(text: string): Uint8Array {
185
221
  return new Uint8Array(Buffer.from(text, 'base64'));
186
222
  }
187
223
 
224
+ interface PostgresReactionRecord {
225
+ idempotency_key: string;
226
+ type: string;
227
+ version: unknown;
228
+ payload: unknown;
229
+ source_client_id: string;
230
+ source_client_commit_id: string;
231
+ source_commit_seq: unknown;
232
+ created_at_ms: unknown;
233
+ available_at_ms: unknown;
234
+ status: StoredReaction['status'];
235
+ attempts: unknown;
236
+ max_attempts: unknown;
237
+ lease_owner: string | null;
238
+ lease_expires_at_ms: unknown | null;
239
+ completed_at_ms: unknown | null;
240
+ last_failure: unknown | null;
241
+ }
242
+
243
+ function toStoredReaction(record: PostgresReactionRecord): StoredReaction {
244
+ return {
245
+ idempotencyKey: record.idempotency_key,
246
+ type: record.type,
247
+ version: asNumber(record.version),
248
+ payload: asJson(record.payload) as DurableJsonValue,
249
+ sourceClientId: record.source_client_id,
250
+ sourceClientCommitId: record.source_client_commit_id,
251
+ sourceCommitSeq: asNumber(record.source_commit_seq),
252
+ createdAtMs: asNumber(record.created_at_ms),
253
+ maxAttempts: asNumber(record.max_attempts),
254
+ status: record.status,
255
+ attempts: asNumber(record.attempts),
256
+ availableAtMs: asNumber(record.available_at_ms),
257
+ ...(record.lease_owner !== null ? { leaseOwner: record.lease_owner } : {}),
258
+ ...(record.lease_expires_at_ms !== null
259
+ ? { leaseExpiresAtMs: asNumber(record.lease_expires_at_ms) }
260
+ : {}),
261
+ ...(record.completed_at_ms !== null
262
+ ? { completedAtMs: asNumber(record.completed_at_ms) }
263
+ : {}),
264
+ ...(record.last_failure !== null
265
+ ? { lastFailure: asJson(record.last_failure) as ReactionFailure }
266
+ : {}),
267
+ };
268
+ }
269
+
188
270
  /**
189
271
  * Serialize a push result to a JSON-able object (stored in a JSONB column).
190
272
  * `serverRow` bytes are base64-encoded — JSONB cannot hold raw bytes.
@@ -336,8 +418,8 @@ function toStoredChange(record: ChangeRecord): StoredChange {
336
418
  }
337
419
 
338
420
  /**
339
- * Migration rewrite (DESIGN "optional materialization"): keyset-paged walk
340
- * of a row table inside the migration transaction; when `oldLayout` is
421
+ * Migration rewrite: keyset-paged walk of a row table inside the migration
422
+ * transaction. When `oldLayout` is
341
423
  * given every payload re-encodes under the current columns, and the
342
424
  * projection (when materialized) refreshes from the payload either way.
343
425
  */
@@ -719,6 +801,31 @@ class PostgresTransaction implements StorageTransaction {
719
801
  );
720
802
  }
721
803
 
804
+ async enqueueReactions(reactions: readonly NewReaction[]): Promise<void> {
805
+ this.#assertOpen();
806
+ for (const reaction of reactions) {
807
+ await this.#client.query(
808
+ `INSERT INTO sync_reactions(
809
+ partition, idempotency_key, type, version, payload,
810
+ source_client_id, source_client_commit_id, source_commit_seq,
811
+ created_at_ms, available_at_ms, status, attempts, max_attempts
812
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9,'pending',0,$10)`,
813
+ [
814
+ this.#partition,
815
+ reaction.idempotencyKey,
816
+ reaction.type,
817
+ reaction.version,
818
+ JSON.stringify(reaction.payload),
819
+ reaction.sourceClientId,
820
+ reaction.sourceClientCommitId,
821
+ reaction.sourceCommitSeq,
822
+ reaction.createdAtMs,
823
+ reaction.maxAttempts,
824
+ ],
825
+ );
826
+ }
827
+ }
828
+
722
829
  async commit(): Promise<void> {
723
830
  this.#assertOpen();
724
831
  this.#open = false;
@@ -940,6 +1047,46 @@ export class PostgresServerStorage implements ServerStorage {
940
1047
  return rows[0] === undefined ? 0 : asNumber(rows[0].max_commit_seq);
941
1048
  }
942
1049
 
1050
+ async queryAuthoritative(
1051
+ partition: string,
1052
+ query: AuthoritativeQueryRequest,
1053
+ ): Promise<AuthoritativeQueryResult> {
1054
+ if (this.#tables === undefined) {
1055
+ throw new Error(
1056
+ 'ensureSchema(schema) must run before registered queries',
1057
+ );
1058
+ }
1059
+ const prepared = bindAuthoritativePartition(
1060
+ prepareAuthoritativeQuery(
1061
+ query.sql,
1062
+ query.params,
1063
+ query.tables,
1064
+ this.#tables,
1065
+ ),
1066
+ partition,
1067
+ );
1068
+ return this.#exec.transaction(async (client) => {
1069
+ await client.query(
1070
+ 'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY',
1071
+ );
1072
+ const result = await client.query<Readonly<Record<string, unknown>>>(
1073
+ postgresPlaceholders(prepared.sql),
1074
+ prepared.params,
1075
+ );
1076
+ const cursor = await client.query<{ max_commit_seq: unknown }>(
1077
+ 'SELECT max_commit_seq FROM sync_partitions WHERE partition=$1',
1078
+ [partition],
1079
+ );
1080
+ return {
1081
+ rows: result.rows,
1082
+ maxCommitSeq:
1083
+ cursor.rows[0] === undefined
1084
+ ? 0
1085
+ : asNumber(cursor.rows[0].max_commit_seq),
1086
+ };
1087
+ });
1088
+ }
1089
+
943
1090
  async getHorizonSeq(partition: string): Promise<number> {
944
1091
  const { rows } = await this.#exec.query<{ horizon_seq: unknown }>(
945
1092
  'SELECT horizon_seq FROM sync_partitions WHERE partition=$1',
@@ -1000,6 +1147,210 @@ export class PostgresServerStorage implements ServerStorage {
1000
1147
  return getPushResultOn(this.#exec, partition, clientId, clientCommitId);
1001
1148
  }
1002
1149
 
1150
+ async claimReactions(
1151
+ partition: string,
1152
+ query: ReactionClaimQuery,
1153
+ ): Promise<StoredReaction[]> {
1154
+ if (query.types.length === 0 || query.limit <= 0) return [];
1155
+ const typeParams = query.types.map((_, index) => `$${index + 2}`).join(',');
1156
+ const nowParam = query.types.length + 2;
1157
+ const limitParam = nowParam + 1;
1158
+ const workerParam = limitParam + 1;
1159
+ const expiresParam = workerParam + 1;
1160
+ const { rows } = await this.#exec.query<PostgresReactionRecord>(
1161
+ `WITH due AS (
1162
+ SELECT partition, idempotency_key
1163
+ FROM sync_reactions
1164
+ WHERE partition=$1 AND type IN (${typeParams})
1165
+ AND ((status='pending' AND available_at_ms<=$${nowParam})
1166
+ OR (status='leased' AND lease_expires_at_ms<=$${nowParam}))
1167
+ ORDER BY CASE WHEN status='leased' THEN lease_expires_at_ms
1168
+ ELSE available_at_ms END,
1169
+ created_at_ms, idempotency_key
1170
+ FOR UPDATE SKIP LOCKED
1171
+ LIMIT $${limitParam}
1172
+ )
1173
+ UPDATE sync_reactions AS reaction
1174
+ SET status='leased', attempts=reaction.attempts+1,
1175
+ lease_owner=$${workerParam}, lease_expires_at_ms=$${expiresParam},
1176
+ completed_at_ms=NULL
1177
+ FROM due
1178
+ WHERE reaction.partition=due.partition
1179
+ AND reaction.idempotency_key=due.idempotency_key
1180
+ RETURNING reaction.*`,
1181
+ [
1182
+ partition,
1183
+ ...query.types,
1184
+ query.nowMs,
1185
+ query.limit,
1186
+ query.leaseOwner,
1187
+ Math.min(Number.MAX_SAFE_INTEGER, query.nowMs + query.leaseDurationMs),
1188
+ ],
1189
+ );
1190
+ return rows
1191
+ .map(toStoredReaction)
1192
+ .sort(
1193
+ (a, b) =>
1194
+ a.createdAtMs - b.createdAtMs ||
1195
+ a.idempotencyKey.localeCompare(b.idempotencyKey),
1196
+ );
1197
+ }
1198
+
1199
+ async completeReaction(
1200
+ partition: string,
1201
+ idempotencyKey: string,
1202
+ leaseOwner: string,
1203
+ completedAtMs: number,
1204
+ ): Promise<boolean> {
1205
+ const result = await this.#exec.query(
1206
+ `UPDATE sync_reactions
1207
+ SET status='completed', completed_at_ms=$4,
1208
+ lease_owner=NULL, lease_expires_at_ms=NULL
1209
+ WHERE partition=$1 AND idempotency_key=$2
1210
+ AND status='leased' AND lease_owner=$3`,
1211
+ [partition, idempotencyKey, leaseOwner, completedAtMs],
1212
+ );
1213
+ return result.rowCount === 1;
1214
+ }
1215
+
1216
+ async extendReactionLease(
1217
+ partition: string,
1218
+ idempotencyKey: string,
1219
+ leaseOwner: string,
1220
+ leaseExpiresAtMs: number,
1221
+ ): Promise<boolean> {
1222
+ const result = await this.#exec.query(
1223
+ `UPDATE sync_reactions SET lease_expires_at_ms=$4
1224
+ WHERE partition=$1 AND idempotency_key=$2
1225
+ AND status='leased' AND lease_owner=$3`,
1226
+ [partition, idempotencyKey, leaseOwner, leaseExpiresAtMs],
1227
+ );
1228
+ return result.rowCount === 1;
1229
+ }
1230
+
1231
+ async failReaction(
1232
+ partition: string,
1233
+ idempotencyKey: string,
1234
+ update: ReactionFailureUpdate,
1235
+ ): Promise<boolean> {
1236
+ const result = await this.#exec.query(
1237
+ `UPDATE sync_reactions
1238
+ SET status=$4, available_at_ms=$5, last_failure=$6,
1239
+ lease_owner=NULL, lease_expires_at_ms=NULL
1240
+ WHERE partition=$1 AND idempotency_key=$2
1241
+ AND status='leased' AND lease_owner=$3`,
1242
+ [
1243
+ partition,
1244
+ idempotencyKey,
1245
+ update.leaseOwner,
1246
+ update.retryAtMs === undefined ? 'dead-letter' : 'pending',
1247
+ update.retryAtMs ?? update.failure.atMs,
1248
+ JSON.stringify(update.failure),
1249
+ ],
1250
+ );
1251
+ return result.rowCount === 1;
1252
+ }
1253
+
1254
+ async retryReaction(
1255
+ partition: string,
1256
+ idempotencyKey: string,
1257
+ nowMs: number,
1258
+ ): Promise<boolean> {
1259
+ const result = await this.#exec.query(
1260
+ `UPDATE sync_reactions
1261
+ SET status='pending', attempts=0, available_at_ms=$3,
1262
+ last_failure=NULL, lease_owner=NULL, lease_expires_at_ms=NULL,
1263
+ completed_at_ms=NULL
1264
+ WHERE partition=$1 AND idempotency_key=$2 AND status='dead-letter'`,
1265
+ [partition, idempotencyKey, nowMs],
1266
+ );
1267
+ return result.rowCount === 1;
1268
+ }
1269
+
1270
+ async getReaction(
1271
+ partition: string,
1272
+ idempotencyKey: string,
1273
+ ): Promise<StoredReaction | undefined> {
1274
+ const { rows } = await this.#exec.query<PostgresReactionRecord>(
1275
+ 'SELECT * FROM sync_reactions WHERE partition=$1 AND idempotency_key=$2',
1276
+ [partition, idempotencyKey],
1277
+ );
1278
+ return rows[0] === undefined ? undefined : toStoredReaction(rows[0]);
1279
+ }
1280
+
1281
+ async listReactions(
1282
+ partition: string,
1283
+ query: ReactionListQuery,
1284
+ ): Promise<StoredReaction[]> {
1285
+ const where = ['partition=$1'];
1286
+ const params: unknown[] = [partition];
1287
+ if (query.statuses !== undefined && query.statuses.length > 0) {
1288
+ const placeholders = query.statuses.map(
1289
+ (_, index) => `$${params.length + index + 1}`,
1290
+ );
1291
+ where.push(`status IN (${placeholders.join(',')})`);
1292
+ params.push(...query.statuses);
1293
+ }
1294
+ if (query.types !== undefined && query.types.length > 0) {
1295
+ const placeholders = query.types.map(
1296
+ (_, index) => `$${params.length + index + 1}`,
1297
+ );
1298
+ where.push(`type IN (${placeholders.join(',')})`);
1299
+ params.push(...query.types);
1300
+ }
1301
+ params.push(query.limit);
1302
+ const { rows } = await this.#exec.query<PostgresReactionRecord>(
1303
+ `SELECT * FROM sync_reactions WHERE ${where.join(' AND ')}
1304
+ ORDER BY created_at_ms DESC, idempotency_key DESC LIMIT $${params.length}`,
1305
+ params,
1306
+ );
1307
+ return rows.map(toStoredReaction);
1308
+ }
1309
+
1310
+ async pruneReactions(
1311
+ partition: string,
1312
+ query: ReactionPruneQuery,
1313
+ ): Promise<PrunedReactionCounts> {
1314
+ if (query.limit <= 0) return { completed: 0, deadLetter: 0 };
1315
+ const { rows } = await this.#exec.query<{
1316
+ status: 'completed' | 'dead-letter';
1317
+ }>(
1318
+ `WITH targets AS (
1319
+ SELECT partition, idempotency_key
1320
+ FROM sync_reactions
1321
+ WHERE partition=$1
1322
+ AND ((status='completed' AND completed_at_ms IS NOT NULL
1323
+ AND completed_at_ms<$2)
1324
+ OR (status='dead-letter' AND available_at_ms<$3))
1325
+ ORDER BY CASE WHEN status='completed' THEN completed_at_ms
1326
+ ELSE available_at_ms END,
1327
+ idempotency_key
1328
+ FOR UPDATE SKIP LOCKED
1329
+ LIMIT $4
1330
+ )
1331
+ DELETE FROM sync_reactions AS reaction
1332
+ USING targets
1333
+ WHERE reaction.partition=targets.partition
1334
+ AND reaction.idempotency_key=targets.idempotency_key
1335
+ AND ((reaction.status='completed'
1336
+ AND reaction.completed_at_ms IS NOT NULL
1337
+ AND reaction.completed_at_ms<$2)
1338
+ OR (reaction.status='dead-letter'
1339
+ AND reaction.available_at_ms<$3))
1340
+ RETURNING reaction.status`,
1341
+ [
1342
+ partition,
1343
+ query.completedBeforeMs,
1344
+ query.deadLetterBeforeMs,
1345
+ query.limit,
1346
+ ],
1347
+ );
1348
+ return {
1349
+ completed: rows.filter((row) => row.status === 'completed').length,
1350
+ deadLetter: rows.filter((row) => row.status === 'dead-letter').length,
1351
+ };
1352
+ }
1353
+
1003
1354
  async readCommitWindow(
1004
1355
  partition: string,
1005
1356
  query: CommitWindowQuery,
@@ -1251,7 +1602,7 @@ export class PostgresServerStorage implements ServerStorage {
1251
1602
  return rows.map((r) => r.blob_id);
1252
1603
  }
1253
1604
 
1254
- // -- admin/console read surface (TODO §2.5) --------------------------------
1605
+ // -- admin/console read surface --------------------------------------------
1255
1606
 
1256
1607
  async listClientRecords(partition: string): Promise<ClientRecord[]> {
1257
1608
  const { rows } = await this.#exec.query<{
package/src/pull.ts CHANGED
@@ -253,7 +253,7 @@ async function* sqliteImageSegment(
253
253
  // builder through `sqliteImageBuilder`; when omitted we default to the
254
254
  // in-tree `buildSqliteImage` ONLY on a Bun runtime, reached by a *dynamic*
255
255
  // import so `bun:sqlite` is never a static dependency of the pull path
256
- // (TODO §4.2 neutrality enforced by test/runtime-neutrality.test.ts). On
256
+ // (runtime neutrality is enforced by test/runtime-neutrality.test.ts). On
257
257
  // Workers/edge (no `Bun`) this yields the rows lane for a bit-2 client — a
258
258
  // support floor, not a fallback (§5.3: sqlite is an *accept*, not a demand).
259
259
  const buildImage = await resolveImageBuilder(ctx);