@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.
- package/README.md +20 -6
- package/dist/admin.d.ts +3 -1
- package/dist/admin.js +7 -1
- package/dist/blob-handlers.js +1 -1
- package/dist/context.d.ts +8 -1
- package/dist/d1-storage.d.ts +11 -3
- package/dist/d1-storage.js +84 -2
- package/dist/errors.js +10 -0
- package/dist/handler.js +50 -4
- package/dist/operations.js +3 -3
- package/dist/pg-executor.d.ts +11 -1
- package/dist/postgres-storage.d.ts +20 -5
- package/dist/postgres-storage.js +345 -9
- package/dist/pull.js +9 -5
- package/dist/push.js +18 -0
- package/dist/readiness.d.ts +9 -2
- package/dist/readiness.js +27 -2
- package/dist/realtime.d.ts +40 -1
- package/dist/realtime.js +116 -19
- 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 +50 -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 +23 -0
- package/dist/sqlite-segment-store.js +153 -42
- package/dist/sqlite-storage.d.ts +11 -3
- package/dist/sqlite-storage.js +346 -34
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +4 -0
- package/dist/storage.d.ts +152 -2
- package/dist/storage.js +40 -1
- package/dist/validate.d.ts +20 -1
- package/package.json +2 -2
- package/src/admin.ts +13 -1
- package/src/blob-handlers.ts +1 -1
- package/src/context.ts +8 -0
- package/src/d1-storage.ts +143 -1
- package/src/errors.ts +10 -0
- package/src/handler.ts +62 -4
- package/src/operations.ts +17 -13
- package/src/pg-executor.ts +11 -1
- package/src/postgres-storage.ts +520 -5
- package/src/pull.ts +9 -1
- package/src/push.ts +18 -0
- package/src/readiness.ts +39 -4
- package/src/realtime.ts +117 -18
- 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 +79 -28
- package/src/segment-store.ts +250 -18
- package/src/signed-url.ts +12 -2
- package/src/sqlite-dialect.ts +23 -0
- package/src/sqlite-segment-store.ts +245 -65
- package/src/sqlite-storage.ts +494 -36
- package/src/storage-errors.ts +13 -1
- package/src/storage.ts +243 -1
- 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
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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> {
|