@syncular/server 0.15.47 → 0.16.1

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 (80) hide show
  1. package/README.md +48 -5
  2. package/dist/admin.d.ts +1 -5
  3. package/dist/admin.js +3 -12
  4. package/dist/authoritative-query.d.ts +14 -6
  5. package/dist/authoritative-query.js +60 -82
  6. package/dist/blob-handlers.js +4 -1
  7. package/dist/context.d.ts +3 -1
  8. package/dist/context.js +4 -0
  9. package/dist/d1-storage.d.ts +8 -2
  10. package/dist/d1-storage.js +134 -26
  11. package/dist/errors.js +6 -0
  12. package/dist/events.d.ts +2 -1
  13. package/dist/frame-bytes.d.ts +2 -2
  14. package/dist/frame-bytes.js +33 -14
  15. package/dist/handler.js +58 -14
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/operations.d.ts +2 -0
  19. package/dist/operations.js +25 -5
  20. package/dist/postgres-storage.d.ts +9 -3
  21. package/dist/postgres-storage.js +110 -20
  22. package/dist/prune.d.ts +3 -1
  23. package/dist/prune.js +18 -14
  24. package/dist/pull.d.ts +1 -1
  25. package/dist/pull.js +74 -37
  26. package/dist/push.js +5 -5
  27. package/dist/realtime.d.ts +4 -1
  28. package/dist/realtime.js +33 -9
  29. package/dist/restore.d.ts +13 -0
  30. package/dist/restore.js +13 -0
  31. package/dist/s3-segment-store.js +10 -1
  32. package/dist/seed.js +36 -4
  33. package/dist/segment-download.js +5 -2
  34. package/dist/segment-store.d.ts +3 -0
  35. package/dist/segment-store.js +1 -0
  36. package/dist/sqlite-bun-driver.d.ts +2 -1
  37. package/dist/sqlite-bun-driver.js +5 -2
  38. package/dist/sqlite-bun.js +2 -2
  39. package/dist/sqlite-dialect.d.ts +1 -1
  40. package/dist/sqlite-dialect.js +7 -0
  41. package/dist/sqlite-image.d.ts +3 -3
  42. package/dist/sqlite-image.js +10 -6
  43. package/dist/sqlite-node.js +2 -2
  44. package/dist/sqlite-segment-store.js +14 -5
  45. package/dist/sqlite-storage.d.ts +8 -2
  46. package/dist/sqlite-storage.js +147 -36
  47. package/dist/storage-errors.d.ts +1 -1
  48. package/dist/storage-errors.js +3 -0
  49. package/dist/storage.d.ts +38 -10
  50. package/package.json +2 -2
  51. package/src/admin.ts +7 -15
  52. package/src/authoritative-query.ts +89 -94
  53. package/src/blob-handlers.ts +8 -1
  54. package/src/context.ts +16 -1
  55. package/src/d1-storage.ts +193 -29
  56. package/src/errors.ts +6 -0
  57. package/src/events.ts +2 -1
  58. package/src/frame-bytes.ts +40 -14
  59. package/src/handler.ts +102 -29
  60. package/src/index.ts +1 -0
  61. package/src/operations.ts +37 -4
  62. package/src/postgres-storage.ts +184 -38
  63. package/src/prune.ts +29 -15
  64. package/src/pull.ts +90 -41
  65. package/src/push.ts +5 -5
  66. package/src/realtime.ts +46 -7
  67. package/src/restore.ts +28 -0
  68. package/src/s3-segment-store.ts +10 -1
  69. package/src/seed.ts +46 -4
  70. package/src/segment-download.ts +11 -2
  71. package/src/segment-store.ts +4 -0
  72. package/src/sqlite-bun-driver.ts +6 -2
  73. package/src/sqlite-bun.ts +2 -2
  74. package/src/sqlite-dialect.ts +7 -0
  75. package/src/sqlite-image.ts +26 -15
  76. package/src/sqlite-node.ts +2 -2
  77. package/src/sqlite-segment-store.ts +18 -4
  78. package/src/sqlite-storage.ts +203 -39
  79. package/src/storage-errors.ts +10 -1
  80. package/src/storage.ts +57 -10
@@ -14,28 +14,16 @@ export interface BoundAuthoritativeQuery {
14
14
 
15
15
  const PARTITION_BIND = Symbol('syncular.authoritative_partition');
16
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
- );
17
+ /** Compiler-proven occurrences in the exact generated positional statement. */
18
+ export interface AuthoritativeRelationPlan {
19
+ readonly sql: string;
20
+ readonly relations: readonly {
21
+ readonly table: string;
22
+ readonly start: number;
23
+ readonly end: number;
24
+ readonly alias?: string;
25
+ }[];
26
+ }
39
27
 
40
28
  function protectedSqlEnd(sql: string, index: number): number | undefined {
41
29
  const char = sql[index];
@@ -64,66 +52,57 @@ function protectedSqlEnd(sql: string, index: number): number | undefined {
64
52
  return undefined;
65
53
  }
66
54
 
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[],
55
+ /** Validate trusted generated metadata before registration or storage execution. */
56
+ export function validateAuthoritativeRelationPlan(
57
+ plan: AuthoritativeRelationPlan,
87
58
  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');
59
+ ): void {
60
+ if (
61
+ plan === undefined ||
62
+ typeof plan.sql !== 'string' ||
63
+ !Array.isArray(plan.relations)
64
+ ) {
65
+ throw new Error(
66
+ 'registered query requires generated relation plans; regenerate queries',
67
+ );
68
+ }
69
+ const sql = plan.sql;
92
70
  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
71
  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');
72
+ let previousEnd = 0;
73
+ for (const relation of plan.relations) {
74
+ if (
75
+ !Number.isSafeInteger(relation.start) ||
76
+ !Number.isSafeInteger(relation.end) ||
77
+ relation.start < previousEnd ||
78
+ relation.end <= relation.start ||
79
+ relation.end > sql.length
80
+ ) {
81
+ throw new Error(
82
+ 'registered query relation boundaries do not match its SQL; regenerate queries',
83
+ );
106
84
  }
107
- if (!table.materialize) {
108
- throw new Error('registered query targets a non-materialized table');
85
+ previousEnd = relation.end;
86
+ if (!declared.has(relation.table)) {
87
+ throw new Error('registered query table metadata does not match its SQL');
109
88
  }
110
- let alias = match[4];
111
- if (alias !== undefined && RESERVED_ALIAS.has(alias.toLowerCase())) {
112
- alias = undefined;
89
+ const spelling = sql.slice(relation.start, relation.end);
90
+ const quote = spelling[0];
91
+ const name =
92
+ quote === '['
93
+ ? spelling.slice(1, -1)
94
+ : quote === '"' || quote === '`'
95
+ ? spelling
96
+ .slice(1, -1)
97
+ .split(quote + quote)
98
+ .join(quote)
99
+ : spelling;
100
+ if (name.toLowerCase() !== relation.table.toLowerCase()) {
101
+ throw new Error(
102
+ 'registered query relation name does not match its SQL; regenerate queries',
103
+ );
113
104
  }
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);
105
+ found.add(relation.table);
127
106
  }
128
107
  if (
129
108
  found.size !== declared.size ||
@@ -131,42 +110,58 @@ export function prepareAuthoritativeQuery(
131
110
  ) {
132
111
  throw new Error('registered query table metadata does not match its SQL');
133
112
  }
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
- }
113
+ }
143
114
 
115
+ /** Bind every compiler-proven table occurrence to the authenticated partition. */
116
+ export function prepareAuthoritativeQuery(
117
+ plan: AuthoritativeRelationPlan,
118
+ params: readonly AuthoritativeQueryValue[],
119
+ declaredTables: readonly string[],
120
+ tables: ReadonlyMap<string, CompiledTable>,
121
+ ): PreparedAuthoritativeQuery {
122
+ validateAuthoritativeRelationPlan(plan, declaredTables);
123
+ const sql = plan.sql;
124
+ const replacements = plan.relations.map((relation) => {
125
+ const table = tables.get(relation.table);
126
+ if (table === undefined)
127
+ throw new Error('registered query targets an unknown table');
128
+ if (!table.materialize)
129
+ throw new Error('registered query targets a non-materialized table');
130
+ return {
131
+ start: relation.start,
132
+ end: relation.end,
133
+ text: `(SELECT ${table.columns.map((column) => quoteIdent(column.name)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=?)${relation.alias === undefined ? ` AS ${quoteIdent(table.name)}` : ''}`,
134
+ };
135
+ });
144
136
  const bound: (AuthoritativeQueryValue | typeof PARTITION_BIND)[] = [];
145
137
  let anonymousIndex = 0;
146
138
  let rendered = '';
147
- for (let index = 0; index < rewritten.length; index += 1) {
148
- if (rewritten.startsWith('/*syncular_partition*/?', index)) {
149
- rendered += '?';
139
+ let nextRelation = 0;
140
+ for (let index = 0; index < sql.length; index += 1) {
141
+ const replacement = replacements[nextRelation];
142
+ if (replacement?.start === index) {
143
+ rendered += replacement.text;
150
144
  bound.push(PARTITION_BIND);
151
- index += '/*syncular_partition*/?'.length - 1;
145
+ index = replacement.end - 1;
146
+ nextRelation += 1;
152
147
  continue;
153
148
  }
154
- const protectedEnd = protectedSqlEnd(rewritten, index);
149
+ const protectedEnd = protectedSqlEnd(sql, index);
155
150
  if (protectedEnd !== undefined) {
156
- rendered += rewritten.slice(index, protectedEnd);
151
+ rendered += sql.slice(index, protectedEnd);
157
152
  index = protectedEnd - 1;
158
153
  continue;
159
154
  }
160
- const char = rewritten[index] as string;
155
+ const char = sql[index] as string;
161
156
  if (char !== '?') {
162
157
  rendered += char;
163
158
  continue;
164
159
  }
165
160
  let end = index + 1;
166
- while (end < rewritten.length && /[0-9]/.test(rewritten[end] as string)) {
161
+ while (end < sql.length && /[0-9]/.test(sql[end] as string)) {
167
162
  end += 1;
168
163
  }
169
- const numbered = rewritten.slice(index + 1, end);
164
+ const numbered = sql.slice(index + 1, end);
170
165
  const parameterIndex =
171
166
  numbered.length > 0
172
167
  ? Number.parseInt(numbered, 10) - 1
@@ -8,7 +8,11 @@
8
8
  */
9
9
  import { blobIdFor, isBlobId } from './blob-store';
10
10
  import type { SyncRequestContext } from './context';
11
- import { clockOf, RESOLVER_OUTAGE } from './context';
11
+ import {
12
+ clockOf,
13
+ RESOLVER_OUTAGE,
14
+ touchAuthenticatedPartition,
15
+ } from './context';
12
16
  import { SyncError, syncError } from './errors';
13
17
  import { emitEvent } from './events';
14
18
  import { compileSchema } from './schema';
@@ -72,6 +76,7 @@ export async function handleBlobUpload(
72
76
  ctx: SyncRequestContext,
73
77
  request: BlobUploadRequest,
74
78
  ): Promise<void> {
79
+ await touchAuthenticatedPartition(ctx);
75
80
  const store = ctx.blobs;
76
81
  if (store === undefined) {
77
82
  throw syncError('blob.not_found', 'this server has no blob store (§5.9)');
@@ -125,6 +130,7 @@ export async function handleBlobDownload(
125
130
  ctx: SyncRequestContext,
126
131
  blobId: string,
127
132
  ): Promise<BlobDownloadResult> {
133
+ await touchAuthenticatedPartition(ctx);
128
134
  const events = ctx.events;
129
135
  if (events === undefined) return downloadBlob(ctx, blobId);
130
136
  const clock = clockOf(ctx);
@@ -301,6 +307,7 @@ export async function handleBlobUploadGrant(
301
307
  ctx: SyncRequestContext,
302
308
  request: BlobUploadGrantRequest,
303
309
  ): Promise<BlobUploadGrantResult> {
310
+ await touchAuthenticatedPartition(ctx);
304
311
  const store = ctx.blobs;
305
312
  if (store === undefined) {
306
313
  throw syncError('blob.not_found', 'this server has no blob store (§5.9)');
package/src/context.ts CHANGED
@@ -18,7 +18,11 @@ import type {
18
18
  SegmentUrlConfig,
19
19
  } from './signed-url';
20
20
  import type { SqliteImageBuilder } from './sqlite-image';
21
- import type { ServerStorage, StoredCommit } from './storage';
21
+ import type {
22
+ PartitionRegistryEntry,
23
+ ServerStorage,
24
+ StoredCommit,
25
+ } from './storage';
22
26
  import type { CommitValidator, ValidatorRegistry } from './validate';
23
27
 
24
28
  /** SSP2 body content type (§1.1). */
@@ -192,3 +196,14 @@ export function clockOf(ctx: SyncServerConfig): () => number {
192
196
  export function limitsOf(ctx: SyncServerConfig): ServerLimits {
193
197
  return { ...DEFAULT_LIMITS, ...ctx.limits };
194
198
  }
199
+
200
+ /** Refresh the registry after host authentication and return log continuity. */
201
+ export function touchAuthenticatedPartition(
202
+ ctx: SyncRequestContext,
203
+ ): Promise<PartitionRegistryEntry> {
204
+ return ctx.storage.touchPartition(
205
+ ctx.partition,
206
+ clockOf(ctx)(),
207
+ crypto.randomUUID(),
208
+ );
209
+ }
package/src/d1-storage.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { validateCommitPruneQuery } from './prune';
2
+ import { StorageQueryError } from './storage-errors';
3
+ import type { CommitPruneQuery, CommitPruneResult } from './storage';
1
4
  /**
2
5
  * Cloudflare D1 server storage for Workers deployments.
3
6
  *
@@ -95,6 +98,7 @@ import type {
95
98
  IndexRowScanQuery,
96
99
  NewCommit,
97
100
  NewReaction,
101
+ PartitionRegistryEntry,
98
102
  PrunedReactionCounts,
99
103
  ReactionClaimQuery,
100
104
  ReactionFailure,
@@ -752,6 +756,14 @@ export class D1ServerStorage implements ServerStorage {
752
756
  for (const statement of sqliteDdlStatements()) {
753
757
  await this.#db.exec(`${statement.replace(/\s+/g, ' ')};`);
754
758
  }
759
+ const { results } = await this.#db
760
+ .prepare('PRAGMA table_info("sync_clients")')
761
+ .all<{ name: string }>();
762
+ if (!results.some((column) => column.name === 'wire_version')) {
763
+ await this.#db.exec(
764
+ 'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1;',
765
+ );
766
+ }
755
767
  }
756
768
 
757
769
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
@@ -859,6 +871,95 @@ export class D1ServerStorage implements ServerStorage {
859
871
  this.#schemaVersion = schema.version;
860
872
  }
861
873
 
874
+ async touchPartition(
875
+ partition: string,
876
+ authenticatedAtMs: number,
877
+ initialLogEpoch: string,
878
+ ): Promise<PartitionRegistryEntry> {
879
+ if (initialLogEpoch.length === 0) {
880
+ throw new Error('initial log epoch must be non-empty');
881
+ }
882
+ await this.#db
883
+ .prepare(
884
+ `INSERT INTO sync_partition_registry(
885
+ partition, log_epoch, last_authenticated_at_ms
886
+ ) VALUES (?,?,?)
887
+ ON CONFLICT(partition) DO UPDATE SET
888
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
889
+ )
890
+ .bind(partition, initialLogEpoch, authenticatedAtMs)
891
+ .run();
892
+ const row = await this.#db
893
+ .prepare(
894
+ `SELECT log_epoch, epoch_required, last_authenticated_at_ms
895
+ FROM sync_partition_registry WHERE partition=?`,
896
+ )
897
+ .bind(partition)
898
+ .first<{
899
+ log_epoch: string;
900
+ epoch_required: number;
901
+ last_authenticated_at_ms: number;
902
+ }>();
903
+ if (row === null)
904
+ throw new Error('partition registry write did not persist');
905
+ return {
906
+ partition,
907
+ logEpoch: row.log_epoch,
908
+ epochRequired: row.epoch_required === 1,
909
+ lastAuthenticatedAtMs: row.last_authenticated_at_ms,
910
+ };
911
+ }
912
+
913
+ async rotatePartitionLogEpoch(
914
+ partition: string,
915
+ logEpoch: string,
916
+ authenticatedAtMs: number,
917
+ ): Promise<PartitionRegistryEntry> {
918
+ if (logEpoch.length === 0) throw new Error('log epoch must be non-empty');
919
+ await this.#db.batch([
920
+ this.#db
921
+ .prepare(
922
+ `INSERT INTO sync_partition_registry(
923
+ partition, log_epoch, epoch_required, last_authenticated_at_ms
924
+ ) VALUES (?,?,1,?)
925
+ ON CONFLICT(partition) DO UPDATE SET
926
+ log_epoch=excluded.log_epoch,
927
+ epoch_required=1,
928
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
929
+ )
930
+ .bind(partition, logEpoch, authenticatedAtMs),
931
+ this.#db
932
+ .prepare('DELETE FROM sync_clients WHERE partition=?')
933
+ .bind(partition),
934
+ ]);
935
+ return {
936
+ partition,
937
+ logEpoch,
938
+ epochRequired: true,
939
+ lastAuthenticatedAtMs: authenticatedAtMs,
940
+ };
941
+ }
942
+
943
+ async listPartitionRegistry(): Promise<PartitionRegistryEntry[]> {
944
+ const { results } = await this.#db
945
+ .prepare(
946
+ `SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
947
+ FROM sync_partition_registry ORDER BY partition`,
948
+ )
949
+ .all<{
950
+ partition: string;
951
+ log_epoch: string;
952
+ epoch_required: number;
953
+ last_authenticated_at_ms: number;
954
+ }>();
955
+ return results.map((row) => ({
956
+ partition: row.partition,
957
+ logEpoch: row.log_epoch,
958
+ epochRequired: row.epoch_required === 1,
959
+ lastAuthenticatedAtMs: row.last_authenticated_at_ms,
960
+ }));
961
+ }
962
+
862
963
  /** Keyset-paged migration rewrite (see the sqlite storage's counterpart). */
863
964
  async #rewriteRows(
864
965
  table: CompiledTable,
@@ -929,7 +1030,7 @@ export class D1ServerStorage implements ServerStorage {
929
1030
  }
930
1031
  const prepared = bindAuthoritativePartition(
931
1032
  prepareAuthoritativeQuery(
932
- query.sql,
1033
+ query.plan,
933
1034
  query.params,
934
1035
  query.tables,
935
1036
  this.#tables,
@@ -973,6 +1074,16 @@ export class D1ServerStorage implements ServerStorage {
973
1074
  };
974
1075
  }
975
1076
 
1077
+ async getPartitionLogEpoch(partition: string): Promise<string | undefined> {
1078
+ const row = await this.#db
1079
+ .prepare(
1080
+ 'SELECT log_epoch FROM sync_partition_registry WHERE partition=?',
1081
+ )
1082
+ .bind(partition)
1083
+ .first<{ log_epoch: string }>();
1084
+ return row?.log_epoch;
1085
+ }
1086
+
976
1087
  async getHorizonSeq(partition: string): Promise<number> {
977
1088
  const row = await this.#db
978
1089
  .prepare('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
@@ -984,33 +1095,76 @@ export class D1ServerStorage implements ServerStorage {
984
1095
  async setHorizonSeq(partition: string, seq: number): Promise<void> {
985
1096
  await this.#db
986
1097
  .prepare(
987
- 'INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?) ON CONFLICT(partition) DO UPDATE SET horizon_seq=excluded.horizon_seq',
1098
+ 'INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?) ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)',
988
1099
  )
989
1100
  .bind(partition, seq)
990
1101
  .run();
991
1102
  }
992
1103
 
993
- async pruneCommitsThrough(partition: string, seq: number): Promise<number> {
994
- const before = await this.#db
995
- .prepare(
996
- 'SELECT count(*) AS n FROM sync_commits WHERE partition=? AND commit_seq<=?',
997
- )
998
- .bind(partition, seq)
999
- .first<{ n: number }>();
1000
- await this.#db.batch([
1104
+ async pruneCommitsThrough(
1105
+ partition: string,
1106
+ query: CommitPruneQuery,
1107
+ ): Promise<CommitPruneResult> {
1108
+ validateCommitPruneQuery(query);
1109
+ if (!this.#pushApplySerialized)
1110
+ throw new Error(
1111
+ 'D1 pruning requires externally serialized partition writes',
1112
+ );
1113
+ const epochGuard =
1114
+ 'EXISTS (SELECT 1 FROM sync_partition_registry WHERE partition=? AND log_epoch=?)';
1115
+ const horizon =
1116
+ '(SELECT horizon_seq FROM sync_partitions WHERE partition=?)';
1117
+ const results = await this.#db.batch([
1001
1118
  this.#db
1002
- .prepare('DELETE FROM sync_commits WHERE partition=? AND commit_seq<=?')
1003
- .bind(partition, seq),
1119
+ .prepare(
1120
+ `SELECT log_epoch, coalesce(${horizon},0) AS previous_horizon_seq FROM sync_partition_registry WHERE partition=?`,
1121
+ )
1122
+ .bind(partition, partition),
1004
1123
  this.#db
1005
- .prepare('DELETE FROM sync_changes WHERE partition=? AND commit_seq<=?')
1006
- .bind(partition, seq),
1124
+ .prepare(`INSERT INTO sync_partitions(partition,horizon_seq) SELECT ?,? WHERE ${epochGuard}
1125
+ ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)`)
1126
+ .bind(partition, query.throughSeq, partition, query.logEpoch),
1007
1127
  this.#db
1008
1128
  .prepare(
1009
- 'DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?',
1129
+ `SELECT horizon_seq, (SELECT count(*) FROM sync_commits WHERE partition=? AND commit_seq<=${horizon}) AS removed_commits FROM sync_partitions WHERE partition=?`,
1010
1130
  )
1011
- .bind(partition, seq),
1131
+ .bind(partition, partition, partition),
1132
+ ...['sync_commits', 'sync_changes', 'sync_change_scopes'].map((table) =>
1133
+ this.#db
1134
+ .prepare(
1135
+ `DELETE FROM ${table} WHERE partition=? AND commit_seq<=${horizon} AND ${epochGuard}`,
1136
+ )
1137
+ .bind(partition, partition, partition, query.logEpoch),
1138
+ ),
1012
1139
  ]);
1013
- return before?.n ?? 0;
1140
+ const [before, after] = [results[0], results[2]].map((result) => {
1141
+ if (
1142
+ typeof result !== 'object' ||
1143
+ result === null ||
1144
+ !('results' in result) ||
1145
+ !Array.isArray(result.results)
1146
+ ) {
1147
+ throw new Error('D1 pruning returned an invalid batch result');
1148
+ }
1149
+ const row: unknown = result.results[0];
1150
+ return typeof row === 'object' && row !== null
1151
+ ? (row as Readonly<Record<string, unknown>>)
1152
+ : undefined;
1153
+ });
1154
+ if (before?.log_epoch !== query.logEpoch)
1155
+ throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
1156
+ if (
1157
+ typeof before.previous_horizon_seq !== 'number' ||
1158
+ typeof after?.horizon_seq !== 'number' ||
1159
+ typeof after.removed_commits !== 'number'
1160
+ ) {
1161
+ throw new Error('D1 pruning returned invalid horizon metadata');
1162
+ }
1163
+ return {
1164
+ previousHorizonSeq: before.previous_horizon_seq,
1165
+ horizonSeq: after.horizon_seq,
1166
+ removedCommits: after.removed_commits,
1167
+ };
1014
1168
  }
1015
1169
 
1016
1170
  async getCommitSeqBefore(
@@ -1390,12 +1544,13 @@ export class D1ServerStorage implements ServerStorage {
1390
1544
  ): Promise<ClientRecord | undefined> {
1391
1545
  const record = await this.#db
1392
1546
  .prepare(
1393
- 'SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
1547
+ 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
1394
1548
  )
1395
1549
  .bind(partition, clientId)
1396
1550
  .first<{
1397
1551
  client_id: string;
1398
1552
  actor_id: string;
1553
+ wire_version: number;
1399
1554
  cursor: number;
1400
1555
  subscriptions: string;
1401
1556
  updated_at_ms: number;
@@ -1404,6 +1559,7 @@ export class D1ServerStorage implements ServerStorage {
1404
1559
  return {
1405
1560
  clientId: record.client_id,
1406
1561
  actorId: record.actor_id,
1562
+ wireVersion: record.wire_version,
1407
1563
  cursor: record.cursor,
1408
1564
  updatedAtMs: record.updated_at_ms,
1409
1565
  subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
@@ -1416,12 +1572,13 @@ export class D1ServerStorage implements ServerStorage {
1416
1572
  ): Promise<void> {
1417
1573
  await this.#db
1418
1574
  .prepare(
1419
- 'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?)',
1575
+ 'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)',
1420
1576
  )
1421
1577
  .bind(
1422
1578
  partition,
1423
1579
  record.clientId,
1424
1580
  record.actorId,
1581
+ record.wireVersion,
1425
1582
  record.cursor,
1426
1583
  JSON.stringify(record.subscriptions),
1427
1584
  record.updatedAtMs,
@@ -1429,6 +1586,19 @@ export class D1ServerStorage implements ServerStorage {
1429
1586
  .run();
1430
1587
  }
1431
1588
 
1589
+ async getActiveClientCursorFloor(
1590
+ partition: string,
1591
+ cutoffMs: number,
1592
+ ): Promise<number | null> {
1593
+ const row = await this.#db
1594
+ .prepare(
1595
+ 'SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?',
1596
+ )
1597
+ .bind(partition, cutoffMs)
1598
+ .first<{ cursor: number | null }>();
1599
+ return row!.cursor;
1600
+ }
1601
+
1432
1602
  async listClientCursors(partition: string): Promise<ClientCursorInfo[]> {
1433
1603
  const { results } = await this.#db
1434
1604
  .prepare(
@@ -1494,12 +1664,13 @@ export class D1ServerStorage implements ServerStorage {
1494
1664
  async listClientRecords(partition: string): Promise<ClientRecord[]> {
1495
1665
  const { results } = await this.#db
1496
1666
  .prepare(
1497
- 'SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
1667
+ 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
1498
1668
  )
1499
1669
  .bind(partition)
1500
1670
  .all<{
1501
1671
  client_id: string;
1502
1672
  actor_id: string;
1673
+ wire_version: number;
1503
1674
  cursor: number;
1504
1675
  subscriptions: string;
1505
1676
  updated_at_ms: number;
@@ -1507,6 +1678,7 @@ export class D1ServerStorage implements ServerStorage {
1507
1678
  return results.map((record) => ({
1508
1679
  clientId: record.client_id,
1509
1680
  actorId: record.actor_id,
1681
+ wireVersion: record.wire_version,
1510
1682
  cursor: record.cursor,
1511
1683
  updatedAtMs: record.updated_at_ms,
1512
1684
  subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
@@ -1628,14 +1800,6 @@ export class D1ServerStorage implements ServerStorage {
1628
1800
  }
1629
1801
 
1630
1802
  async listPartitions(): Promise<string[]> {
1631
- // Union: the registry row appears on first commit, the client row on
1632
- // first pull — a partition with only one of the two still shows up.
1633
- const { results } = await this.#db
1634
- .prepare(
1635
- `SELECT partition FROM sync_partitions
1636
- UNION SELECT partition FROM sync_clients ORDER BY partition`,
1637
- )
1638
- .all<{ partition: string }>();
1639
- return results.map((r) => r.partition);
1803
+ return (await this.listPartitionRegistry()).map((entry) => entry.partition);
1640
1804
  }
1641
1805
  }
package/src/errors.ts CHANGED
@@ -199,6 +199,12 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
199
199
  recommendedAction: 'upgradeClient',
200
200
  httpStatus: 400,
201
201
  },
202
+ 'sync.client_wire_unsupported': {
203
+ category: 'schema-mismatch',
204
+ retryable: false,
205
+ recommendedAction: 'upgradeClient',
206
+ httpStatus: 400,
207
+ },
202
208
  'sync.websocket_connection_limit': {
203
209
  category: 'rate-limited',
204
210
  retryable: true,
package/src/events.ts CHANGED
@@ -29,10 +29,11 @@ export interface RequestHandledEvent {
29
29
  /**
30
30
  * `ok` — response streamed to END;
31
31
  * `schema_floor` — §2.4 required-schema answer;
32
+ * `reset` — §2.1 log-epoch reset answer;
32
33
  * `rejected` — request validation failed before any bytes (§1.7);
33
34
  * `error` — in-band ERROR frame (§1.6) or a thrown host failure.
34
35
  */
35
- readonly outcome: 'ok' | 'schema_floor' | 'rejected' | 'error';
36
+ readonly outcome: 'ok' | 'schema_floor' | 'reset' | 'rejected' | 'error';
36
37
  /** §10.2 code for `rejected`/`error`; `"internal"` for non-SyncErrors. */
37
38
  readonly errorCode?: string;
38
39
  readonly pushCommits: number;