@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/postgres-storage.ts
CHANGED
|
@@ -66,11 +66,18 @@ import {
|
|
|
66
66
|
layoutsOf,
|
|
67
67
|
migratePayload,
|
|
68
68
|
parseLayouts,
|
|
69
|
+
quoteIdent,
|
|
69
70
|
retiredTableNames,
|
|
70
71
|
rewritePlan,
|
|
71
72
|
rewriteRowSql,
|
|
72
73
|
rewriteValues,
|
|
73
74
|
SCHEMA_META_DDL_POSTGRES,
|
|
75
|
+
SYNC_COLUMN_VERSIONS_COLUMN,
|
|
76
|
+
SYNC_PARTITION_COLUMN,
|
|
77
|
+
SYNC_PAYLOAD_COLUMN,
|
|
78
|
+
SYNC_ROW_ID_COLUMN,
|
|
79
|
+
SYNC_SCOPES_COLUMN,
|
|
80
|
+
SYNC_VERSION_COLUMN,
|
|
74
81
|
type StoredColumnLayout,
|
|
75
82
|
scanRowBatchSql,
|
|
76
83
|
scanRowPageSql,
|
|
@@ -89,8 +96,11 @@ import type {
|
|
|
89
96
|
AuthoritativeQueryResult,
|
|
90
97
|
CheckpointDeclaration,
|
|
91
98
|
ClientCursorInfo,
|
|
99
|
+
ClientPushResult,
|
|
92
100
|
ClientRecord,
|
|
93
101
|
ClientSubscription,
|
|
102
|
+
CommitChangeSummary,
|
|
103
|
+
CommitDetail,
|
|
94
104
|
CommitMetadata,
|
|
95
105
|
CommitMetadataQuery,
|
|
96
106
|
CommitWindowQuery,
|
|
@@ -105,6 +115,9 @@ import type {
|
|
|
105
115
|
ReactionFailureUpdate,
|
|
106
116
|
ReactionListQuery,
|
|
107
117
|
ReactionPruneQuery,
|
|
118
|
+
RowChangeRecord,
|
|
119
|
+
RowChangesQuery,
|
|
120
|
+
RowListQuery,
|
|
108
121
|
RowScanQuery,
|
|
109
122
|
ScopeActivityQuery,
|
|
110
123
|
ScopeCommitActivity,
|
|
@@ -2584,6 +2597,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
2584
2597
|
partition: string,
|
|
2585
2598
|
query: CommitMetadataQuery,
|
|
2586
2599
|
): Promise<CommitMetadata[]> {
|
|
2600
|
+
// Newest-first window, resumed by `afterSeq` (exclusive lower bound) and
|
|
2601
|
+
// ended by `beforeSeq` (exclusive upper bound) when paging older. An
|
|
2602
|
+
// omitted bound is the largest safe commitSeq, so one statement serves
|
|
2603
|
+
// both the bounded and unbounded read.
|
|
2604
|
+
const beforeSeq = query.beforeSeq ?? Number.MAX_SAFE_INTEGER;
|
|
2587
2605
|
const { rows } = query.table
|
|
2588
2606
|
? await this.#exec.query<{
|
|
2589
2607
|
commit_seq: unknown;
|
|
@@ -2594,11 +2612,11 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
2594
2612
|
}>(
|
|
2595
2613
|
`SELECT c.commit_seq, c.client_id, c.client_commit_id, c.actor_id, c.created_at_ms
|
|
2596
2614
|
FROM sync_commits c
|
|
2597
|
-
WHERE c.partition=$1 AND c.commit_seq>$2
|
|
2615
|
+
WHERE c.partition=$1 AND c.commit_seq>$2 AND c.commit_seq<$3
|
|
2598
2616
|
AND EXISTS (SELECT 1 FROM sync_changes ch
|
|
2599
|
-
WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=$
|
|
2600
|
-
ORDER BY c.commit_seq DESC LIMIT $
|
|
2601
|
-
[partition, query.afterSeq, query.table, query.limit],
|
|
2617
|
+
WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=$4)
|
|
2618
|
+
ORDER BY c.commit_seq DESC LIMIT $5`,
|
|
2619
|
+
[partition, query.afterSeq, beforeSeq, query.table, query.limit],
|
|
2602
2620
|
)
|
|
2603
2621
|
: await this.#exec.query<{
|
|
2604
2622
|
commit_seq: unknown;
|
|
@@ -2609,10 +2627,42 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
2609
2627
|
}>(
|
|
2610
2628
|
`SELECT commit_seq, client_id, client_commit_id, actor_id, created_at_ms
|
|
2611
2629
|
FROM sync_commits
|
|
2612
|
-
WHERE partition=$1 AND commit_seq>$2
|
|
2613
|
-
ORDER BY commit_seq DESC LIMIT $
|
|
2614
|
-
[partition, query.afterSeq, query.limit],
|
|
2630
|
+
WHERE partition=$1 AND commit_seq>$2 AND commit_seq<$3
|
|
2631
|
+
ORDER BY commit_seq DESC LIMIT $4`,
|
|
2632
|
+
[partition, query.afterSeq, beforeSeq, query.limit],
|
|
2615
2633
|
);
|
|
2634
|
+
// `changeSample`: one extra statement for the whole page — the first
|
|
2635
|
+
// `sample` changes (change-index order) of every listed commit.
|
|
2636
|
+
const sampleCount =
|
|
2637
|
+
query.changeSample !== undefined &&
|
|
2638
|
+
Number.isInteger(query.changeSample) &&
|
|
2639
|
+
query.changeSample > 0
|
|
2640
|
+
? query.changeSample
|
|
2641
|
+
: 0;
|
|
2642
|
+
const samples = new Map<number, CommitChangeSummary[]>();
|
|
2643
|
+
if (sampleCount > 0 && rows.length > 0) {
|
|
2644
|
+
const seqs = rows.map((row) => asNumber(row.commit_seq));
|
|
2645
|
+
const { rows: changeRows } = await this.#exec.query<{
|
|
2646
|
+
commit_seq: unknown;
|
|
2647
|
+
tbl: string;
|
|
2648
|
+
row_id: string;
|
|
2649
|
+
op: unknown;
|
|
2650
|
+
}>(
|
|
2651
|
+
`SELECT commit_seq, tbl, row_id, op FROM sync_changes
|
|
2652
|
+
WHERE partition=$1 AND commit_seq IN (${seqs.map((_, index) => `$${index + 2}`).join(',')}) AND idx<$${seqs.length + 2}
|
|
2653
|
+
ORDER BY commit_seq, idx`,
|
|
2654
|
+
[partition, ...seqs, sampleCount],
|
|
2655
|
+
);
|
|
2656
|
+
for (const change of changeRows) {
|
|
2657
|
+
const sample = samples.get(asNumber(change.commit_seq)) ?? [];
|
|
2658
|
+
sample.push({
|
|
2659
|
+
table: change.tbl,
|
|
2660
|
+
rowId: change.row_id,
|
|
2661
|
+
op: asNumber(change.op) === 1 ? 'upsert' : 'delete',
|
|
2662
|
+
});
|
|
2663
|
+
samples.set(asNumber(change.commit_seq), sample);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2616
2666
|
const out: CommitMetadata[] = [];
|
|
2617
2667
|
for (const row of rows) {
|
|
2618
2668
|
const commitSeq = asNumber(row.commit_seq);
|
|
@@ -2628,11 +2678,127 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
2628
2678
|
createdAtMs: asNumber(row.created_at_ms),
|
|
2629
2679
|
changeCount: changes.rows.reduce((sum, c) => sum + asNumber(c.n), 0),
|
|
2630
2680
|
tables: changes.rows.map((c) => c.tbl),
|
|
2681
|
+
...(sampleCount > 0 ? { sample: samples.get(commitSeq) ?? [] } : {}),
|
|
2631
2682
|
});
|
|
2632
2683
|
}
|
|
2633
2684
|
return out;
|
|
2634
2685
|
}
|
|
2635
2686
|
|
|
2687
|
+
async getCommit(
|
|
2688
|
+
partition: string,
|
|
2689
|
+
commitSeq: number,
|
|
2690
|
+
): Promise<CommitDetail | undefined> {
|
|
2691
|
+
const { rows } = await this.#exec.query<{
|
|
2692
|
+
client_id: string;
|
|
2693
|
+
client_commit_id: string;
|
|
2694
|
+
actor_id: string;
|
|
2695
|
+
created_at_ms: unknown;
|
|
2696
|
+
}>(
|
|
2697
|
+
'SELECT client_id, client_commit_id, actor_id, created_at_ms FROM sync_commits WHERE partition=$1 AND commit_seq=$2',
|
|
2698
|
+
[partition, commitSeq],
|
|
2699
|
+
);
|
|
2700
|
+
const meta = rows[0];
|
|
2701
|
+
if (meta === undefined) return undefined;
|
|
2702
|
+
const { rows: changeRows } = await this.#exec.query<ChangeRecord>(
|
|
2703
|
+
'SELECT tbl, row_id, op, row_version, scopes, payload FROM sync_changes WHERE partition=$1 AND commit_seq=$2 ORDER BY idx',
|
|
2704
|
+
[partition, commitSeq],
|
|
2705
|
+
);
|
|
2706
|
+
const changes = changeRows.map(toStoredChange);
|
|
2707
|
+
return {
|
|
2708
|
+
commitSeq,
|
|
2709
|
+
clientId: meta.client_id,
|
|
2710
|
+
clientCommitId: meta.client_commit_id,
|
|
2711
|
+
actorId: meta.actor_id,
|
|
2712
|
+
createdAtMs: asNumber(meta.created_at_ms),
|
|
2713
|
+
changeCount: changes.length,
|
|
2714
|
+
tables: [...new Set(changes.map((change) => change.table))].sort(),
|
|
2715
|
+
changes,
|
|
2716
|
+
};
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
async listRowChanges(
|
|
2720
|
+
partition: string,
|
|
2721
|
+
query: RowChangesQuery,
|
|
2722
|
+
): Promise<RowChangeRecord[]> {
|
|
2723
|
+
// Retained-log read for one row: no dedicated index (the write path pays
|
|
2724
|
+
// nothing for it), bounded by retention (§4.6).
|
|
2725
|
+
const { rows } = await this.#exec.query<{
|
|
2726
|
+
commit_seq: unknown;
|
|
2727
|
+
op: unknown;
|
|
2728
|
+
row_version: unknown;
|
|
2729
|
+
actor_id: string;
|
|
2730
|
+
client_id: string;
|
|
2731
|
+
created_at_ms: unknown;
|
|
2732
|
+
}>(
|
|
2733
|
+
`SELECT ch.commit_seq, ch.op, ch.row_version, c.actor_id, c.client_id, c.created_at_ms
|
|
2734
|
+
FROM sync_changes ch
|
|
2735
|
+
JOIN sync_commits c
|
|
2736
|
+
ON c.partition=ch.partition AND c.commit_seq=ch.commit_seq
|
|
2737
|
+
WHERE ch.partition=$1 AND ch.tbl=$2 AND ch.row_id=$3
|
|
2738
|
+
ORDER BY ch.commit_seq DESC, ch.idx DESC LIMIT $4`,
|
|
2739
|
+
[partition, query.table, query.rowId, query.limit],
|
|
2740
|
+
);
|
|
2741
|
+
return rows.map((row) => ({
|
|
2742
|
+
commitSeq: asNumber(row.commit_seq),
|
|
2743
|
+
op: asNumber(row.op) === 1 ? 'upsert' : 'delete',
|
|
2744
|
+
...(row.row_version === null || row.row_version === undefined
|
|
2745
|
+
? {}
|
|
2746
|
+
: { rowVersion: asNumber(row.row_version) }),
|
|
2747
|
+
actorId: row.actor_id,
|
|
2748
|
+
clientId: row.client_id,
|
|
2749
|
+
createdAtMs: asNumber(row.created_at_ms),
|
|
2750
|
+
}));
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
async listRows(partition: string, query: RowListQuery): Promise<StoredRow[]> {
|
|
2754
|
+
// Keyset page over the row table's (partition, rowId) primary key: an
|
|
2755
|
+
// index range read, no scope filter, no per-row lookup.
|
|
2756
|
+
const { rows } = await this.#exec.query<RowRecord>(
|
|
2757
|
+
`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
|
|
2758
|
+
FROM ${quoteIdent(this.table(query.table).name)}
|
|
2759
|
+
WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>$2
|
|
2760
|
+
ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT $3`,
|
|
2761
|
+
[partition, query.afterRowId ?? '', query.limit],
|
|
2762
|
+
);
|
|
2763
|
+
return rows.map(toStoredRow);
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
async countRows(partition: string, table: string): Promise<number> {
|
|
2767
|
+
const { rows } = await this.#exec.query<{ n: unknown }>(
|
|
2768
|
+
`SELECT count(*) AS n FROM ${quoteIdent(this.table(table).name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=$1`,
|
|
2769
|
+
[partition],
|
|
2770
|
+
);
|
|
2771
|
+
return rows[0] === undefined ? 0 : asNumber(rows[0].n);
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
async listPushResults(
|
|
2775
|
+
partition: string,
|
|
2776
|
+
clientCommitId: string,
|
|
2777
|
+
): Promise<ClientPushResult[]> {
|
|
2778
|
+
// Retained outcomes for one clientCommitId: no dedicated index, ordered
|
|
2779
|
+
// by client so the read is deterministic.
|
|
2780
|
+
const { rows } = await this.#exec.query<{
|
|
2781
|
+
client_id: string;
|
|
2782
|
+
result: unknown;
|
|
2783
|
+
}>(
|
|
2784
|
+
'SELECT client_id, result FROM sync_push_results WHERE partition=$1 AND client_commit_id=$2 ORDER BY client_id',
|
|
2785
|
+
[partition, clientCommitId],
|
|
2786
|
+
);
|
|
2787
|
+
return rows.map((row) => {
|
|
2788
|
+
try {
|
|
2789
|
+
return {
|
|
2790
|
+
clientId: row.client_id,
|
|
2791
|
+
result: deserializePushResult(asJson(row.result)),
|
|
2792
|
+
};
|
|
2793
|
+
} catch {
|
|
2794
|
+
throw syncError(
|
|
2795
|
+
'sync.idempotency_cache_miss',
|
|
2796
|
+
'persisted push result unreadable (§6.3)',
|
|
2797
|
+
);
|
|
2798
|
+
}
|
|
2799
|
+
});
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2636
2802
|
async scopeActivity(
|
|
2637
2803
|
partition: string,
|
|
2638
2804
|
query: ScopeActivityQuery,
|
|
@@ -2674,27 +2840,6 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
2674
2840
|
return out;
|
|
2675
2841
|
}
|
|
2676
2842
|
|
|
2677
|
-
async getRowScopes(
|
|
2678
|
-
partition: string,
|
|
2679
|
-
table: string,
|
|
2680
|
-
rowId: string,
|
|
2681
|
-
): Promise<
|
|
2682
|
-
{ serverVersion: number; scopes: Record<string, string> } | undefined
|
|
2683
|
-
> {
|
|
2684
|
-
const { rows } = await this.#exec.query<{
|
|
2685
|
-
server_version: unknown;
|
|
2686
|
-
scopes: unknown;
|
|
2687
|
-
}>(selectRowScopesSql(this.table(table), 'postgres'), [partition, rowId]);
|
|
2688
|
-
const row = rows[0];
|
|
2689
|
-
if (row === undefined) return undefined;
|
|
2690
|
-
return {
|
|
2691
|
-
serverVersion: asNumber(row.server_version),
|
|
2692
|
-
scopes: (typeof row.scopes === 'string'
|
|
2693
|
-
? JSON.parse(row.scopes)
|
|
2694
|
-
: row.scopes) as Record<string, string>,
|
|
2695
|
-
};
|
|
2696
|
-
}
|
|
2697
|
-
|
|
2698
2843
|
async listPartitions(): Promise<string[]> {
|
|
2699
2844
|
return (await this.listPartitionRegistry()).map((entry) => entry.partition);
|
|
2700
2845
|
}
|
package/src/pull.ts
CHANGED
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
import { scopeDigest } from './scopes';
|
|
24
24
|
import type { SegmentRecord, SegmentStore } from './segment-store';
|
|
25
25
|
import { issueSegmentUrl } from './signed-url';
|
|
26
|
-
import type { SqliteImageBuilder } from './sqlite-image';
|
|
27
26
|
import type { ServerStorage, StoredCommit, StoredRow } from './storage';
|
|
28
27
|
import { projectRowPayload, projectRowValues } from './schema-window';
|
|
29
28
|
|
|
@@ -33,26 +32,6 @@ const imageBuilds = new WeakMap<
|
|
|
33
32
|
WeakMap<SegmentStore, Map<string, Promise<SegmentRecord>>>
|
|
34
33
|
>();
|
|
35
34
|
|
|
36
|
-
/**
|
|
37
|
-
* Resolve the §5.3 image builder: the host-injected one if present, else the
|
|
38
|
-
* in-tree `buildSqliteImage` on a Bun runtime (dynamic import so `bun:sqlite`
|
|
39
|
-
* is never a static dep of the neutral core), else `undefined` (rows lane).
|
|
40
|
-
* Memoized so the dynamic import happens at most once per process.
|
|
41
|
-
*/
|
|
42
|
-
let cachedDefaultBuilder: SqliteImageBuilder | null | undefined;
|
|
43
|
-
async function resolveImageBuilder(
|
|
44
|
-
ctx: SyncRequestContext,
|
|
45
|
-
): Promise<SqliteImageBuilder | undefined> {
|
|
46
|
-
if (ctx.sqliteImageBuilder !== undefined) return ctx.sqliteImageBuilder;
|
|
47
|
-
if (cachedDefaultBuilder === undefined) {
|
|
48
|
-
const hasBun = (globalThis as { Bun?: unknown }).Bun !== undefined;
|
|
49
|
-
cachedDefaultBuilder = hasBun
|
|
50
|
-
? (await import('./sqlite-bun')).buildSqliteImage
|
|
51
|
-
: null;
|
|
52
|
-
}
|
|
53
|
-
return cachedDefaultBuilder ?? undefined;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
35
|
/** §4.2 accept bitmask. */
|
|
57
36
|
export const ACCEPT_INLINE_ROWS = 1 << 0;
|
|
58
37
|
export const ACCEPT_EXTERNAL_ROWS = 1 << 1;
|
|
@@ -280,14 +259,9 @@ async function* sqliteImageSegment(
|
|
|
280
259
|
// rows page — smaller tables stay on the (typically inline) rows lane.
|
|
281
260
|
// The probe is the rows lane's first page, read by `prepareSections`.
|
|
282
261
|
if (probe.length <= limits.limitSnapshotRows) return false;
|
|
283
|
-
// §5.3:
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
// import so `bun:sqlite` is never a static dependency of the pull path
|
|
287
|
-
// (runtime neutrality is enforced by test/runtime-neutrality.test.ts). On
|
|
288
|
-
// Workers/edge (no `Bun`) this yields the rows lane for a bit-2 client — a
|
|
289
|
-
// support floor, not a fallback (§5.3: sqlite is an *accept*, not a demand).
|
|
290
|
-
const buildImage = await resolveImageBuilder(ctx);
|
|
262
|
+
// §5.3: hosts opt into image construction with a runtime-specific builder.
|
|
263
|
+
// Without one, matching stored images remain usable and cold pulls use rows.
|
|
264
|
+
const buildImage = ctx.sqliteImageBuilder;
|
|
291
265
|
if (buildImage === undefined) return false;
|
|
292
266
|
let stores = imageBuilds.get(storage);
|
|
293
267
|
if (stores === undefined) {
|
package/src/realtime.ts
CHANGED
|
@@ -44,9 +44,11 @@ import {
|
|
|
44
44
|
reportError,
|
|
45
45
|
SyncError,
|
|
46
46
|
type SyncularErrorHandler,
|
|
47
|
+
type SyncularErrorMapper,
|
|
47
48
|
syncError,
|
|
48
49
|
} from './errors';
|
|
49
50
|
import { emitEvent, type SyncularServerEvents } from './events';
|
|
51
|
+
import { composeEvents } from './events-ring';
|
|
50
52
|
import { createSyncResponseStream } from './handler';
|
|
51
53
|
import {
|
|
52
54
|
type CompiledSchema,
|
|
@@ -599,7 +601,12 @@ export class RealtimeSession {
|
|
|
599
601
|
const sync =
|
|
600
602
|
error instanceof DecodeError
|
|
601
603
|
? syncError(error.code, error.message)
|
|
602
|
-
: adapterSyncError(
|
|
604
|
+
: adapterSyncError(
|
|
605
|
+
error,
|
|
606
|
+
(error, context) => this.#hub.onError?.(error, context),
|
|
607
|
+
'realtime',
|
|
608
|
+
(error, context) => this.#hub.mapError?.(error, context),
|
|
609
|
+
);
|
|
603
610
|
finishRound(); // END is in this one chunk
|
|
604
611
|
await this.#sendRoundChunk(
|
|
605
612
|
errorResponseBytes(sync, this.wireVersion, this.logEpoch),
|
|
@@ -625,7 +632,12 @@ export class RealtimeSession {
|
|
|
625
632
|
const sync =
|
|
626
633
|
error instanceof DecodeError
|
|
627
634
|
? syncError(error.code, error.message)
|
|
628
|
-
: adapterSyncError(
|
|
635
|
+
: adapterSyncError(
|
|
636
|
+
error,
|
|
637
|
+
(error, context) => this.#hub.onError?.(error, context),
|
|
638
|
+
'realtime',
|
|
639
|
+
(error, context) => this.#hub.mapError?.(error, context),
|
|
640
|
+
);
|
|
629
641
|
finishRound();
|
|
630
642
|
await this.#sendRoundChunk(
|
|
631
643
|
errorResponseBytes(sync, this.wireVersion, this.logEpoch),
|
|
@@ -641,7 +653,11 @@ export class RealtimeSession {
|
|
|
641
653
|
} catch (error) {
|
|
642
654
|
// A host failure mid-stream leaves the byte stream unfinishable —
|
|
643
655
|
// fail loud, drop the connection (§8.7 / §1.4 abort rule).
|
|
644
|
-
reportError(
|
|
656
|
+
reportError(
|
|
657
|
+
error,
|
|
658
|
+
(error, context) => this.#hub.onError?.(error, context),
|
|
659
|
+
'realtime',
|
|
660
|
+
);
|
|
645
661
|
this.#violation('sync round failed mid-stream');
|
|
646
662
|
} finally {
|
|
647
663
|
finishRound();
|
|
@@ -892,8 +908,16 @@ export class RealtimeSession {
|
|
|
892
908
|
}
|
|
893
909
|
}
|
|
894
910
|
|
|
911
|
+
/** One open realtime socket. */
|
|
912
|
+
export interface RealtimeConnection {
|
|
913
|
+
readonly sessionId: string;
|
|
914
|
+
readonly clientId: string;
|
|
915
|
+
readonly actorId: string;
|
|
916
|
+
readonly openedAtMs: number;
|
|
917
|
+
}
|
|
918
|
+
|
|
895
919
|
export class RealtimeHub {
|
|
896
|
-
|
|
920
|
+
#config: RealtimeHubConfig;
|
|
897
921
|
readonly #schema: CompiledSchema;
|
|
898
922
|
readonly #sessions = new Set<RealtimeSession>();
|
|
899
923
|
/** §8.6 presence registry — ephemeral, in-memory, never persisted. */
|
|
@@ -913,10 +937,35 @@ export class RealtimeHub {
|
|
|
913
937
|
);
|
|
914
938
|
}
|
|
915
939
|
|
|
940
|
+
/**
|
|
941
|
+
* Compose `sink` into the hub's `events`, so socket rounds and sessions
|
|
942
|
+
* opened afterwards report to it as well. `createSyncularHono` uses it to
|
|
943
|
+
* feed the admin console's event ring.
|
|
944
|
+
*/
|
|
945
|
+
addEventSink(sink: SyncularServerEvents): void {
|
|
946
|
+
const events = this.#config.events;
|
|
947
|
+
this.#config = {
|
|
948
|
+
...this.#config,
|
|
949
|
+
events: events === undefined ? sink : composeEvents(events, sink),
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
916
953
|
get sessionCount(): number {
|
|
917
954
|
return this.#sessions.size;
|
|
918
955
|
}
|
|
919
956
|
|
|
957
|
+
/** The partition's open sockets, for the admin console. */
|
|
958
|
+
connections(partition: string): RealtimeConnection[] {
|
|
959
|
+
return [...this.#sessions]
|
|
960
|
+
.filter((session) => session.partition === partition)
|
|
961
|
+
.map((session) => ({
|
|
962
|
+
sessionId: session.sessionId,
|
|
963
|
+
clientId: session.clientId,
|
|
964
|
+
actorId: session.actorId,
|
|
965
|
+
openedAtMs: session.openedAtMs,
|
|
966
|
+
}));
|
|
967
|
+
}
|
|
968
|
+
|
|
920
969
|
/** §8.6.2 published-document size cap (bytes). */
|
|
921
970
|
get maxPresenceBytes(): number {
|
|
922
971
|
return this.#config.maxPresenceBytes ?? DEFAULT_MAX_PRESENCE_BYTES;
|
|
@@ -1108,6 +1157,11 @@ export class RealtimeHub {
|
|
|
1108
1157
|
return this.#config.onError;
|
|
1109
1158
|
}
|
|
1110
1159
|
|
|
1160
|
+
/** The host's adapter error mapper (`SyncServerConfig.mapError`), if any. */
|
|
1161
|
+
get mapError(): SyncularErrorMapper | undefined {
|
|
1162
|
+
return this.#config.mapError;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1111
1165
|
requestContext(session: RealtimeSession): SyncRequestContext {
|
|
1112
1166
|
return this.requestContextFor(session);
|
|
1113
1167
|
}
|
package/src/sqlite-image.ts
CHANGED
|
@@ -69,7 +69,7 @@ export interface SqliteImageInput {
|
|
|
69
69
|
* a real SQLite engine, which is not available on every runtime. The core
|
|
70
70
|
* takes the builder as an optional capability rather than importing a driver
|
|
71
71
|
* on the pull path. A Bun or Node host passes `buildSqliteImage`; a Workers
|
|
72
|
-
* host omits it and serves the rows lane.
|
|
72
|
+
* host omits it and serves matching stored images or the rows lane.
|
|
73
73
|
*/
|
|
74
74
|
export type SqliteImageBuilder = (
|
|
75
75
|
input: SqliteImageInput,
|