@syncular/server 0.21.0 → 0.22.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.
@@ -24,13 +24,229 @@ export interface SegmentMetadata {
24
24
  readonly nextRowCursor: string | null;
25
25
  }
26
26
 
27
+ /**
28
+ * One publication of a content address (§5.1): the full descriptor context
29
+ * that authorized the bytes plus that publication's own cache lifetime.
30
+ * Identical bytes have one content address, so one stored entry carries one
31
+ * publication per distinct (partition, logEpoch, table, schemaVersion,
32
+ * mediaType, scopeDigest, asOfCommitSeq, cursors) context it was published
33
+ * under. Authorization selects a matching publication; it never unions
34
+ * digests across a different partition or epoch.
35
+ */
36
+ export interface SegmentPublication extends SegmentMetadata {
37
+ readonly byteLength: number;
38
+ readonly createdAtMs: number;
39
+ readonly expiresAtMs: number;
40
+ }
41
+
27
42
  export interface SegmentRecord extends SegmentMetadata {
28
43
  readonly segmentId: string;
44
+ /**
45
+ * Every scope digest this content was published under (§3.5), primary
46
+ * first. A compatibility view over `publications`; download and `find`
47
+ * authorize on a matching publication, not on this union.
48
+ */
49
+ readonly scopeDigests: readonly string[];
50
+ /** Every publication of this content address, oldest first (§5.1). */
51
+ readonly publications: readonly SegmentPublication[];
29
52
  readonly byteLength: number;
30
53
  readonly createdAtMs: number;
31
54
  readonly expiresAtMs: number;
32
55
  }
33
56
 
57
+ /** Canonical identity of one publication; two equal keys are one grant. */
58
+ export function publicationKey(metadata: SegmentMetadata): string {
59
+ return JSON.stringify([
60
+ metadata.partition,
61
+ metadata.logEpoch,
62
+ metadata.table,
63
+ metadata.schemaVersion,
64
+ metadata.mediaType,
65
+ metadata.scopeDigest,
66
+ metadata.asOfCommitSeq,
67
+ metadata.rowCursor,
68
+ metadata.nextRowCursor,
69
+ ]);
70
+ }
71
+
72
+ /**
73
+ * Materialize publications for a record written before provenance existed
74
+ * (in-flight 0.21 objects, §5.1). Only the merged top-level context is
75
+ * known, so each recorded digest is one publication under it: that is the
76
+ * old single-record read behavior preserved verbatim.
77
+ */
78
+ export function publicationsForRecord(
79
+ record: SegmentMetadata & {
80
+ readonly scopeDigests: readonly string[];
81
+ readonly byteLength: number;
82
+ readonly createdAtMs: number;
83
+ readonly expiresAtMs: number;
84
+ },
85
+ ): SegmentPublication[] {
86
+ return record.scopeDigests.map((scopeDigest) => ({
87
+ partition: record.partition,
88
+ logEpoch: record.logEpoch,
89
+ table: record.table,
90
+ schemaVersion: record.schemaVersion,
91
+ mediaType: record.mediaType,
92
+ scopeDigest,
93
+ asOfCommitSeq: record.asOfCommitSeq,
94
+ rowCount: record.rowCount,
95
+ rowCursor: record.rowCursor,
96
+ nextRowCursor: record.nextRowCursor,
97
+ byteLength: record.byteLength,
98
+ createdAtMs: record.createdAtMs,
99
+ expiresAtMs: record.expiresAtMs,
100
+ }));
101
+ }
102
+
103
+ /**
104
+ * The newest publication matching a context, or undefined (§5.5). Optional
105
+ * `nowMs` skips publications whose own TTL has elapsed, so an expired
106
+ * `asOf`/schema publication never shadows a newer live one.
107
+ */
108
+ export interface SegmentPublicationSelector {
109
+ readonly partition: string;
110
+ readonly logEpoch: string;
111
+ readonly scopeDigest: string;
112
+ readonly table?: string;
113
+ readonly schemaVersion?: number;
114
+ readonly mediaType?: 'rows' | 'sqlite';
115
+ readonly asOfCommitSeq?: number;
116
+ readonly rowCursor?: string | null;
117
+ readonly nowMs?: number;
118
+ }
119
+
120
+ export function selectSegmentPublication(
121
+ record: SegmentRecord,
122
+ context: SegmentPublicationSelector,
123
+ ): SegmentPublication | undefined {
124
+ for (let index = record.publications.length - 1; index >= 0; index--) {
125
+ const publication = record.publications[index]!;
126
+ if (
127
+ publication.partition === context.partition &&
128
+ publication.logEpoch === context.logEpoch &&
129
+ publication.scopeDigest === context.scopeDigest &&
130
+ (context.table === undefined || publication.table === context.table) &&
131
+ (context.schemaVersion === undefined ||
132
+ publication.schemaVersion === context.schemaVersion) &&
133
+ (context.mediaType === undefined ||
134
+ publication.mediaType === context.mediaType) &&
135
+ (context.asOfCommitSeq === undefined ||
136
+ publication.asOfCommitSeq === context.asOfCommitSeq) &&
137
+ (context.rowCursor === undefined ||
138
+ publication.rowCursor === context.rowCursor) &&
139
+ (context.nowMs === undefined || publication.expiresAtMs > context.nowMs)
140
+ ) {
141
+ return publication;
142
+ }
143
+ }
144
+ return undefined;
145
+ }
146
+
147
+ /**
148
+ * Project a record onto one publication. The compatibility view is narrowed
149
+ * to that grant: `scopeDigests` and `publications` hold only the chosen
150
+ * publication, so a caller that authorizes from them cannot union a grant
151
+ * from a different partition or epoch.
152
+ */
153
+ export function publicationRecord(
154
+ record: SegmentRecord,
155
+ publication: SegmentPublication,
156
+ ): SegmentRecord {
157
+ return {
158
+ ...record,
159
+ ...publication,
160
+ scopeDigests: [publication.scopeDigest],
161
+ publications: [publication],
162
+ };
163
+ }
164
+
165
+ /** Byte equality for the content-address collision check (§5.1). */
166
+ export function segmentBytesEqual(a: Uint8Array, b: Uint8Array): boolean {
167
+ if (a.length !== b.length) return false;
168
+ for (let i = 0; i < a.length; i++) {
169
+ if (a[i] !== b[i]) return false;
170
+ }
171
+ return true;
172
+ }
173
+
174
+ /**
175
+ * Merge one publication into the stored entry for the same content address
176
+ * (§5.1). Identical bytes are one entry: the record takes the incoming
177
+ * publication's metadata (a later publisher's descriptor, scope digest, and
178
+ * cursors stay consistent with what it just published), the scope digests
179
+ * union with the incoming digest first (the record's primary `scopeDigest`),
180
+ * `createdAtMs` keeps the earliest sighting, and `expiresAtMs` takes the
181
+ * later expiry. A publication with the incoming context merges into that
182
+ * publication's own lifetime; a publication with a new context is appended,
183
+ * so no context's grant is overwritten or extended by another. Publications
184
+ * whose own TTL has elapsed at `nowMs` are pruned from the stored entry —
185
+ * SPEC §5.5 lets the cache forget expired grants, and pruning on put keeps
186
+ * the publication set bounded by live contexts instead of by every context
187
+ * ever seen. Callers MUST have proven the bytes identical first.
188
+ */
189
+ export function mergeSegmentRecord(
190
+ existing: SegmentRecord | undefined,
191
+ metadata: SegmentMetadata,
192
+ segmentId: string,
193
+ byteLength: number,
194
+ nowMs: number,
195
+ ttlMs: number,
196
+ ): SegmentRecord {
197
+ // Prune grants that have already elapsed at this write (§5.5): bounded by
198
+ // live contexts, not by the unbounded set of contexts ever published.
199
+ const prior = (existing?.publications ?? []).filter(
200
+ (publication) => publication.expiresAtMs > nowMs,
201
+ );
202
+ const fresh: SegmentPublication = {
203
+ ...metadata,
204
+ byteLength,
205
+ createdAtMs: nowMs,
206
+ expiresAtMs: nowMs + ttlMs,
207
+ };
208
+ const key = publicationKey(metadata);
209
+ const at = prior.findIndex(
210
+ (publication) => publicationKey(publication) === key,
211
+ );
212
+ // Re-publication of one context merges that publication's lifetime only:
213
+ // an unrelated refresh must not extend another publication's grant (§5.1).
214
+ const publications =
215
+ at < 0
216
+ ? [...prior, fresh]
217
+ : prior.map((publication, index) =>
218
+ index === at
219
+ ? {
220
+ ...fresh,
221
+ createdAtMs: Math.min(publication.createdAtMs, nowMs),
222
+ expiresAtMs: Math.max(publication.expiresAtMs, nowMs + ttlMs),
223
+ }
224
+ : publication,
225
+ );
226
+ return {
227
+ ...metadata,
228
+ segmentId,
229
+ // Compatibility view over the retained publications only, incoming
230
+ // primary first: a pruned publication's digest is gone, not just its
231
+ // descriptor.
232
+ scopeDigests: [
233
+ metadata.scopeDigest,
234
+ ...new Set(
235
+ publications
236
+ .map((publication) => publication.scopeDigest)
237
+ .filter((digest) => digest !== metadata.scopeDigest),
238
+ ),
239
+ ],
240
+ publications,
241
+ byteLength,
242
+ createdAtMs: Math.min(existing?.createdAtMs ?? nowMs, nowMs),
243
+ expiresAtMs: Math.max(
244
+ existing?.expiresAtMs ?? nowMs + ttlMs,
245
+ nowMs + ttlMs,
246
+ ),
247
+ };
248
+ }
249
+
34
250
  /**
35
251
  * The §5.3 reuse key: sqlite images are not byte-deterministic, so
36
252
  * cross-client dedup is a metadata lookup, not hash convergence — one
@@ -81,6 +297,14 @@ export interface SegmentStore {
81
297
  * Unexpired record for the §5.3 reuse key (whole-table segments only:
82
298
  * `rowCursor` null), or undefined. Servers MUST reuse instead of
83
299
  * rebuilding sqlite images while one exists (§5.3).
300
+ *
301
+ * The digest matches when it is one of the record's `scopeDigests`
302
+ * (§5.1). That cannot hand a sqlite image to the wrong digest: a sqlite
303
+ * image embeds its `scopeDigest` in its bytes, and a merge requires byte
304
+ * equality, so two images published under different digests are never
305
+ * byte-identical and never share one entry. Only rows segments merge
306
+ * across digests, and their bytes carry no digest for a reader to compare
307
+ * against the descriptor.
84
308
  */
85
309
  find(key: SegmentFindKey, nowMs: number): Promise<SegmentRecord | undefined>;
86
310
  /** Admin/console counters: ADDITIVE, optional. */
@@ -105,13 +329,20 @@ export class MemorySegmentStore implements SegmentStore {
105
329
  nowMs: number,
106
330
  ): Promise<SegmentRecord> {
107
331
  const segmentId = await segmentIdFor(bytes);
108
- const record: SegmentRecord = {
109
- ...metadata,
332
+ const existing = this.#entries.get(segmentId);
333
+ if (existing !== undefined && !segmentBytesEqual(existing.bytes, bytes)) {
334
+ throw new Error(
335
+ `MemorySegmentStore: content-address collision at ${segmentId} (§5.1)`,
336
+ );
337
+ }
338
+ const record = mergeSegmentRecord(
339
+ existing?.record,
340
+ metadata,
110
341
  segmentId,
111
- byteLength: bytes.length,
112
- createdAtMs: nowMs,
113
- expiresAtMs: nowMs + this.#ttlMs,
114
- };
342
+ bytes.length,
343
+ nowMs,
344
+ this.#ttlMs,
345
+ );
115
346
  this.#entries.set(segmentId, { record, bytes });
116
347
  return record;
117
348
  }
@@ -127,18 +358,19 @@ export class MemorySegmentStore implements SegmentStore {
127
358
  nowMs: number,
128
359
  ): Promise<SegmentRecord | undefined> {
129
360
  for (const { record } of this.#entries.values()) {
130
- if (
131
- record.partition === key.partition &&
132
- record.logEpoch === key.logEpoch &&
133
- record.table === key.table &&
134
- record.schemaVersion === key.schemaVersion &&
135
- record.mediaType === key.mediaType &&
136
- record.scopeDigest === key.scopeDigest &&
137
- record.asOfCommitSeq === key.asOfCommitSeq &&
138
- record.rowCursor === null &&
139
- record.expiresAtMs > nowMs
140
- ) {
141
- return record;
361
+ const publication = selectSegmentPublication(record, {
362
+ partition: key.partition,
363
+ logEpoch: key.logEpoch,
364
+ table: key.table,
365
+ schemaVersion: key.schemaVersion,
366
+ mediaType: key.mediaType,
367
+ scopeDigest: key.scopeDigest,
368
+ asOfCommitSeq: key.asOfCommitSeq,
369
+ rowCursor: null,
370
+ nowMs,
371
+ });
372
+ if (publication !== undefined) {
373
+ return publicationRecord(record, publication);
142
374
  }
143
375
  }
144
376
  return undefined;
package/src/signed-url.ts CHANGED
@@ -264,13 +264,19 @@ const DEFAULT_SKEW_SECONDS = 60;
264
264
  /**
265
265
  * Verify a segment token per §5.4. Throws `SyncError sync.forbidden` on any
266
266
  * failure (MAC, expiry, or claim mismatch).
267
+ *
268
+ * `scopeDigest` is the set of scope digests the token may authorize. Pass the
269
+ * digests of the stored record's publications that match the verifying host's
270
+ * partition: identical bytes are one content address (§5.1), but a digest
271
+ * recorded under another partition is not this host's grant (§5.4). A single
272
+ * string stays valid for a record with one publication.
267
273
  */
268
274
  export async function verifySegmentToken(
269
275
  key: string | Uint8Array,
270
276
  token: string,
271
277
  expected: {
272
278
  readonly segmentId: string;
273
- readonly scopeDigest: string;
279
+ readonly scopeDigest: string | readonly string[];
274
280
  readonly audience: string;
275
281
  readonly nowMs: number;
276
282
  readonly skewSeconds?: number;
@@ -301,7 +307,11 @@ export async function verifySegmentToken(
301
307
  throw forbidden('token expired');
302
308
  }
303
309
  if (claims.seg !== expected.segmentId) throw forbidden('segment mismatch');
304
- if (claims.sd !== expected.scopeDigest)
310
+ const scopeDigests =
311
+ typeof expected.scopeDigest === 'string'
312
+ ? [expected.scopeDigest]
313
+ : expected.scopeDigest;
314
+ if (!scopeDigests.includes(claims.sd))
305
315
  throw forbidden('scope-digest mismatch');
306
316
  if (claims.aud !== expected.audience) throw forbidden('audience mismatch');
307
317
  return claims;
@@ -36,6 +36,12 @@ import type {
36
36
  * range that returns already-ordered rows — the same covering-index shape
37
37
  * the Postgres storage documents (§3.1, performance-by-
38
38
  * construction).
39
+ *
40
+ * `sync_row_scopes_by_row` is the one secondary scope index: a per-row
41
+ * scope replacement predicates on `(partition, tbl, row_id)`, and `row_id`
42
+ * is the LAST PRIMARY KEY column, so that predicate cannot narrow the
43
+ * inverted index. Declared here so every SQLite-family storage (and D1,
44
+ * which applies this same DDL) keeps the same plan shape as Postgres.
39
45
  */
40
46
  export const SQLITE_DDL = `
41
47
  CREATE TABLE IF NOT EXISTS sync_partitions(
@@ -54,6 +60,8 @@ CREATE TABLE IF NOT EXISTS sync_row_scopes(
54
60
  var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
55
61
  PRIMARY KEY(partition, tbl, var, value, row_id)
56
62
  );
63
+ CREATE INDEX IF NOT EXISTS sync_row_scopes_by_row
64
+ ON sync_row_scopes(partition, tbl, row_id);
57
65
  CREATE TABLE IF NOT EXISTS sync_commits(
58
66
  partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,
59
67
  client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,