@syncular/server 0.30.16 → 0.32.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 +196 -76
- package/dist/admin.d.ts +394 -50
- package/dist/admin.js +714 -82
- package/dist/context.d.ts +16 -6
- package/dist/d1-storage.d.ts +6 -5
- package/dist/d1-storage.js +148 -27
- package/dist/errors.d.ts +17 -4
- package/dist/errors.js +31 -6
- package/dist/events-ring.d.ts +12 -2
- package/dist/events-ring.js +27 -2
- package/dist/events.d.ts +21 -3
- package/dist/handler.js +19 -6
- package/dist/postgres-storage.d.ts +6 -5
- package/dist/postgres-storage.js +102 -18
- package/dist/pull.js +3 -26
- package/dist/realtime.d.ts +18 -1
- package/dist/realtime.js +31 -3
- package/dist/sqlite-image.d.ts +1 -1
- package/dist/sqlite-storage.d.ts +6 -5
- package/dist/sqlite-storage.js +115 -18
- package/dist/storage.d.ts +58 -7
- package/package.json +2 -2
- package/src/admin.ts +1215 -120
- package/src/context.ts +16 -6
- package/src/d1-storage.ts +210 -31
- package/src/errors.ts +45 -5
- package/src/events-ring.ts +31 -2
- package/src/events.ts +27 -4
- package/src/handler.ts +20 -6
- package/src/postgres-storage.ts +173 -28
- package/src/pull.ts +3 -29
- package/src/realtime.ts +58 -4
- package/src/sqlite-image.ts +1 -1
- package/src/sqlite-storage.ts +181 -26
- package/src/storage.ts +72 -9
package/src/context.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { ScopeMap } from '@syncular/core';
|
|
8
8
|
import type { BlobStore } from './blob-store';
|
|
9
9
|
import type { CrdtMergerRegistry } from './crdt-merger';
|
|
10
|
-
import type { SyncularErrorHandler } from './errors';
|
|
10
|
+
import type { SyncularErrorHandler, SyncularErrorMapper } from './errors';
|
|
11
11
|
import type { SyncularServerEvents } from './events';
|
|
12
12
|
import type { LeaseStore } from './lease-store';
|
|
13
13
|
import type { AnyReactionPlanner } from './reactions';
|
|
@@ -182,11 +182,10 @@ export interface SyncServerConfig {
|
|
|
182
182
|
*/
|
|
183
183
|
readonly blobUploadUrls?: BlobUploadPresignConfig;
|
|
184
184
|
/**
|
|
185
|
-
* §5.3 sqlite-image builder
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* `@syncular/server/sqlite`.
|
|
185
|
+
* §5.3 sqlite-image builder. The host explicitly imports and supplies
|
|
186
|
+
* `buildSqliteImage` from `@syncular/server/sqlite` on Bun or Node.
|
|
187
|
+
* Absent ⇒ serve matching stored images or negotiate the rows lane.
|
|
188
|
+
* The neutral core never imports a runtime-specific builder.
|
|
190
189
|
*/
|
|
191
190
|
readonly sqliteImageBuilder?: SqliteImageBuilder;
|
|
192
191
|
readonly realtime?: RealtimeNotifier;
|
|
@@ -203,6 +202,17 @@ export interface SyncServerConfig {
|
|
|
203
202
|
* handler never changes the response.
|
|
204
203
|
*/
|
|
205
204
|
readonly onError?: SyncularErrorHandler;
|
|
205
|
+
/**
|
|
206
|
+
* Optional synchronous adapter error mapper (§10.2). After `onError`
|
|
207
|
+
* observes an adapter exception, `mapError` may return a catalog
|
|
208
|
+
* `SyncError` (including structured `details`, e.g. `retryAfterMs`) that
|
|
209
|
+
* the adapter answers instead of `sync.internal_error`; `undefined` keeps
|
|
210
|
+
* the default. A throw, a non-`SyncError` return, or malformed `details`
|
|
211
|
+
* JSON is contained as `sync.internal_error`. Unlike `onError`, this hook
|
|
212
|
+
* may affect the response. It does not change the remote-operation
|
|
213
|
+
* `operation.*` envelope.
|
|
214
|
+
*/
|
|
215
|
+
readonly mapError?: SyncularErrorMapper;
|
|
206
216
|
}
|
|
207
217
|
|
|
208
218
|
/** Per-request context: the config plus host-authenticated identity. */
|
package/src/d1-storage.ts
CHANGED
|
@@ -68,6 +68,12 @@ import {
|
|
|
68
68
|
rewriteRowSql,
|
|
69
69
|
rewriteValues,
|
|
70
70
|
SCHEMA_META_DDL_SQLITE,
|
|
71
|
+
SYNC_COLUMN_VERSIONS_COLUMN,
|
|
72
|
+
SYNC_PARTITION_COLUMN,
|
|
73
|
+
SYNC_PAYLOAD_COLUMN,
|
|
74
|
+
SYNC_ROW_ID_COLUMN,
|
|
75
|
+
SYNC_SCOPES_COLUMN,
|
|
76
|
+
SYNC_VERSION_COLUMN,
|
|
71
77
|
type StoredColumnLayout,
|
|
72
78
|
scanRowPageSql,
|
|
73
79
|
schemaDdl,
|
|
@@ -86,10 +92,12 @@ import {
|
|
|
86
92
|
collectCommitWindowPage,
|
|
87
93
|
deserializePushResult,
|
|
88
94
|
placeholders,
|
|
95
|
+
type SqliteChangeRecord,
|
|
89
96
|
type SqliteCommitWindowRecord,
|
|
90
97
|
type SqliteRowRecord,
|
|
91
98
|
serializePushResult,
|
|
92
99
|
sqliteDdlStatements,
|
|
100
|
+
toStoredChange,
|
|
93
101
|
toStoredRow,
|
|
94
102
|
} from './sqlite-dialect';
|
|
95
103
|
import type {
|
|
@@ -97,8 +105,11 @@ import type {
|
|
|
97
105
|
AuthoritativeQueryResult,
|
|
98
106
|
CheckpointDeclaration,
|
|
99
107
|
ClientCursorInfo,
|
|
108
|
+
ClientPushResult,
|
|
100
109
|
ClientRecord,
|
|
101
110
|
ClientSubscription,
|
|
111
|
+
CommitChangeSummary,
|
|
112
|
+
CommitDetail,
|
|
102
113
|
CommitMetadata,
|
|
103
114
|
CommitMetadataQuery,
|
|
104
115
|
CommitWindowQuery,
|
|
@@ -113,6 +124,9 @@ import type {
|
|
|
113
124
|
ReactionFailureUpdate,
|
|
114
125
|
ReactionListQuery,
|
|
115
126
|
ReactionPruneQuery,
|
|
127
|
+
RowChangeRecord,
|
|
128
|
+
RowChangesQuery,
|
|
129
|
+
RowListQuery,
|
|
116
130
|
RowScanQuery,
|
|
117
131
|
ScopeActivityQuery,
|
|
118
132
|
ScopeCommitActivity,
|
|
@@ -660,13 +674,19 @@ class D1Transaction implements StorageTransaction {
|
|
|
660
674
|
row,
|
|
661
675
|
});
|
|
662
676
|
const p = this.#partition;
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
677
|
+
// Scope index maintenance: delete only the old keys the new scope map no
|
|
678
|
+
// longer carries, then insert the new keys. Keys present in both are left
|
|
679
|
+
// alone, so the `INSERT OR IGNORE` below finds them and writes nothing.
|
|
680
|
+
// This DELETE precedes the row upsert in the same atomic batch, and its
|
|
681
|
+
// subquery reads the row's pre-upsert scope map.
|
|
682
|
+
const scopesJson = JSON.stringify(row.scopes);
|
|
683
|
+
this.#buffer_(
|
|
684
|
+
`${deleteSqliteRowScopesSql(compiled)}
|
|
685
|
+
AND (var, value) NOT IN (
|
|
686
|
+
SELECT key, value FROM json_each(?)
|
|
687
|
+
)`,
|
|
688
|
+
[p, table, row.rowId, p, row.rowId, scopesJson],
|
|
689
|
+
);
|
|
670
690
|
this.#buffer_(
|
|
671
691
|
upsertSql(compiled, 'sqlite'),
|
|
672
692
|
upsertValues(compiled, p, row, 'sqlite'),
|
|
@@ -2310,9 +2330,18 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2310
2330
|
partition: string,
|
|
2311
2331
|
record: ClientRecord,
|
|
2312
2332
|
): Promise<void> {
|
|
2333
|
+
// Update the existing record in place, so the table keeps one row per
|
|
2334
|
+
// (partition, client_id). Every non-key field is written from the new
|
|
2335
|
+
// record; a delete-and-insert would drop and recreate the row instead.
|
|
2313
2336
|
await this.#db
|
|
2314
2337
|
.prepare(
|
|
2315
|
-
|
|
2338
|
+
`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)
|
|
2339
|
+
ON CONFLICT(partition, client_id) DO UPDATE SET
|
|
2340
|
+
actor_id=excluded.actor_id,
|
|
2341
|
+
wire_version=excluded.wire_version,
|
|
2342
|
+
cursor=excluded.cursor,
|
|
2343
|
+
subscriptions=excluded.subscriptions,
|
|
2344
|
+
updated_at_ms=excluded.updated_at_ms`,
|
|
2316
2345
|
)
|
|
2317
2346
|
.bind(
|
|
2318
2347
|
partition,
|
|
@@ -2464,17 +2493,22 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2464
2493
|
partition: string,
|
|
2465
2494
|
query: CommitMetadataQuery,
|
|
2466
2495
|
): Promise<CommitMetadata[]> {
|
|
2496
|
+
// Newest-first window, resumed by `afterSeq` (exclusive lower bound) and
|
|
2497
|
+
// ended by `beforeSeq` (exclusive upper bound) when paging older. An
|
|
2498
|
+
// omitted bound is the largest safe commitSeq, so one statement serves
|
|
2499
|
+
// both the bounded and unbounded read.
|
|
2500
|
+
const beforeSeq = query.beforeSeq ?? Number.MAX_SAFE_INTEGER;
|
|
2467
2501
|
const { results: rows } = query.table
|
|
2468
2502
|
? await this.#db
|
|
2469
2503
|
.prepare(
|
|
2470
2504
|
`SELECT c.commit_seq, c.client_id, c.client_commit_id, c.actor_id, c.created_at_ms
|
|
2471
2505
|
FROM sync_commits c
|
|
2472
|
-
WHERE c.partition=? AND c.commit_seq>?
|
|
2506
|
+
WHERE c.partition=? AND c.commit_seq>? AND c.commit_seq<?
|
|
2473
2507
|
AND EXISTS (SELECT 1 FROM sync_changes ch
|
|
2474
2508
|
WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=?)
|
|
2475
2509
|
ORDER BY c.commit_seq DESC LIMIT ?`,
|
|
2476
2510
|
)
|
|
2477
|
-
.bind(partition, query.afterSeq, query.table, query.limit)
|
|
2511
|
+
.bind(partition, query.afterSeq, beforeSeq, query.table, query.limit)
|
|
2478
2512
|
.all<{
|
|
2479
2513
|
commit_seq: number;
|
|
2480
2514
|
client_id: string;
|
|
@@ -2486,10 +2520,10 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2486
2520
|
.prepare(
|
|
2487
2521
|
`SELECT commit_seq, client_id, client_commit_id, actor_id, created_at_ms
|
|
2488
2522
|
FROM sync_commits
|
|
2489
|
-
WHERE partition=? AND commit_seq>?
|
|
2523
|
+
WHERE partition=? AND commit_seq>? AND commit_seq<?
|
|
2490
2524
|
ORDER BY commit_seq DESC LIMIT ?`,
|
|
2491
2525
|
)
|
|
2492
|
-
.bind(partition, query.afterSeq, query.limit)
|
|
2526
|
+
.bind(partition, query.afterSeq, beforeSeq, query.limit)
|
|
2493
2527
|
.all<{
|
|
2494
2528
|
commit_seq: number;
|
|
2495
2529
|
client_id: string;
|
|
@@ -2498,6 +2532,46 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2498
2532
|
created_at_ms: number;
|
|
2499
2533
|
}>();
|
|
2500
2534
|
const out: CommitMetadata[] = [];
|
|
2535
|
+
// `changeSample`: extra statement(s) for the whole page — the first
|
|
2536
|
+
// `sample` changes (change-index order) of every listed commit.
|
|
2537
|
+
const sampleCount =
|
|
2538
|
+
query.changeSample !== undefined &&
|
|
2539
|
+
Number.isInteger(query.changeSample) &&
|
|
2540
|
+
query.changeSample > 0
|
|
2541
|
+
? query.changeSample
|
|
2542
|
+
: 0;
|
|
2543
|
+
const samples = new Map<number, CommitChangeSummary[]>();
|
|
2544
|
+
if (sampleCount > 0 && rows.length > 0) {
|
|
2545
|
+
const seqs = rows.map((row) => row.commit_seq);
|
|
2546
|
+
// D1 caps the bound parameters of one statement, so a page larger than
|
|
2547
|
+
// that cap splits into a few chunk statements, never one per commit.
|
|
2548
|
+
const chunkSize = D1_MAX_BIND_PARAMS - 2;
|
|
2549
|
+
for (let offset = 0; offset < seqs.length; offset += chunkSize) {
|
|
2550
|
+
const chunk = seqs.slice(offset, offset + chunkSize);
|
|
2551
|
+
const { results: changes } = await this.#db
|
|
2552
|
+
.prepare(
|
|
2553
|
+
`SELECT commit_seq, tbl, row_id, op FROM sync_changes
|
|
2554
|
+
WHERE partition=? AND commit_seq IN (${placeholders(chunk.length)}) AND idx<?
|
|
2555
|
+
ORDER BY commit_seq, idx`,
|
|
2556
|
+
)
|
|
2557
|
+
.bind(partition, ...chunk, sampleCount)
|
|
2558
|
+
.all<{
|
|
2559
|
+
commit_seq: number;
|
|
2560
|
+
tbl: string;
|
|
2561
|
+
row_id: string;
|
|
2562
|
+
op: number;
|
|
2563
|
+
}>();
|
|
2564
|
+
for (const change of changes) {
|
|
2565
|
+
const sample = samples.get(change.commit_seq) ?? [];
|
|
2566
|
+
sample.push({
|
|
2567
|
+
table: change.tbl,
|
|
2568
|
+
rowId: change.row_id,
|
|
2569
|
+
op: change.op === 1 ? 'upsert' : 'delete',
|
|
2570
|
+
});
|
|
2571
|
+
samples.set(change.commit_seq, sample);
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2501
2575
|
for (const row of rows) {
|
|
2502
2576
|
const { results: changes } = await this.#db
|
|
2503
2577
|
.prepare(
|
|
@@ -2513,11 +2587,135 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2513
2587
|
createdAtMs: row.created_at_ms,
|
|
2514
2588
|
changeCount: changes.reduce((sum, c) => sum + c.n, 0),
|
|
2515
2589
|
tables: changes.map((c) => c.tbl),
|
|
2590
|
+
...(sampleCount > 0
|
|
2591
|
+
? { sample: samples.get(row.commit_seq) ?? [] }
|
|
2592
|
+
: {}),
|
|
2516
2593
|
});
|
|
2517
2594
|
}
|
|
2518
2595
|
return out;
|
|
2519
2596
|
}
|
|
2520
2597
|
|
|
2598
|
+
async getCommit(
|
|
2599
|
+
partition: string,
|
|
2600
|
+
commitSeq: number,
|
|
2601
|
+
): Promise<CommitDetail | undefined> {
|
|
2602
|
+
const meta = await this.#db
|
|
2603
|
+
.prepare(
|
|
2604
|
+
'SELECT client_id, client_commit_id, actor_id, created_at_ms FROM sync_commits WHERE partition=? AND commit_seq=?',
|
|
2605
|
+
)
|
|
2606
|
+
.bind(partition, commitSeq)
|
|
2607
|
+
.first<{
|
|
2608
|
+
client_id: string;
|
|
2609
|
+
client_commit_id: string;
|
|
2610
|
+
actor_id: string;
|
|
2611
|
+
created_at_ms: number;
|
|
2612
|
+
}>();
|
|
2613
|
+
if (meta === null) return undefined;
|
|
2614
|
+
const { results: changeRows } = await this.#db
|
|
2615
|
+
.prepare(
|
|
2616
|
+
'SELECT tbl, row_id, op, row_version, scopes, payload FROM sync_changes WHERE partition=? AND commit_seq=? ORDER BY idx',
|
|
2617
|
+
)
|
|
2618
|
+
.bind(partition, commitSeq)
|
|
2619
|
+
.all<SqliteChangeRecord>();
|
|
2620
|
+
const changes = changeRows.map(toStoredChange);
|
|
2621
|
+
return {
|
|
2622
|
+
commitSeq,
|
|
2623
|
+
clientId: meta.client_id,
|
|
2624
|
+
clientCommitId: meta.client_commit_id,
|
|
2625
|
+
actorId: meta.actor_id,
|
|
2626
|
+
createdAtMs: meta.created_at_ms,
|
|
2627
|
+
changeCount: changes.length,
|
|
2628
|
+
tables: [...new Set(changes.map((change) => change.table))].sort(),
|
|
2629
|
+
changes,
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
async listRowChanges(
|
|
2634
|
+
partition: string,
|
|
2635
|
+
query: RowChangesQuery,
|
|
2636
|
+
): Promise<RowChangeRecord[]> {
|
|
2637
|
+
// Retained-log read for one row: no dedicated index (the write path pays
|
|
2638
|
+
// nothing for it), bounded by retention (§4.6).
|
|
2639
|
+
const { results: rows } = await this.#db
|
|
2640
|
+
.prepare(
|
|
2641
|
+
`SELECT ch.commit_seq, ch.op, ch.row_version, c.actor_id, c.client_id, c.created_at_ms
|
|
2642
|
+
FROM sync_changes ch
|
|
2643
|
+
JOIN sync_commits c
|
|
2644
|
+
ON c.partition=ch.partition AND c.commit_seq=ch.commit_seq
|
|
2645
|
+
WHERE ch.partition=? AND ch.tbl=? AND ch.row_id=?
|
|
2646
|
+
ORDER BY ch.commit_seq DESC, ch.idx DESC LIMIT ?`,
|
|
2647
|
+
)
|
|
2648
|
+
.bind(partition, query.table, query.rowId, query.limit)
|
|
2649
|
+
.all<{
|
|
2650
|
+
commit_seq: number;
|
|
2651
|
+
op: number;
|
|
2652
|
+
row_version: number | null;
|
|
2653
|
+
actor_id: string;
|
|
2654
|
+
client_id: string;
|
|
2655
|
+
created_at_ms: number;
|
|
2656
|
+
}>();
|
|
2657
|
+
return rows.map((row) => ({
|
|
2658
|
+
commitSeq: row.commit_seq,
|
|
2659
|
+
op: row.op === 1 ? 'upsert' : 'delete',
|
|
2660
|
+
...(row.row_version !== null ? { rowVersion: row.row_version } : {}),
|
|
2661
|
+
actorId: row.actor_id,
|
|
2662
|
+
clientId: row.client_id,
|
|
2663
|
+
createdAtMs: row.created_at_ms,
|
|
2664
|
+
}));
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
async listRows(partition: string, query: RowListQuery): Promise<StoredRow[]> {
|
|
2668
|
+
// Keyset page over the row table's (partition, rowId) primary key: an
|
|
2669
|
+
// index range read, no scope filter, no per-row lookup.
|
|
2670
|
+
const { results } = await this.#schemaDatabase()
|
|
2671
|
+
.prepare(
|
|
2672
|
+
`SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, ${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions
|
|
2673
|
+
FROM ${quoteIdent(this.table(query.table).name)}
|
|
2674
|
+
WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>?
|
|
2675
|
+
ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ?`,
|
|
2676
|
+
)
|
|
2677
|
+
.bind(partition, query.afterRowId ?? '', query.limit)
|
|
2678
|
+
.all<SqliteRowRecord>();
|
|
2679
|
+
return results.map(toStoredRow);
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
async countRows(partition: string, table: string): Promise<number> {
|
|
2683
|
+
const record = await this.#schemaDatabase()
|
|
2684
|
+
.prepare(
|
|
2685
|
+
`SELECT count(*) AS n FROM ${quoteIdent(this.table(table).name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=?`,
|
|
2686
|
+
)
|
|
2687
|
+
.bind(partition)
|
|
2688
|
+
.first<{ n: number }>();
|
|
2689
|
+
return record?.n ?? 0;
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
async listPushResults(
|
|
2693
|
+
partition: string,
|
|
2694
|
+
clientCommitId: string,
|
|
2695
|
+
): Promise<ClientPushResult[]> {
|
|
2696
|
+
// Retained outcomes for one clientCommitId: no dedicated index, ordered
|
|
2697
|
+
// by client so the read is deterministic.
|
|
2698
|
+
const { results } = await this.#schemaDatabase()
|
|
2699
|
+
.prepare(
|
|
2700
|
+
'SELECT client_id, result FROM sync_push_results WHERE partition=? AND client_commit_id=? ORDER BY client_id',
|
|
2701
|
+
)
|
|
2702
|
+
.bind(partition, clientCommitId)
|
|
2703
|
+
.all<{ client_id: string; result: string }>();
|
|
2704
|
+
return results.map((record) => {
|
|
2705
|
+
try {
|
|
2706
|
+
return {
|
|
2707
|
+
clientId: record.client_id,
|
|
2708
|
+
result: deserializePushResult(record.result),
|
|
2709
|
+
};
|
|
2710
|
+
} catch {
|
|
2711
|
+
throw syncError(
|
|
2712
|
+
'sync.idempotency_cache_miss',
|
|
2713
|
+
'persisted push result unreadable (§6.3)',
|
|
2714
|
+
);
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2521
2719
|
async scopeActivity(
|
|
2522
2720
|
partition: string,
|
|
2523
2721
|
query: ScopeActivityQuery,
|
|
@@ -2556,25 +2754,6 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2556
2754
|
return out;
|
|
2557
2755
|
}
|
|
2558
2756
|
|
|
2559
|
-
async getRowScopes(
|
|
2560
|
-
partition: string,
|
|
2561
|
-
table: string,
|
|
2562
|
-
rowId: string,
|
|
2563
|
-
): Promise<
|
|
2564
|
-
{ serverVersion: number; scopes: Record<string, string> } | undefined
|
|
2565
|
-
> {
|
|
2566
|
-
const db = this.#schemaDatabase();
|
|
2567
|
-
const record = await db
|
|
2568
|
-
.prepare(selectRowScopesSql(this.table(table), 'sqlite'))
|
|
2569
|
-
.bind(partition, rowId)
|
|
2570
|
-
.first<{ server_version: number; scopes: string }>();
|
|
2571
|
-
if (record === null) return undefined;
|
|
2572
|
-
return {
|
|
2573
|
-
serverVersion: record.server_version,
|
|
2574
|
-
scopes: JSON.parse(record.scopes) as Record<string, string>,
|
|
2575
|
-
};
|
|
2576
|
-
}
|
|
2577
|
-
|
|
2578
2757
|
async listPartitions(): Promise<string[]> {
|
|
2579
2758
|
return (await this.listPartitionRegistry()).map((entry) => entry.partition);
|
|
2580
2759
|
}
|
package/src/errors.ts
CHANGED
|
@@ -282,10 +282,10 @@ export class SyncError extends Error {
|
|
|
282
282
|
readonly details?: string;
|
|
283
283
|
|
|
284
284
|
constructor(code: string, message?: string, details?: string) {
|
|
285
|
-
|
|
286
|
-
if (entry === undefined) {
|
|
285
|
+
if (!Object.hasOwn(ERROR_CATALOG, code)) {
|
|
287
286
|
throw new Error(`unknown error code ${code} (not in the §10.2 catalog)`);
|
|
288
287
|
}
|
|
288
|
+
const entry = ERROR_CATALOG[code]!;
|
|
289
289
|
super(message ?? code);
|
|
290
290
|
this.code = code;
|
|
291
291
|
this.category = entry.category;
|
|
@@ -344,19 +344,59 @@ export type SyncularErrorHandler = (
|
|
|
344
344
|
context: { readonly route: SyncularErrorRoute },
|
|
345
345
|
) => void;
|
|
346
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Optional synchronous error mapper. After `onError` has observed the
|
|
349
|
+
* original, `mapError` may translate an adapter exception to a catalog
|
|
350
|
+
* `SyncError` (with `details` metadata such as `retryAfterMs`) that the
|
|
351
|
+
* adapter answers instead of `sync.internal_error`. Return `undefined` to
|
|
352
|
+
* keep the default. A throw, a non-`SyncError` return, or `details` that is
|
|
353
|
+
* not a JSON document is contained as `sync.internal_error`.
|
|
354
|
+
*/
|
|
355
|
+
export type SyncularErrorMapper = (
|
|
356
|
+
error: unknown,
|
|
357
|
+
context: { readonly route: SyncularErrorRoute },
|
|
358
|
+
) => SyncError | undefined;
|
|
359
|
+
|
|
347
360
|
/**
|
|
348
361
|
* The protocol error for an exception an adapter caught. A `SyncError`
|
|
349
|
-
* passes through unchanged. Anything else goes to `onError` and
|
|
350
|
-
* `sync.internal_error` (§10.2)
|
|
351
|
-
*
|
|
362
|
+
* passes through unchanged. Anything else goes to `onError` and answers
|
|
363
|
+
* `sync.internal_error` (§10.2) unless the optional `mapError` hook returns a
|
|
364
|
+
* catalog `SyncError` to answer instead. The default `sync.internal_error`
|
|
365
|
+
* message never carries the exception text; a mapped error's message is the
|
|
366
|
+
* host's choice.
|
|
352
367
|
*/
|
|
353
368
|
export function adapterSyncError(
|
|
354
369
|
error: unknown,
|
|
355
370
|
onError: SyncularErrorHandler | undefined,
|
|
356
371
|
route: SyncularErrorRoute,
|
|
372
|
+
mapError?: SyncularErrorMapper,
|
|
357
373
|
): SyncError {
|
|
358
374
|
if (error instanceof SyncError) return error;
|
|
359
375
|
reportError(error, onError, route);
|
|
376
|
+
try {
|
|
377
|
+
const mapped = mapError?.(error, { route });
|
|
378
|
+
if (mapped instanceof SyncError) {
|
|
379
|
+
const { code, message, details } = mapped;
|
|
380
|
+
let detailsValid = details === undefined;
|
|
381
|
+
if (!detailsValid && typeof details === 'string') {
|
|
382
|
+
JSON.parse(details);
|
|
383
|
+
detailsValid = true;
|
|
384
|
+
}
|
|
385
|
+
if (
|
|
386
|
+
typeof code === 'string' &&
|
|
387
|
+
typeof message === 'string' &&
|
|
388
|
+
Object.hasOwn(ERROR_CATALOG, code) &&
|
|
389
|
+
detailsValid
|
|
390
|
+
) {
|
|
391
|
+
// Reconstruct from the validated fields: the catalog owns
|
|
392
|
+
// retryability and status, so a mutated `SyncError` cannot smuggle a
|
|
393
|
+
// different entry onto the wire.
|
|
394
|
+
return new SyncError(code, message, details);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
} catch {
|
|
398
|
+
// A throwing mapper or getter, or malformed details, keeps the default.
|
|
399
|
+
}
|
|
360
400
|
return new SyncError('sync.internal_error', 'internal server error');
|
|
361
401
|
}
|
|
362
402
|
|
package/src/events-ring.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* without any infrastructure dependency.
|
|
4
4
|
*
|
|
5
5
|
* `RingBufferEvents` is a `SyncularServerEvents` sink that retains the last
|
|
6
|
-
* N events in memory and exposes a `query({type?,
|
|
7
|
-
* actorId?, limit})` plus a `subscribe(listener)` hook for live tails. It
|
|
6
|
+
* N events in memory and exposes a `query({type?, partition?, sinceMs?,
|
|
7
|
+
* clientId?, actorId?, limit})` plus a `subscribe(listener)` hook for live tails. It
|
|
8
8
|
* composes with any other sink through `composeEvents(...sinks)`, so a host
|
|
9
9
|
* can keep `consoleJsonEvents()` (or a Sentry adapter) AND feed the console
|
|
10
10
|
* event tail from the same emissions. Fire-and-forget discipline is
|
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
export interface RingEventQuery {
|
|
21
21
|
/** Restrict to one event `type` (e.g. `push.applied`). */
|
|
22
22
|
readonly type?: SyncularServerEvent['type'];
|
|
23
|
+
/** Restrict to one partition (every event carries one). */
|
|
24
|
+
readonly partition?: string;
|
|
25
|
+
/** Restrict to the events of one sync request. */
|
|
26
|
+
readonly requestId?: string;
|
|
23
27
|
/** Only events with `atMs >= sinceMs`. */
|
|
24
28
|
readonly sinceMs?: number;
|
|
25
29
|
/** Restrict to events carrying this `clientId` (not every type does). */
|
|
@@ -41,6 +45,12 @@ export function matchesRingQuery(
|
|
|
41
45
|
query: Omit<RingEventQuery, 'limit'>,
|
|
42
46
|
): boolean {
|
|
43
47
|
if (query.type !== undefined && event.type !== query.type) return false;
|
|
48
|
+
if (query.partition !== undefined && event.partition !== query.partition) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (query.requestId !== undefined && event.requestId !== query.requestId) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
44
54
|
if (query.sinceMs !== undefined && event.atMs < query.sinceMs) return false;
|
|
45
55
|
const carrier = event as { clientId?: string; actorId?: string };
|
|
46
56
|
if (query.clientId !== undefined && carrier.clientId !== query.clientId) {
|
|
@@ -67,6 +77,7 @@ export class RingBufferEvents implements SyncularServerEvents {
|
|
|
67
77
|
/** Index of the oldest element in the circular buffer. */
|
|
68
78
|
#head = 0;
|
|
69
79
|
#size = 0;
|
|
80
|
+
#evictedThroughMs: number | undefined;
|
|
70
81
|
/** Live subscribers (the SSE tail). Each notify is guarded. */
|
|
71
82
|
readonly #listeners = new Set<(event: SyncularServerEvent) => void>();
|
|
72
83
|
|
|
@@ -84,6 +95,8 @@ export class RingBufferEvents implements SyncularServerEvents {
|
|
|
84
95
|
this.#size += 1;
|
|
85
96
|
} else {
|
|
86
97
|
// Full: overwrite the oldest and advance the head.
|
|
98
|
+
const evicted = this.#buffer[this.#head];
|
|
99
|
+
if (evicted !== undefined) this.#markEvicted(evicted.atMs);
|
|
87
100
|
this.#buffer[this.#head] = event;
|
|
88
101
|
this.#head = (this.#head + 1) % this.#capacity;
|
|
89
102
|
}
|
|
@@ -119,6 +132,21 @@ export class RingBufferEvents implements SyncularServerEvents {
|
|
|
119
132
|
return this.#size;
|
|
120
133
|
}
|
|
121
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The newest `atMs` among events the ring dropped (evicted when full, or
|
|
137
|
+
* cleared); `undefined` while nothing was dropped. A window that starts
|
|
138
|
+
* at or before it is incomplete.
|
|
139
|
+
*/
|
|
140
|
+
get evictedThroughMs(): number | undefined {
|
|
141
|
+
return this.#evictedThroughMs;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#markEvicted(atMs: number): void {
|
|
145
|
+
if (this.#evictedThroughMs === undefined || atMs > this.#evictedThroughMs) {
|
|
146
|
+
this.#evictedThroughMs = atMs;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
122
150
|
/**
|
|
123
151
|
* Newest-first slice of the retained events. Filters by `type` and
|
|
124
152
|
* `sinceMs`, then caps to `limit`. Returns a fresh array — the caller may
|
|
@@ -139,6 +167,7 @@ export class RingBufferEvents implements SyncularServerEvents {
|
|
|
139
167
|
|
|
140
168
|
/** Drop all retained events. */
|
|
141
169
|
clear(): void {
|
|
170
|
+
for (const event of this.#buffer) this.#markEvicted(event.atMs);
|
|
142
171
|
this.#buffer = [];
|
|
143
172
|
this.#head = 0;
|
|
144
173
|
this.#size = 0;
|
package/src/events.ts
CHANGED
|
@@ -29,11 +29,21 @@ export interface RequestHandledEvent {
|
|
|
29
29
|
/**
|
|
30
30
|
* `ok` — response streamed to END;
|
|
31
31
|
* `schema_floor` — §2.4 required-schema answer;
|
|
32
|
-
* `
|
|
32
|
+
* `epoch_acquired` — §2.1 answer to a request without `logEpoch` (a new
|
|
33
|
+
* or wiped replica's first round);
|
|
34
|
+
* `reset` — §2.1 answer to a `logEpoch` that no longer matches the log;
|
|
33
35
|
* `rejected` — request validation failed before any bytes (§1.7);
|
|
34
36
|
* `error` — in-band ERROR frame (§1.6) or a thrown host failure.
|
|
35
37
|
*/
|
|
36
|
-
readonly outcome:
|
|
38
|
+
readonly outcome:
|
|
39
|
+
| 'ok'
|
|
40
|
+
| 'schema_floor'
|
|
41
|
+
| 'epoch_acquired'
|
|
42
|
+
| 'reset'
|
|
43
|
+
| 'rejected'
|
|
44
|
+
| 'error';
|
|
45
|
+
/** The REQ_HEADER clientId; absent when the request failed to decode. */
|
|
46
|
+
readonly clientId?: string;
|
|
37
47
|
/** §10.2 code for `rejected`/`error`; `"internal"` for non-SyncErrors. */
|
|
38
48
|
readonly errorCode?: string;
|
|
39
49
|
readonly pushCommits: number;
|
|
@@ -64,6 +74,9 @@ export interface PushRejectedEvent extends PushEventBase {
|
|
|
64
74
|
readonly type: 'push.rejected';
|
|
65
75
|
readonly code: string;
|
|
66
76
|
readonly opIndex: number;
|
|
77
|
+
/** The terminating operation's table and rowId. */
|
|
78
|
+
readonly table?: string;
|
|
79
|
+
readonly rowId?: string;
|
|
67
80
|
/** True when the rejection was replayed from the idempotency cache. */
|
|
68
81
|
readonly replay: boolean;
|
|
69
82
|
/** Original host time for outcomes recorded by a metadata-aware server. */
|
|
@@ -76,6 +89,9 @@ export interface PushRejectedEvent extends PushEventBase {
|
|
|
76
89
|
export interface PushConflictedEvent extends PushEventBase {
|
|
77
90
|
readonly type: 'push.conflicted';
|
|
78
91
|
readonly opIndex: number;
|
|
92
|
+
/** The conflicting operation's table and rowId. */
|
|
93
|
+
readonly table?: string;
|
|
94
|
+
readonly rowId?: string;
|
|
79
95
|
/** True when the conflict was replayed from the idempotency cache. */
|
|
80
96
|
readonly replay: boolean;
|
|
81
97
|
readonly recordedAtMs?: number;
|
|
@@ -306,7 +322,13 @@ export interface LeaseRevokedEvent {
|
|
|
306
322
|
readonly leaseId: string;
|
|
307
323
|
}
|
|
308
324
|
|
|
309
|
-
|
|
325
|
+
/**
|
|
326
|
+
* Every event. `requestId` is present on the events emitted while serving
|
|
327
|
+
* one sync request (`request.handled`, `push.*`, `pull.served`,
|
|
328
|
+
* `lease.issued`, `reaction.queued`, `scopes.resolve_failed`) and shared by
|
|
329
|
+
* all of them, so a sink can group one request's events.
|
|
330
|
+
*/
|
|
331
|
+
export type SyncularServerEvent = (
|
|
310
332
|
| RequestHandledEvent
|
|
311
333
|
| PushAppliedEvent
|
|
312
334
|
| PushRejectedEvent
|
|
@@ -329,7 +351,8 @@ export type SyncularServerEvent =
|
|
|
329
351
|
| PruneCompletedEvent
|
|
330
352
|
| ScopesResolveFailedEvent
|
|
331
353
|
| LeaseIssuedEvent
|
|
332
|
-
| LeaseRevokedEvent
|
|
354
|
+
| LeaseRevokedEvent
|
|
355
|
+
) & { readonly requestId?: string };
|
|
333
356
|
|
|
334
357
|
/**
|
|
335
358
|
* The seam. Optional on the server config — when absent, no event object
|
package/src/handler.ts
CHANGED
|
@@ -373,7 +373,7 @@ async function planRequest(
|
|
|
373
373
|
|
|
374
374
|
/** Mutable outcome box shared with the instrumented stream wrapper. */
|
|
375
375
|
interface RequestReport {
|
|
376
|
-
outcome: 'ok' | 'schema_floor' | 'reset' | 'error';
|
|
376
|
+
outcome: 'ok' | 'schema_floor' | 'epoch_acquired' | 'reset' | 'error';
|
|
377
377
|
errorCode?: string;
|
|
378
378
|
}
|
|
379
379
|
|
|
@@ -421,11 +421,14 @@ function emitPushEvent(
|
|
|
421
421
|
}
|
|
422
422
|
// Rejected (§6.3): exactly one terminating operation record.
|
|
423
423
|
const record = frame.results[0];
|
|
424
|
+
const op = record !== undefined ? push.operations[record.opIndex] : undefined;
|
|
425
|
+
const target = op !== undefined ? { table: op.table, rowId: op.rowId } : {};
|
|
424
426
|
if (record?.status === 'conflict') {
|
|
425
427
|
emitEvent(events, {
|
|
426
428
|
type: 'push.conflicted',
|
|
427
429
|
...base,
|
|
428
430
|
opIndex: record.opIndex,
|
|
431
|
+
...target,
|
|
429
432
|
replay: processed.replayed,
|
|
430
433
|
...(processed.recordedAtMs !== undefined
|
|
431
434
|
? { recordedAtMs: processed.recordedAtMs }
|
|
@@ -444,6 +447,7 @@ function emitPushEvent(
|
|
|
444
447
|
? record.code
|
|
445
448
|
: 'sync.invalid_request',
|
|
446
449
|
opIndex: record?.opIndex ?? 0,
|
|
450
|
+
...target,
|
|
447
451
|
replay: processed.replayed,
|
|
448
452
|
...(processed.recordedAtMs !== undefined
|
|
449
453
|
? { recordedAtMs: processed.recordedAtMs }
|
|
@@ -491,7 +495,10 @@ async function* streamResponse(
|
|
|
491
495
|
plan.wireVersion,
|
|
492
496
|
);
|
|
493
497
|
if (plan.epochReset) {
|
|
494
|
-
if (report !== undefined)
|
|
498
|
+
if (report !== undefined) {
|
|
499
|
+
report.outcome =
|
|
500
|
+
plan.header.logEpoch === undefined ? 'epoch_acquired' : 'reset';
|
|
501
|
+
}
|
|
495
502
|
yield END_FRAME_BYTES;
|
|
496
503
|
return;
|
|
497
504
|
}
|
|
@@ -729,14 +736,20 @@ export async function createSyncResponseStream(
|
|
|
729
736
|
bytes: Uint8Array,
|
|
730
737
|
ctx: SyncRequestContext,
|
|
731
738
|
): Promise<AsyncIterable<Uint8Array>> {
|
|
732
|
-
const
|
|
733
|
-
if (
|
|
739
|
+
const sink = ctx.events;
|
|
740
|
+
if (sink === undefined) return createStreamCore(bytes, ctx, undefined);
|
|
734
741
|
// Instrumented path: measure with the ctx clock, count bytes at the
|
|
735
|
-
// seam, emit exactly one `request.handled` per request.
|
|
742
|
+
// seam, emit exactly one `request.handled` per request. Every event of
|
|
743
|
+
// this request carries one `requestId`.
|
|
744
|
+
const requestId = crypto.randomUUID();
|
|
745
|
+
const events: SyncularServerEvents = {
|
|
746
|
+
emit: (event) => sink.emit({ ...event, requestId }),
|
|
747
|
+
};
|
|
748
|
+
const requestCtx = { ...ctx, events };
|
|
736
749
|
const clock = clockOf(ctx);
|
|
737
750
|
const startedAtMs = clock();
|
|
738
751
|
try {
|
|
739
|
-
return await createStreamCore(bytes,
|
|
752
|
+
return await createStreamCore(bytes, requestCtx, events, startedAtMs);
|
|
740
753
|
} catch (error) {
|
|
741
754
|
emitEvent(events, {
|
|
742
755
|
type: 'request.handled',
|
|
@@ -878,6 +891,7 @@ async function* instrumentedStream(
|
|
|
878
891
|
bytesIn,
|
|
879
892
|
bytesOut,
|
|
880
893
|
outcome: report.outcome,
|
|
894
|
+
clientId: plan.header.clientId,
|
|
881
895
|
...(report.errorCode !== undefined
|
|
882
896
|
? { errorCode: report.errorCode }
|
|
883
897
|
: {}),
|