@syncular/server 0.15.47 → 0.16.1
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 +48 -5
- package/dist/admin.d.ts +1 -5
- package/dist/admin.js +3 -12
- package/dist/authoritative-query.d.ts +14 -6
- package/dist/authoritative-query.js +60 -82
- package/dist/blob-handlers.js +4 -1
- package/dist/context.d.ts +3 -1
- package/dist/context.js +4 -0
- package/dist/d1-storage.d.ts +8 -2
- package/dist/d1-storage.js +134 -26
- 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.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/operations.d.ts +2 -0
- package/dist/operations.js +25 -5
- package/dist/postgres-storage.d.ts +9 -3
- package/dist/postgres-storage.js +110 -20
- package/dist/prune.d.ts +3 -1
- package/dist/prune.js +18 -14
- package/dist/pull.d.ts +1 -1
- package/dist/pull.js +74 -37
- package/dist/push.js +5 -5
- 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-bun-driver.d.ts +2 -1
- package/dist/sqlite-bun-driver.js +5 -2
- package/dist/sqlite-bun.js +2 -2
- package/dist/sqlite-dialect.d.ts +1 -1
- package/dist/sqlite-dialect.js +7 -0
- package/dist/sqlite-image.d.ts +3 -3
- package/dist/sqlite-image.js +10 -6
- package/dist/sqlite-node.js +2 -2
- package/dist/sqlite-segment-store.js +14 -5
- package/dist/sqlite-storage.d.ts +8 -2
- package/dist/sqlite-storage.js +147 -36
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +3 -0
- package/dist/storage.d.ts +38 -10
- package/package.json +2 -2
- package/src/admin.ts +7 -15
- package/src/authoritative-query.ts +89 -94
- package/src/blob-handlers.ts +8 -1
- package/src/context.ts +16 -1
- package/src/d1-storage.ts +193 -29
- 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.ts +1 -0
- package/src/operations.ts +37 -4
- package/src/postgres-storage.ts +184 -38
- package/src/prune.ts +29 -15
- package/src/pull.ts +90 -41
- package/src/push.ts +5 -5
- 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-bun-driver.ts +6 -2
- package/src/sqlite-bun.ts +2 -2
- package/src/sqlite-dialect.ts +7 -0
- package/src/sqlite-image.ts +26 -15
- package/src/sqlite-node.ts +2 -2
- package/src/sqlite-segment-store.ts +18 -4
- package/src/sqlite-storage.ts +203 -39
- package/src/storage-errors.ts +10 -1
- package/src/storage.ts +57 -10
package/dist/sqlite-storage.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { validateCommitPruneQuery } from './prune.js';
|
|
2
|
+
import { StorageQueryError } from './storage-errors.js';
|
|
1
3
|
/**
|
|
2
4
|
* SQLite server storage over the shared synchronous driver.
|
|
3
5
|
*
|
|
@@ -199,7 +201,7 @@ export class SqliteServerStorage {
|
|
|
199
201
|
/** Set by `ensureSchema`: app-table lookup for the relational row store. */
|
|
200
202
|
#tables;
|
|
201
203
|
#schemaVersion;
|
|
202
|
-
async #
|
|
204
|
+
async #serializeWrite(operation) {
|
|
203
205
|
const previous = this.#transactionTail;
|
|
204
206
|
let release;
|
|
205
207
|
this.#transactionTail = new Promise((resolve) => {
|
|
@@ -219,6 +221,12 @@ export class SqliteServerStorage {
|
|
|
219
221
|
}
|
|
220
222
|
this.db = db;
|
|
221
223
|
this.db.exec(SQLITE_DDL);
|
|
224
|
+
const clientColumns = this.db
|
|
225
|
+
.query('PRAGMA table_info("sync_clients")')
|
|
226
|
+
.all();
|
|
227
|
+
if (!clientColumns.some((column) => column.name === 'wire_version')) {
|
|
228
|
+
this.db.exec('ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1');
|
|
229
|
+
}
|
|
222
230
|
}
|
|
223
231
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
224
232
|
table(name) {
|
|
@@ -294,6 +302,74 @@ export class SqliteServerStorage {
|
|
|
294
302
|
this.#tables = schema.tables;
|
|
295
303
|
this.#schemaVersion = schema.version;
|
|
296
304
|
}
|
|
305
|
+
async touchPartition(partition, authenticatedAtMs, initialLogEpoch) {
|
|
306
|
+
if (initialLogEpoch.length === 0) {
|
|
307
|
+
throw new Error('initial log epoch must be non-empty');
|
|
308
|
+
}
|
|
309
|
+
this.db
|
|
310
|
+
.query(`INSERT INTO sync_partition_registry(
|
|
311
|
+
partition, log_epoch, last_authenticated_at_ms
|
|
312
|
+
) VALUES (?,?,?)
|
|
313
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
314
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
|
|
315
|
+
.run(partition, initialLogEpoch, authenticatedAtMs);
|
|
316
|
+
const row = this.db
|
|
317
|
+
.query(`SELECT log_epoch, epoch_required, last_authenticated_at_ms
|
|
318
|
+
FROM sync_partition_registry WHERE partition=?`)
|
|
319
|
+
.get(partition);
|
|
320
|
+
if (row === null)
|
|
321
|
+
throw new Error('partition registry write did not persist');
|
|
322
|
+
return {
|
|
323
|
+
partition,
|
|
324
|
+
logEpoch: row.log_epoch,
|
|
325
|
+
epochRequired: row.epoch_required === 1,
|
|
326
|
+
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
async rotatePartitionLogEpoch(partition, logEpoch, authenticatedAtMs) {
|
|
330
|
+
if (logEpoch.length === 0)
|
|
331
|
+
throw new Error('log epoch must be non-empty');
|
|
332
|
+
return this.#serializeWrite(() => {
|
|
333
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
334
|
+
try {
|
|
335
|
+
this.db
|
|
336
|
+
.query(`INSERT INTO sync_partition_registry(
|
|
337
|
+
partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
338
|
+
) VALUES (?,?,1,?)
|
|
339
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
340
|
+
log_epoch=excluded.log_epoch,
|
|
341
|
+
epoch_required=1,
|
|
342
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
|
|
343
|
+
.run(partition, logEpoch, authenticatedAtMs);
|
|
344
|
+
this.db
|
|
345
|
+
.query('DELETE FROM sync_clients WHERE partition=?')
|
|
346
|
+
.run(partition);
|
|
347
|
+
this.db.exec('COMMIT');
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
this.db.exec('ROLLBACK');
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
partition,
|
|
355
|
+
logEpoch,
|
|
356
|
+
epochRequired: true,
|
|
357
|
+
lastAuthenticatedAtMs: authenticatedAtMs,
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
async listPartitionRegistry() {
|
|
362
|
+
return this.db
|
|
363
|
+
.query(`SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
364
|
+
FROM sync_partition_registry ORDER BY partition`)
|
|
365
|
+
.all()
|
|
366
|
+
.map((row) => ({
|
|
367
|
+
partition: row.partition,
|
|
368
|
+
logEpoch: row.log_epoch,
|
|
369
|
+
epochRequired: row.epoch_required === 1,
|
|
370
|
+
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
371
|
+
}));
|
|
372
|
+
}
|
|
297
373
|
/**
|
|
298
374
|
* Migration rewrite: keyset-paged walk of a row table. When `oldLayout` is
|
|
299
375
|
* given every payload re-encodes under
|
|
@@ -369,7 +445,7 @@ export class SqliteServerStorage {
|
|
|
369
445
|
if (this.#tables === undefined) {
|
|
370
446
|
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
371
447
|
}
|
|
372
|
-
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.
|
|
448
|
+
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
|
|
373
449
|
const previous = this.#transactionTail;
|
|
374
450
|
let release;
|
|
375
451
|
this.#transactionTail = new Promise((resolve) => {
|
|
@@ -399,6 +475,11 @@ export class SqliteServerStorage {
|
|
|
399
475
|
release();
|
|
400
476
|
}
|
|
401
477
|
}
|
|
478
|
+
async getPartitionLogEpoch(partition) {
|
|
479
|
+
return this.db
|
|
480
|
+
.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=?')
|
|
481
|
+
.get(partition)?.log_epoch;
|
|
482
|
+
}
|
|
402
483
|
async getHorizonSeq(partition) {
|
|
403
484
|
const row = this.db
|
|
404
485
|
.query('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
|
|
@@ -406,24 +487,52 @@ export class SqliteServerStorage {
|
|
|
406
487
|
return row?.horizon_seq ?? 0;
|
|
407
488
|
}
|
|
408
489
|
async setHorizonSeq(partition, seq) {
|
|
409
|
-
this
|
|
410
|
-
.
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
490
|
+
await this.#serializeWrite(() => {
|
|
491
|
+
this.db
|
|
492
|
+
.query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?)
|
|
493
|
+
ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)`)
|
|
494
|
+
.run(partition, seq);
|
|
495
|
+
});
|
|
415
496
|
}
|
|
416
|
-
async pruneCommitsThrough(partition,
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
497
|
+
async pruneCommitsThrough(partition, query) {
|
|
498
|
+
validateCommitPruneQuery(query);
|
|
499
|
+
return this.#serializeWrite(() => {
|
|
500
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
501
|
+
try {
|
|
502
|
+
const epoch = this.db
|
|
503
|
+
.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=?')
|
|
504
|
+
.get(partition)?.log_epoch;
|
|
505
|
+
if (epoch !== query.logEpoch)
|
|
506
|
+
throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
|
|
507
|
+
const previousHorizonSeq = this.db
|
|
508
|
+
.query('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
|
|
509
|
+
.get(partition)?.horizon_seq ?? 0;
|
|
510
|
+
const horizonSeq = Math.max(previousHorizonSeq, query.throughSeq);
|
|
511
|
+
this.db
|
|
512
|
+
.query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?)
|
|
513
|
+
ON CONFLICT(partition) DO UPDATE SET horizon_seq=excluded.horizon_seq`)
|
|
514
|
+
.run(partition, horizonSeq);
|
|
515
|
+
const removed = this.db
|
|
516
|
+
.query('DELETE FROM sync_commits WHERE partition=? AND commit_seq<=?')
|
|
517
|
+
.run(partition, horizonSeq);
|
|
518
|
+
this.db
|
|
519
|
+
.query('DELETE FROM sync_changes WHERE partition=? AND commit_seq<=?')
|
|
520
|
+
.run(partition, horizonSeq);
|
|
521
|
+
this.db
|
|
522
|
+
.query('DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?')
|
|
523
|
+
.run(partition, horizonSeq);
|
|
524
|
+
this.db.exec('COMMIT');
|
|
525
|
+
return {
|
|
526
|
+
previousHorizonSeq,
|
|
527
|
+
horizonSeq,
|
|
528
|
+
removedCommits: Number(removed.changes),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
this.db.exec('ROLLBACK');
|
|
533
|
+
throw error;
|
|
534
|
+
}
|
|
535
|
+
});
|
|
427
536
|
}
|
|
428
537
|
async getCommitSeqBefore(partition, createdBeforeMs) {
|
|
429
538
|
const row = this.db
|
|
@@ -453,7 +562,7 @@ export class SqliteServerStorage {
|
|
|
453
562
|
async claimReactions(partition, query) {
|
|
454
563
|
if (query.types.length === 0 || query.limit <= 0)
|
|
455
564
|
return [];
|
|
456
|
-
return this.#
|
|
565
|
+
return this.#serializeWrite(() => {
|
|
457
566
|
const typeParams = query.types.map(() => '?').join(',');
|
|
458
567
|
const records = this.db
|
|
459
568
|
.query(`UPDATE sync_reactions
|
|
@@ -479,7 +588,7 @@ export class SqliteServerStorage {
|
|
|
479
588
|
});
|
|
480
589
|
}
|
|
481
590
|
async completeReaction(partition, idempotencyKey, leaseOwner, completedAtMs) {
|
|
482
|
-
return this.#
|
|
591
|
+
return this.#serializeWrite(() => {
|
|
483
592
|
const result = this.db
|
|
484
593
|
.query(`UPDATE sync_reactions
|
|
485
594
|
SET status='completed', completed_at_ms=?,
|
|
@@ -491,7 +600,7 @@ export class SqliteServerStorage {
|
|
|
491
600
|
});
|
|
492
601
|
}
|
|
493
602
|
async extendReactionLease(partition, idempotencyKey, leaseOwner, leaseExpiresAtMs) {
|
|
494
|
-
return this.#
|
|
603
|
+
return this.#serializeWrite(() => {
|
|
495
604
|
const result = this.db
|
|
496
605
|
.query(`UPDATE sync_reactions SET lease_expires_at_ms=?
|
|
497
606
|
WHERE partition=? AND idempotency_key=?
|
|
@@ -502,7 +611,7 @@ export class SqliteServerStorage {
|
|
|
502
611
|
}
|
|
503
612
|
async failReaction(partition, idempotencyKey, update) {
|
|
504
613
|
const retry = update.retryAtMs !== undefined;
|
|
505
|
-
return this.#
|
|
614
|
+
return this.#serializeWrite(() => {
|
|
506
615
|
const result = this.db
|
|
507
616
|
.query(`UPDATE sync_reactions
|
|
508
617
|
SET status=?, available_at_ms=?, last_failure=?,
|
|
@@ -514,7 +623,7 @@ export class SqliteServerStorage {
|
|
|
514
623
|
});
|
|
515
624
|
}
|
|
516
625
|
async retryReaction(partition, idempotencyKey, nowMs) {
|
|
517
|
-
return this.#
|
|
626
|
+
return this.#serializeWrite(() => {
|
|
518
627
|
const result = this.db
|
|
519
628
|
.query(`UPDATE sync_reactions
|
|
520
629
|
SET status='pending', attempts=0, available_at_ms=?,
|
|
@@ -552,7 +661,7 @@ export class SqliteServerStorage {
|
|
|
552
661
|
async pruneReactions(partition, query) {
|
|
553
662
|
if (query.limit <= 0)
|
|
554
663
|
return { completed: 0, deadLetter: 0 };
|
|
555
|
-
return this.#
|
|
664
|
+
return this.#serializeWrite(() => {
|
|
556
665
|
const records = this.db
|
|
557
666
|
.query(`DELETE FROM sync_reactions
|
|
558
667
|
WHERE partition=? AND idempotency_key IN (
|
|
@@ -659,13 +768,14 @@ export class SqliteServerStorage {
|
|
|
659
768
|
}
|
|
660
769
|
async getClientRecord(partition, clientId) {
|
|
661
770
|
const record = this.db
|
|
662
|
-
.query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
|
|
771
|
+
.query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
|
|
663
772
|
.get(partition, clientId);
|
|
664
773
|
if (record === null)
|
|
665
774
|
return undefined;
|
|
666
775
|
return {
|
|
667
776
|
clientId: record.client_id,
|
|
668
777
|
actorId: record.actor_id,
|
|
778
|
+
wireVersion: record.wire_version,
|
|
669
779
|
cursor: record.cursor,
|
|
670
780
|
updatedAtMs: record.updated_at_ms,
|
|
671
781
|
subscriptions: JSON.parse(record.subscriptions),
|
|
@@ -673,8 +783,14 @@ export class SqliteServerStorage {
|
|
|
673
783
|
}
|
|
674
784
|
async putClientRecord(partition, record) {
|
|
675
785
|
this.db
|
|
676
|
-
.query('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, cursor, subscriptions, updated_at_ms) VALUES (
|
|
677
|
-
.run(partition, record.clientId, record.actorId, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs);
|
|
786
|
+
.query('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)')
|
|
787
|
+
.run(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs);
|
|
788
|
+
}
|
|
789
|
+
async getActiveClientCursorFloor(partition, cutoffMs) {
|
|
790
|
+
const row = this.db
|
|
791
|
+
.query('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?')
|
|
792
|
+
.get(partition, cutoffMs);
|
|
793
|
+
return row.cursor;
|
|
678
794
|
}
|
|
679
795
|
async listClientCursors(partition) {
|
|
680
796
|
const records = this.db
|
|
@@ -719,11 +835,12 @@ export class SqliteServerStorage {
|
|
|
719
835
|
// -- admin/console read surface --------------------------------------------
|
|
720
836
|
async listClientRecords(partition) {
|
|
721
837
|
const records = this.db
|
|
722
|
-
.query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
|
|
838
|
+
.query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
|
|
723
839
|
.all(partition);
|
|
724
840
|
return records.map((record) => ({
|
|
725
841
|
clientId: record.client_id,
|
|
726
842
|
actorId: record.actor_id,
|
|
843
|
+
wireVersion: record.wire_version,
|
|
727
844
|
cursor: record.cursor,
|
|
728
845
|
updatedAtMs: record.updated_at_ms,
|
|
729
846
|
subscriptions: JSON.parse(record.subscriptions),
|
|
@@ -800,12 +917,6 @@ export class SqliteServerStorage {
|
|
|
800
917
|
};
|
|
801
918
|
}
|
|
802
919
|
async listPartitions() {
|
|
803
|
-
|
|
804
|
-
// first pull — a partition with only one of the two still shows up.
|
|
805
|
-
const rows = this.db
|
|
806
|
-
.query(`SELECT partition FROM sync_partitions
|
|
807
|
-
UNION SELECT partition FROM sync_clients ORDER BY partition`)
|
|
808
|
-
.all();
|
|
809
|
-
return rows.map((r) => r.partition);
|
|
920
|
+
return (await this.listPartitionRegistry()).map((entry) => entry.partition);
|
|
810
921
|
}
|
|
811
922
|
}
|
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';
|
|
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';
|
|
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,9 @@ export class StorageConstraintError extends Error {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
const STORAGE_QUERY_MESSAGES = {
|
|
15
|
+
'sync.storage.prune_epoch_mismatch': 'partition log epoch changed; recompute retention inputs',
|
|
16
|
+
'sync.storage.partition_unregistered': 'pruning requires a registered partition',
|
|
17
|
+
'sync.storage.invalid_prune_cursor': 'pruning requires a non-negative safe integer cursor and a non-empty log epoch',
|
|
15
18
|
'sync.storage.scan_requires_scope': 'scope-indexed row scans require at least one scope variable',
|
|
16
19
|
'sync.storage.index_not_found': 'trusted row lookup requires a declared relational index',
|
|
17
20
|
'sync.storage.index_not_materialized': 'trusted row lookup requires a materialized relational table',
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AuthoritativeRelationPlan } from './authoritative-query.js';
|
|
1
2
|
/**
|
|
2
3
|
* Storage interface (defined by the SPEC's needs, implementation-agnostic).
|
|
3
4
|
*
|
|
@@ -18,6 +19,15 @@
|
|
|
18
19
|
*/
|
|
19
20
|
import type { PushOperationResult, RowValue, ScopeMap } from '@syncular/core';
|
|
20
21
|
import type { CompiledSchema } from './schema.js';
|
|
22
|
+
export interface CommitPruneQuery {
|
|
23
|
+
readonly logEpoch: string;
|
|
24
|
+
readonly throughSeq: number;
|
|
25
|
+
}
|
|
26
|
+
export interface CommitPruneResult {
|
|
27
|
+
readonly previousHorizonSeq: number;
|
|
28
|
+
readonly horizonSeq: number;
|
|
29
|
+
readonly removedCommits: number;
|
|
30
|
+
}
|
|
21
31
|
/** The current stored state of a synced row. */
|
|
22
32
|
export interface StoredRow {
|
|
23
33
|
readonly rowId: string;
|
|
@@ -144,6 +154,8 @@ export interface ClientSubscription {
|
|
|
144
154
|
export interface ClientRecord {
|
|
145
155
|
readonly clientId: string;
|
|
146
156
|
readonly actorId: string;
|
|
157
|
+
/** SSP2 version last accepted from this client; selects realtime deltas. */
|
|
158
|
+
readonly wireVersion: number;
|
|
147
159
|
/** Minimum `nextCursor` across the last pull's active subscriptions. */
|
|
148
160
|
readonly cursor: number;
|
|
149
161
|
readonly updatedAtMs: number;
|
|
@@ -195,6 +207,13 @@ export interface ClientCursorInfo {
|
|
|
195
207
|
readonly cursor: number;
|
|
196
208
|
readonly updatedAtMs: number;
|
|
197
209
|
}
|
|
210
|
+
/** Durable partition identity refreshed after host authentication (§2.1). */
|
|
211
|
+
export interface PartitionRegistryEntry {
|
|
212
|
+
readonly partition: string;
|
|
213
|
+
readonly logEpoch: string;
|
|
214
|
+
readonly epochRequired: boolean;
|
|
215
|
+
readonly lastAuthenticatedAtMs: number;
|
|
216
|
+
}
|
|
198
217
|
/**
|
|
199
218
|
* Commit-log metadata (no change payloads) for the admin/console read
|
|
200
219
|
* surface. `changeCount` is the number of changes the commit carries;
|
|
@@ -235,7 +254,7 @@ export interface ScopeActivityQuery {
|
|
|
235
254
|
export type AuthoritativeQueryValue = string | number | bigint | boolean | Uint8Array | null;
|
|
236
255
|
export interface AuthoritativeQueryRequest {
|
|
237
256
|
/** Generated, positional SQLite-family SQL. It never comes from the request. */
|
|
238
|
-
readonly
|
|
257
|
+
readonly plan: AuthoritativeRelationPlan;
|
|
239
258
|
readonly params: readonly AuthoritativeQueryValue[];
|
|
240
259
|
/** Generated dependency set, used to validate and partition every relation. */
|
|
241
260
|
readonly tables: readonly string[];
|
|
@@ -340,15 +359,24 @@ export interface ServerStorage {
|
|
|
340
359
|
* lazily as a defensive backstop.
|
|
341
360
|
*/
|
|
342
361
|
ensureSchema(schema: CompiledSchema): Promise<void>;
|
|
362
|
+
/** Create or refresh the authenticated partition registry row (§2.1). */
|
|
363
|
+
touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
|
|
364
|
+
/** Rotate log continuity after restore and discard stale cursor records. */
|
|
365
|
+
rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
|
|
366
|
+
/** Registry entries ordered by partition for maintenance loops. */
|
|
367
|
+
listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
|
|
368
|
+
/** Read continuity without refreshing authenticated activity. */
|
|
369
|
+
getPartitionLogEpoch(partition: string): Promise<string | undefined>;
|
|
343
370
|
begin(partition: string): Promise<StorageTransaction>;
|
|
344
371
|
getMaxCommitSeq(partition: string): Promise<number>;
|
|
345
372
|
getHorizonSeq(partition: string): Promise<number>;
|
|
373
|
+
/** Monotonic within the current epoch; use atomic pruning for maintenance. */
|
|
346
374
|
setHorizonSeq(partition: string, seq: number): Promise<void>;
|
|
347
375
|
/**
|
|
348
|
-
*
|
|
349
|
-
*
|
|
376
|
+
* Atomically verifies the log epoch, advances the horizon monotonically,
|
|
377
|
+
* and removes log/change/scope records through the effective horizon.
|
|
350
378
|
*/
|
|
351
|
-
pruneCommitsThrough(partition: string,
|
|
379
|
+
pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
|
|
352
380
|
/** Newest commitSeq created strictly before the timestamp; 0 if none. */
|
|
353
381
|
getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
|
|
354
382
|
getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
@@ -397,7 +425,9 @@ export interface ServerStorage {
|
|
|
397
425
|
scanRowsByIndex?(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
398
426
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
399
427
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
400
|
-
/**
|
|
428
|
+
/** Minimum cursor with updatedAtMs >= cutoff; null when none are active. */
|
|
429
|
+
getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
|
|
430
|
+
/** Cursor records for client listings and administrative counts. */
|
|
401
431
|
listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
|
|
402
432
|
/**
|
|
403
433
|
* Blob reference index reads (§5.9.4) — ADDITIVE, optional (mirrors the
|
|
@@ -427,10 +457,8 @@ export interface ServerStorage {
|
|
|
427
457
|
* change scope index (never a log scan).
|
|
428
458
|
* `getRowScopes`: the (table, rowId) row's current server_version and
|
|
429
459
|
* 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).
|
|
460
|
+
* `listPartitions`: the partition-only compatibility view of
|
|
461
|
+
* `listPartitionRegistry`, sorted.
|
|
434
462
|
*/
|
|
435
463
|
listClientRecords?(partition: string): Promise<ClientRecord[]>;
|
|
436
464
|
listCommitMetadata?(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
|
|
@@ -439,7 +467,7 @@ export interface ServerStorage {
|
|
|
439
467
|
serverVersion: number;
|
|
440
468
|
scopes: Record<string, string>;
|
|
441
469
|
} | undefined>;
|
|
442
|
-
listPartitions
|
|
470
|
+
listPartitions(): Promise<string[]>;
|
|
443
471
|
}
|
|
444
472
|
/** A row referencing a blob, with the scopes needed to authorize download. */
|
|
445
473
|
export interface BlobReferencingRow {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
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.16.1"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/admin.ts
CHANGED
|
@@ -396,12 +396,10 @@ export class SyncularAdmin {
|
|
|
396
396
|
const nowMs = this.#clock();
|
|
397
397
|
const maxCommitSeq = await this.#storage.getMaxCommitSeq(partition);
|
|
398
398
|
const horizonSeq = await this.#storage.getHorizonSeq(partition);
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const activeCursorFloor =
|
|
404
|
-
activeCursors.length > 0 ? Math.min(...activeCursors) : null;
|
|
399
|
+
const activeCursorFloor = await this.#storage.getActiveClientCursorFloor(
|
|
400
|
+
partition,
|
|
401
|
+
nowMs - this.#retention.activeWindowMs,
|
|
402
|
+
);
|
|
405
403
|
const cursorFloor = activeCursorFloor ?? Number.MAX_SAFE_INTEGER;
|
|
406
404
|
const forcedSeq = await this.#storage.getCommitSeqBefore(
|
|
407
405
|
partition,
|
|
@@ -520,17 +518,11 @@ export class SyncularAdmin {
|
|
|
520
518
|
};
|
|
521
519
|
}
|
|
522
520
|
|
|
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
|
-
*/
|
|
521
|
+
/** Every authenticated partition in the storage-backed registry. */
|
|
528
522
|
async listPartitions(): Promise<string[]> {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
'storage',
|
|
523
|
+
return (await this.#storage.listPartitionRegistry()).map(
|
|
524
|
+
(entry) => entry.partition,
|
|
532
525
|
);
|
|
533
|
-
return list();
|
|
534
526
|
}
|
|
535
527
|
|
|
536
528
|
/**
|