@syncular/server 0.21.1 → 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.
- package/README.md +20 -6
- package/dist/pg-executor.d.ts +11 -1
- package/dist/postgres-storage.d.ts +9 -2
- package/dist/postgres-storage.js +79 -3
- package/dist/pull.js +9 -5
- package/dist/realtime.d.ts +40 -1
- package/dist/realtime.js +96 -18
- package/dist/relational-rows.d.ts +20 -0
- package/dist/relational-rows.js +29 -0
- package/dist/s3-segment-store.d.ts +20 -8
- package/dist/s3-segment-store.js +216 -68
- package/dist/schema.js +11 -0
- package/dist/segment-download.d.ts +1 -1
- package/dist/segment-download.js +42 -17
- package/dist/segment-store.d.ts +86 -0
- package/dist/segment-store.js +156 -17
- package/dist/signed-url.d.ts +7 -1
- package/dist/signed-url.js +10 -1
- package/dist/sqlite-dialect.d.ts +7 -1
- package/dist/sqlite-dialect.js +8 -0
- package/dist/sqlite-segment-store.js +153 -42
- package/dist/sqlite-storage.js +50 -1
- package/dist/storage.d.ts +25 -0
- package/dist/validate.d.ts +20 -1
- package/package.json +2 -2
- package/src/pg-executor.ts +11 -1
- package/src/postgres-storage.ts +90 -2
- package/src/pull.ts +9 -1
- package/src/realtime.ts +95 -17
- package/src/relational-rows.ts +36 -0
- package/src/s3-segment-store.ts +275 -84
- package/src/schema.ts +15 -0
- package/src/segment-download.ts +63 -28
- package/src/segment-store.ts +250 -18
- package/src/signed-url.ts +12 -2
- package/src/sqlite-dialect.ts +8 -0
- package/src/sqlite-segment-store.ts +245 -65
- package/src/sqlite-storage.ts +55 -0
- package/src/storage.ts +25 -0
- package/src/validate.ts +20 -1
package/src/segment-store.ts
CHANGED
|
@@ -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
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
)
|
|
141
|
-
|
|
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
|
-
|
|
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;
|
package/src/sqlite-dialect.ts
CHANGED
|
@@ -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,
|