@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
|
@@ -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> {
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from './authoritative-query';
|
|
16
16
|
import { syncError } from './errors';
|
|
17
17
|
import {
|
|
18
|
+
assertPhysicalColumns,
|
|
18
19
|
commitWindowPageSql,
|
|
19
20
|
deleteRowSql,
|
|
20
21
|
deleteSqliteRowScopesSql,
|
|
@@ -481,6 +482,60 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
481
482
|
`stored schema version ${marker.schema_version} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`,
|
|
482
483
|
);
|
|
483
484
|
}
|
|
485
|
+
if (marker !== null && marker.schema_version === schema.version) {
|
|
486
|
+
// Version equality is not layout equality: a marker written by another
|
|
487
|
+
// build at the same version describes rows the running codec cannot
|
|
488
|
+
// decode. Compare the stored layouts instead of trusting the number.
|
|
489
|
+
const storedLayouts = parseLayouts(marker.layouts);
|
|
490
|
+
const configuredLayouts = parseLayouts(layoutsOf(schema));
|
|
491
|
+
let mismatch: string | undefined;
|
|
492
|
+
for (const [tableName, columns] of Object.entries(configuredLayouts)) {
|
|
493
|
+
const stored = storedLayouts[tableName];
|
|
494
|
+
const columnCount = Math.max(columns.length, stored?.length ?? 0);
|
|
495
|
+
for (let index = 0; index < columnCount; index++) {
|
|
496
|
+
const expected = columns[index];
|
|
497
|
+
const actual = stored?.[index];
|
|
498
|
+
if (
|
|
499
|
+
expected !== undefined &&
|
|
500
|
+
actual !== undefined &&
|
|
501
|
+
actual.name === expected.name &&
|
|
502
|
+
actual.type === expected.type &&
|
|
503
|
+
actual.nullable === expected.nullable
|
|
504
|
+
) {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
if (mismatch !== undefined) break;
|
|
511
|
+
}
|
|
512
|
+
if (mismatch === undefined) {
|
|
513
|
+
for (const tableName of Object.keys(storedLayouts)) {
|
|
514
|
+
if (!(tableName in configuredLayouts)) {
|
|
515
|
+
mismatch = `table ${JSON.stringify(tableName)}`;
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (mismatch !== undefined) {
|
|
521
|
+
throw new Error(
|
|
522
|
+
`stored schema layouts disagree with the configured schema at version ${schema.version} (${mismatch}) — refusing to serve a database whose stored rows the running code cannot decode`,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
// The persisted layouts describe the codec's app columns only: read the
|
|
526
|
+
// physical tables so a same-version database missing a
|
|
527
|
+
// storage-internal column is refused at startup instead of failing at
|
|
528
|
+
// the first write.
|
|
529
|
+
for (const table of schema.tables.values()) {
|
|
530
|
+
const escapedTableName = table.name.replaceAll('"', '""');
|
|
531
|
+
const columns = this.db
|
|
532
|
+
.query<{ name: string }, []>(
|
|
533
|
+
`PRAGMA table_info("${escapedTableName}")`,
|
|
534
|
+
)
|
|
535
|
+
.all();
|
|
536
|
+
assertPhysicalColumns(table, new Set(columns.map((c) => c.name)));
|
|
537
|
+
}
|
|
538
|
+
}
|
|
484
539
|
if (marker === null || marker.schema_version < schema.version) {
|
|
485
540
|
// Introspect existing app tables, then apply the migration subset
|
|
486
541
|
// (CREATE TABLE / ADD COLUMN / rebuild indexes) to reach `schema`, then
|
package/src/storage.ts
CHANGED
|
@@ -458,6 +458,19 @@ export interface ServerStorage {
|
|
|
458
458
|
/** Read continuity without refreshing authenticated activity. */
|
|
459
459
|
getPartitionLogEpoch(partition: string): Promise<string | undefined>;
|
|
460
460
|
|
|
461
|
+
/**
|
|
462
|
+
* Open a storage transaction. This is the raw primitive, not a command
|
|
463
|
+
* boundary: it acquires NO partition write lock. In-tree SQLite begins
|
|
464
|
+
* `IMMEDIATE`, so a hand-rolled command on SQLite happens to be serialized
|
|
465
|
+
* against a concurrent push; PostgreSQL's `BEGIN` takes no row lock, so the
|
|
466
|
+
* same command races a push on Postgres and a SQLite-only test conceals it.
|
|
467
|
+
*
|
|
468
|
+
* A trusted command must therefore run through `registerRemoteCommand`,
|
|
469
|
+
* which builds after `lockPartitionForPush` and the idempotency recheck and
|
|
470
|
+
* shares the committed transaction, or call `lockPartitionForPush` before
|
|
471
|
+
* its first authority, version, or replay read. SSP2 request handling does
|
|
472
|
+
* this for you; only host-authored commands choose.
|
|
473
|
+
*/
|
|
461
474
|
begin(partition: string): Promise<StorageTransaction>;
|
|
462
475
|
|
|
463
476
|
getMaxCommitSeq(partition: string): Promise<number>;
|
|
@@ -560,6 +573,18 @@ export interface ServerStorage {
|
|
|
560
573
|
* Optional registered-query capability. The implementation MUST replace
|
|
561
574
|
* every generated app-table relation with a partition-filtered relation and
|
|
562
575
|
* return rows plus maxCommitSeq from one consistent database snapshot.
|
|
576
|
+
*
|
|
577
|
+
* The storage serializes this call behind an open transaction on a
|
|
578
|
+
* single-connection executor (SQLite, PGlite). A validator runs inside the
|
|
579
|
+
* push transaction, so on that executor a validator that calls
|
|
580
|
+
* `queryAuthoritative` waits on the transaction it is already inside and
|
|
581
|
+
* the push never completes. A pool-backed executor (`pg`, `Bun.sql`) hands
|
|
582
|
+
* the call a second connection instead: the validator does not deadlock,
|
|
583
|
+
* but it reads committed state without the candidate write. On either
|
|
584
|
+
* executor read candidate state through the whole-commit `CommitValidator`
|
|
585
|
+
* reader (§6.8). A call made from any other context waits for the open
|
|
586
|
+
* transaction to finish on a single-connection executor, and runs
|
|
587
|
+
* concurrently on a pool-backed one.
|
|
563
588
|
*/
|
|
564
589
|
queryAuthoritative?(
|
|
565
590
|
partition: string,
|
package/src/validate.ts
CHANGED
|
@@ -123,7 +123,19 @@ export type CommitValidator = (
|
|
|
123
123
|
input: ValidateCommitInput,
|
|
124
124
|
) => void | Promise<void>;
|
|
125
125
|
|
|
126
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* Ambient context a validator may consult (§6.7).
|
|
128
|
+
*
|
|
129
|
+
* A validator runs inside the push transaction. On a single-connection
|
|
130
|
+
* executor (SQLite, PGlite) the storage serializes authoritative queries
|
|
131
|
+
* behind that transaction, so a validator that calls `queryAuthoritative`
|
|
132
|
+
* waits for the transaction it is already inside and the push never
|
|
133
|
+
* completes. On a pool-backed executor (`pg`, `Bun.sql`) the query takes a
|
|
134
|
+
* second connection and returns committed state without the candidate write.
|
|
135
|
+
* Either way a rule that must read rows belongs in the whole-commit
|
|
136
|
+
* `CommitValidator`, whose reader runs get and scan operations on the same
|
|
137
|
+
* transaction (§6.8).
|
|
138
|
+
*/
|
|
127
139
|
export interface ValidateContext {
|
|
128
140
|
/** Host-authenticated actor (§1.1) performing the write. */
|
|
129
141
|
readonly actorId: string;
|
|
@@ -139,6 +151,13 @@ export interface ValidateContext {
|
|
|
139
151
|
* any other error is treated as a rejection with a generic
|
|
140
152
|
* `sync.constraint_violation` code (a validator SHOULD throw
|
|
141
153
|
* `ValidationRejection` to control the code). MUST NOT mutate the row.
|
|
154
|
+
*
|
|
155
|
+
* The hook runs inside the push transaction and cannot read candidate rows
|
|
156
|
+
* from an authoritative query: on a single-connection executor
|
|
157
|
+
* `queryAuthoritative` serializes behind that transaction and the push never
|
|
158
|
+
* completes, and on a pool-backed executor it reads committed state without
|
|
159
|
+
* the candidate write. Read rows with the whole-commit `CommitValidator`
|
|
160
|
+
* reader instead (§6.8).
|
|
142
161
|
*/
|
|
143
162
|
export type Validator = (
|
|
144
163
|
op: ValidateOperation,
|