@syncular/server 0.15.45 → 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 (51) hide show
  1. package/README.md +134 -4
  2. package/dist/admin.d.ts +10 -4
  3. package/dist/admin.js +10 -0
  4. package/dist/authoritative-query.d.ts +20 -0
  5. package/dist/authoritative-query.js +184 -0
  6. package/dist/context.d.ts +9 -0
  7. package/dist/context.js +2 -0
  8. package/dist/d1-storage.d.ts +10 -1
  9. package/dist/d1-storage.js +216 -0
  10. package/dist/errors.d.ts +1 -1
  11. package/dist/errors.js +43 -1
  12. package/dist/events.d.ts +52 -3
  13. package/dist/handler.js +4 -1
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.js +4 -0
  16. package/dist/operations-realtime.d.ts +16 -0
  17. package/dist/operations-realtime.js +196 -0
  18. package/dist/operations.d.ts +97 -0
  19. package/dist/operations.js +392 -0
  20. package/dist/postgres-storage.d.ts +11 -2
  21. package/dist/postgres-storage.js +220 -0
  22. package/dist/push.d.ts +8 -2
  23. package/dist/push.js +75 -21
  24. package/dist/reactions.d.ts +167 -0
  25. package/dist/reactions.js +442 -0
  26. package/dist/realtime.js +4 -1
  27. package/dist/sqlite-dialect.d.ts +1 -1
  28. package/dist/sqlite-dialect.js +20 -0
  29. package/dist/sqlite-storage.d.ts +10 -1
  30. package/dist/sqlite-storage.js +215 -0
  31. package/dist/storage.d.ts +109 -0
  32. package/dist/validate.js +1 -0
  33. package/package.json +2 -2
  34. package/src/admin.ts +27 -3
  35. package/src/authoritative-query.ts +218 -0
  36. package/src/context.ts +10 -0
  37. package/src/d1-storage.ts +352 -0
  38. package/src/errors.ts +43 -1
  39. package/src/events.ts +64 -2
  40. package/src/handler.ts +13 -1
  41. package/src/index.ts +32 -0
  42. package/src/operations-realtime.ts +272 -0
  43. package/src/operations.ts +720 -0
  44. package/src/postgres-storage.ts +351 -0
  45. package/src/push.ts +97 -29
  46. package/src/reactions.ts +741 -0
  47. package/src/realtime.ts +7 -1
  48. package/src/sqlite-dialect.ts +20 -0
  49. package/src/sqlite-storage.ts +365 -0
  50. package/src/storage.ts +165 -0
  51. package/src/validate.ts +1 -0
@@ -0,0 +1,218 @@
1
+ import type { CompiledTable } from './schema';
2
+ import { quoteIdent, SYNC_PARTITION_COLUMN } from './relational-rows';
3
+ import type { AuthoritativeQueryValue } from './storage';
4
+
5
+ export interface PreparedAuthoritativeQuery {
6
+ readonly sql: string;
7
+ readonly params: readonly (AuthoritativeQueryValue | typeof PARTITION_BIND)[];
8
+ }
9
+
10
+ export interface BoundAuthoritativeQuery {
11
+ readonly sql: string;
12
+ readonly params: readonly AuthoritativeQueryValue[];
13
+ }
14
+
15
+ const PARTITION_BIND = Symbol('syncular.authoritative_partition');
16
+
17
+ const RESERVED_ALIAS = new Set([
18
+ 'on',
19
+ 'where',
20
+ 'group',
21
+ 'order',
22
+ 'inner',
23
+ 'left',
24
+ 'right',
25
+ 'full',
26
+ 'outer',
27
+ 'natural',
28
+ 'join',
29
+ 'cross',
30
+ 'using',
31
+ 'limit',
32
+ 'having',
33
+ ]);
34
+ const IDENT = '[A-Za-z_][A-Za-z0-9_]*';
35
+ const TABLE_REF_RE = new RegExp(
36
+ `\\b(FROM|(?:NATURAL\\s+)?(?:(?:LEFT|RIGHT|FULL)(?:\\s+OUTER)?|INNER|CROSS)?\\s*JOIN)\\s+((?:\\(\\s*)*)(${IDENT})(?:\\s+(?:AS\\s+)?((?!(?:${[...RESERVED_ALIAS].join('|')})\\b)${IDENT}))?`,
37
+ 'gi',
38
+ );
39
+
40
+ function protectedSqlEnd(sql: string, index: number): number | undefined {
41
+ const char = sql[index];
42
+ const next = sql[index + 1];
43
+ if (char === "'" || char === '"' || char === '`') {
44
+ let end = index + 1;
45
+ while (end < sql.length) {
46
+ if (sql[end] === char && sql[end + 1] === char) end += 2;
47
+ else if (sql[end] === char) return end + 1;
48
+ else end += 1;
49
+ }
50
+ return sql.length;
51
+ }
52
+ if (char === '[') {
53
+ const end = sql.indexOf(']', index + 1);
54
+ return end < 0 ? sql.length : end + 1;
55
+ }
56
+ if (char === '-' && next === '-') {
57
+ const end = sql.indexOf('\n', index);
58
+ return end < 0 ? sql.length : end;
59
+ }
60
+ if (char === '/' && next === '*') {
61
+ const end = sql.indexOf('*/', index + 2);
62
+ return end < 0 ? sql.length : end + 2;
63
+ }
64
+ return undefined;
65
+ }
66
+
67
+ function maskedSql(sql: string): string {
68
+ let out = '';
69
+ let index = 0;
70
+ while (index < sql.length) {
71
+ const end = protectedSqlEnd(sql, index);
72
+ if (end === undefined) out += sql[index];
73
+ else out += sql.slice(index, end).replace(/[^\n]/g, ' ');
74
+ index = end ?? index + 1;
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * Turn generated local SQL into a partition-local authoritative statement.
81
+ * Only relations declared by the generated descriptor are rewritten. Values
82
+ * remain parameters; request data is never interpolated into SQL.
83
+ */
84
+ export function prepareAuthoritativeQuery(
85
+ sql: string,
86
+ params: readonly AuthoritativeQueryValue[],
87
+ declaredTables: readonly string[],
88
+ tables: ReadonlyMap<string, CompiledTable>,
89
+ ): PreparedAuthoritativeQuery {
90
+ if (maskedSql(sql).includes(';'))
91
+ throw new Error('registered query must be one SELECT');
92
+ const declared = new Set(declaredTables);
93
+ const masked = maskedSql(sql);
94
+ const replacements: Array<{
95
+ readonly start: number;
96
+ readonly end: number;
97
+ readonly text: string;
98
+ }> = [];
99
+ const found = new Set<string>();
100
+ for (const match of masked.matchAll(TABLE_REF_RE)) {
101
+ const rawTable = match[3] as string;
102
+ const table = tables.get(rawTable);
103
+ if (table === undefined) continue;
104
+ if (!declared.has(table.name)) {
105
+ throw new Error('registered query table metadata does not match its SQL');
106
+ }
107
+ if (!table.materialize) {
108
+ throw new Error('registered query targets a non-materialized table');
109
+ }
110
+ let alias = match[4];
111
+ if (alias !== undefined && RESERVED_ALIAS.has(alias.toLowerCase())) {
112
+ alias = undefined;
113
+ }
114
+ const matchStart = match.index ?? 0;
115
+ const afterOperator = (match[1] as string).length;
116
+ const relative = match[0]
117
+ .toLowerCase()
118
+ .indexOf(rawTable.toLowerCase(), afterOperator);
119
+ const start = matchStart + relative;
120
+ const projection = table.columns.map((column) => quoteIdent(column.name));
121
+ replacements.push({
122
+ start,
123
+ end: start + rawTable.length,
124
+ text: `(SELECT ${projection.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=/*syncular_partition*/?)${alias === undefined ? ` AS ${quoteIdent(table.name)}` : ''}`,
125
+ });
126
+ found.add(table.name);
127
+ }
128
+ if (
129
+ found.size !== declared.size ||
130
+ [...declared].some((table) => !found.has(table))
131
+ ) {
132
+ throw new Error('registered query table metadata does not match its SQL');
133
+ }
134
+ let rewritten = sql;
135
+ for (const replacement of replacements.sort(
136
+ (left, right) => right.start - left.start,
137
+ )) {
138
+ rewritten =
139
+ rewritten.slice(0, replacement.start) +
140
+ replacement.text +
141
+ rewritten.slice(replacement.end);
142
+ }
143
+
144
+ const bound: (AuthoritativeQueryValue | typeof PARTITION_BIND)[] = [];
145
+ let anonymousIndex = 0;
146
+ let rendered = '';
147
+ for (let index = 0; index < rewritten.length; index += 1) {
148
+ if (rewritten.startsWith('/*syncular_partition*/?', index)) {
149
+ rendered += '?';
150
+ bound.push(PARTITION_BIND);
151
+ index += '/*syncular_partition*/?'.length - 1;
152
+ continue;
153
+ }
154
+ const protectedEnd = protectedSqlEnd(rewritten, index);
155
+ if (protectedEnd !== undefined) {
156
+ rendered += rewritten.slice(index, protectedEnd);
157
+ index = protectedEnd - 1;
158
+ continue;
159
+ }
160
+ const char = rewritten[index] as string;
161
+ if (char !== '?') {
162
+ rendered += char;
163
+ continue;
164
+ }
165
+ let end = index + 1;
166
+ while (end < rewritten.length && /[0-9]/.test(rewritten[end] as string)) {
167
+ end += 1;
168
+ }
169
+ const numbered = rewritten.slice(index + 1, end);
170
+ const parameterIndex =
171
+ numbered.length > 0
172
+ ? Number.parseInt(numbered, 10) - 1
173
+ : anonymousIndex++;
174
+ if (numbered.length > 0) {
175
+ anonymousIndex = Math.max(anonymousIndex, parameterIndex + 1);
176
+ }
177
+ if (parameterIndex < 0 || parameterIndex >= params.length) {
178
+ throw new Error('registered query bind metadata does not match its SQL');
179
+ }
180
+ const value = params[parameterIndex];
181
+ if (value === undefined) {
182
+ throw new Error('registered query bind metadata does not match its SQL');
183
+ }
184
+ rendered += '?';
185
+ bound.push(value);
186
+ index = end - 1;
187
+ }
188
+ return { sql: rendered, params: bound };
189
+ }
190
+
191
+ export function bindAuthoritativePartition(
192
+ prepared: PreparedAuthoritativeQuery,
193
+ partition: string,
194
+ ): BoundAuthoritativeQuery {
195
+ return {
196
+ sql: prepared.sql,
197
+ params: prepared.params.map((value) =>
198
+ value === PARTITION_BIND ? partition : value,
199
+ ),
200
+ };
201
+ }
202
+
203
+ export function postgresPlaceholders(sql: string): string {
204
+ let bind = 0;
205
+ let rendered = '';
206
+ for (let index = 0; index < sql.length; index += 1) {
207
+ const protectedEnd = protectedSqlEnd(sql, index);
208
+ if (protectedEnd !== undefined) {
209
+ rendered += sql.slice(index, protectedEnd);
210
+ index = protectedEnd - 1;
211
+ } else if (sql[index] === '?') {
212
+ rendered += `$${++bind}`;
213
+ } else {
214
+ rendered += sql[index];
215
+ }
216
+ }
217
+ return rendered;
218
+ }
package/src/context.ts CHANGED
@@ -9,6 +9,7 @@ import type { BlobStore } from './blob-store';
9
9
  import type { CrdtMergerRegistry } from './crdt-merger';
10
10
  import type { SyncularServerEvents } from './events';
11
11
  import type { LeaseStore } from './lease-store';
12
+ import type { AnyReactionPlanner } from './reactions';
12
13
  import type { ServerSchema } from './schema';
13
14
  import type { SegmentStore } from './segment-store';
14
15
  import type {
@@ -23,6 +24,9 @@ import type { CommitValidator, ValidatorRegistry } from './validate';
23
24
  /** SSP2 body content type (§1.1). */
24
25
  export const SSP2_CONTENT_TYPE = 'application/vnd.syncular.sync.v2';
25
26
 
27
+ /** Internal idempotency namespace. Ordinary SSP2 client IDs cannot use it. */
28
+ export const REMOTE_COMMAND_CLIENT_ID_PREFIX = '["remote-command",';
29
+
26
30
  export interface ResolveScopesArgs {
27
31
  readonly partition: string;
28
32
  readonly actorId: string;
@@ -119,6 +123,12 @@ export interface SyncServerConfig {
119
123
  * commit-log/idempotency append. A throw rolls back the complete commit.
120
124
  */
121
125
  readonly commitValidator?: CommitValidator;
126
+ /**
127
+ * Pure durable-reaction planner. Runs once after candidate validation and
128
+ * before commit-log/idempotency append. Its bounded records are enqueued in
129
+ * the same transaction; handlers run later through `ReactionRunner`.
130
+ */
131
+ readonly reactionPlanner?: AnyReactionPlanner;
122
132
  readonly resolveScopes: ResolveScopes;
123
133
  /**
124
134
  * §7.3 auth leases. Absent ⇒ the feature is off: no `LEASE` frame is
package/src/d1-storage.ts CHANGED
@@ -40,6 +40,10 @@
40
40
  * rather than a lock D1 does not expose.
41
41
  */
42
42
  import { decodeRow, type RowValue } from '@syncular/core';
43
+ import {
44
+ bindAuthoritativePartition,
45
+ prepareAuthoritativeQuery,
46
+ } from './authoritative-query';
43
47
  import { syncError } from './errors';
44
48
  import {
45
49
  commitWindowPageSql,
@@ -79,14 +83,24 @@ import {
79
83
  toStoredRow,
80
84
  } from './sqlite-dialect';
81
85
  import type {
86
+ AuthoritativeQueryRequest,
87
+ AuthoritativeQueryResult,
82
88
  ClientCursorInfo,
83
89
  ClientRecord,
84
90
  ClientSubscription,
85
91
  CommitMetadata,
86
92
  CommitMetadataQuery,
87
93
  CommitWindowQuery,
94
+ DurableJsonValue,
88
95
  IndexRowScanQuery,
89
96
  NewCommit,
97
+ NewReaction,
98
+ PrunedReactionCounts,
99
+ ReactionClaimQuery,
100
+ ReactionFailure,
101
+ ReactionFailureUpdate,
102
+ ReactionListQuery,
103
+ ReactionPruneQuery,
90
104
  RowScanQuery,
91
105
  ScopeActivityQuery,
92
106
  ScopeCommitActivity,
@@ -94,6 +108,7 @@ import type {
94
108
  StorageTransaction,
95
109
  StoredCommit,
96
110
  StoredPushResult,
111
+ StoredReaction,
97
112
  StoredRow,
98
113
  } from './storage';
99
114
  import { isD1ConstraintError, StorageConstraintError } from './storage-errors';
@@ -122,6 +137,52 @@ interface BufferedStatement {
122
137
  readonly params: readonly unknown[];
123
138
  }
124
139
 
140
+ interface D1ReactionRecord {
141
+ idempotency_key: string;
142
+ type: string;
143
+ version: number;
144
+ payload: string;
145
+ source_client_id: string;
146
+ source_client_commit_id: string;
147
+ source_commit_seq: number;
148
+ created_at_ms: number;
149
+ available_at_ms: number;
150
+ status: StoredReaction['status'];
151
+ attempts: number;
152
+ max_attempts: number;
153
+ lease_owner: string | null;
154
+ lease_expires_at_ms: number | null;
155
+ completed_at_ms: number | null;
156
+ last_failure: string | null;
157
+ }
158
+
159
+ function toStoredReaction(record: D1ReactionRecord): StoredReaction {
160
+ return {
161
+ idempotencyKey: record.idempotency_key,
162
+ type: record.type,
163
+ version: record.version,
164
+ payload: JSON.parse(record.payload) as DurableJsonValue,
165
+ sourceClientId: record.source_client_id,
166
+ sourceClientCommitId: record.source_client_commit_id,
167
+ sourceCommitSeq: record.source_commit_seq,
168
+ createdAtMs: record.created_at_ms,
169
+ maxAttempts: record.max_attempts,
170
+ status: record.status,
171
+ attempts: record.attempts,
172
+ availableAtMs: record.available_at_ms,
173
+ ...(record.lease_owner !== null ? { leaseOwner: record.lease_owner } : {}),
174
+ ...(record.lease_expires_at_ms !== null
175
+ ? { leaseExpiresAtMs: record.lease_expires_at_ms }
176
+ : {}),
177
+ ...(record.completed_at_ms !== null
178
+ ? { completedAtMs: record.completed_at_ms }
179
+ : {}),
180
+ ...(record.last_failure !== null
181
+ ? { lastFailure: JSON.parse(record.last_failure) as ReactionFailure }
182
+ : {}),
183
+ };
184
+ }
185
+
125
186
  function relationalValuesEqual(left: RowValue, right: RowValue): boolean {
126
187
  if (left instanceof Uint8Array && right instanceof Uint8Array) {
127
188
  return (
@@ -588,6 +649,32 @@ class D1Transaction implements StorageTransaction {
588
649
  );
589
650
  }
590
651
 
652
+ async enqueueReactions(reactions: readonly NewReaction[]): Promise<void> {
653
+ this.#assertOpen();
654
+ for (const reaction of reactions) {
655
+ this.#buffer_(
656
+ `INSERT INTO sync_reactions(
657
+ partition, idempotency_key, type, version, payload,
658
+ source_client_id, source_client_commit_id, source_commit_seq,
659
+ created_at_ms, available_at_ms, status, attempts, max_attempts
660
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,'pending',0,?)`,
661
+ [
662
+ this.#partition,
663
+ reaction.idempotencyKey,
664
+ reaction.type,
665
+ reaction.version,
666
+ JSON.stringify(reaction.payload),
667
+ reaction.sourceClientId,
668
+ reaction.sourceClientCommitId,
669
+ reaction.sourceCommitSeq,
670
+ reaction.createdAtMs,
671
+ reaction.createdAtMs,
672
+ reaction.maxAttempts,
673
+ ],
674
+ );
675
+ }
676
+ }
677
+
591
678
  async commit(): Promise<void> {
592
679
  this.#assertOpen();
593
680
  if (this.#buffer.length === 0) {
@@ -831,6 +918,61 @@ export class D1ServerStorage implements ServerStorage {
831
918
  return row?.max_commit_seq ?? 0;
832
919
  }
833
920
 
921
+ async queryAuthoritative(
922
+ partition: string,
923
+ query: AuthoritativeQueryRequest,
924
+ ): Promise<AuthoritativeQueryResult> {
925
+ if (this.#tables === undefined) {
926
+ throw new Error(
927
+ 'ensureSchema(schema) must run before registered queries',
928
+ );
929
+ }
930
+ const prepared = bindAuthoritativePartition(
931
+ prepareAuthoritativeQuery(
932
+ query.sql,
933
+ query.params,
934
+ query.tables,
935
+ this.#tables,
936
+ ),
937
+ partition,
938
+ );
939
+ const results = await this.#db.batch([
940
+ this.#db.prepare(prepared.sql).bind(...prepared.params),
941
+ this.#db
942
+ .prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
943
+ .bind(partition),
944
+ ]);
945
+ const rowsResult = results[0];
946
+ const cursorResult = results[1];
947
+ if (
948
+ typeof rowsResult !== 'object' ||
949
+ rowsResult === null ||
950
+ !('results' in rowsResult) ||
951
+ !Array.isArray(rowsResult.results) ||
952
+ typeof cursorResult !== 'object' ||
953
+ cursorResult === null ||
954
+ !('results' in cursorResult) ||
955
+ !Array.isArray(cursorResult.results)
956
+ ) {
957
+ throw new Error('D1 registered query returned an invalid batch result');
958
+ }
959
+ const cursor = cursorResult.results[0];
960
+ const maxCommitSeq =
961
+ typeof cursor === 'object' &&
962
+ cursor !== null &&
963
+ 'max_commit_seq' in cursor &&
964
+ typeof cursor.max_commit_seq === 'number'
965
+ ? cursor.max_commit_seq
966
+ : 0;
967
+ return {
968
+ rows: rowsResult.results.filter(
969
+ (row): row is Readonly<Record<string, unknown>> =>
970
+ typeof row === 'object' && row !== null,
971
+ ),
972
+ maxCommitSeq,
973
+ };
974
+ }
975
+
834
976
  async getHorizonSeq(partition: string): Promise<number> {
835
977
  const row = await this.#db
836
978
  .prepare('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
@@ -918,6 +1060,216 @@ export class D1ServerStorage implements ServerStorage {
918
1060
  }
919
1061
  }
920
1062
 
1063
+ async claimReactions(
1064
+ partition: string,
1065
+ query: ReactionClaimQuery,
1066
+ ): Promise<StoredReaction[]> {
1067
+ if (query.types.length === 0 || query.limit <= 0) return [];
1068
+ if (query.types.length + 7 > D1_MAX_BIND_PARAMS) {
1069
+ throw new Error('D1 reaction claim exceeds the bound-parameter limit');
1070
+ }
1071
+ const typeParams = query.types.map(() => '?').join(',');
1072
+ const { results } = await this.#db
1073
+ .prepare(
1074
+ `UPDATE sync_reactions
1075
+ SET status='leased', attempts=attempts+1,
1076
+ lease_owner=?, lease_expires_at_ms=?, completed_at_ms=NULL
1077
+ WHERE (partition, idempotency_key) IN (
1078
+ SELECT partition, idempotency_key
1079
+ FROM sync_reactions
1080
+ WHERE partition=? AND type IN (${typeParams})
1081
+ AND ((status='pending' AND available_at_ms<=?)
1082
+ OR (status='leased' AND lease_expires_at_ms<=?))
1083
+ ORDER BY CASE WHEN status='leased' THEN lease_expires_at_ms
1084
+ ELSE available_at_ms END,
1085
+ created_at_ms, idempotency_key
1086
+ LIMIT ?
1087
+ )
1088
+ RETURNING *`,
1089
+ )
1090
+ .bind(
1091
+ query.leaseOwner,
1092
+ Math.min(Number.MAX_SAFE_INTEGER, query.nowMs + query.leaseDurationMs),
1093
+ partition,
1094
+ ...query.types,
1095
+ query.nowMs,
1096
+ query.nowMs,
1097
+ query.limit,
1098
+ )
1099
+ .all<D1ReactionRecord>();
1100
+ return results
1101
+ .map(toStoredReaction)
1102
+ .sort(
1103
+ (a, b) =>
1104
+ a.createdAtMs - b.createdAtMs ||
1105
+ a.idempotencyKey.localeCompare(b.idempotencyKey),
1106
+ );
1107
+ }
1108
+
1109
+ async completeReaction(
1110
+ partition: string,
1111
+ idempotencyKey: string,
1112
+ leaseOwner: string,
1113
+ completedAtMs: number,
1114
+ ): Promise<boolean> {
1115
+ const record = await this.#db
1116
+ .prepare(
1117
+ `UPDATE sync_reactions
1118
+ SET status='completed', completed_at_ms=?,
1119
+ lease_owner=NULL, lease_expires_at_ms=NULL
1120
+ WHERE partition=? AND idempotency_key=?
1121
+ AND status='leased' AND lease_owner=?
1122
+ RETURNING idempotency_key`,
1123
+ )
1124
+ .bind(completedAtMs, partition, idempotencyKey, leaseOwner)
1125
+ .first<{ idempotency_key: string }>();
1126
+ return record !== null;
1127
+ }
1128
+
1129
+ async extendReactionLease(
1130
+ partition: string,
1131
+ idempotencyKey: string,
1132
+ leaseOwner: string,
1133
+ leaseExpiresAtMs: number,
1134
+ ): Promise<boolean> {
1135
+ const record = await this.#db
1136
+ .prepare(
1137
+ `UPDATE sync_reactions SET lease_expires_at_ms=?
1138
+ WHERE partition=? AND idempotency_key=?
1139
+ AND status='leased' AND lease_owner=?
1140
+ RETURNING idempotency_key`,
1141
+ )
1142
+ .bind(leaseExpiresAtMs, partition, idempotencyKey, leaseOwner)
1143
+ .first<{ idempotency_key: string }>();
1144
+ return record !== null;
1145
+ }
1146
+
1147
+ async failReaction(
1148
+ partition: string,
1149
+ idempotencyKey: string,
1150
+ update: ReactionFailureUpdate,
1151
+ ): Promise<boolean> {
1152
+ const record = await this.#db
1153
+ .prepare(
1154
+ `UPDATE sync_reactions
1155
+ SET status=?, available_at_ms=?, last_failure=?,
1156
+ lease_owner=NULL, lease_expires_at_ms=NULL
1157
+ WHERE partition=? AND idempotency_key=?
1158
+ AND status='leased' AND lease_owner=?
1159
+ RETURNING idempotency_key`,
1160
+ )
1161
+ .bind(
1162
+ update.retryAtMs === undefined ? 'dead-letter' : 'pending',
1163
+ update.retryAtMs ?? update.failure.atMs,
1164
+ JSON.stringify(update.failure),
1165
+ partition,
1166
+ idempotencyKey,
1167
+ update.leaseOwner,
1168
+ )
1169
+ .first<{ idempotency_key: string }>();
1170
+ return record !== null;
1171
+ }
1172
+
1173
+ async retryReaction(
1174
+ partition: string,
1175
+ idempotencyKey: string,
1176
+ nowMs: number,
1177
+ ): Promise<boolean> {
1178
+ const record = await this.#db
1179
+ .prepare(
1180
+ `UPDATE sync_reactions
1181
+ SET status='pending', attempts=0, available_at_ms=?,
1182
+ last_failure=NULL, lease_owner=NULL, lease_expires_at_ms=NULL,
1183
+ completed_at_ms=NULL
1184
+ WHERE partition=? AND idempotency_key=? AND status='dead-letter'
1185
+ RETURNING idempotency_key`,
1186
+ )
1187
+ .bind(nowMs, partition, idempotencyKey)
1188
+ .first<{ idempotency_key: string }>();
1189
+ return record !== null;
1190
+ }
1191
+
1192
+ async getReaction(
1193
+ partition: string,
1194
+ idempotencyKey: string,
1195
+ ): Promise<StoredReaction | undefined> {
1196
+ const record = await this.#db
1197
+ .prepare(
1198
+ 'SELECT * FROM sync_reactions WHERE partition=? AND idempotency_key=?',
1199
+ )
1200
+ .bind(partition, idempotencyKey)
1201
+ .first<D1ReactionRecord>();
1202
+ return record === null ? undefined : toStoredReaction(record);
1203
+ }
1204
+
1205
+ async listReactions(
1206
+ partition: string,
1207
+ query: ReactionListQuery,
1208
+ ): Promise<StoredReaction[]> {
1209
+ const where = ['partition=?'];
1210
+ const params: (string | number)[] = [partition];
1211
+ if (query.statuses !== undefined && query.statuses.length > 0) {
1212
+ where.push(`status IN (${query.statuses.map(() => '?').join(',')})`);
1213
+ params.push(...query.statuses);
1214
+ }
1215
+ if (query.types !== undefined && query.types.length > 0) {
1216
+ where.push(`type IN (${query.types.map(() => '?').join(',')})`);
1217
+ params.push(...query.types);
1218
+ }
1219
+ if (params.length + 1 > D1_MAX_BIND_PARAMS) {
1220
+ throw new Error('D1 reaction list exceeds the bound-parameter limit');
1221
+ }
1222
+ params.push(query.limit);
1223
+ const { results } = await this.#db
1224
+ .prepare(
1225
+ `SELECT * FROM sync_reactions WHERE ${where.join(' AND ')}
1226
+ ORDER BY created_at_ms DESC, idempotency_key DESC LIMIT ?`,
1227
+ )
1228
+ .bind(...params)
1229
+ .all<D1ReactionRecord>();
1230
+ return results.map(toStoredReaction);
1231
+ }
1232
+
1233
+ async pruneReactions(
1234
+ partition: string,
1235
+ query: ReactionPruneQuery,
1236
+ ): Promise<PrunedReactionCounts> {
1237
+ if (query.limit <= 0) return { completed: 0, deadLetter: 0 };
1238
+ const { results } = await this.#db
1239
+ .prepare(
1240
+ `DELETE FROM sync_reactions
1241
+ WHERE partition=? AND idempotency_key IN (
1242
+ SELECT idempotency_key FROM sync_reactions
1243
+ WHERE partition=?
1244
+ AND ((status='completed' AND completed_at_ms IS NOT NULL
1245
+ AND completed_at_ms<?)
1246
+ OR (status='dead-letter' AND available_at_ms<?))
1247
+ ORDER BY CASE WHEN status='completed' THEN completed_at_ms
1248
+ ELSE available_at_ms END,
1249
+ idempotency_key
1250
+ LIMIT ?
1251
+ )
1252
+ AND ((status='completed' AND completed_at_ms IS NOT NULL
1253
+ AND completed_at_ms<?)
1254
+ OR (status='dead-letter' AND available_at_ms<?))
1255
+ RETURNING status`,
1256
+ )
1257
+ .bind(
1258
+ partition,
1259
+ partition,
1260
+ query.completedBeforeMs,
1261
+ query.deadLetterBeforeMs,
1262
+ query.limit,
1263
+ query.completedBeforeMs,
1264
+ query.deadLetterBeforeMs,
1265
+ )
1266
+ .all<{ status: 'completed' | 'dead-letter' }>();
1267
+ return {
1268
+ completed: results.filter((row) => row.status === 'completed').length,
1269
+ deadLetter: results.filter((row) => row.status === 'dead-letter').length,
1270
+ };
1271
+ }
1272
+
921
1273
  async readCommitWindow(
922
1274
  partition: string,
923
1275
  query: CommitWindowQuery,
package/src/errors.ts CHANGED
@@ -14,8 +14,50 @@ export interface ErrorCatalogEntry {
14
14
  readonly httpStatus: number;
15
15
  }
16
16
 
17
- /** The §10.2 wire catalog (21 sync.* + 4 blob.* codes), keyed by stable code. */
17
+ /** The §10.2 wire catalog, keyed by stable code. */
18
18
  export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
19
+ 'operation.unknown': {
20
+ category: 'not-found',
21
+ retryable: false,
22
+ recommendedAction: 'regenerateClient',
23
+ httpStatus: 404,
24
+ },
25
+ 'operation.forbidden': {
26
+ category: 'forbidden',
27
+ retryable: false,
28
+ recommendedAction: 'checkPermissions',
29
+ httpStatus: 403,
30
+ },
31
+ 'operation.invalid_request': {
32
+ category: 'invalid-request',
33
+ retryable: false,
34
+ recommendedAction: 'fixRequest',
35
+ httpStatus: 400,
36
+ },
37
+ 'operation.result_too_large': {
38
+ category: 'invalid-request',
39
+ retryable: false,
40
+ recommendedAction: 'fixRequest',
41
+ httpStatus: 400,
42
+ },
43
+ 'operation.storage_unsupported': {
44
+ category: 'internal',
45
+ retryable: false,
46
+ recommendedAction: 'inspectServer',
47
+ httpStatus: 500,
48
+ },
49
+ 'operation.query_failed': {
50
+ category: 'internal',
51
+ retryable: false,
52
+ recommendedAction: 'inspectServer',
53
+ httpStatus: 500,
54
+ },
55
+ 'operation.execution_failed': {
56
+ category: 'internal',
57
+ retryable: false,
58
+ recommendedAction: 'inspectServer',
59
+ httpStatus: 500,
60
+ },
19
61
  'sync.auth_required': {
20
62
  category: 'auth-required',
21
63
  retryable: true,