@syncular/server 0.16.1 → 0.17.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 +25 -5
- package/dist/d1-storage.d.ts +9 -0
- package/dist/d1-storage.js +409 -106
- package/dist/postgres-storage.d.ts +2 -0
- package/dist/postgres-storage.js +41 -14
- package/dist/pull.js +16 -7
- package/dist/realtime.d.ts +1 -1
- package/dist/realtime.js +16 -10
- package/dist/relational-rows.d.ts +7 -1
- package/dist/relational-rows.js +17 -2
- package/dist/sqlite-storage.d.ts +2 -0
- package/dist/sqlite-storage.js +26 -6
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +4 -0
- package/dist/storage.d.ts +9 -0
- package/package.json +2 -2
- package/src/d1-storage.ts +555 -142
- package/src/postgres-storage.ts +64 -21
- package/src/pull.ts +22 -7
- package/src/realtime.ts +20 -9
- package/src/relational-rows.ts +18 -2
- package/src/sqlite-storage.ts +45 -9
- package/src/storage-errors.ts +12 -0
- package/src/storage.ts +21 -0
package/dist/postgres-storage.js
CHANGED
|
@@ -110,6 +110,11 @@ CREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob
|
|
|
110
110
|
ON sync_blob_refs(partition, blob_id);
|
|
111
111
|
`;
|
|
112
112
|
async function lockPartitionOn(client, partition) {
|
|
113
|
+
const locked = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
|
|
114
|
+
if (locked.rows.length > 0)
|
|
115
|
+
return;
|
|
116
|
+
// The first writer initializes the partition. Concurrent initializers may
|
|
117
|
+
// wait on this insert, so acquire the row lock again before applying writes.
|
|
113
118
|
await client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
|
|
114
119
|
ON CONFLICT (partition) DO NOTHING`, [partition]);
|
|
115
120
|
await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
|
|
@@ -438,26 +443,37 @@ class PostgresTransaction {
|
|
|
438
443
|
// Allocate the next dense commitSeq under a per-partition row lock: the
|
|
439
444
|
// UPDATE … RETURNING serializes concurrent pushes to this partition and
|
|
440
445
|
// never leaves a gap on rollback (see the file header).
|
|
441
|
-
const { rows } = await q.query(`
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
446
|
+
const { rows } = await q.query(`WITH allocated AS (
|
|
447
|
+
INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 1)
|
|
448
|
+
ON CONFLICT (partition) DO UPDATE
|
|
449
|
+
SET max_commit_seq = sync_partitions.max_commit_seq + 1
|
|
450
|
+
RETURNING max_commit_seq
|
|
451
|
+
)
|
|
452
|
+
INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms)
|
|
453
|
+
SELECT $1, max_commit_seq, $2, $3, $4, $5 FROM allocated
|
|
454
|
+
RETURNING commit_seq`, [
|
|
448
455
|
p,
|
|
449
|
-
commitSeq,
|
|
450
456
|
commit.clientId,
|
|
451
457
|
commit.clientCommitId,
|
|
452
458
|
commit.actorId,
|
|
453
459
|
commit.createdAtMs,
|
|
454
460
|
]);
|
|
461
|
+
const commitSeq = asNumber(rows[0]?.commit_seq);
|
|
455
462
|
for (let idx = 0; idx < commit.changes.length; idx++) {
|
|
456
463
|
const change = commit.changes[idx];
|
|
457
464
|
if (change === undefined)
|
|
458
465
|
continue;
|
|
459
|
-
|
|
460
|
-
|
|
466
|
+
// Bind serialized scopes as text before parsing JSONB. Drivers that
|
|
467
|
+
// encode JSONB parameters would otherwise store this string as a scalar.
|
|
468
|
+
await q.query(`WITH inserted AS (
|
|
469
|
+
INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
|
|
470
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::text::jsonb,$9)
|
|
471
|
+
RETURNING partition, tbl, commit_seq, scopes
|
|
472
|
+
)
|
|
473
|
+
INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
|
|
474
|
+
SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
|
|
475
|
+
FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
|
|
476
|
+
ON CONFLICT DO NOTHING`, [
|
|
461
477
|
p,
|
|
462
478
|
commitSeq,
|
|
463
479
|
idx,
|
|
@@ -468,10 +484,6 @@ class PostgresTransaction {
|
|
|
468
484
|
JSON.stringify(change.scopes),
|
|
469
485
|
change.payload ?? null,
|
|
470
486
|
]);
|
|
471
|
-
for (const [variable, value] of Object.entries(change.scopes)) {
|
|
472
|
-
await q.query(`INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
|
|
473
|
-
VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`, [p, change.table, variable, value, commitSeq]);
|
|
474
|
-
}
|
|
475
487
|
}
|
|
476
488
|
return commitSeq;
|
|
477
489
|
}
|
|
@@ -622,6 +634,9 @@ export class PostgresServerStorage {
|
|
|
622
634
|
await client.query('DELETE FROM sync_row_scopes WHERE tbl=$1', [
|
|
623
635
|
tableName,
|
|
624
636
|
]);
|
|
637
|
+
await client.query('DELETE FROM sync_blob_refs WHERE tbl=$1', [
|
|
638
|
+
tableName,
|
|
639
|
+
]);
|
|
625
640
|
await client.query(dropTableDdl(tableName));
|
|
626
641
|
}
|
|
627
642
|
for (const statement of schemaDdl(schema, existing, 'postgres', existingIndexes)) {
|
|
@@ -1094,6 +1109,18 @@ export class PostgresServerStorage {
|
|
|
1094
1109
|
record.updatedAtMs,
|
|
1095
1110
|
]);
|
|
1096
1111
|
}
|
|
1112
|
+
async advanceClientCursor(partition, clientId, actorId, logEpoch, cursor, updatedAtMs) {
|
|
1113
|
+
await this.#exec.query(`UPDATE sync_clients
|
|
1114
|
+
SET cursor=GREATEST(cursor, $1), updated_at_ms=GREATEST(updated_at_ms, $2)
|
|
1115
|
+
WHERE partition=$3 AND client_id=$4 AND actor_id=$5
|
|
1116
|
+
AND EXISTS (SELECT 1 FROM sync_partition_registry
|
|
1117
|
+
WHERE partition=sync_clients.partition AND log_epoch=$6)`, [cursor, updatedAtMs, partition, clientId, actorId, logEpoch]);
|
|
1118
|
+
}
|
|
1119
|
+
async updateClientCursor(partition, clientId, cursor, updatedAtMs) {
|
|
1120
|
+
await this.#exec.query(`UPDATE sync_clients
|
|
1121
|
+
SET cursor=GREATEST(cursor, $3), updated_at_ms=GREATEST(updated_at_ms, $4)
|
|
1122
|
+
WHERE partition=$1 AND client_id=$2`, [partition, clientId, cursor, updatedAtMs]);
|
|
1123
|
+
}
|
|
1097
1124
|
async getActiveClientCursorFloor(partition, cutoffMs) {
|
|
1098
1125
|
const { rows } = await this.#exec.query('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=$1 AND updated_at_ms>=$2', [partition, cutoffMs]);
|
|
1099
1126
|
return rows[0].cursor === null ? null : asNumber(rows[0].cursor);
|
package/dist/pull.js
CHANGED
|
@@ -361,6 +361,22 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
|
|
|
361
361
|
return { nextCursor: sub.cursor, active: false };
|
|
362
362
|
}
|
|
363
363
|
const token = parseBootstrapToken(sub.bootstrapState, sub.table);
|
|
364
|
+
let commits = [];
|
|
365
|
+
if (token === undefined &&
|
|
366
|
+
sub.cursor >= 0 &&
|
|
367
|
+
sub.cursor >= horizonSeq &&
|
|
368
|
+
sub.cursor <= maxSeq) {
|
|
369
|
+
commits = await ctx.storage.readCommitWindow(ctx.partition, {
|
|
370
|
+
table: sub.table,
|
|
371
|
+
scopeFilter: plan.effective,
|
|
372
|
+
afterSeq: sub.cursor,
|
|
373
|
+
throughSeq: maxSeq,
|
|
374
|
+
limitChanges: limits.limitCommits + 1,
|
|
375
|
+
});
|
|
376
|
+
// Validate continuity before committing to an active section. A prune
|
|
377
|
+
// during a paged read can otherwise make an incomplete window look empty.
|
|
378
|
+
horizonSeq = Math.max(horizonSeq, await ctx.storage.getHorizonSeq(ctx.partition));
|
|
379
|
+
}
|
|
364
380
|
// §4.6: a cursor behind the horizon (and not resuming a bootstrap)
|
|
365
381
|
// cannot compute deltas — answer `reset` and echo the cursor.
|
|
366
382
|
if (token === undefined && sub.cursor >= 0 && sub.cursor < horizonSeq) {
|
|
@@ -421,13 +437,6 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
|
|
|
421
437
|
effectiveScopes: plan.effective,
|
|
422
438
|
bootstrap: false,
|
|
423
439
|
};
|
|
424
|
-
const commits = await ctx.storage.readCommitWindow(ctx.partition, {
|
|
425
|
-
table: sub.table,
|
|
426
|
-
scopeFilter: plan.effective,
|
|
427
|
-
afterSeq: sub.cursor,
|
|
428
|
-
throughSeq: maxSeq,
|
|
429
|
-
limitChanges: limits.limitCommits + 1,
|
|
430
|
-
});
|
|
431
440
|
let delivered = 0;
|
|
432
441
|
let deliveredCommits = 0;
|
|
433
442
|
let lastDeliveredSeq = sub.cursor;
|
package/dist/realtime.d.ts
CHANGED
|
@@ -137,7 +137,7 @@ export declare class RealtimeSession {
|
|
|
137
137
|
handleBinary(bytes: Uint8Array): Promise<void> | undefined;
|
|
138
138
|
sendHeartbeat(): void;
|
|
139
139
|
sendWake(reason: WakeReason): void;
|
|
140
|
-
/** Called by the hub for every applied commit
|
|
140
|
+
/** Called by the hub for every applied commit notification. */
|
|
141
141
|
deliverCommit(commit: StoredCommit): void;
|
|
142
142
|
close(): void;
|
|
143
143
|
}
|
package/dist/realtime.js
CHANGED
|
@@ -191,6 +191,10 @@ export class RealtimeSession {
|
|
|
191
191
|
if (typeof cursor !== 'number' || !Number.isSafeInteger(cursor))
|
|
192
192
|
return;
|
|
193
193
|
this.cursor = Math.max(this.cursor, cursor);
|
|
194
|
+
// The client may have caught up through another binding (§8.4). Its ack
|
|
195
|
+
// proves those commits no longer need socket notifications, so the next
|
|
196
|
+
// notification must be adjacent to the acknowledged cursor.
|
|
197
|
+
this.lastKnownSeq = Math.max(this.lastKnownSeq, this.cursor);
|
|
194
198
|
if (this.cursor >= this.lastKnownSeq)
|
|
195
199
|
this.wakePending = false;
|
|
196
200
|
// §8.2: acks update the client cursor record without an HTTP pull.
|
|
@@ -468,14 +472,7 @@ export class RealtimeSession {
|
|
|
468
472
|
}
|
|
469
473
|
async #persistCursor() {
|
|
470
474
|
try {
|
|
471
|
-
|
|
472
|
-
if (record === undefined)
|
|
473
|
-
return;
|
|
474
|
-
await this.#storage.putClientRecord(this.partition, {
|
|
475
|
-
...record,
|
|
476
|
-
cursor: Math.max(record.cursor, this.cursor),
|
|
477
|
-
updatedAtMs: this.#clock(),
|
|
478
|
-
});
|
|
475
|
+
await this.#storage.advanceClientCursor(this.partition, this.clientId, this.actorId, this.logEpoch, this.cursor, this.#clock());
|
|
479
476
|
}
|
|
480
477
|
catch {
|
|
481
478
|
// Cursor persistence is best-effort; the next pull repairs it.
|
|
@@ -523,8 +520,9 @@ export class RealtimeSession {
|
|
|
523
520
|
});
|
|
524
521
|
}
|
|
525
522
|
}
|
|
526
|
-
/** Called by the hub for every applied commit
|
|
523
|
+
/** Called by the hub for every applied commit notification. */
|
|
527
524
|
deliverCommit(commit) {
|
|
525
|
+
const contiguous = commit.commitSeq === this.lastKnownSeq + 1;
|
|
528
526
|
this.lastKnownSeq = Math.max(this.lastKnownSeq, commit.commitSeq);
|
|
529
527
|
const sections = [];
|
|
530
528
|
for (const registration of this.registrations) {
|
|
@@ -544,6 +542,14 @@ export class RealtimeSession {
|
|
|
544
542
|
if (changes.length > 0)
|
|
545
543
|
sections.push({ registration, changes });
|
|
546
544
|
}
|
|
545
|
+
if (!contiguous) {
|
|
546
|
+
// Pushes allocate commitSeq under the partition lock but notify after
|
|
547
|
+
// commit, so racing notifications may arrive out of order. A gap,
|
|
548
|
+
// duplicate, or regression cannot be a delta: the client would apply
|
|
549
|
+
// and acknowledge a cursor that does not prove the intervening log.
|
|
550
|
+
this.sendWake('catchup-required');
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
547
553
|
if (sections.length === 0)
|
|
548
554
|
return;
|
|
549
555
|
if (this.#activeRound !== undefined) {
|
|
@@ -600,7 +606,7 @@ export class RealtimeSession {
|
|
|
600
606
|
tagged[0] = REALTIME_TAG_DELTA;
|
|
601
607
|
tagged.set(bytes, 1);
|
|
602
608
|
this.#sendSafe(tagged);
|
|
603
|
-
this.cursor = commit.commitSeq;
|
|
609
|
+
this.cursor = Math.max(this.cursor, commit.commitSeq);
|
|
604
610
|
const events = this.#events;
|
|
605
611
|
if (events !== undefined) {
|
|
606
612
|
emitEvent(events, {
|
|
@@ -87,7 +87,7 @@ export declare const SYNC_INDEX_PREFIX = "sync_ix_";
|
|
|
87
87
|
export declare function physicalIndexName(declaredName: string): string;
|
|
88
88
|
/**
|
|
89
89
|
* CREATE INDEX IF NOT EXISTS for the table's user-declared indexes. These use
|
|
90
|
-
* the
|
|
90
|
+
* the declared columns with a leading server partition column. Cross-table
|
|
91
91
|
* index-name uniqueness is the user's schema concern, as it is client-side.
|
|
92
92
|
* Server-side the physical name carries the {@link SYNC_INDEX_PREFIX}
|
|
93
93
|
* ownership marker.
|
|
@@ -181,6 +181,12 @@ export declare function commitWindowPageSql(valueCount: number, dialect: Relatio
|
|
|
181
181
|
export declare function selectRowScopesSql(table: CompiledTable, dialect: RelationalDialect): string;
|
|
182
182
|
/** DELETE one stored row. Params: [partition, rowId]. */
|
|
183
183
|
export declare function deleteRowSql(table: CompiledTable, dialect: RelationalDialect): string;
|
|
184
|
+
/**
|
|
185
|
+
* SQLite: remove the old row's exact scope keys before replacing or deleting the row.
|
|
186
|
+
* Params: [partition, table, rowId, partition, rowId]. The caller keeps this
|
|
187
|
+
* statement and the row write in the same transaction (or D1 atomic batch).
|
|
188
|
+
*/
|
|
189
|
+
export declare function deleteSqliteRowScopesSql(table: CompiledTable): string;
|
|
184
190
|
/**
|
|
185
191
|
* The schema-version marker table gates DDL work. `ensureSchema` compares the
|
|
186
192
|
* stored version and skips
|
package/dist/relational-rows.js
CHANGED
|
@@ -177,7 +177,7 @@ export function physicalIndexName(declaredName) {
|
|
|
177
177
|
}
|
|
178
178
|
/**
|
|
179
179
|
* CREATE INDEX IF NOT EXISTS for the table's user-declared indexes. These use
|
|
180
|
-
* the
|
|
180
|
+
* the declared columns with a leading server partition column. Cross-table
|
|
181
181
|
* index-name uniqueness is the user's schema concern, as it is client-side.
|
|
182
182
|
* Server-side the physical name carries the {@link SYNC_INDEX_PREFIX}
|
|
183
183
|
* ownership marker.
|
|
@@ -188,7 +188,7 @@ export function createIndexDdl(table) {
|
|
|
188
188
|
return [];
|
|
189
189
|
return table.indexes.map((index) => {
|
|
190
190
|
const unique = index.unique ? 'UNIQUE ' : '';
|
|
191
|
-
const columns = index.columns
|
|
191
|
+
const columns = [SYNC_PARTITION_COLUMN, ...index.columns]
|
|
192
192
|
.map((column) => quoteIdent(column))
|
|
193
193
|
.join(', ');
|
|
194
194
|
return `CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(physicalIndexName(index.name))} ON ${quoteIdent(table.name)} (${columns})`;
|
|
@@ -401,6 +401,21 @@ export function deleteRowSql(table, dialect) {
|
|
|
401
401
|
const p = dialect === 'sqlite' ? ['?', '?'] : ['$1', '$2'];
|
|
402
402
|
return `DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${p[0]} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${p[1]}`;
|
|
403
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* SQLite: remove the old row's exact scope keys before replacing or deleting the row.
|
|
406
|
+
* Params: [partition, table, rowId, partition, rowId]. The caller keeps this
|
|
407
|
+
* statement and the row write in the same transaction (or D1 atomic batch).
|
|
408
|
+
*/
|
|
409
|
+
export function deleteSqliteRowScopesSql(table) {
|
|
410
|
+
return `DELETE FROM sync_row_scopes
|
|
411
|
+
WHERE partition=? AND tbl=? AND row_id=?
|
|
412
|
+
AND (var, value) IN (
|
|
413
|
+
SELECT key, value FROM json_each((
|
|
414
|
+
SELECT ${quoteIdent(SYNC_SCOPES_COLUMN)} FROM ${quoteIdent(table.name)}
|
|
415
|
+
WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=?
|
|
416
|
+
))
|
|
417
|
+
)`;
|
|
418
|
+
}
|
|
404
419
|
/**
|
|
405
420
|
* The schema-version marker table gates DDL work. `ensureSchema` compares the
|
|
406
421
|
* stored version and skips
|
package/dist/sqlite-storage.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export declare class SqliteServerStorage implements ServerStorage {
|
|
|
37
37
|
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
38
38
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
39
39
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
40
|
+
advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
|
|
41
|
+
updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
|
|
40
42
|
getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
|
|
41
43
|
listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
|
|
42
44
|
listRowsReferencingBlob(partition: string, blobId: string): Promise<{
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -10,7 +10,7 @@ import { StorageQueryError } from './storage-errors.js';
|
|
|
10
10
|
*/
|
|
11
11
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
12
12
|
import { syncError } from './errors.js';
|
|
13
|
-
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
|
+
import { commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
14
14
|
import { matchesEffective } from './scopes.js';
|
|
15
15
|
import { collectCommitWindowPage, deserializePushResult, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
|
|
16
16
|
import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
|
|
@@ -108,8 +108,9 @@ class SqliteTransaction {
|
|
|
108
108
|
async deleteRow(table, rowId) {
|
|
109
109
|
this.#assertOpen();
|
|
110
110
|
const db = this.#storage.db;
|
|
111
|
-
|
|
112
|
-
db.query(
|
|
111
|
+
const compiled = this.#storage.table(table);
|
|
112
|
+
db.query(deleteSqliteRowScopesSql(compiled)).run(this.#partition, table, rowId, this.#partition, rowId);
|
|
113
|
+
db.query(deleteRowSql(compiled, 'sqlite')).run(this.#partition, rowId);
|
|
113
114
|
// §5.9.4: a deleted row references no blobs.
|
|
114
115
|
db.query('DELETE FROM sync_blob_refs WHERE partition=? AND tbl=? AND row_id=?').run(this.#partition, table, rowId);
|
|
115
116
|
}
|
|
@@ -277,6 +278,9 @@ export class SqliteServerStorage {
|
|
|
277
278
|
this.db
|
|
278
279
|
.query('DELETE FROM sync_row_scopes WHERE tbl=?')
|
|
279
280
|
.run(tableName);
|
|
281
|
+
this.db
|
|
282
|
+
.query('DELETE FROM sync_blob_refs WHERE tbl=?')
|
|
283
|
+
.run(tableName);
|
|
280
284
|
this.db.exec(dropTableDdl(tableName));
|
|
281
285
|
}
|
|
282
286
|
for (const statement of schemaDdl(schema, existing, 'sqlite', existingIndexes)) {
|
|
@@ -423,12 +427,12 @@ export class SqliteServerStorage {
|
|
|
423
427
|
/** Internal: write a row + refresh its scope-index entries. */
|
|
424
428
|
writeRow(partition, table, row) {
|
|
425
429
|
const compiled = this.table(table);
|
|
430
|
+
this.db
|
|
431
|
+
.query(deleteSqliteRowScopesSql(compiled))
|
|
432
|
+
.run(partition, table, row.rowId, partition, row.rowId);
|
|
426
433
|
this.db
|
|
427
434
|
.query(upsertSql(compiled, 'sqlite'))
|
|
428
435
|
.run(...upsertValues(compiled, partition, row, 'sqlite'));
|
|
429
|
-
this.db
|
|
430
|
-
.query('DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?')
|
|
431
|
-
.run(partition, table, row.rowId);
|
|
432
436
|
for (const [variable, value] of Object.entries(row.scopes)) {
|
|
433
437
|
this.db
|
|
434
438
|
.query('INSERT OR IGNORE INTO sync_row_scopes(partition, tbl, var, value, row_id) VALUES (?,?,?,?,?)')
|
|
@@ -786,6 +790,22 @@ export class SqliteServerStorage {
|
|
|
786
790
|
.query('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)')
|
|
787
791
|
.run(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs);
|
|
788
792
|
}
|
|
793
|
+
async advanceClientCursor(partition, clientId, actorId, logEpoch, cursor, updatedAtMs) {
|
|
794
|
+
this.db
|
|
795
|
+
.query(`UPDATE sync_clients
|
|
796
|
+
SET cursor=MAX(cursor, ?), updated_at_ms=MAX(updated_at_ms, ?)
|
|
797
|
+
WHERE partition=? AND client_id=? AND actor_id=?
|
|
798
|
+
AND EXISTS (SELECT 1 FROM sync_partition_registry
|
|
799
|
+
WHERE partition=sync_clients.partition AND log_epoch=?)`)
|
|
800
|
+
.run(cursor, updatedAtMs, partition, clientId, actorId, logEpoch);
|
|
801
|
+
}
|
|
802
|
+
async updateClientCursor(partition, clientId, cursor, updatedAtMs) {
|
|
803
|
+
this.db
|
|
804
|
+
.query(`UPDATE sync_clients
|
|
805
|
+
SET cursor=MAX(cursor, ?), updated_at_ms=MAX(updated_at_ms, ?)
|
|
806
|
+
WHERE partition=? AND client_id=?`)
|
|
807
|
+
.run(cursor, updatedAtMs, partition, clientId);
|
|
808
|
+
}
|
|
789
809
|
async getActiveClientCursorFloor(partition, cutoffMs) {
|
|
790
810
|
const row = this.db
|
|
791
811
|
.query('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?')
|
package/dist/storage-errors.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export declare class StorageConstraintError extends Error {
|
|
|
9
9
|
constructor(cause: unknown, opIndex?: number);
|
|
10
10
|
}
|
|
11
11
|
/** Stable, privacy-safe failures for trusted server storage queries. */
|
|
12
|
-
export type StorageQueryErrorCode = 'sync.storage.scan_requires_scope' | 'sync.storage.index_not_found' | 'sync.storage.index_not_materialized' | 'sync.storage.index_value_count_mismatch' | 'sync.storage.invalid_limit' | 'sync.storage.prune_epoch_mismatch' | 'sync.storage.partition_unregistered' | 'sync.storage.invalid_prune_cursor';
|
|
12
|
+
export type StorageQueryErrorCode = 'sync.storage.schema_migration_pending' | 'sync.storage.schema_migration_conflict' | 'sync.storage.schema_changed' | 'sync.storage.invalid_migration_budget' | 'sync.storage.scan_requires_scope' | 'sync.storage.index_not_found' | 'sync.storage.index_not_materialized' | 'sync.storage.index_value_count_mismatch' | 'sync.storage.invalid_limit' | 'sync.storage.prune_epoch_mismatch' | 'sync.storage.partition_unregistered' | 'sync.storage.invalid_prune_cursor';
|
|
13
13
|
/**
|
|
14
14
|
* Host-only query error. Messages never include identifiers, values, SQL,
|
|
15
15
|
* paths, or row data; callers branch on `code`, never message text.
|
package/dist/storage-errors.js
CHANGED
|
@@ -12,6 +12,10 @@ export class StorageConstraintError extends Error {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
const STORAGE_QUERY_MESSAGES = {
|
|
15
|
+
'sync.storage.schema_migration_pending': 'schema migration requires another invocation',
|
|
16
|
+
'sync.storage.schema_migration_conflict': 'another schema migration target is already pending',
|
|
17
|
+
'sync.storage.schema_changed': 'storage schema is not ready for this operation',
|
|
18
|
+
'sync.storage.invalid_migration_budget': 'migration statement budget must be an integer from 10 through 1000',
|
|
15
19
|
'sync.storage.prune_epoch_mismatch': 'partition log epoch changed; recompute retention inputs',
|
|
16
20
|
'sync.storage.partition_unregistered': 'pruning requires a registered partition',
|
|
17
21
|
'sync.storage.invalid_prune_cursor': 'pruning requires a non-negative safe integer cursor and a non-empty log epoch',
|
package/dist/storage.d.ts
CHANGED
|
@@ -425,6 +425,15 @@ export interface ServerStorage {
|
|
|
425
425
|
scanRowsByIndex?(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
426
426
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
427
427
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
428
|
+
/**
|
|
429
|
+
* Atomically advance an existing record's cursor and activity timestamp.
|
|
430
|
+
* Preserve registration fields; require matching actor and current log epoch.
|
|
431
|
+
* Missing records and mismatched identities are unchanged (§8.2).
|
|
432
|
+
*/
|
|
433
|
+
advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
|
|
434
|
+
/** Advance an existing client's ACK cursor and timestamp atomically,
|
|
435
|
+
* preserving actor, wire version, and subscriptions. Missing records stay absent. */
|
|
436
|
+
updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
|
|
428
437
|
/** Minimum cursor with updatedAtMs >= cutoff; null when none are active. */
|
|
429
438
|
getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
|
|
430
439
|
/** Cursor records for client listings and administrative counts. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
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
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@syncular/core": "0.
|
|
71
|
+
"@syncular/core": "0.17.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|