@syncular/server 0.31.0 → 0.32.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 +124 -74
- package/dist/admin.d.ts +394 -50
- package/dist/admin.js +714 -82
- package/dist/d1-storage.d.ts +6 -5
- package/dist/d1-storage.js +136 -21
- 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 +36 -13
- package/dist/operations.js +4 -4
- package/dist/postgres-storage.d.ts +6 -5
- package/dist/postgres-storage.js +102 -18
- package/dist/push.js +28 -7
- package/dist/realtime.d.ts +15 -0
- package/dist/realtime.js +46 -7
- package/dist/seed.js +4 -3
- 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/d1-storage.ts +199 -25
- package/src/events-ring.ts +31 -2
- package/src/events.ts +27 -4
- package/src/handler.ts +47 -16
- package/src/operations.ts +8 -4
- package/src/postgres-storage.ts +173 -28
- package/src/push.ts +40 -9
- package/src/realtime.ts +60 -7
- package/src/seed.ts +4 -3
- 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/push.ts
CHANGED
|
@@ -503,6 +503,7 @@ async function applyOperation(
|
|
|
503
503
|
if (
|
|
504
504
|
present[i] &&
|
|
505
505
|
!crdtIndexes.has(i) &&
|
|
506
|
+
i !== table.primaryKeyIndex &&
|
|
506
507
|
effectiveVersion(i) > op.baseVersion
|
|
507
508
|
) {
|
|
508
509
|
marked.push(i);
|
|
@@ -978,7 +979,20 @@ async function enforceReferences(
|
|
|
978
979
|
const operations: ValidateCommitOperation[] = [];
|
|
979
980
|
const originByIndex: number[] = [];
|
|
980
981
|
|
|
982
|
+
const finalByRow = new Map<
|
|
983
|
+
string,
|
|
984
|
+
{ readonly first: StagedWrite; readonly last: StagedWrite }
|
|
985
|
+
>();
|
|
981
986
|
for (const write of staged) {
|
|
987
|
+
const rowKey = `${write.table.name}\u0000${write.rowId}`;
|
|
988
|
+
const existing = finalByRow.get(rowKey);
|
|
989
|
+
finalByRow.set(rowKey, {
|
|
990
|
+
first: existing?.first ?? write,
|
|
991
|
+
last: write,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
for (const { last: write } of finalByRow.values()) {
|
|
982
996
|
if (write.op !== 'upsert' || write.values === undefined) continue;
|
|
983
997
|
for (const reference of write.table.references) {
|
|
984
998
|
const value = write.values[reference.columnIndex];
|
|
@@ -1028,9 +1042,9 @@ async function enforceReferences(
|
|
|
1028
1042
|
readonly rowId: string;
|
|
1029
1043
|
readonly originOpIndex: number;
|
|
1030
1044
|
}> = [];
|
|
1031
|
-
for (const
|
|
1032
|
-
if (
|
|
1033
|
-
schedule(
|
|
1045
|
+
for (const { first, last } of finalByRow.values()) {
|
|
1046
|
+
if (last.op === 'delete' && first.stored !== undefined) {
|
|
1047
|
+
schedule(last.table, last.rowId, last.opIndex, scheduled, queue);
|
|
1034
1048
|
}
|
|
1035
1049
|
}
|
|
1036
1050
|
|
|
@@ -1040,6 +1054,13 @@ async function enforceReferences(
|
|
|
1040
1054
|
const key = `${item.table.name}\u0000${item.rowId}`;
|
|
1041
1055
|
if (processed.has(key)) continue;
|
|
1042
1056
|
processed.add(key);
|
|
1057
|
+
const parentPkColumn = item.table.columns[item.table.primaryKeyIndex];
|
|
1058
|
+
const parentKeyValue: RowValue =
|
|
1059
|
+
parentPkColumn?.type === 'integer'
|
|
1060
|
+
? Number(item.rowId)
|
|
1061
|
+
: parentPkColumn?.type === 'boolean'
|
|
1062
|
+
? item.rowId === 'true'
|
|
1063
|
+
: item.rowId;
|
|
1043
1064
|
for (const reference of item.table.referencedBy) {
|
|
1044
1065
|
if (tx.scanRowsByIndex === undefined) {
|
|
1045
1066
|
throw new Error(
|
|
@@ -1052,7 +1073,7 @@ async function enforceReferences(
|
|
|
1052
1073
|
const page = await tx.scanRowsByIndex({
|
|
1053
1074
|
table: reference.table,
|
|
1054
1075
|
index: reference.index,
|
|
1055
|
-
values: [
|
|
1076
|
+
values: [parentKeyValue],
|
|
1056
1077
|
afterRowId,
|
|
1057
1078
|
limit: 1_000,
|
|
1058
1079
|
});
|
|
@@ -1081,6 +1102,21 @@ async function enforceReferences(
|
|
|
1081
1102
|
);
|
|
1082
1103
|
}
|
|
1083
1104
|
for (const childRow of children) {
|
|
1105
|
+
if (reference.onDelete === 'CASCADE') {
|
|
1106
|
+
if (
|
|
1107
|
+
!schedule(
|
|
1108
|
+
child,
|
|
1109
|
+
childRow.rowId,
|
|
1110
|
+
item.originOpIndex,
|
|
1111
|
+
scheduled,
|
|
1112
|
+
queue,
|
|
1113
|
+
)
|
|
1114
|
+
) {
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
} else if (scheduled.has(`${child.name}\u0000${childRow.rowId}`)) {
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1084
1120
|
if (originByIndex.length >= cascadeLimit) {
|
|
1085
1121
|
return {
|
|
1086
1122
|
kind: 'terminate',
|
|
@@ -1091,11 +1127,6 @@ async function enforceReferences(
|
|
|
1091
1127
|
),
|
|
1092
1128
|
};
|
|
1093
1129
|
}
|
|
1094
|
-
if (
|
|
1095
|
-
!schedule(child, childRow.rowId, item.originOpIndex, scheduled, queue)
|
|
1096
|
-
) {
|
|
1097
|
-
continue;
|
|
1098
|
-
}
|
|
1099
1130
|
const syntheticIndex = clientOpCount + originByIndex.length;
|
|
1100
1131
|
const storedValues = decodeRow(child.columns, childRow.payload);
|
|
1101
1132
|
if (reference.onDelete === 'CASCADE') {
|
package/src/realtime.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
encodeMessage,
|
|
24
24
|
encodePresenceError,
|
|
25
25
|
encodePresenceFanout,
|
|
26
|
+
isSupportedProtocolWireVersion,
|
|
26
27
|
MessageStreamScanner,
|
|
27
28
|
PROTOCOL_WIRE_VERSION,
|
|
28
29
|
type PresenceKind,
|
|
@@ -48,6 +49,7 @@ import {
|
|
|
48
49
|
syncError,
|
|
49
50
|
} from './errors';
|
|
50
51
|
import { emitEvent, type SyncularServerEvents } from './events';
|
|
52
|
+
import { composeEvents } from './events-ring';
|
|
51
53
|
import { createSyncResponseStream } from './handler';
|
|
52
54
|
import {
|
|
53
55
|
type CompiledSchema,
|
|
@@ -277,6 +279,8 @@ export class RealtimeSession {
|
|
|
277
279
|
#registrationGeneration = 0;
|
|
278
280
|
/** Generation of the most recent refresh whose result was applied. */
|
|
279
281
|
#registrationApplied = 0;
|
|
282
|
+
/** Highest sequence that required a catch-up wake for this session. */
|
|
283
|
+
#wakeRequiredThroughSeq: number;
|
|
280
284
|
|
|
281
285
|
constructor(
|
|
282
286
|
hub: RealtimeHub,
|
|
@@ -300,6 +304,7 @@ export class RealtimeSession {
|
|
|
300
304
|
this.cursor = cursor;
|
|
301
305
|
this.lastKnownSeq = latestSeq;
|
|
302
306
|
this.wakePending = cursor < latestSeq;
|
|
307
|
+
this.#wakeRequiredThroughSeq = cursor < latestSeq ? latestSeq : 0;
|
|
303
308
|
this.registrations = registrations;
|
|
304
309
|
this.openedAtMs = clock();
|
|
305
310
|
this.#send = options.send;
|
|
@@ -335,9 +340,9 @@ export class RealtimeSession {
|
|
|
335
340
|
// proves those commits no longer need socket notifications, so the next
|
|
336
341
|
// notification must be adjacent to the acknowledged cursor.
|
|
337
342
|
this.lastKnownSeq = Math.max(this.lastKnownSeq, this.cursor);
|
|
338
|
-
if (this.cursor >= this
|
|
343
|
+
if (this.cursor >= this.#wakeRequiredThroughSeq) this.wakePending = false;
|
|
339
344
|
// §8.2: acks update the client cursor record without an HTTP pull.
|
|
340
|
-
this.#persistCursor();
|
|
345
|
+
this.#persistCursor(cursor);
|
|
341
346
|
}
|
|
342
347
|
|
|
343
348
|
/**
|
|
@@ -570,7 +575,7 @@ export class RealtimeSession {
|
|
|
570
575
|
try {
|
|
571
576
|
const requestedWireVersion =
|
|
572
577
|
(requestBytes[4] ?? 0) | ((requestBytes[5] ?? 0) << 8);
|
|
573
|
-
if (requestedWireVersion
|
|
578
|
+
if (isSupportedProtocolWireVersion(requestedWireVersion)) {
|
|
574
579
|
this.wireVersion = requestedWireVersion;
|
|
575
580
|
}
|
|
576
581
|
let stream: AsyncIterable<Uint8Array> | undefined;
|
|
@@ -720,7 +725,7 @@ export class RealtimeSession {
|
|
|
720
725
|
this.#hub.disconnect(this);
|
|
721
726
|
}
|
|
722
727
|
|
|
723
|
-
#persistCursor(): void {
|
|
728
|
+
#persistCursor(cursor: number): void {
|
|
724
729
|
// §8.2: start the write now and keep it on the control tail so `drain()`
|
|
725
730
|
// can await it. Two acks may write concurrently: storage folds them with
|
|
726
731
|
// `MAX(cursor, ?)`, so neither order loses the later cursor.
|
|
@@ -730,7 +735,7 @@ export class RealtimeSession {
|
|
|
730
735
|
this.clientId,
|
|
731
736
|
this.actorId,
|
|
732
737
|
this.logEpoch,
|
|
733
|
-
|
|
738
|
+
cursor,
|
|
734
739
|
this.#clock(),
|
|
735
740
|
);
|
|
736
741
|
})();
|
|
@@ -766,6 +771,10 @@ export class RealtimeSession {
|
|
|
766
771
|
|
|
767
772
|
sendWake(reason: WakeReason): void {
|
|
768
773
|
this.wakePending = true;
|
|
774
|
+
this.#wakeRequiredThroughSeq = Math.max(
|
|
775
|
+
this.#wakeRequiredThroughSeq,
|
|
776
|
+
this.lastKnownSeq,
|
|
777
|
+
);
|
|
769
778
|
this.#sendSafe(
|
|
770
779
|
JSON.stringify({
|
|
771
780
|
event: 'sync',
|
|
@@ -907,8 +916,16 @@ export class RealtimeSession {
|
|
|
907
916
|
}
|
|
908
917
|
}
|
|
909
918
|
|
|
919
|
+
/** One open realtime socket. */
|
|
920
|
+
export interface RealtimeConnection {
|
|
921
|
+
readonly sessionId: string;
|
|
922
|
+
readonly clientId: string;
|
|
923
|
+
readonly actorId: string;
|
|
924
|
+
readonly openedAtMs: number;
|
|
925
|
+
}
|
|
926
|
+
|
|
910
927
|
export class RealtimeHub {
|
|
911
|
-
|
|
928
|
+
#config: RealtimeHubConfig;
|
|
912
929
|
readonly #schema: CompiledSchema;
|
|
913
930
|
readonly #sessions = new Set<RealtimeSession>();
|
|
914
931
|
/** §8.6 presence registry — ephemeral, in-memory, never persisted. */
|
|
@@ -928,10 +945,35 @@ export class RealtimeHub {
|
|
|
928
945
|
);
|
|
929
946
|
}
|
|
930
947
|
|
|
948
|
+
/**
|
|
949
|
+
* Compose `sink` into the hub's `events`, so socket rounds and sessions
|
|
950
|
+
* opened afterwards report to it as well. `createSyncularHono` uses it to
|
|
951
|
+
* feed the admin console's event ring.
|
|
952
|
+
*/
|
|
953
|
+
addEventSink(sink: SyncularServerEvents): void {
|
|
954
|
+
const events = this.#config.events;
|
|
955
|
+
this.#config = {
|
|
956
|
+
...this.#config,
|
|
957
|
+
events: events === undefined ? sink : composeEvents(events, sink),
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
931
961
|
get sessionCount(): number {
|
|
932
962
|
return this.#sessions.size;
|
|
933
963
|
}
|
|
934
964
|
|
|
965
|
+
/** The partition's open sockets, for the admin console. */
|
|
966
|
+
connections(partition: string): RealtimeConnection[] {
|
|
967
|
+
return [...this.#sessions]
|
|
968
|
+
.filter((session) => session.partition === partition)
|
|
969
|
+
.map((session) => ({
|
|
970
|
+
sessionId: session.sessionId,
|
|
971
|
+
clientId: session.clientId,
|
|
972
|
+
actorId: session.actorId,
|
|
973
|
+
openedAtMs: session.openedAtMs,
|
|
974
|
+
}));
|
|
975
|
+
}
|
|
976
|
+
|
|
935
977
|
/** §8.6.2 published-document size cap (bytes). */
|
|
936
978
|
get maxPresenceBytes(): number {
|
|
937
979
|
return this.#config.maxPresenceBytes ?? DEFAULT_MAX_PRESENCE_BYTES;
|
|
@@ -1087,6 +1129,12 @@ export class RealtimeHub {
|
|
|
1087
1129
|
...(this.#config.commitValidator !== undefined
|
|
1088
1130
|
? { commitValidator: this.#config.commitValidator }
|
|
1089
1131
|
: {}),
|
|
1132
|
+
...(this.#config.reactionPlanner !== undefined
|
|
1133
|
+
? { reactionPlanner: this.#config.reactionPlanner }
|
|
1134
|
+
: {}),
|
|
1135
|
+
...(this.#config.checkpoints !== undefined
|
|
1136
|
+
? { checkpoints: this.#config.checkpoints }
|
|
1137
|
+
: {}),
|
|
1090
1138
|
...(this.#config.clock !== undefined
|
|
1091
1139
|
? { clock: this.#config.clock }
|
|
1092
1140
|
: {}),
|
|
@@ -1114,6 +1162,9 @@ export class RealtimeHub {
|
|
|
1114
1162
|
...(this.#config.onError !== undefined
|
|
1115
1163
|
? { onError: this.#config.onError }
|
|
1116
1164
|
: {}),
|
|
1165
|
+
...(this.#config.mapError !== undefined
|
|
1166
|
+
? { mapError: this.#config.mapError }
|
|
1167
|
+
: {}),
|
|
1117
1168
|
realtime: this,
|
|
1118
1169
|
};
|
|
1119
1170
|
}
|
|
@@ -1177,7 +1228,9 @@ export class RealtimeHub {
|
|
|
1177
1228
|
this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES,
|
|
1178
1229
|
storage,
|
|
1179
1230
|
registry.logEpoch,
|
|
1180
|
-
record
|
|
1231
|
+
record !== undefined && isSupportedProtocolWireVersion(record.wireVersion)
|
|
1232
|
+
? record.wireVersion
|
|
1233
|
+
: PROTOCOL_WIRE_VERSION,
|
|
1181
1234
|
this.#config.events,
|
|
1182
1235
|
);
|
|
1183
1236
|
this.#sessions.add(session);
|
package/src/seed.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { SyncError } from './errors';
|
|
|
24
24
|
import type { SyncularServerEvent, SyncularServerEvents } from './events';
|
|
25
25
|
import { composeEvents } from './events-ring';
|
|
26
26
|
import { handleSyncRequest } from './handler';
|
|
27
|
+
import { renderScopeValue } from './scopes';
|
|
27
28
|
|
|
28
29
|
/** One app-shaped seed mutation — the same vocabulary as client mutations. */
|
|
29
30
|
export type SeedMutation =
|
|
@@ -171,11 +172,11 @@ export async function seedMutations(
|
|
|
171
172
|
(column) => (byColumn.get(column.name) ?? null) as RowValue,
|
|
172
173
|
);
|
|
173
174
|
const pkIndex = table.columns.findIndex((c) => c.name === table.primaryKey);
|
|
174
|
-
const rowId = values[pkIndex];
|
|
175
|
-
if (
|
|
175
|
+
const rowId = renderScopeValue(values[pkIndex]);
|
|
176
|
+
if (rowId === undefined) {
|
|
176
177
|
throw new SyncError(
|
|
177
178
|
'sync.invalid_request',
|
|
178
|
-
`seedMutations: table ${table.name}: upsert requires a non-empty
|
|
179
|
+
`seedMutations: table ${table.name}: upsert requires a non-empty primary key`,
|
|
179
180
|
);
|
|
180
181
|
}
|
|
181
182
|
return {
|