@syncular/server 0.15.46 → 0.15.48
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 +17 -4
- package/dist/admin.d.ts +1 -5
- package/dist/admin.js +2 -7
- package/dist/blob-handlers.js +4 -1
- package/dist/context.d.ts +5 -2
- package/dist/context.js +4 -0
- package/dist/d1-storage.d.ts +4 -1
- package/dist/d1-storage.js +75 -11
- package/dist/errors.js +6 -0
- package/dist/events.d.ts +2 -1
- package/dist/frame-bytes.d.ts +2 -2
- package/dist/frame-bytes.js +33 -14
- package/dist/handler.js +58 -14
- package/dist/index-bun.d.ts +2 -0
- package/dist/index-bun.js +2 -0
- package/dist/index-node.d.ts +2 -0
- package/dist/index-node.js +2 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -6
- package/dist/operations.js +2 -1
- package/dist/postgres-storage.d.ts +5 -2
- package/dist/postgres-storage.js +71 -10
- package/dist/pull.d.ts +1 -1
- package/dist/pull.js +12 -6
- package/dist/realtime.d.ts +4 -1
- package/dist/realtime.js +33 -9
- package/dist/restore.d.ts +13 -0
- package/dist/restore.js +13 -0
- package/dist/s3-segment-store.js +10 -1
- package/dist/seed.js +36 -4
- package/dist/segment-download.js +5 -2
- package/dist/segment-store.d.ts +3 -0
- package/dist/segment-store.js +1 -0
- package/dist/sqlite-blob-store.d.ts +4 -9
- package/dist/sqlite-blob-store.js +5 -10
- package/dist/sqlite-bun-driver.d.ts +12 -0
- package/dist/sqlite-bun-driver.js +30 -0
- package/dist/sqlite-bun.d.ts +24 -0
- package/dist/sqlite-bun.js +40 -0
- package/dist/sqlite-dialect.d.ts +8 -8
- package/dist/sqlite-dialect.js +9 -2
- package/dist/sqlite-driver.d.ts +26 -0
- package/dist/sqlite-driver.js +8 -0
- package/dist/sqlite-image.d.ts +7 -9
- package/dist/sqlite-image.js +26 -28
- package/dist/sqlite-lease-store.d.ts +4 -9
- package/dist/sqlite-lease-store.js +5 -10
- package/dist/sqlite-node-driver.d.ts +10 -0
- package/dist/sqlite-node-driver.js +30 -0
- package/dist/sqlite-node.d.ts +24 -0
- package/dist/sqlite-node.js +50 -0
- package/dist/sqlite-segment-store.d.ts +4 -10
- package/dist/sqlite-segment-store.js +20 -14
- package/dist/sqlite-storage.d.ts +7 -12
- package/dist/sqlite-storage.js +89 -16
- package/dist/storage-errors.js +4 -1
- package/dist/storage.d.ts +18 -5
- package/package.json +18 -3
- package/src/admin.ts +3 -9
- package/src/blob-handlers.ts +8 -1
- package/src/context.ts +18 -2
- package/src/d1-storage.ts +107 -12
- package/src/errors.ts +6 -0
- package/src/events.ts +2 -1
- package/src/frame-bytes.ts +40 -14
- package/src/handler.ts +102 -29
- package/src/index-bun.ts +9 -0
- package/src/index-node.ts +9 -0
- package/src/index.ts +9 -6
- package/src/operations.ts +6 -1
- package/src/postgres-storage.ts +102 -13
- package/src/pull.ts +12 -1
- package/src/realtime.ts +46 -7
- package/src/restore.ts +28 -0
- package/src/s3-segment-store.ts +10 -1
- package/src/seed.ts +46 -4
- package/src/segment-download.ts +11 -2
- package/src/segment-store.ts +4 -0
- package/src/sqlite-blob-store.ts +11 -10
- package/src/sqlite-bun-driver.ts +46 -0
- package/src/sqlite-bun.ts +53 -0
- package/src/sqlite-dialect.ts +14 -7
- package/src/sqlite-driver.ts +44 -0
- package/src/sqlite-image.ts +44 -49
- package/src/sqlite-lease-store.ts +11 -10
- package/src/sqlite-node-driver.ts +46 -0
- package/src/sqlite-node.ts +62 -0
- package/src/sqlite-segment-store.ts +29 -15
- package/src/sqlite-storage.ts +131 -19
- package/src/storage-errors.ts +4 -1
- package/src/storage.ts +28 -5
|
@@ -1,23 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SQLite-backed segment store
|
|
3
|
-
* dependency-free). Bun-specific by design: it imports `bun:sqlite` at the
|
|
4
|
-
* top level, so it lives in its own module — importing it opts into the Bun
|
|
5
|
-
* runtime. The runtime-neutral `SegmentStore` interface, `MemorySegmentStore`,
|
|
6
|
-
* and `segmentIdFor` stay in `segment-store.ts` so the Workers/edge core can
|
|
7
|
-
* import them without pulling in `bun:sqlite` (runtime neutrality is enforced
|
|
8
|
-
* by `test/runtime-neutrality.test.ts`).
|
|
2
|
+
* SQLite-backed segment store over the shared synchronous driver.
|
|
9
3
|
*/
|
|
10
|
-
import { Database } from 'bun:sqlite';
|
|
11
4
|
import { DEFAULT_SEGMENT_TTL_MS, segmentIdFor, } from './segment-store.js';
|
|
5
|
+
import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
|
|
12
6
|
export class SqliteSegmentStore {
|
|
13
7
|
db;
|
|
14
8
|
#ttlMs;
|
|
15
9
|
constructor(db = ':memory:', options) {
|
|
16
|
-
|
|
10
|
+
if (typeof db === 'string') {
|
|
11
|
+
throw new SqliteAdapterRequiredError();
|
|
12
|
+
}
|
|
13
|
+
this.db = db;
|
|
17
14
|
this.#ttlMs = options?.ttlMs ?? DEFAULT_SEGMENT_TTL_MS;
|
|
18
15
|
this.db.exec(`
|
|
19
16
|
CREATE TABLE IF NOT EXISTS sync_segments(
|
|
20
17
|
segment_id TEXT PRIMARY KEY, partition TEXT NOT NULL,
|
|
18
|
+
log_epoch TEXT NOT NULL,
|
|
21
19
|
tbl TEXT NOT NULL, schema_version INTEGER NOT NULL,
|
|
22
20
|
media_type TEXT NOT NULL, scope_digest TEXT NOT NULL,
|
|
23
21
|
as_of_commit_seq INTEGER NOT NULL, row_count INTEGER NOT NULL,
|
|
@@ -26,6 +24,12 @@ export class SqliteSegmentStore {
|
|
|
26
24
|
expires_at_ms INTEGER NOT NULL, bytes BLOB NOT NULL
|
|
27
25
|
);
|
|
28
26
|
`);
|
|
27
|
+
const columns = this.db
|
|
28
|
+
.query('PRAGMA table_info(sync_segments)')
|
|
29
|
+
.all();
|
|
30
|
+
if (!columns.some((column) => column.name === 'log_epoch')) {
|
|
31
|
+
this.db.exec("ALTER TABLE sync_segments ADD COLUMN log_epoch TEXT NOT NULL DEFAULT ''");
|
|
32
|
+
}
|
|
29
33
|
}
|
|
30
34
|
async put(metadata, bytes, nowMs) {
|
|
31
35
|
const segmentId = await segmentIdFor(bytes);
|
|
@@ -38,11 +42,11 @@ export class SqliteSegmentStore {
|
|
|
38
42
|
};
|
|
39
43
|
this.db
|
|
40
44
|
.query(`INSERT OR REPLACE INTO sync_segments(
|
|
41
|
-
segment_id, partition, tbl, schema_version, media_type,
|
|
45
|
+
segment_id, partition, log_epoch, tbl, schema_version, media_type,
|
|
42
46
|
scope_digest, as_of_commit_seq, row_count, row_cursor,
|
|
43
47
|
next_row_cursor, byte_length, created_at_ms, expires_at_ms, bytes
|
|
44
|
-
) VALUES (
|
|
45
|
-
.run(record.segmentId, record.partition, record.table, record.schemaVersion, record.mediaType, record.scopeDigest, record.asOfCommitSeq, record.rowCount, record.rowCursor, record.nextRowCursor, record.byteLength, record.createdAtMs, record.expiresAtMs, bytes);
|
|
48
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
49
|
+
.run(record.segmentId, record.partition, record.logEpoch, record.table, record.schemaVersion, record.mediaType, record.scopeDigest, record.asOfCommitSeq, record.rowCount, record.rowCursor, record.nextRowCursor, record.byteLength, record.createdAtMs, record.expiresAtMs, bytes);
|
|
46
50
|
return record;
|
|
47
51
|
}
|
|
48
52
|
async get(segmentId) {
|
|
@@ -55,6 +59,7 @@ export class SqliteSegmentStore {
|
|
|
55
59
|
record: {
|
|
56
60
|
segmentId: row.segment_id,
|
|
57
61
|
partition: row.partition,
|
|
62
|
+
logEpoch: row.log_epoch,
|
|
58
63
|
table: row.tbl,
|
|
59
64
|
schemaVersion: row.schema_version,
|
|
60
65
|
mediaType: row.media_type === 'sqlite' ? 'sqlite' : 'rows',
|
|
@@ -75,16 +80,17 @@ export class SqliteSegmentStore {
|
|
|
75
80
|
.query(`SELECT segment_id, row_count, next_row_cursor, byte_length,
|
|
76
81
|
created_at_ms, expires_at_ms
|
|
77
82
|
FROM sync_segments
|
|
78
|
-
WHERE partition=? AND tbl=? AND schema_version=? AND media_type=?
|
|
83
|
+
WHERE partition=? AND log_epoch=? AND tbl=? AND schema_version=? AND media_type=?
|
|
79
84
|
AND scope_digest=? AND as_of_commit_seq=? AND row_cursor IS NULL
|
|
80
85
|
AND expires_at_ms > ?
|
|
81
86
|
LIMIT 1`)
|
|
82
|
-
.get(key.partition, key.table, key.schemaVersion, key.mediaType, key.scopeDigest, key.asOfCommitSeq, nowMs);
|
|
87
|
+
.get(key.partition, key.logEpoch, key.table, key.schemaVersion, key.mediaType, key.scopeDigest, key.asOfCommitSeq, nowMs);
|
|
83
88
|
if (row === null)
|
|
84
89
|
return undefined;
|
|
85
90
|
return {
|
|
86
91
|
segmentId: row.segment_id,
|
|
87
92
|
partition: key.partition,
|
|
93
|
+
logEpoch: key.logEpoch,
|
|
88
94
|
table: key.table,
|
|
89
95
|
schemaVersion: key.schemaVersion,
|
|
90
96
|
mediaType: key.mediaType,
|
package/dist/sqlite-storage.d.ts
CHANGED
|
@@ -1,21 +1,16 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SQLite storage via `bun:sqlite` (dev-speed, dependency-free).
|
|
3
|
-
*
|
|
4
|
-
* Scope fanout is index-first: both the commit log and the
|
|
5
|
-
* current-row table carry a (table, variable, value) inverted index; reads
|
|
6
|
-
* select candidates from the index and verify the full multi-variable
|
|
7
|
-
* match against the stored scope map — never a log scan.
|
|
8
|
-
*/
|
|
9
|
-
import { Database } from 'bun:sqlite';
|
|
10
1
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
11
|
-
import
|
|
2
|
+
import { type SqliteDatabase } from './sqlite-driver.js';
|
|
3
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
12
4
|
export declare class SqliteServerStorage implements ServerStorage {
|
|
13
5
|
#private;
|
|
14
|
-
readonly db:
|
|
15
|
-
constructor(db?:
|
|
6
|
+
readonly db: SqliteDatabase;
|
|
7
|
+
constructor(db?: SqliteDatabase | string);
|
|
16
8
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
17
9
|
table(name: string): CompiledTable;
|
|
18
10
|
ensureSchema(schema: CompiledSchema): Promise<void>;
|
|
11
|
+
touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
|
|
12
|
+
rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
|
|
13
|
+
listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
|
|
19
14
|
begin(partition: string): Promise<StorageTransaction>;
|
|
20
15
|
/** Internal: write a row + refresh its scope-index entries. */
|
|
21
16
|
writeRow(partition: string, table: string, row: StoredRow): void;
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SQLite storage
|
|
2
|
+
* SQLite server storage over the shared synchronous driver.
|
|
3
3
|
*
|
|
4
4
|
* Scope fanout is index-first: both the commit log and the
|
|
5
5
|
* current-row table carry a (table, variable, value) inverted index; reads
|
|
6
6
|
* select candidates from the index and verify the full multi-variable
|
|
7
7
|
* match against the stored scope map — never a log scan.
|
|
8
8
|
*/
|
|
9
|
-
import { Database } from 'bun:sqlite';
|
|
10
9
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
11
10
|
import { syncError } from './errors.js';
|
|
12
11
|
import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
13
12
|
import { matchesEffective } from './scopes.js';
|
|
14
13
|
import { collectCommitWindowPage, deserializePushResult, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
|
|
14
|
+
import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
|
|
15
15
|
import { isSqliteConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
16
16
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
17
17
|
function toStoredReaction(record) {
|
|
@@ -62,7 +62,7 @@ class SqliteTransaction {
|
|
|
62
62
|
}
|
|
63
63
|
getPushResult(clientId, clientCommitId) {
|
|
64
64
|
this.#assertOpen();
|
|
65
|
-
// One shared
|
|
65
|
+
// One shared SQLite connection: this read runs inside this
|
|
66
66
|
// transaction's BEGIN IMMEDIATE.
|
|
67
67
|
return this.#storage.getPushResult(this.#partition, clientId, clientCommitId);
|
|
68
68
|
}
|
|
@@ -194,7 +194,7 @@ class SqliteTransaction {
|
|
|
194
194
|
}
|
|
195
195
|
export class SqliteServerStorage {
|
|
196
196
|
db;
|
|
197
|
-
/** One
|
|
197
|
+
/** One SQLite connection can own only one transaction at a time. */
|
|
198
198
|
#transactionTail = Promise.resolve();
|
|
199
199
|
/** Set by `ensureSchema`: app-table lookup for the relational row store. */
|
|
200
200
|
#tables;
|
|
@@ -214,8 +214,17 @@ export class SqliteServerStorage {
|
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
216
|
constructor(db = ':memory:') {
|
|
217
|
-
|
|
217
|
+
if (typeof db === 'string') {
|
|
218
|
+
throw new SqliteAdapterRequiredError();
|
|
219
|
+
}
|
|
220
|
+
this.db = db;
|
|
218
221
|
this.db.exec(SQLITE_DDL);
|
|
222
|
+
const clientColumns = this.db
|
|
223
|
+
.query('PRAGMA table_info("sync_clients")')
|
|
224
|
+
.all();
|
|
225
|
+
if (!clientColumns.some((column) => column.name === 'wire_version')) {
|
|
226
|
+
this.db.exec('ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1');
|
|
227
|
+
}
|
|
219
228
|
}
|
|
220
229
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
221
230
|
table(name) {
|
|
@@ -291,6 +300,74 @@ export class SqliteServerStorage {
|
|
|
291
300
|
this.#tables = schema.tables;
|
|
292
301
|
this.#schemaVersion = schema.version;
|
|
293
302
|
}
|
|
303
|
+
async touchPartition(partition, authenticatedAtMs, initialLogEpoch) {
|
|
304
|
+
if (initialLogEpoch.length === 0) {
|
|
305
|
+
throw new Error('initial log epoch must be non-empty');
|
|
306
|
+
}
|
|
307
|
+
this.db
|
|
308
|
+
.query(`INSERT INTO sync_partition_registry(
|
|
309
|
+
partition, log_epoch, last_authenticated_at_ms
|
|
310
|
+
) VALUES (?,?,?)
|
|
311
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
312
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
|
|
313
|
+
.run(partition, initialLogEpoch, authenticatedAtMs);
|
|
314
|
+
const row = this.db
|
|
315
|
+
.query(`SELECT log_epoch, epoch_required, last_authenticated_at_ms
|
|
316
|
+
FROM sync_partition_registry WHERE partition=?`)
|
|
317
|
+
.get(partition);
|
|
318
|
+
if (row === null)
|
|
319
|
+
throw new Error('partition registry write did not persist');
|
|
320
|
+
return {
|
|
321
|
+
partition,
|
|
322
|
+
logEpoch: row.log_epoch,
|
|
323
|
+
epochRequired: row.epoch_required === 1,
|
|
324
|
+
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
async rotatePartitionLogEpoch(partition, logEpoch, authenticatedAtMs) {
|
|
328
|
+
if (logEpoch.length === 0)
|
|
329
|
+
throw new Error('log epoch must be non-empty');
|
|
330
|
+
return this.#serializeReactionWrite(() => {
|
|
331
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
332
|
+
try {
|
|
333
|
+
this.db
|
|
334
|
+
.query(`INSERT INTO sync_partition_registry(
|
|
335
|
+
partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
336
|
+
) VALUES (?,?,1,?)
|
|
337
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
338
|
+
log_epoch=excluded.log_epoch,
|
|
339
|
+
epoch_required=1,
|
|
340
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
|
|
341
|
+
.run(partition, logEpoch, authenticatedAtMs);
|
|
342
|
+
this.db
|
|
343
|
+
.query('DELETE FROM sync_clients WHERE partition=?')
|
|
344
|
+
.run(partition);
|
|
345
|
+
this.db.exec('COMMIT');
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
this.db.exec('ROLLBACK');
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
partition,
|
|
353
|
+
logEpoch,
|
|
354
|
+
epochRequired: true,
|
|
355
|
+
lastAuthenticatedAtMs: authenticatedAtMs,
|
|
356
|
+
};
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
async listPartitionRegistry() {
|
|
360
|
+
return this.db
|
|
361
|
+
.query(`SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
362
|
+
FROM sync_partition_registry ORDER BY partition`)
|
|
363
|
+
.all()
|
|
364
|
+
.map((row) => ({
|
|
365
|
+
partition: row.partition,
|
|
366
|
+
logEpoch: row.log_epoch,
|
|
367
|
+
epochRequired: row.epoch_required === 1,
|
|
368
|
+
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
294
371
|
/**
|
|
295
372
|
* Migration rewrite: keyset-paged walk of a row table. When `oldLayout` is
|
|
296
373
|
* given every payload re-encodes under
|
|
@@ -656,13 +733,14 @@ export class SqliteServerStorage {
|
|
|
656
733
|
}
|
|
657
734
|
async getClientRecord(partition, clientId) {
|
|
658
735
|
const record = this.db
|
|
659
|
-
.query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
|
|
736
|
+
.query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
|
|
660
737
|
.get(partition, clientId);
|
|
661
738
|
if (record === null)
|
|
662
739
|
return undefined;
|
|
663
740
|
return {
|
|
664
741
|
clientId: record.client_id,
|
|
665
742
|
actorId: record.actor_id,
|
|
743
|
+
wireVersion: record.wire_version,
|
|
666
744
|
cursor: record.cursor,
|
|
667
745
|
updatedAtMs: record.updated_at_ms,
|
|
668
746
|
subscriptions: JSON.parse(record.subscriptions),
|
|
@@ -670,8 +748,8 @@ export class SqliteServerStorage {
|
|
|
670
748
|
}
|
|
671
749
|
async putClientRecord(partition, record) {
|
|
672
750
|
this.db
|
|
673
|
-
.query('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, cursor, subscriptions, updated_at_ms) VALUES (
|
|
674
|
-
.run(partition, record.clientId, record.actorId, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs);
|
|
751
|
+
.query('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)')
|
|
752
|
+
.run(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs);
|
|
675
753
|
}
|
|
676
754
|
async listClientCursors(partition) {
|
|
677
755
|
const records = this.db
|
|
@@ -716,11 +794,12 @@ export class SqliteServerStorage {
|
|
|
716
794
|
// -- admin/console read surface --------------------------------------------
|
|
717
795
|
async listClientRecords(partition) {
|
|
718
796
|
const records = this.db
|
|
719
|
-
.query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
|
|
797
|
+
.query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
|
|
720
798
|
.all(partition);
|
|
721
799
|
return records.map((record) => ({
|
|
722
800
|
clientId: record.client_id,
|
|
723
801
|
actorId: record.actor_id,
|
|
802
|
+
wireVersion: record.wire_version,
|
|
724
803
|
cursor: record.cursor,
|
|
725
804
|
updatedAtMs: record.updated_at_ms,
|
|
726
805
|
subscriptions: JSON.parse(record.subscriptions),
|
|
@@ -797,12 +876,6 @@ export class SqliteServerStorage {
|
|
|
797
876
|
};
|
|
798
877
|
}
|
|
799
878
|
async listPartitions() {
|
|
800
|
-
|
|
801
|
-
// first pull — a partition with only one of the two still shows up.
|
|
802
|
-
const rows = this.db
|
|
803
|
-
.query(`SELECT partition FROM sync_partitions
|
|
804
|
-
UNION SELECT partition FROM sync_clients ORDER BY partition`)
|
|
805
|
-
.all();
|
|
806
|
-
return rows.map((r) => r.partition);
|
|
879
|
+
return (await this.listPartitionRegistry()).map((entry) => entry.partition);
|
|
807
880
|
}
|
|
808
881
|
}
|
package/dist/storage-errors.js
CHANGED
|
@@ -44,7 +44,10 @@ export function isSqliteConstraintError(error) {
|
|
|
44
44
|
return true;
|
|
45
45
|
}
|
|
46
46
|
const errno = candidate?.errno;
|
|
47
|
-
|
|
47
|
+
if (typeof errno === 'number' && (errno & 0xff) === 19)
|
|
48
|
+
return true;
|
|
49
|
+
const errcode = candidate?.errcode;
|
|
50
|
+
return typeof errcode === 'number' && (errcode & 0xff) === 19;
|
|
48
51
|
}
|
|
49
52
|
/** PostgreSQL SQLSTATE class 23: integrity constraint violation. */
|
|
50
53
|
export function isPostgresConstraintError(error) {
|
package/dist/storage.d.ts
CHANGED
|
@@ -144,6 +144,8 @@ export interface ClientSubscription {
|
|
|
144
144
|
export interface ClientRecord {
|
|
145
145
|
readonly clientId: string;
|
|
146
146
|
readonly actorId: string;
|
|
147
|
+
/** SSP2 version last accepted from this client; selects realtime deltas. */
|
|
148
|
+
readonly wireVersion: number;
|
|
147
149
|
/** Minimum `nextCursor` across the last pull's active subscriptions. */
|
|
148
150
|
readonly cursor: number;
|
|
149
151
|
readonly updatedAtMs: number;
|
|
@@ -195,6 +197,13 @@ export interface ClientCursorInfo {
|
|
|
195
197
|
readonly cursor: number;
|
|
196
198
|
readonly updatedAtMs: number;
|
|
197
199
|
}
|
|
200
|
+
/** Durable partition identity refreshed after host authentication (§2.1). */
|
|
201
|
+
export interface PartitionRegistryEntry {
|
|
202
|
+
readonly partition: string;
|
|
203
|
+
readonly logEpoch: string;
|
|
204
|
+
readonly epochRequired: boolean;
|
|
205
|
+
readonly lastAuthenticatedAtMs: number;
|
|
206
|
+
}
|
|
198
207
|
/**
|
|
199
208
|
* Commit-log metadata (no change payloads) for the admin/console read
|
|
200
209
|
* surface. `changeCount` is the number of changes the commit carries;
|
|
@@ -340,6 +349,12 @@ export interface ServerStorage {
|
|
|
340
349
|
* lazily as a defensive backstop.
|
|
341
350
|
*/
|
|
342
351
|
ensureSchema(schema: CompiledSchema): Promise<void>;
|
|
352
|
+
/** Create or refresh the authenticated partition registry row (§2.1). */
|
|
353
|
+
touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
|
|
354
|
+
/** Rotate log continuity after restore and discard stale cursor records. */
|
|
355
|
+
rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
|
|
356
|
+
/** Registry entries ordered by partition for maintenance loops. */
|
|
357
|
+
listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
|
|
343
358
|
begin(partition: string): Promise<StorageTransaction>;
|
|
344
359
|
getMaxCommitSeq(partition: string): Promise<number>;
|
|
345
360
|
getHorizonSeq(partition: string): Promise<number>;
|
|
@@ -427,10 +442,8 @@ export interface ServerStorage {
|
|
|
427
442
|
* change scope index (never a log scan).
|
|
428
443
|
* `getRowScopes`: the (table, rowId) row's current server_version and
|
|
429
444
|
* stored scopes without decoding its payload — the row inspector.
|
|
430
|
-
* `listPartitions`:
|
|
431
|
-
*
|
|
432
|
-
* records, sorted. Powers the console's fleet view / partition picker;
|
|
433
|
-
* deliberately NOT partition-scoped (the one cross-partition read).
|
|
445
|
+
* `listPartitions`: the partition-only compatibility view of
|
|
446
|
+
* `listPartitionRegistry`, sorted.
|
|
434
447
|
*/
|
|
435
448
|
listClientRecords?(partition: string): Promise<ClientRecord[]>;
|
|
436
449
|
listCommitMetadata?(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
|
|
@@ -439,7 +452,7 @@ export interface ServerStorage {
|
|
|
439
452
|
serverVersion: number;
|
|
440
453
|
scopes: Record<string, string>;
|
|
441
454
|
} | undefined>;
|
|
442
|
-
listPartitions
|
|
455
|
+
listPartitions(): Promise<string[]>;
|
|
443
456
|
}
|
|
444
457
|
/** A row referencing a blob, with the scopes needed to authorize download. */
|
|
445
458
|
export interface BlobReferencingRow {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.48",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -27,13 +27,25 @@
|
|
|
27
27
|
},
|
|
28
28
|
"exports": {
|
|
29
29
|
".": {
|
|
30
|
-
"bun": "./src/index.ts",
|
|
30
|
+
"bun": "./src/index-bun.ts",
|
|
31
|
+
"node": {
|
|
32
|
+
"types": "./dist/index-node.d.ts",
|
|
33
|
+
"default": "./dist/index-node.js"
|
|
34
|
+
},
|
|
31
35
|
"browser": "./dist/index.js",
|
|
32
36
|
"import": {
|
|
33
37
|
"types": "./dist/index.d.ts",
|
|
34
38
|
"default": "./dist/index.js"
|
|
35
39
|
}
|
|
36
40
|
},
|
|
41
|
+
"./sqlite": {
|
|
42
|
+
"bun": "./src/sqlite-bun.ts",
|
|
43
|
+
"node": {
|
|
44
|
+
"types": "./dist/sqlite-node.d.ts",
|
|
45
|
+
"default": "./dist/sqlite-node.js"
|
|
46
|
+
},
|
|
47
|
+
"types": "./dist/sqlite-node.d.ts"
|
|
48
|
+
},
|
|
37
49
|
"./pglite": {
|
|
38
50
|
"bun": "./src/pg-executor-pglite.ts",
|
|
39
51
|
"browser": "./dist/pg-executor-pglite.js",
|
|
@@ -52,8 +64,11 @@
|
|
|
52
64
|
"!dist/**/*.test.js",
|
|
53
65
|
"!dist/**/*.test.d.ts"
|
|
54
66
|
],
|
|
67
|
+
"scripts": {
|
|
68
|
+
"verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
|
|
69
|
+
},
|
|
55
70
|
"dependencies": {
|
|
56
|
-
"@syncular/core": "0.15.
|
|
71
|
+
"@syncular/core": "0.15.48"
|
|
57
72
|
},
|
|
58
73
|
"devDependencies": {
|
|
59
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/admin.ts
CHANGED
|
@@ -520,17 +520,11 @@ export class SyncularAdmin {
|
|
|
520
520
|
};
|
|
521
521
|
}
|
|
522
522
|
|
|
523
|
-
/**
|
|
524
|
-
* Every partition the storage knows (commit log + client records) — the
|
|
525
|
-
* fleet-view backing and the console's partition picker. Fails loud when
|
|
526
|
-
* the backend omits the optional `listPartitions`.
|
|
527
|
-
*/
|
|
523
|
+
/** Every authenticated partition in the storage-backed registry. */
|
|
528
524
|
async listPartitions(): Promise<string[]> {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
'storage',
|
|
525
|
+
return (await this.#storage.listPartitionRegistry()).map(
|
|
526
|
+
(entry) => entry.partition,
|
|
532
527
|
);
|
|
533
|
-
return list();
|
|
534
528
|
}
|
|
535
529
|
|
|
536
530
|
/**
|
package/src/blob-handlers.ts
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { blobIdFor, isBlobId } from './blob-store';
|
|
10
10
|
import type { SyncRequestContext } from './context';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
clockOf,
|
|
13
|
+
RESOLVER_OUTAGE,
|
|
14
|
+
touchAuthenticatedPartition,
|
|
15
|
+
} from './context';
|
|
12
16
|
import { SyncError, syncError } from './errors';
|
|
13
17
|
import { emitEvent } from './events';
|
|
14
18
|
import { compileSchema } from './schema';
|
|
@@ -72,6 +76,7 @@ export async function handleBlobUpload(
|
|
|
72
76
|
ctx: SyncRequestContext,
|
|
73
77
|
request: BlobUploadRequest,
|
|
74
78
|
): Promise<void> {
|
|
79
|
+
await touchAuthenticatedPartition(ctx);
|
|
75
80
|
const store = ctx.blobs;
|
|
76
81
|
if (store === undefined) {
|
|
77
82
|
throw syncError('blob.not_found', 'this server has no blob store (§5.9)');
|
|
@@ -125,6 +130,7 @@ export async function handleBlobDownload(
|
|
|
125
130
|
ctx: SyncRequestContext,
|
|
126
131
|
blobId: string,
|
|
127
132
|
): Promise<BlobDownloadResult> {
|
|
133
|
+
await touchAuthenticatedPartition(ctx);
|
|
128
134
|
const events = ctx.events;
|
|
129
135
|
if (events === undefined) return downloadBlob(ctx, blobId);
|
|
130
136
|
const clock = clockOf(ctx);
|
|
@@ -301,6 +307,7 @@ export async function handleBlobUploadGrant(
|
|
|
301
307
|
ctx: SyncRequestContext,
|
|
302
308
|
request: BlobUploadGrantRequest,
|
|
303
309
|
): Promise<BlobUploadGrantResult> {
|
|
310
|
+
await touchAuthenticatedPartition(ctx);
|
|
304
311
|
const store = ctx.blobs;
|
|
305
312
|
if (store === undefined) {
|
|
306
313
|
throw syncError('blob.not_found', 'this server has no blob store (§5.9)');
|
package/src/context.ts
CHANGED
|
@@ -18,7 +18,11 @@ import type {
|
|
|
18
18
|
SegmentUrlConfig,
|
|
19
19
|
} from './signed-url';
|
|
20
20
|
import type { SqliteImageBuilder } from './sqlite-image';
|
|
21
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
PartitionRegistryEntry,
|
|
23
|
+
ServerStorage,
|
|
24
|
+
StoredCommit,
|
|
25
|
+
} from './storage';
|
|
22
26
|
import type { CommitValidator, ValidatorRegistry } from './validate';
|
|
23
27
|
|
|
24
28
|
/** SSP2 body content type (§1.1). */
|
|
@@ -167,7 +171,8 @@ export interface SyncServerConfig {
|
|
|
167
171
|
* §5.3 sqlite-image builder, injected so the pull path never
|
|
168
172
|
* statically imports `bun:sqlite`. Absent ⇒ the sqlite-image lane is off
|
|
169
173
|
* (bit-2 clients are served the rows lane) — the Workers/edge posture. A
|
|
170
|
-
* Bun
|
|
174
|
+
* Bun or Node host wires `buildSqliteImage` from
|
|
175
|
+
* `@syncular/server/sqlite`.
|
|
171
176
|
*/
|
|
172
177
|
readonly sqliteImageBuilder?: SqliteImageBuilder;
|
|
173
178
|
readonly realtime?: RealtimeNotifier;
|
|
@@ -191,3 +196,14 @@ export function clockOf(ctx: SyncServerConfig): () => number {
|
|
|
191
196
|
export function limitsOf(ctx: SyncServerConfig): ServerLimits {
|
|
192
197
|
return { ...DEFAULT_LIMITS, ...ctx.limits };
|
|
193
198
|
}
|
|
199
|
+
|
|
200
|
+
/** Refresh the registry after host authentication and return log continuity. */
|
|
201
|
+
export function touchAuthenticatedPartition(
|
|
202
|
+
ctx: SyncRequestContext,
|
|
203
|
+
): Promise<PartitionRegistryEntry> {
|
|
204
|
+
return ctx.storage.touchPartition(
|
|
205
|
+
ctx.partition,
|
|
206
|
+
clockOf(ctx)(),
|
|
207
|
+
crypto.randomUUID(),
|
|
208
|
+
);
|
|
209
|
+
}
|