@syncular/server 0.31.0 → 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 +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 +128 -19
- 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/realtime.d.ts +15 -0
- package/dist/realtime.js +24 -0
- 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 +187 -23
- 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/realtime.ts +35 -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/realtime.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
syncError,
|
|
49
49
|
} from './errors';
|
|
50
50
|
import { emitEvent, type SyncularServerEvents } from './events';
|
|
51
|
+
import { composeEvents } from './events-ring';
|
|
51
52
|
import { createSyncResponseStream } from './handler';
|
|
52
53
|
import {
|
|
53
54
|
type CompiledSchema,
|
|
@@ -907,8 +908,16 @@ export class RealtimeSession {
|
|
|
907
908
|
}
|
|
908
909
|
}
|
|
909
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
|
+
|
|
910
919
|
export class RealtimeHub {
|
|
911
|
-
|
|
920
|
+
#config: RealtimeHubConfig;
|
|
912
921
|
readonly #schema: CompiledSchema;
|
|
913
922
|
readonly #sessions = new Set<RealtimeSession>();
|
|
914
923
|
/** §8.6 presence registry — ephemeral, in-memory, never persisted. */
|
|
@@ -928,10 +937,35 @@ export class RealtimeHub {
|
|
|
928
937
|
);
|
|
929
938
|
}
|
|
930
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
|
+
|
|
931
953
|
get sessionCount(): number {
|
|
932
954
|
return this.#sessions.size;
|
|
933
955
|
}
|
|
934
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
|
+
|
|
935
969
|
/** §8.6.2 published-document size cap (bytes). */
|
|
936
970
|
get maxPresenceBytes(): number {
|
|
937
971
|
return this.#config.maxPresenceBytes ?? DEFAULT_MAX_PRESENCE_BYTES;
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -30,11 +30,18 @@ import {
|
|
|
30
30
|
layoutsOf,
|
|
31
31
|
migratePayload,
|
|
32
32
|
parseLayouts,
|
|
33
|
+
quoteIdent,
|
|
33
34
|
retiredTableNames,
|
|
34
35
|
rewritePlan,
|
|
35
36
|
rewriteRowSql,
|
|
36
37
|
rewriteValues,
|
|
37
38
|
SCHEMA_META_DDL_SQLITE,
|
|
39
|
+
SYNC_COLUMN_VERSIONS_COLUMN,
|
|
40
|
+
SYNC_PARTITION_COLUMN,
|
|
41
|
+
SYNC_PAYLOAD_COLUMN,
|
|
42
|
+
SYNC_ROW_ID_COLUMN,
|
|
43
|
+
SYNC_SCOPES_COLUMN,
|
|
44
|
+
SYNC_VERSION_COLUMN,
|
|
38
45
|
type StoredColumnLayout,
|
|
39
46
|
scanRowPageSql,
|
|
40
47
|
schemaDdl,
|
|
@@ -51,9 +58,11 @@ import {
|
|
|
51
58
|
deserializePushResult,
|
|
52
59
|
placeholders,
|
|
53
60
|
SQLITE_DDL,
|
|
61
|
+
type SqliteChangeRecord,
|
|
54
62
|
type SqliteCommitWindowRecord,
|
|
55
63
|
type SqliteRowRecord,
|
|
56
64
|
serializePushResult,
|
|
65
|
+
toStoredChange,
|
|
57
66
|
toStoredRow,
|
|
58
67
|
} from './sqlite-dialect';
|
|
59
68
|
import {
|
|
@@ -66,8 +75,11 @@ import type {
|
|
|
66
75
|
AuthoritativeQueryValue,
|
|
67
76
|
CheckpointDeclaration,
|
|
68
77
|
ClientCursorInfo,
|
|
78
|
+
ClientPushResult,
|
|
69
79
|
ClientRecord,
|
|
70
80
|
ClientSubscription,
|
|
81
|
+
CommitChangeSummary,
|
|
82
|
+
CommitDetail,
|
|
71
83
|
CommitMetadata,
|
|
72
84
|
CommitMetadataQuery,
|
|
73
85
|
CommitWindowQuery,
|
|
@@ -82,6 +94,9 @@ import type {
|
|
|
82
94
|
ReactionFailureUpdate,
|
|
83
95
|
ReactionListQuery,
|
|
84
96
|
ReactionPruneQuery,
|
|
97
|
+
RowChangeRecord,
|
|
98
|
+
RowChangesQuery,
|
|
99
|
+
RowListQuery,
|
|
85
100
|
RowScanQuery,
|
|
86
101
|
ScopeActivityQuery,
|
|
87
102
|
ScopeCommitActivity,
|
|
@@ -1949,7 +1964,11 @@ END`);
|
|
|
1949
1964
|
partition: string,
|
|
1950
1965
|
query: CommitMetadataQuery,
|
|
1951
1966
|
): Promise<CommitMetadata[]> {
|
|
1952
|
-
// Newest-first window, resumed by `afterSeq` (exclusive lower bound)
|
|
1967
|
+
// Newest-first window, resumed by `afterSeq` (exclusive lower bound) and
|
|
1968
|
+
// ended by `beforeSeq` (exclusive upper bound) when paging older. An
|
|
1969
|
+
// omitted bound is the largest safe commitSeq, so one statement serves
|
|
1970
|
+
// both the bounded and unbounded read.
|
|
1971
|
+
const beforeSeq = query.beforeSeq ?? Number.MAX_SAFE_INTEGER;
|
|
1953
1972
|
const rows = query.table
|
|
1954
1973
|
? this.db
|
|
1955
1974
|
.query<
|
|
@@ -1960,16 +1979,16 @@ END`);
|
|
|
1960
1979
|
actor_id: string;
|
|
1961
1980
|
created_at_ms: number;
|
|
1962
1981
|
},
|
|
1963
|
-
[string, number, string, number]
|
|
1982
|
+
[string, number, number, string, number]
|
|
1964
1983
|
>(
|
|
1965
1984
|
`SELECT c.commit_seq, c.client_id, c.client_commit_id, c.actor_id, c.created_at_ms
|
|
1966
1985
|
FROM sync_commits c
|
|
1967
|
-
WHERE c.partition=? AND c.commit_seq>?
|
|
1986
|
+
WHERE c.partition=? AND c.commit_seq>? AND c.commit_seq<?
|
|
1968
1987
|
AND EXISTS (SELECT 1 FROM sync_changes ch
|
|
1969
1988
|
WHERE ch.partition=c.partition AND ch.commit_seq=c.commit_seq AND ch.tbl=?)
|
|
1970
1989
|
ORDER BY c.commit_seq DESC LIMIT ?`,
|
|
1971
1990
|
)
|
|
1972
|
-
.all(partition, query.afterSeq, query.table, query.limit)
|
|
1991
|
+
.all(partition, query.afterSeq, beforeSeq, query.table, query.limit)
|
|
1973
1992
|
: this.db
|
|
1974
1993
|
.query<
|
|
1975
1994
|
{
|
|
@@ -1979,14 +1998,45 @@ END`);
|
|
|
1979
1998
|
actor_id: string;
|
|
1980
1999
|
created_at_ms: number;
|
|
1981
2000
|
},
|
|
1982
|
-
[string, number, number]
|
|
2001
|
+
[string, number, number, number]
|
|
1983
2002
|
>(
|
|
1984
2003
|
`SELECT commit_seq, client_id, client_commit_id, actor_id, created_at_ms
|
|
1985
2004
|
FROM sync_commits
|
|
1986
|
-
WHERE partition=? AND commit_seq>?
|
|
2005
|
+
WHERE partition=? AND commit_seq>? AND commit_seq<?
|
|
1987
2006
|
ORDER BY commit_seq DESC LIMIT ?`,
|
|
1988
2007
|
)
|
|
1989
|
-
.all(partition, query.afterSeq, query.limit);
|
|
2008
|
+
.all(partition, query.afterSeq, beforeSeq, query.limit);
|
|
2009
|
+
// `changeSample`: one extra statement for the whole page — the first
|
|
2010
|
+
// `sample` changes (change-index order) of every listed commit.
|
|
2011
|
+
const sampleCount =
|
|
2012
|
+
query.changeSample !== undefined &&
|
|
2013
|
+
Number.isInteger(query.changeSample) &&
|
|
2014
|
+
query.changeSample > 0
|
|
2015
|
+
? query.changeSample
|
|
2016
|
+
: 0;
|
|
2017
|
+
const samples = new Map<number, CommitChangeSummary[]>();
|
|
2018
|
+
if (sampleCount > 0 && rows.length > 0) {
|
|
2019
|
+
const seqs = rows.map((row) => row.commit_seq);
|
|
2020
|
+
const records = this.db
|
|
2021
|
+
.query<
|
|
2022
|
+
{ commit_seq: number; tbl: string; row_id: string; op: number },
|
|
2023
|
+
(string | number)[]
|
|
2024
|
+
>(
|
|
2025
|
+
`SELECT commit_seq, tbl, row_id, op FROM sync_changes
|
|
2026
|
+
WHERE partition=? AND commit_seq IN (${placeholders(seqs.length)}) AND idx<?
|
|
2027
|
+
ORDER BY commit_seq, idx`,
|
|
2028
|
+
)
|
|
2029
|
+
.all(partition, ...seqs, sampleCount);
|
|
2030
|
+
for (const record of records) {
|
|
2031
|
+
const sample = samples.get(record.commit_seq) ?? [];
|
|
2032
|
+
sample.push({
|
|
2033
|
+
table: record.tbl,
|
|
2034
|
+
rowId: record.row_id,
|
|
2035
|
+
op: record.op === 1 ? 'upsert' : 'delete',
|
|
2036
|
+
});
|
|
2037
|
+
samples.set(record.commit_seq, sample);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
1990
2040
|
return rows.map((row) => {
|
|
1991
2041
|
const changes = this.db
|
|
1992
2042
|
.query<{ tbl: string; n: number }, [string, number]>(
|
|
@@ -2001,10 +2051,134 @@ END`);
|
|
|
2001
2051
|
createdAtMs: row.created_at_ms,
|
|
2002
2052
|
changeCount: changes.reduce((sum, c) => sum + c.n, 0),
|
|
2003
2053
|
tables: changes.map((c) => c.tbl),
|
|
2054
|
+
...(sampleCount > 0
|
|
2055
|
+
? { sample: samples.get(row.commit_seq) ?? [] }
|
|
2056
|
+
: {}),
|
|
2004
2057
|
};
|
|
2005
2058
|
});
|
|
2006
2059
|
}
|
|
2007
2060
|
|
|
2061
|
+
async getCommit(
|
|
2062
|
+
partition: string,
|
|
2063
|
+
commitSeq: number,
|
|
2064
|
+
): Promise<CommitDetail | undefined> {
|
|
2065
|
+
const meta = this.db
|
|
2066
|
+
.query<
|
|
2067
|
+
{
|
|
2068
|
+
client_id: string;
|
|
2069
|
+
client_commit_id: string;
|
|
2070
|
+
actor_id: string;
|
|
2071
|
+
created_at_ms: number;
|
|
2072
|
+
},
|
|
2073
|
+
[string, number]
|
|
2074
|
+
>(
|
|
2075
|
+
'SELECT client_id, client_commit_id, actor_id, created_at_ms FROM sync_commits WHERE partition=? AND commit_seq=?',
|
|
2076
|
+
)
|
|
2077
|
+
.get(partition, commitSeq);
|
|
2078
|
+
if (meta === null) return undefined;
|
|
2079
|
+
const changes = this.db
|
|
2080
|
+
.query<SqliteChangeRecord, [string, number]>(
|
|
2081
|
+
'SELECT tbl, row_id, op, row_version, scopes, payload FROM sync_changes WHERE partition=? AND commit_seq=? ORDER BY idx',
|
|
2082
|
+
)
|
|
2083
|
+
.all(partition, commitSeq)
|
|
2084
|
+
.map(toStoredChange);
|
|
2085
|
+
return {
|
|
2086
|
+
commitSeq,
|
|
2087
|
+
clientId: meta.client_id,
|
|
2088
|
+
clientCommitId: meta.client_commit_id,
|
|
2089
|
+
actorId: meta.actor_id,
|
|
2090
|
+
createdAtMs: meta.created_at_ms,
|
|
2091
|
+
changeCount: changes.length,
|
|
2092
|
+
tables: [...new Set(changes.map((change) => change.table))].sort(),
|
|
2093
|
+
changes,
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
async listRowChanges(
|
|
2098
|
+
partition: string,
|
|
2099
|
+
query: RowChangesQuery,
|
|
2100
|
+
): Promise<RowChangeRecord[]> {
|
|
2101
|
+
// Retained-log read for one row: no dedicated index (the write path pays
|
|
2102
|
+
// nothing for it), bounded by retention (§4.6).
|
|
2103
|
+
return this.db
|
|
2104
|
+
.query<
|
|
2105
|
+
{
|
|
2106
|
+
commit_seq: number;
|
|
2107
|
+
op: number;
|
|
2108
|
+
row_version: number | null;
|
|
2109
|
+
actor_id: string;
|
|
2110
|
+
client_id: string;
|
|
2111
|
+
created_at_ms: number;
|
|
2112
|
+
},
|
|
2113
|
+
[string, string, string, number]
|
|
2114
|
+
>(
|
|
2115
|
+
`SELECT ch.commit_seq, ch.op, ch.row_version, c.actor_id, c.client_id, c.created_at_ms
|
|
2116
|
+
FROM sync_changes ch
|
|
2117
|
+
JOIN sync_commits c
|
|
2118
|
+
ON c.partition=ch.partition AND c.commit_seq=ch.commit_seq
|
|
2119
|
+
WHERE ch.partition=? AND ch.tbl=? AND ch.row_id=?
|
|
2120
|
+
ORDER BY ch.commit_seq DESC, ch.idx DESC LIMIT ?`,
|
|
2121
|
+
)
|
|
2122
|
+
.all(partition, query.table, query.rowId, query.limit)
|
|
2123
|
+
.map((row) => ({
|
|
2124
|
+
commitSeq: row.commit_seq,
|
|
2125
|
+
op: row.op === 1 ? 'upsert' : 'delete',
|
|
2126
|
+
...(row.row_version !== null ? { rowVersion: row.row_version } : {}),
|
|
2127
|
+
actorId: row.actor_id,
|
|
2128
|
+
clientId: row.client_id,
|
|
2129
|
+
createdAtMs: row.created_at_ms,
|
|
2130
|
+
}));
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
async listRows(partition: string, query: RowListQuery): Promise<StoredRow[]> {
|
|
2134
|
+
// Keyset page over the row table's (partition, rowId) primary key: an
|
|
2135
|
+
// index range read, no scope filter, no per-row lookup.
|
|
2136
|
+
return this.db
|
|
2137
|
+
.query<SqliteRowRecord, [string, string, number]>(
|
|
2138
|
+
`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
|
|
2139
|
+
FROM ${quoteIdent(this.table(query.table).name)}
|
|
2140
|
+
WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>?
|
|
2141
|
+
ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ?`,
|
|
2142
|
+
)
|
|
2143
|
+
.all(partition, query.afterRowId ?? '', query.limit)
|
|
2144
|
+
.map(toStoredRow);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
async countRows(partition: string, table: string): Promise<number> {
|
|
2148
|
+
const record = this.db
|
|
2149
|
+
.query<{ n: number }, [string]>(
|
|
2150
|
+
`SELECT count(*) AS n FROM ${quoteIdent(this.table(table).name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=?`,
|
|
2151
|
+
)
|
|
2152
|
+
.get(partition);
|
|
2153
|
+
return record?.n ?? 0;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
async listPushResults(
|
|
2157
|
+
partition: string,
|
|
2158
|
+
clientCommitId: string,
|
|
2159
|
+
): Promise<ClientPushResult[]> {
|
|
2160
|
+
// Retained outcomes for one clientCommitId: no dedicated index, ordered
|
|
2161
|
+
// by client so the read is deterministic.
|
|
2162
|
+
return this.db
|
|
2163
|
+
.query<{ client_id: string; result: string }, [string, string]>(
|
|
2164
|
+
'SELECT client_id, result FROM sync_push_results WHERE partition=? AND client_commit_id=? ORDER BY client_id',
|
|
2165
|
+
)
|
|
2166
|
+
.all(partition, clientCommitId)
|
|
2167
|
+
.map((record) => {
|
|
2168
|
+
try {
|
|
2169
|
+
return {
|
|
2170
|
+
clientId: record.client_id,
|
|
2171
|
+
result: deserializePushResult(record.result),
|
|
2172
|
+
};
|
|
2173
|
+
} catch {
|
|
2174
|
+
throw syncError(
|
|
2175
|
+
'sync.idempotency_cache_miss',
|
|
2176
|
+
'persisted push result unreadable (§6.3)',
|
|
2177
|
+
);
|
|
2178
|
+
}
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2008
2182
|
async scopeActivity(
|
|
2009
2183
|
partition: string,
|
|
2010
2184
|
query: ScopeActivityQuery,
|
|
@@ -2044,25 +2218,6 @@ END`);
|
|
|
2044
2218
|
return out;
|
|
2045
2219
|
}
|
|
2046
2220
|
|
|
2047
|
-
async getRowScopes(
|
|
2048
|
-
partition: string,
|
|
2049
|
-
table: string,
|
|
2050
|
-
rowId: string,
|
|
2051
|
-
): Promise<
|
|
2052
|
-
{ serverVersion: number; scopes: Record<string, string> } | undefined
|
|
2053
|
-
> {
|
|
2054
|
-
const record = this.db
|
|
2055
|
-
.query<{ server_version: number; scopes: string }, [string, string]>(
|
|
2056
|
-
selectRowScopesSql(this.table(table), 'sqlite'),
|
|
2057
|
-
)
|
|
2058
|
-
.get(partition, rowId);
|
|
2059
|
-
if (record === null) return undefined;
|
|
2060
|
-
return {
|
|
2061
|
-
serverVersion: record.server_version,
|
|
2062
|
-
scopes: JSON.parse(record.scopes) as Record<string, string>,
|
|
2063
|
-
};
|
|
2064
|
-
}
|
|
2065
|
-
|
|
2066
2221
|
async listPartitions(): Promise<string[]> {
|
|
2067
2222
|
return (await this.listPartitionRegistry()).map((entry) => entry.partition);
|
|
2068
2223
|
}
|