@syncular/server 0.21.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +20 -6
  2. package/dist/admin.d.ts +3 -1
  3. package/dist/admin.js +7 -1
  4. package/dist/blob-handlers.js +1 -1
  5. package/dist/context.d.ts +8 -1
  6. package/dist/d1-storage.d.ts +11 -3
  7. package/dist/d1-storage.js +84 -2
  8. package/dist/errors.js +10 -0
  9. package/dist/handler.js +50 -4
  10. package/dist/operations.js +3 -3
  11. package/dist/pg-executor.d.ts +11 -1
  12. package/dist/postgres-storage.d.ts +20 -5
  13. package/dist/postgres-storage.js +345 -9
  14. package/dist/pull.js +9 -5
  15. package/dist/push.js +18 -0
  16. package/dist/readiness.d.ts +9 -2
  17. package/dist/readiness.js +27 -2
  18. package/dist/realtime.d.ts +40 -1
  19. package/dist/realtime.js +116 -19
  20. package/dist/relational-rows.d.ts +20 -0
  21. package/dist/relational-rows.js +29 -0
  22. package/dist/s3-segment-store.d.ts +20 -8
  23. package/dist/s3-segment-store.js +216 -68
  24. package/dist/schema.js +11 -0
  25. package/dist/segment-download.d.ts +1 -1
  26. package/dist/segment-download.js +50 -17
  27. package/dist/segment-store.d.ts +86 -0
  28. package/dist/segment-store.js +156 -17
  29. package/dist/signed-url.d.ts +7 -1
  30. package/dist/signed-url.js +10 -1
  31. package/dist/sqlite-dialect.d.ts +7 -1
  32. package/dist/sqlite-dialect.js +23 -0
  33. package/dist/sqlite-segment-store.js +153 -42
  34. package/dist/sqlite-storage.d.ts +11 -3
  35. package/dist/sqlite-storage.js +346 -34
  36. package/dist/storage-errors.d.ts +1 -1
  37. package/dist/storage-errors.js +4 -0
  38. package/dist/storage.d.ts +152 -2
  39. package/dist/storage.js +40 -1
  40. package/dist/validate.d.ts +20 -1
  41. package/package.json +2 -2
  42. package/src/admin.ts +13 -1
  43. package/src/blob-handlers.ts +1 -1
  44. package/src/context.ts +8 -0
  45. package/src/d1-storage.ts +143 -1
  46. package/src/errors.ts +10 -0
  47. package/src/handler.ts +62 -4
  48. package/src/operations.ts +17 -13
  49. package/src/pg-executor.ts +11 -1
  50. package/src/postgres-storage.ts +520 -5
  51. package/src/pull.ts +9 -1
  52. package/src/push.ts +18 -0
  53. package/src/readiness.ts +39 -4
  54. package/src/realtime.ts +117 -18
  55. package/src/relational-rows.ts +36 -0
  56. package/src/s3-segment-store.ts +275 -84
  57. package/src/schema.ts +15 -0
  58. package/src/segment-download.ts +79 -28
  59. package/src/segment-store.ts +250 -18
  60. package/src/signed-url.ts +12 -2
  61. package/src/sqlite-dialect.ts +23 -0
  62. package/src/sqlite-segment-store.ts +245 -65
  63. package/src/sqlite-storage.ts +494 -36
  64. package/src/storage-errors.ts +13 -1
  65. package/src/storage.ts +243 -1
  66. package/src/validate.ts +20 -1
@@ -3,12 +3,19 @@
3
3
  */
4
4
  import {
5
5
  DEFAULT_SEGMENT_TTL_MS,
6
+ mergeSegmentRecord,
7
+ publicationKey,
8
+ publicationRecord,
9
+ publicationsForRecord,
6
10
  type SegmentFindKey,
7
11
  type SegmentMetadata,
12
+ type SegmentPublication,
8
13
  type SegmentRecord,
14
+ segmentBytesEqual,
9
15
  type SegmentStore,
10
16
  type SegmentStoreStats,
11
17
  segmentIdFor,
18
+ selectSegmentPublication,
12
19
  } from './segment-store';
13
20
  import {
14
21
  SqliteAdapterRequiredError,
@@ -40,6 +47,33 @@ export class SqliteSegmentStore implements SegmentStore {
40
47
  expires_at_ms INTEGER NOT NULL, bytes BLOB NOT NULL
41
48
  );
42
49
  `);
50
+ // One row per scope digest a content address was published under (§5.1):
51
+ // identical bytes from two scopes share a `segment_id` and must both stay
52
+ // downloadable (§5.5).
53
+ this.db.exec(`
54
+ CREATE TABLE IF NOT EXISTS sync_segment_scopes(
55
+ segment_id TEXT NOT NULL, scope_digest TEXT NOT NULL,
56
+ PRIMARY KEY (segment_id, scope_digest)
57
+ );
58
+ `);
59
+ // One row per publication of a content address (§5.1): the same bytes
60
+ // under a different partition, epoch, table, or pin are distinct grants
61
+ // and must each stay downloadable. Rows written before this table existed
62
+ // materialize from the top-level record plus `sync_segment_scopes` on
63
+ // read, so an in-flight 0.21 object loses no publication.
64
+ this.db.exec(`
65
+ CREATE TABLE IF NOT EXISTS sync_segment_publications(
66
+ segment_id TEXT NOT NULL, pub_key TEXT NOT NULL,
67
+ partition TEXT NOT NULL, log_epoch TEXT NOT NULL,
68
+ tbl TEXT NOT NULL, schema_version INTEGER NOT NULL,
69
+ media_type TEXT NOT NULL, scope_digest TEXT NOT NULL,
70
+ as_of_commit_seq INTEGER NOT NULL, row_count INTEGER NOT NULL,
71
+ row_cursor TEXT, next_row_cursor TEXT,
72
+ byte_length INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,
73
+ expires_at_ms INTEGER NOT NULL,
74
+ PRIMARY KEY (segment_id, pub_key)
75
+ );
76
+ `);
43
77
  const columns = this.db
44
78
  .query<{ name: string }, []>('PRAGMA table_info(sync_segments)')
45
79
  .all();
@@ -48,6 +82,64 @@ export class SqliteSegmentStore implements SegmentStore {
48
82
  "ALTER TABLE sync_segments ADD COLUMN log_epoch TEXT NOT NULL DEFAULT ''",
49
83
  );
50
84
  }
85
+ // Backfill digests recorded before the side table existed, so an
86
+ // upgraded store keeps `find`/download working for cached segments.
87
+ this.db.exec(
88
+ `INSERT OR IGNORE INTO sync_segment_scopes(segment_id, scope_digest)
89
+ SELECT segment_id, scope_digest FROM sync_segments`,
90
+ );
91
+ }
92
+
93
+ /** Every digest recorded for a content address, primary first. */
94
+ #scopeDigestsFor(segmentId: string, primary: string): string[] {
95
+ const rows = this.db
96
+ .query<{ scope_digest: string }, [string]>(
97
+ 'SELECT scope_digest FROM sync_segment_scopes WHERE segment_id=?',
98
+ )
99
+ .all(segmentId);
100
+ const digests = rows.map((row) => row.scope_digest);
101
+ return [primary, ...digests.filter((digest) => digest !== primary)];
102
+ }
103
+
104
+ /** Stored publications for a content address, oldest first. */
105
+ #publicationsFor(segmentId: string): SegmentPublication[] {
106
+ const rows = this.db
107
+ .query<
108
+ {
109
+ partition: string;
110
+ log_epoch: string;
111
+ tbl: string;
112
+ schema_version: number;
113
+ media_type: string;
114
+ scope_digest: string;
115
+ as_of_commit_seq: number;
116
+ row_count: number;
117
+ row_cursor: string | null;
118
+ next_row_cursor: string | null;
119
+ byte_length: number;
120
+ created_at_ms: number;
121
+ expires_at_ms: number;
122
+ },
123
+ [string]
124
+ >(
125
+ 'SELECT * FROM sync_segment_publications WHERE segment_id=? ORDER BY rowid',
126
+ )
127
+ .all(segmentId);
128
+ return rows.map((row) => ({
129
+ partition: row.partition,
130
+ logEpoch: row.log_epoch,
131
+ table: row.tbl,
132
+ schemaVersion: row.schema_version,
133
+ mediaType: row.media_type === 'sqlite' ? 'sqlite' : 'rows',
134
+ scopeDigest: row.scope_digest,
135
+ asOfCommitSeq: row.as_of_commit_seq,
136
+ rowCount: row.row_count,
137
+ rowCursor: row.row_cursor,
138
+ nextRowCursor: row.next_row_cursor,
139
+ byteLength: row.byte_length,
140
+ createdAtMs: row.created_at_ms,
141
+ expiresAtMs: row.expires_at_ms,
142
+ }));
51
143
  }
52
144
 
53
145
  async put(
@@ -56,38 +148,99 @@ export class SqliteSegmentStore implements SegmentStore {
56
148
  nowMs: number,
57
149
  ): Promise<SegmentRecord> {
58
150
  const segmentId = await segmentIdFor(bytes);
59
- const record: SegmentRecord = {
60
- ...metadata,
151
+ const existing = await this.get(segmentId);
152
+ if (existing !== undefined && !segmentBytesEqual(existing.bytes, bytes)) {
153
+ throw new Error(
154
+ `SqliteSegmentStore: content-address collision at ${segmentId} (§5.1)`,
155
+ );
156
+ }
157
+ const record = mergeSegmentRecord(
158
+ existing?.record,
159
+ metadata,
61
160
  segmentId,
62
- byteLength: bytes.length,
63
- createdAtMs: nowMs,
64
- expiresAtMs: nowMs + this.#ttlMs,
65
- };
66
- this.db
67
- .query(
68
- `INSERT OR REPLACE INTO sync_segments(
69
- segment_id, partition, log_epoch, tbl, schema_version, media_type,
70
- scope_digest, as_of_commit_seq, row_count, row_cursor,
71
- next_row_cursor, byte_length, created_at_ms, expires_at_ms, bytes
72
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
73
- )
74
- .run(
75
- record.segmentId,
76
- record.partition,
77
- record.logEpoch,
78
- record.table,
79
- record.schemaVersion,
80
- record.mediaType,
81
- record.scopeDigest,
82
- record.asOfCommitSeq,
83
- record.rowCount,
84
- record.rowCursor,
85
- record.nextRowCursor,
86
- record.byteLength,
87
- record.createdAtMs,
88
- record.expiresAtMs,
89
- bytes,
161
+ bytes.length,
162
+ nowMs,
163
+ this.#ttlMs,
164
+ );
165
+ // The bytes are proven identical to the stored ones, so replacing the
166
+ // row with the merged record (metadata, digest set, merged times) is the
167
+ // merge: the other digests live in `sync_segment_scopes` below. The
168
+ // publication set is rewritten from the merged record in one transaction
169
+ // so a crash cannot leave provenance half-applied.
170
+ this.db.exec('BEGIN IMMEDIATE');
171
+ try {
172
+ this.db
173
+ .query(
174
+ `INSERT OR REPLACE INTO sync_segments(
175
+ segment_id, partition, log_epoch, tbl, schema_version, media_type,
176
+ scope_digest, as_of_commit_seq, row_count, row_cursor,
177
+ next_row_cursor, byte_length, created_at_ms, expires_at_ms, bytes
178
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
179
+ )
180
+ .run(
181
+ record.segmentId,
182
+ record.partition,
183
+ record.logEpoch,
184
+ record.table,
185
+ record.schemaVersion,
186
+ record.mediaType,
187
+ record.scopeDigest,
188
+ record.asOfCommitSeq,
189
+ record.rowCount,
190
+ record.rowCursor,
191
+ record.nextRowCursor,
192
+ record.byteLength,
193
+ record.createdAtMs,
194
+ record.expiresAtMs,
195
+ bytes,
196
+ );
197
+ // Rebuild the digest side table from the merged record so a pruned
198
+ // (expired) publication's digest disappears from the compatibility
199
+ // view instead of lingering as a stale scope row.
200
+ this.db
201
+ .query('DELETE FROM sync_segment_scopes WHERE segment_id=?')
202
+ .run(record.segmentId);
203
+ const insertScope = this.db.query(
204
+ `INSERT OR IGNORE INTO sync_segment_scopes(segment_id, scope_digest)
205
+ VALUES (?,?)`,
206
+ );
207
+ for (const digest of record.scopeDigests) {
208
+ insertScope.run(record.segmentId, digest);
209
+ }
210
+ this.db
211
+ .query('DELETE FROM sync_segment_publications WHERE segment_id=?')
212
+ .run(record.segmentId);
213
+ const insertPublication = this.db.query(
214
+ `INSERT INTO sync_segment_publications(
215
+ segment_id, pub_key, partition, log_epoch, tbl, schema_version,
216
+ media_type, scope_digest, as_of_commit_seq, row_count, row_cursor,
217
+ next_row_cursor, byte_length, created_at_ms, expires_at_ms
218
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
90
219
  );
220
+ for (const publication of record.publications) {
221
+ insertPublication.run(
222
+ record.segmentId,
223
+ publicationKey(publication),
224
+ publication.partition,
225
+ publication.logEpoch,
226
+ publication.table,
227
+ publication.schemaVersion,
228
+ publication.mediaType,
229
+ publication.scopeDigest,
230
+ publication.asOfCommitSeq,
231
+ publication.rowCount,
232
+ publication.rowCursor,
233
+ publication.nextRowCursor,
234
+ publication.byteLength,
235
+ publication.createdAtMs,
236
+ publication.expiresAtMs,
237
+ );
238
+ }
239
+ this.db.exec('COMMIT');
240
+ } catch (error) {
241
+ this.db.exec('ROLLBACK');
242
+ throw error;
243
+ }
91
244
  return record;
92
245
  }
93
246
 
@@ -117,22 +270,33 @@ export class SqliteSegmentStore implements SegmentStore {
117
270
  >('SELECT * FROM sync_segments WHERE segment_id=?')
118
271
  .get(segmentId);
119
272
  if (row === null) return undefined;
273
+ const scopeDigests = this.#scopeDigestsFor(
274
+ row.segment_id,
275
+ row.scope_digest,
276
+ );
277
+ const top = {
278
+ segmentId: row.segment_id,
279
+ partition: row.partition,
280
+ logEpoch: row.log_epoch,
281
+ table: row.tbl,
282
+ schemaVersion: row.schema_version,
283
+ mediaType:
284
+ row.media_type === 'sqlite' ? ('sqlite' as const) : ('rows' as const),
285
+ scopeDigest: row.scope_digest,
286
+ scopeDigests,
287
+ asOfCommitSeq: row.as_of_commit_seq,
288
+ rowCount: row.row_count,
289
+ rowCursor: row.row_cursor,
290
+ nextRowCursor: row.next_row_cursor,
291
+ byteLength: row.byte_length,
292
+ createdAtMs: row.created_at_ms,
293
+ expiresAtMs: row.expires_at_ms,
294
+ };
295
+ const stored = this.#publicationsFor(row.segment_id);
120
296
  return {
121
297
  record: {
122
- segmentId: row.segment_id,
123
- partition: row.partition,
124
- logEpoch: row.log_epoch,
125
- table: row.tbl,
126
- schemaVersion: row.schema_version,
127
- mediaType: row.media_type === 'sqlite' ? 'sqlite' : 'rows',
128
- scopeDigest: row.scope_digest,
129
- asOfCommitSeq: row.as_of_commit_seq,
130
- rowCount: row.row_count,
131
- rowCursor: row.row_cursor,
132
- nextRowCursor: row.next_row_cursor,
133
- byteLength: row.byte_length,
134
- createdAtMs: row.created_at_ms,
135
- expiresAtMs: row.expires_at_ms,
298
+ ...top,
299
+ publications: stored.length > 0 ? stored : publicationsForRecord(top),
136
300
  },
137
301
  bytes: new Uint8Array(row.bytes),
138
302
  };
@@ -142,21 +306,14 @@ export class SqliteSegmentStore implements SegmentStore {
142
306
  key: SegmentFindKey,
143
307
  nowMs: number,
144
308
  ): Promise<SegmentRecord | undefined> {
145
- const row = this.db
309
+ // A publication row is the provenance index; a record written before the
310
+ // table existed falls back to the legacy top-level/scope join below.
311
+ const publication = this.db
146
312
  .query<
147
- {
148
- segment_id: string;
149
- row_count: number;
150
- next_row_cursor: string | null;
151
- byte_length: number;
152
- created_at_ms: number;
153
- expires_at_ms: number;
154
- },
313
+ { segment_id: string },
155
314
  [string, string, string, number, string, string, number, number]
156
315
  >(
157
- `SELECT segment_id, row_count, next_row_cursor, byte_length,
158
- created_at_ms, expires_at_ms
159
- FROM sync_segments
316
+ `SELECT segment_id FROM sync_segment_publications
160
317
  WHERE partition=? AND log_epoch=? AND tbl=? AND schema_version=? AND media_type=?
161
318
  AND scope_digest=? AND as_of_commit_seq=? AND row_cursor IS NULL
162
319
  AND expires_at_ms > ?
@@ -172,9 +329,34 @@ export class SqliteSegmentStore implements SegmentStore {
172
329
  key.asOfCommitSeq,
173
330
  nowMs,
174
331
  );
175
- if (row === null) return undefined;
176
- return {
177
- segmentId: row.segment_id,
332
+ const legacy = this.db
333
+ .query<
334
+ { segment_id: string },
335
+ [string, string, string, number, string, string, number, number]
336
+ >(
337
+ `SELECT s.segment_id
338
+ FROM sync_segments s
339
+ JOIN sync_segment_scopes sc ON sc.segment_id = s.segment_id
340
+ WHERE s.partition=? AND s.log_epoch=? AND s.tbl=? AND s.schema_version=? AND s.media_type=?
341
+ AND sc.scope_digest=? AND s.as_of_commit_seq=? AND s.row_cursor IS NULL
342
+ AND s.expires_at_ms > ?
343
+ LIMIT 1`,
344
+ )
345
+ .get(
346
+ key.partition,
347
+ key.logEpoch,
348
+ key.table,
349
+ key.schemaVersion,
350
+ key.mediaType,
351
+ key.scopeDigest,
352
+ key.asOfCommitSeq,
353
+ nowMs,
354
+ );
355
+ const segmentId = publication?.segment_id ?? legacy?.segment_id;
356
+ if (segmentId === undefined) return undefined;
357
+ const entry = await this.get(segmentId);
358
+ if (entry === undefined) return undefined;
359
+ const match = selectSegmentPublication(entry.record, {
178
360
  partition: key.partition,
179
361
  logEpoch: key.logEpoch,
180
362
  table: key.table,
@@ -182,13 +364,11 @@ export class SqliteSegmentStore implements SegmentStore {
182
364
  mediaType: key.mediaType,
183
365
  scopeDigest: key.scopeDigest,
184
366
  asOfCommitSeq: key.asOfCommitSeq,
185
- rowCount: row.row_count,
186
367
  rowCursor: null,
187
- nextRowCursor: row.next_row_cursor,
188
- byteLength: row.byte_length,
189
- createdAtMs: row.created_at_ms,
190
- expiresAtMs: row.expires_at_ms,
191
- };
368
+ nowMs,
369
+ });
370
+ if (match === undefined) return undefined;
371
+ return publicationRecord(entry.record, match);
192
372
  }
193
373
 
194
374
  async stats(): Promise<SegmentStoreStats> {