@syncular/server 0.21.1 → 0.23.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 +20 -6
- package/dist/admin.d.ts +3 -1
- package/dist/admin.js +7 -1
- package/dist/blob-handlers.js +1 -1
- package/dist/context.d.ts +8 -1
- package/dist/d1-storage.d.ts +11 -3
- package/dist/d1-storage.js +84 -2
- package/dist/errors.js +10 -0
- package/dist/handler.js +50 -4
- package/dist/operations.js +3 -3
- package/dist/pg-executor.d.ts +11 -1
- package/dist/postgres-storage.d.ts +20 -5
- package/dist/postgres-storage.js +345 -9
- package/dist/pull.js +9 -5
- package/dist/push.js +18 -0
- package/dist/readiness.d.ts +9 -2
- package/dist/readiness.js +27 -2
- package/dist/realtime.d.ts +40 -1
- package/dist/realtime.js +116 -19
- package/dist/relational-rows.d.ts +20 -0
- package/dist/relational-rows.js +29 -0
- package/dist/s3-segment-store.d.ts +20 -8
- package/dist/s3-segment-store.js +216 -68
- package/dist/schema.js +11 -0
- package/dist/segment-download.d.ts +1 -1
- package/dist/segment-download.js +50 -17
- package/dist/segment-store.d.ts +86 -0
- package/dist/segment-store.js +156 -17
- package/dist/signed-url.d.ts +7 -1
- package/dist/signed-url.js +10 -1
- package/dist/sqlite-dialect.d.ts +7 -1
- package/dist/sqlite-dialect.js +23 -0
- package/dist/sqlite-segment-store.js +153 -42
- package/dist/sqlite-storage.d.ts +11 -3
- package/dist/sqlite-storage.js +346 -34
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +4 -0
- package/dist/storage.d.ts +152 -2
- package/dist/storage.js +40 -1
- package/dist/validate.d.ts +20 -1
- package/package.json +2 -2
- package/src/admin.ts +13 -1
- package/src/blob-handlers.ts +1 -1
- package/src/context.ts +8 -0
- package/src/d1-storage.ts +143 -1
- package/src/errors.ts +10 -0
- package/src/handler.ts +62 -4
- package/src/operations.ts +17 -13
- package/src/pg-executor.ts +11 -1
- package/src/postgres-storage.ts +520 -5
- package/src/pull.ts +9 -1
- package/src/push.ts +18 -0
- package/src/readiness.ts +39 -4
- package/src/realtime.ts +117 -18
- package/src/relational-rows.ts +36 -0
- package/src/s3-segment-store.ts +275 -84
- package/src/schema.ts +15 -0
- package/src/segment-download.ts +79 -28
- package/src/segment-store.ts +250 -18
- package/src/signed-url.ts +12 -2
- package/src/sqlite-dialect.ts +23 -0
- package/src/sqlite-segment-store.ts +245 -65
- package/src/sqlite-storage.ts +494 -36
- package/src/storage-errors.ts +13 -1
- package/src/storage.ts +243 -1
- package/src/validate.ts +20 -1
package/dist/segment-store.js
CHANGED
|
@@ -8,6 +8,144 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { sha256Hex } from './scopes.js';
|
|
10
10
|
export const DEFAULT_SEGMENT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
/** Canonical identity of one publication; two equal keys are one grant. */
|
|
12
|
+
export function publicationKey(metadata) {
|
|
13
|
+
return JSON.stringify([
|
|
14
|
+
metadata.partition,
|
|
15
|
+
metadata.logEpoch,
|
|
16
|
+
metadata.table,
|
|
17
|
+
metadata.schemaVersion,
|
|
18
|
+
metadata.mediaType,
|
|
19
|
+
metadata.scopeDigest,
|
|
20
|
+
metadata.asOfCommitSeq,
|
|
21
|
+
metadata.rowCursor,
|
|
22
|
+
metadata.nextRowCursor,
|
|
23
|
+
]);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Materialize publications for a record written before provenance existed
|
|
27
|
+
* (in-flight 0.21 objects, §5.1). Only the merged top-level context is
|
|
28
|
+
* known, so each recorded digest is one publication under it: that is the
|
|
29
|
+
* old single-record read behavior preserved verbatim.
|
|
30
|
+
*/
|
|
31
|
+
export function publicationsForRecord(record) {
|
|
32
|
+
return record.scopeDigests.map((scopeDigest) => ({
|
|
33
|
+
partition: record.partition,
|
|
34
|
+
logEpoch: record.logEpoch,
|
|
35
|
+
table: record.table,
|
|
36
|
+
schemaVersion: record.schemaVersion,
|
|
37
|
+
mediaType: record.mediaType,
|
|
38
|
+
scopeDigest,
|
|
39
|
+
asOfCommitSeq: record.asOfCommitSeq,
|
|
40
|
+
rowCount: record.rowCount,
|
|
41
|
+
rowCursor: record.rowCursor,
|
|
42
|
+
nextRowCursor: record.nextRowCursor,
|
|
43
|
+
byteLength: record.byteLength,
|
|
44
|
+
createdAtMs: record.createdAtMs,
|
|
45
|
+
expiresAtMs: record.expiresAtMs,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
export function selectSegmentPublication(record, context) {
|
|
49
|
+
for (let index = record.publications.length - 1; index >= 0; index--) {
|
|
50
|
+
const publication = record.publications[index];
|
|
51
|
+
if (publication.partition === context.partition &&
|
|
52
|
+
publication.logEpoch === context.logEpoch &&
|
|
53
|
+
publication.scopeDigest === context.scopeDigest &&
|
|
54
|
+
(context.table === undefined || publication.table === context.table) &&
|
|
55
|
+
(context.schemaVersion === undefined ||
|
|
56
|
+
publication.schemaVersion === context.schemaVersion) &&
|
|
57
|
+
(context.mediaType === undefined ||
|
|
58
|
+
publication.mediaType === context.mediaType) &&
|
|
59
|
+
(context.asOfCommitSeq === undefined ||
|
|
60
|
+
publication.asOfCommitSeq === context.asOfCommitSeq) &&
|
|
61
|
+
(context.rowCursor === undefined ||
|
|
62
|
+
publication.rowCursor === context.rowCursor) &&
|
|
63
|
+
(context.nowMs === undefined || publication.expiresAtMs > context.nowMs)) {
|
|
64
|
+
return publication;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Project a record onto one publication. The compatibility view is narrowed
|
|
71
|
+
* to that grant: `scopeDigests` and `publications` hold only the chosen
|
|
72
|
+
* publication, so a caller that authorizes from them cannot union a grant
|
|
73
|
+
* from a different partition or epoch.
|
|
74
|
+
*/
|
|
75
|
+
export function publicationRecord(record, publication) {
|
|
76
|
+
return {
|
|
77
|
+
...record,
|
|
78
|
+
...publication,
|
|
79
|
+
scopeDigests: [publication.scopeDigest],
|
|
80
|
+
publications: [publication],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Byte equality for the content-address collision check (§5.1). */
|
|
84
|
+
export function segmentBytesEqual(a, b) {
|
|
85
|
+
if (a.length !== b.length)
|
|
86
|
+
return false;
|
|
87
|
+
for (let i = 0; i < a.length; i++) {
|
|
88
|
+
if (a[i] !== b[i])
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Merge one publication into the stored entry for the same content address
|
|
95
|
+
* (§5.1). Identical bytes are one entry: the record takes the incoming
|
|
96
|
+
* publication's metadata (a later publisher's descriptor, scope digest, and
|
|
97
|
+
* cursors stay consistent with what it just published), the scope digests
|
|
98
|
+
* union with the incoming digest first (the record's primary `scopeDigest`),
|
|
99
|
+
* `createdAtMs` keeps the earliest sighting, and `expiresAtMs` takes the
|
|
100
|
+
* later expiry. A publication with the incoming context merges into that
|
|
101
|
+
* publication's own lifetime; a publication with a new context is appended,
|
|
102
|
+
* so no context's grant is overwritten or extended by another. Publications
|
|
103
|
+
* whose own TTL has elapsed at `nowMs` are pruned from the stored entry —
|
|
104
|
+
* SPEC §5.5 lets the cache forget expired grants, and pruning on put keeps
|
|
105
|
+
* the publication set bounded by live contexts instead of by every context
|
|
106
|
+
* ever seen. Callers MUST have proven the bytes identical first.
|
|
107
|
+
*/
|
|
108
|
+
export function mergeSegmentRecord(existing, metadata, segmentId, byteLength, nowMs, ttlMs) {
|
|
109
|
+
// Prune grants that have already elapsed at this write (§5.5): bounded by
|
|
110
|
+
// live contexts, not by the unbounded set of contexts ever published.
|
|
111
|
+
const prior = (existing?.publications ?? []).filter((publication) => publication.expiresAtMs > nowMs);
|
|
112
|
+
const fresh = {
|
|
113
|
+
...metadata,
|
|
114
|
+
byteLength,
|
|
115
|
+
createdAtMs: nowMs,
|
|
116
|
+
expiresAtMs: nowMs + ttlMs,
|
|
117
|
+
};
|
|
118
|
+
const key = publicationKey(metadata);
|
|
119
|
+
const at = prior.findIndex((publication) => publicationKey(publication) === key);
|
|
120
|
+
// Re-publication of one context merges that publication's lifetime only:
|
|
121
|
+
// an unrelated refresh must not extend another publication's grant (§5.1).
|
|
122
|
+
const publications = at < 0
|
|
123
|
+
? [...prior, fresh]
|
|
124
|
+
: prior.map((publication, index) => index === at
|
|
125
|
+
? {
|
|
126
|
+
...fresh,
|
|
127
|
+
createdAtMs: Math.min(publication.createdAtMs, nowMs),
|
|
128
|
+
expiresAtMs: Math.max(publication.expiresAtMs, nowMs + ttlMs),
|
|
129
|
+
}
|
|
130
|
+
: publication);
|
|
131
|
+
return {
|
|
132
|
+
...metadata,
|
|
133
|
+
segmentId,
|
|
134
|
+
// Compatibility view over the retained publications only, incoming
|
|
135
|
+
// primary first: a pruned publication's digest is gone, not just its
|
|
136
|
+
// descriptor.
|
|
137
|
+
scopeDigests: [
|
|
138
|
+
metadata.scopeDigest,
|
|
139
|
+
...new Set(publications
|
|
140
|
+
.map((publication) => publication.scopeDigest)
|
|
141
|
+
.filter((digest) => digest !== metadata.scopeDigest)),
|
|
142
|
+
],
|
|
143
|
+
publications,
|
|
144
|
+
byteLength,
|
|
145
|
+
createdAtMs: Math.min(existing?.createdAtMs ?? nowMs, nowMs),
|
|
146
|
+
expiresAtMs: Math.max(existing?.expiresAtMs ?? nowMs + ttlMs, nowMs + ttlMs),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
11
149
|
export async function segmentIdFor(bytes) {
|
|
12
150
|
return `sha256:${await sha256Hex(bytes)}`;
|
|
13
151
|
}
|
|
@@ -19,13 +157,11 @@ export class MemorySegmentStore {
|
|
|
19
157
|
}
|
|
20
158
|
async put(metadata, bytes, nowMs) {
|
|
21
159
|
const segmentId = await segmentIdFor(bytes);
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
segmentId
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
expiresAtMs: nowMs + this.#ttlMs,
|
|
28
|
-
};
|
|
160
|
+
const existing = this.#entries.get(segmentId);
|
|
161
|
+
if (existing !== undefined && !segmentBytesEqual(existing.bytes, bytes)) {
|
|
162
|
+
throw new Error(`MemorySegmentStore: content-address collision at ${segmentId} (§5.1)`);
|
|
163
|
+
}
|
|
164
|
+
const record = mergeSegmentRecord(existing?.record, metadata, segmentId, bytes.length, nowMs, this.#ttlMs);
|
|
29
165
|
this.#entries.set(segmentId, { record, bytes });
|
|
30
166
|
return record;
|
|
31
167
|
}
|
|
@@ -34,16 +170,19 @@ export class MemorySegmentStore {
|
|
|
34
170
|
}
|
|
35
171
|
async find(key, nowMs) {
|
|
36
172
|
for (const { record } of this.#entries.values()) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
173
|
+
const publication = selectSegmentPublication(record, {
|
|
174
|
+
partition: key.partition,
|
|
175
|
+
logEpoch: key.logEpoch,
|
|
176
|
+
table: key.table,
|
|
177
|
+
schemaVersion: key.schemaVersion,
|
|
178
|
+
mediaType: key.mediaType,
|
|
179
|
+
scopeDigest: key.scopeDigest,
|
|
180
|
+
asOfCommitSeq: key.asOfCommitSeq,
|
|
181
|
+
rowCursor: null,
|
|
182
|
+
nowMs,
|
|
183
|
+
});
|
|
184
|
+
if (publication !== undefined) {
|
|
185
|
+
return publicationRecord(record, publication);
|
|
47
186
|
}
|
|
48
187
|
}
|
|
49
188
|
return undefined;
|
package/dist/signed-url.d.ts
CHANGED
|
@@ -129,10 +129,16 @@ export declare function signSegmentToken(key: string | Uint8Array, claims: Segme
|
|
|
129
129
|
/**
|
|
130
130
|
* Verify a segment token per §5.4. Throws `SyncError sync.forbidden` on any
|
|
131
131
|
* failure (MAC, expiry, or claim mismatch).
|
|
132
|
+
*
|
|
133
|
+
* `scopeDigest` is the set of scope digests the token may authorize. Pass the
|
|
134
|
+
* digests of the stored record's publications that match the verifying host's
|
|
135
|
+
* partition: identical bytes are one content address (§5.1), but a digest
|
|
136
|
+
* recorded under another partition is not this host's grant (§5.4). A single
|
|
137
|
+
* string stays valid for a record with one publication.
|
|
132
138
|
*/
|
|
133
139
|
export declare function verifySegmentToken(key: string | Uint8Array, token: string, expected: {
|
|
134
140
|
readonly segmentId: string;
|
|
135
|
-
readonly scopeDigest: string;
|
|
141
|
+
readonly scopeDigest: string | readonly string[];
|
|
136
142
|
readonly audience: string;
|
|
137
143
|
readonly nowMs: number;
|
|
138
144
|
readonly skewSeconds?: number;
|
package/dist/signed-url.js
CHANGED
|
@@ -108,6 +108,12 @@ const DEFAULT_SKEW_SECONDS = 60;
|
|
|
108
108
|
/**
|
|
109
109
|
* Verify a segment token per §5.4. Throws `SyncError sync.forbidden` on any
|
|
110
110
|
* failure (MAC, expiry, or claim mismatch).
|
|
111
|
+
*
|
|
112
|
+
* `scopeDigest` is the set of scope digests the token may authorize. Pass the
|
|
113
|
+
* digests of the stored record's publications that match the verifying host's
|
|
114
|
+
* partition: identical bytes are one content address (§5.1), but a digest
|
|
115
|
+
* recorded under another partition is not this host's grant (§5.4). A single
|
|
116
|
+
* string stays valid for a record with one publication.
|
|
111
117
|
*/
|
|
112
118
|
export async function verifySegmentToken(key, token, expected) {
|
|
113
119
|
const forbidden = (reason) => syncError('sync.forbidden', `segment token rejected: ${reason}`);
|
|
@@ -135,7 +141,10 @@ export async function verifySegmentToken(key, token, expected) {
|
|
|
135
141
|
}
|
|
136
142
|
if (claims.seg !== expected.segmentId)
|
|
137
143
|
throw forbidden('segment mismatch');
|
|
138
|
-
|
|
144
|
+
const scopeDigests = typeof expected.scopeDigest === 'string'
|
|
145
|
+
? [expected.scopeDigest]
|
|
146
|
+
: expected.scopeDigest;
|
|
147
|
+
if (!scopeDigests.includes(claims.sd))
|
|
139
148
|
throw forbidden('scope-digest mismatch');
|
|
140
149
|
if (claims.aud !== expected.audience)
|
|
141
150
|
throw forbidden('audience mismatch');
|
package/dist/sqlite-dialect.d.ts
CHANGED
|
@@ -29,8 +29,14 @@ import type { StoredChange, StoredCommit, StoredPushResult, StoredRow } from './
|
|
|
29
29
|
* range that returns already-ordered rows — the same covering-index shape
|
|
30
30
|
* the Postgres storage documents (§3.1, performance-by-
|
|
31
31
|
* construction).
|
|
32
|
+
*
|
|
33
|
+
* `sync_row_scopes_by_row` is the one secondary scope index: a per-row
|
|
34
|
+
* scope replacement predicates on `(partition, tbl, row_id)`, and `row_id`
|
|
35
|
+
* is the LAST PRIMARY KEY column, so that predicate cannot narrow the
|
|
36
|
+
* inverted index. Declared here so every SQLite-family storage (and D1,
|
|
37
|
+
* which applies this same DDL) keeps the same plan shape as Postgres.
|
|
32
38
|
*/
|
|
33
|
-
export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\n";
|
|
39
|
+
export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n writer_version INTEGER,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(\n partition TEXT NOT NULL, name TEXT NOT NULL,\n schema_version INTEGER NOT NULL,\n state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),\n watermark INTEGER NOT NULL DEFAULT 0,\n owner_epoch INTEGER NOT NULL DEFAULT 0,\n observed_rows INTEGER NOT NULL DEFAULT 0,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, name)\n);\nCREATE TABLE IF NOT EXISTS sync_writer_fence(\n partition TEXT PRIMARY KEY,\n required_writer_version INTEGER NOT NULL\n);\n";
|
|
34
40
|
/** Split the DDL into individual statements (D1 applies them one by one). */
|
|
35
41
|
export declare function sqliteDdlStatements(): string[];
|
|
36
42
|
/** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
|
package/dist/sqlite-dialect.js
CHANGED
|
@@ -11,6 +11,12 @@ import { matchesEffective } from './scopes.js';
|
|
|
11
11
|
* range that returns already-ordered rows — the same covering-index shape
|
|
12
12
|
* the Postgres storage documents (§3.1, performance-by-
|
|
13
13
|
* construction).
|
|
14
|
+
*
|
|
15
|
+
* `sync_row_scopes_by_row` is the one secondary scope index: a per-row
|
|
16
|
+
* scope replacement predicates on `(partition, tbl, row_id)`, and `row_id`
|
|
17
|
+
* is the LAST PRIMARY KEY column, so that predicate cannot narrow the
|
|
18
|
+
* inverted index. Declared here so every SQLite-family storage (and D1,
|
|
19
|
+
* which applies this same DDL) keeps the same plan shape as Postgres.
|
|
14
20
|
*/
|
|
15
21
|
export const SQLITE_DDL = `
|
|
16
22
|
CREATE TABLE IF NOT EXISTS sync_partitions(
|
|
@@ -29,10 +35,13 @@ CREATE TABLE IF NOT EXISTS sync_row_scopes(
|
|
|
29
35
|
var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
30
36
|
PRIMARY KEY(partition, tbl, var, value, row_id)
|
|
31
37
|
);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS sync_row_scopes_by_row
|
|
39
|
+
ON sync_row_scopes(partition, tbl, row_id);
|
|
32
40
|
CREATE TABLE IF NOT EXISTS sync_commits(
|
|
33
41
|
partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,
|
|
34
42
|
client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,
|
|
35
43
|
actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,
|
|
44
|
+
writer_version INTEGER,
|
|
36
45
|
PRIMARY KEY(partition, commit_seq)
|
|
37
46
|
);
|
|
38
47
|
CREATE INDEX IF NOT EXISTS sync_commits_by_time
|
|
@@ -94,6 +103,20 @@ CREATE TABLE IF NOT EXISTS sync_tombstones(
|
|
|
94
103
|
commit_seq INTEGER NOT NULL,
|
|
95
104
|
PRIMARY KEY(partition, tbl, row_id)
|
|
96
105
|
);
|
|
106
|
+
CREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(
|
|
107
|
+
partition TEXT NOT NULL, name TEXT NOT NULL,
|
|
108
|
+
schema_version INTEGER NOT NULL,
|
|
109
|
+
state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),
|
|
110
|
+
watermark INTEGER NOT NULL DEFAULT 0,
|
|
111
|
+
owner_epoch INTEGER NOT NULL DEFAULT 0,
|
|
112
|
+
observed_rows INTEGER NOT NULL DEFAULT 0,
|
|
113
|
+
updated_at_ms INTEGER NOT NULL,
|
|
114
|
+
PRIMARY KEY(partition, name)
|
|
115
|
+
);
|
|
116
|
+
CREATE TABLE IF NOT EXISTS sync_writer_fence(
|
|
117
|
+
partition TEXT PRIMARY KEY,
|
|
118
|
+
required_writer_version INTEGER NOT NULL
|
|
119
|
+
);
|
|
97
120
|
`;
|
|
98
121
|
/** Split the DDL into individual statements (D1 applies them one by one). */
|
|
99
122
|
export function sqliteDdlStatements() {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SQLite-backed segment store over the shared synchronous driver.
|
|
3
3
|
*/
|
|
4
|
-
import { DEFAULT_SEGMENT_TTL_MS, segmentIdFor, } from './segment-store.js';
|
|
4
|
+
import { DEFAULT_SEGMENT_TTL_MS, mergeSegmentRecord, publicationKey, publicationRecord, publicationsForRecord, segmentBytesEqual, segmentIdFor, selectSegmentPublication, } from './segment-store.js';
|
|
5
5
|
import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
|
|
6
6
|
export class SqliteSegmentStore {
|
|
7
7
|
db;
|
|
@@ -23,6 +23,33 @@ export class SqliteSegmentStore {
|
|
|
23
23
|
byte_length INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,
|
|
24
24
|
expires_at_ms INTEGER NOT NULL, bytes BLOB NOT NULL
|
|
25
25
|
);
|
|
26
|
+
`);
|
|
27
|
+
// One row per scope digest a content address was published under (§5.1):
|
|
28
|
+
// identical bytes from two scopes share a `segment_id` and must both stay
|
|
29
|
+
// downloadable (§5.5).
|
|
30
|
+
this.db.exec(`
|
|
31
|
+
CREATE TABLE IF NOT EXISTS sync_segment_scopes(
|
|
32
|
+
segment_id TEXT NOT NULL, scope_digest TEXT NOT NULL,
|
|
33
|
+
PRIMARY KEY (segment_id, scope_digest)
|
|
34
|
+
);
|
|
35
|
+
`);
|
|
36
|
+
// One row per publication of a content address (§5.1): the same bytes
|
|
37
|
+
// under a different partition, epoch, table, or pin are distinct grants
|
|
38
|
+
// and must each stay downloadable. Rows written before this table existed
|
|
39
|
+
// materialize from the top-level record plus `sync_segment_scopes` on
|
|
40
|
+
// read, so an in-flight 0.21 object loses no publication.
|
|
41
|
+
this.db.exec(`
|
|
42
|
+
CREATE TABLE IF NOT EXISTS sync_segment_publications(
|
|
43
|
+
segment_id TEXT NOT NULL, pub_key TEXT NOT NULL,
|
|
44
|
+
partition TEXT NOT NULL, log_epoch TEXT NOT NULL,
|
|
45
|
+
tbl TEXT NOT NULL, schema_version INTEGER NOT NULL,
|
|
46
|
+
media_type TEXT NOT NULL, scope_digest TEXT NOT NULL,
|
|
47
|
+
as_of_commit_seq INTEGER NOT NULL, row_count INTEGER NOT NULL,
|
|
48
|
+
row_cursor TEXT, next_row_cursor TEXT,
|
|
49
|
+
byte_length INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,
|
|
50
|
+
expires_at_ms INTEGER NOT NULL,
|
|
51
|
+
PRIMARY KEY (segment_id, pub_key)
|
|
52
|
+
);
|
|
26
53
|
`);
|
|
27
54
|
const columns = this.db
|
|
28
55
|
.query('PRAGMA table_info(sync_segments)')
|
|
@@ -30,23 +57,89 @@ export class SqliteSegmentStore {
|
|
|
30
57
|
if (!columns.some((column) => column.name === 'log_epoch')) {
|
|
31
58
|
this.db.exec("ALTER TABLE sync_segments ADD COLUMN log_epoch TEXT NOT NULL DEFAULT ''");
|
|
32
59
|
}
|
|
60
|
+
// Backfill digests recorded before the side table existed, so an
|
|
61
|
+
// upgraded store keeps `find`/download working for cached segments.
|
|
62
|
+
this.db.exec(`INSERT OR IGNORE INTO sync_segment_scopes(segment_id, scope_digest)
|
|
63
|
+
SELECT segment_id, scope_digest FROM sync_segments`);
|
|
64
|
+
}
|
|
65
|
+
/** Every digest recorded for a content address, primary first. */
|
|
66
|
+
#scopeDigestsFor(segmentId, primary) {
|
|
67
|
+
const rows = this.db
|
|
68
|
+
.query('SELECT scope_digest FROM sync_segment_scopes WHERE segment_id=?')
|
|
69
|
+
.all(segmentId);
|
|
70
|
+
const digests = rows.map((row) => row.scope_digest);
|
|
71
|
+
return [primary, ...digests.filter((digest) => digest !== primary)];
|
|
72
|
+
}
|
|
73
|
+
/** Stored publications for a content address, oldest first. */
|
|
74
|
+
#publicationsFor(segmentId) {
|
|
75
|
+
const rows = this.db
|
|
76
|
+
.query('SELECT * FROM sync_segment_publications WHERE segment_id=? ORDER BY rowid')
|
|
77
|
+
.all(segmentId);
|
|
78
|
+
return rows.map((row) => ({
|
|
79
|
+
partition: row.partition,
|
|
80
|
+
logEpoch: row.log_epoch,
|
|
81
|
+
table: row.tbl,
|
|
82
|
+
schemaVersion: row.schema_version,
|
|
83
|
+
mediaType: row.media_type === 'sqlite' ? 'sqlite' : 'rows',
|
|
84
|
+
scopeDigest: row.scope_digest,
|
|
85
|
+
asOfCommitSeq: row.as_of_commit_seq,
|
|
86
|
+
rowCount: row.row_count,
|
|
87
|
+
rowCursor: row.row_cursor,
|
|
88
|
+
nextRowCursor: row.next_row_cursor,
|
|
89
|
+
byteLength: row.byte_length,
|
|
90
|
+
createdAtMs: row.created_at_ms,
|
|
91
|
+
expiresAtMs: row.expires_at_ms,
|
|
92
|
+
}));
|
|
33
93
|
}
|
|
34
94
|
async put(metadata, bytes, nowMs) {
|
|
35
95
|
const segmentId = await segmentIdFor(bytes);
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
segmentId
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
96
|
+
const existing = await this.get(segmentId);
|
|
97
|
+
if (existing !== undefined && !segmentBytesEqual(existing.bytes, bytes)) {
|
|
98
|
+
throw new Error(`SqliteSegmentStore: content-address collision at ${segmentId} (§5.1)`);
|
|
99
|
+
}
|
|
100
|
+
const record = mergeSegmentRecord(existing?.record, metadata, segmentId, bytes.length, nowMs, this.#ttlMs);
|
|
101
|
+
// The bytes are proven identical to the stored ones, so replacing the
|
|
102
|
+
// row with the merged record (metadata, digest set, merged times) is the
|
|
103
|
+
// merge: the other digests live in `sync_segment_scopes` below. The
|
|
104
|
+
// publication set is rewritten from the merged record in one transaction
|
|
105
|
+
// so a crash cannot leave provenance half-applied.
|
|
106
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
107
|
+
try {
|
|
108
|
+
this.db
|
|
109
|
+
.query(`INSERT OR REPLACE INTO sync_segments(
|
|
110
|
+
segment_id, partition, log_epoch, tbl, schema_version, media_type,
|
|
111
|
+
scope_digest, as_of_commit_seq, row_count, row_cursor,
|
|
112
|
+
next_row_cursor, byte_length, created_at_ms, expires_at_ms, bytes
|
|
113
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
114
|
+
.run(record.segmentId, record.partition, record.logEpoch, record.table, record.schemaVersion, record.mediaType, record.scopeDigest, record.asOfCommitSeq, record.rowCount, record.rowCursor, record.nextRowCursor, record.byteLength, record.createdAtMs, record.expiresAtMs, bytes);
|
|
115
|
+
// Rebuild the digest side table from the merged record so a pruned
|
|
116
|
+
// (expired) publication's digest disappears from the compatibility
|
|
117
|
+
// view instead of lingering as a stale scope row.
|
|
118
|
+
this.db
|
|
119
|
+
.query('DELETE FROM sync_segment_scopes WHERE segment_id=?')
|
|
120
|
+
.run(record.segmentId);
|
|
121
|
+
const insertScope = this.db.query(`INSERT OR IGNORE INTO sync_segment_scopes(segment_id, scope_digest)
|
|
122
|
+
VALUES (?,?)`);
|
|
123
|
+
for (const digest of record.scopeDigests) {
|
|
124
|
+
insertScope.run(record.segmentId, digest);
|
|
125
|
+
}
|
|
126
|
+
this.db
|
|
127
|
+
.query('DELETE FROM sync_segment_publications WHERE segment_id=?')
|
|
128
|
+
.run(record.segmentId);
|
|
129
|
+
const insertPublication = this.db.query(`INSERT INTO sync_segment_publications(
|
|
130
|
+
segment_id, pub_key, partition, log_epoch, tbl, schema_version,
|
|
131
|
+
media_type, scope_digest, as_of_commit_seq, row_count, row_cursor,
|
|
132
|
+
next_row_cursor, byte_length, created_at_ms, expires_at_ms
|
|
133
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
|
|
134
|
+
for (const publication of record.publications) {
|
|
135
|
+
insertPublication.run(record.segmentId, publicationKey(publication), publication.partition, publication.logEpoch, publication.table, publication.schemaVersion, publication.mediaType, publication.scopeDigest, publication.asOfCommitSeq, publication.rowCount, publication.rowCursor, publication.nextRowCursor, publication.byteLength, publication.createdAtMs, publication.expiresAtMs);
|
|
136
|
+
}
|
|
137
|
+
this.db.exec('COMMIT');
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
this.db.exec('ROLLBACK');
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
50
143
|
return record;
|
|
51
144
|
}
|
|
52
145
|
async get(segmentId) {
|
|
@@ -55,40 +148,59 @@ export class SqliteSegmentStore {
|
|
|
55
148
|
.get(segmentId);
|
|
56
149
|
if (row === null)
|
|
57
150
|
return undefined;
|
|
151
|
+
const scopeDigests = this.#scopeDigestsFor(row.segment_id, row.scope_digest);
|
|
152
|
+
const top = {
|
|
153
|
+
segmentId: row.segment_id,
|
|
154
|
+
partition: row.partition,
|
|
155
|
+
logEpoch: row.log_epoch,
|
|
156
|
+
table: row.tbl,
|
|
157
|
+
schemaVersion: row.schema_version,
|
|
158
|
+
mediaType: row.media_type === 'sqlite' ? 'sqlite' : 'rows',
|
|
159
|
+
scopeDigest: row.scope_digest,
|
|
160
|
+
scopeDigests,
|
|
161
|
+
asOfCommitSeq: row.as_of_commit_seq,
|
|
162
|
+
rowCount: row.row_count,
|
|
163
|
+
rowCursor: row.row_cursor,
|
|
164
|
+
nextRowCursor: row.next_row_cursor,
|
|
165
|
+
byteLength: row.byte_length,
|
|
166
|
+
createdAtMs: row.created_at_ms,
|
|
167
|
+
expiresAtMs: row.expires_at_ms,
|
|
168
|
+
};
|
|
169
|
+
const stored = this.#publicationsFor(row.segment_id);
|
|
58
170
|
return {
|
|
59
171
|
record: {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
logEpoch: row.log_epoch,
|
|
63
|
-
table: row.tbl,
|
|
64
|
-
schemaVersion: row.schema_version,
|
|
65
|
-
mediaType: row.media_type === 'sqlite' ? 'sqlite' : 'rows',
|
|
66
|
-
scopeDigest: row.scope_digest,
|
|
67
|
-
asOfCommitSeq: row.as_of_commit_seq,
|
|
68
|
-
rowCount: row.row_count,
|
|
69
|
-
rowCursor: row.row_cursor,
|
|
70
|
-
nextRowCursor: row.next_row_cursor,
|
|
71
|
-
byteLength: row.byte_length,
|
|
72
|
-
createdAtMs: row.created_at_ms,
|
|
73
|
-
expiresAtMs: row.expires_at_ms,
|
|
172
|
+
...top,
|
|
173
|
+
publications: stored.length > 0 ? stored : publicationsForRecord(top),
|
|
74
174
|
},
|
|
75
175
|
bytes: new Uint8Array(row.bytes),
|
|
76
176
|
};
|
|
77
177
|
}
|
|
78
178
|
async find(key, nowMs) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
179
|
+
// A publication row is the provenance index; a record written before the
|
|
180
|
+
// table existed falls back to the legacy top-level/scope join below.
|
|
181
|
+
const publication = this.db
|
|
182
|
+
.query(`SELECT segment_id FROM sync_segment_publications
|
|
83
183
|
WHERE partition=? AND log_epoch=? AND tbl=? AND schema_version=? AND media_type=?
|
|
84
184
|
AND scope_digest=? AND as_of_commit_seq=? AND row_cursor IS NULL
|
|
85
185
|
AND expires_at_ms > ?
|
|
86
186
|
LIMIT 1`)
|
|
87
187
|
.get(key.partition, key.logEpoch, key.table, key.schemaVersion, key.mediaType, key.scopeDigest, key.asOfCommitSeq, nowMs);
|
|
88
|
-
|
|
188
|
+
const legacy = this.db
|
|
189
|
+
.query(`SELECT s.segment_id
|
|
190
|
+
FROM sync_segments s
|
|
191
|
+
JOIN sync_segment_scopes sc ON sc.segment_id = s.segment_id
|
|
192
|
+
WHERE s.partition=? AND s.log_epoch=? AND s.tbl=? AND s.schema_version=? AND s.media_type=?
|
|
193
|
+
AND sc.scope_digest=? AND s.as_of_commit_seq=? AND s.row_cursor IS NULL
|
|
194
|
+
AND s.expires_at_ms > ?
|
|
195
|
+
LIMIT 1`)
|
|
196
|
+
.get(key.partition, key.logEpoch, key.table, key.schemaVersion, key.mediaType, key.scopeDigest, key.asOfCommitSeq, nowMs);
|
|
197
|
+
const segmentId = publication?.segment_id ?? legacy?.segment_id;
|
|
198
|
+
if (segmentId === undefined)
|
|
89
199
|
return undefined;
|
|
90
|
-
|
|
91
|
-
|
|
200
|
+
const entry = await this.get(segmentId);
|
|
201
|
+
if (entry === undefined)
|
|
202
|
+
return undefined;
|
|
203
|
+
const match = selectSegmentPublication(entry.record, {
|
|
92
204
|
partition: key.partition,
|
|
93
205
|
logEpoch: key.logEpoch,
|
|
94
206
|
table: key.table,
|
|
@@ -96,13 +208,12 @@ export class SqliteSegmentStore {
|
|
|
96
208
|
mediaType: key.mediaType,
|
|
97
209
|
scopeDigest: key.scopeDigest,
|
|
98
210
|
asOfCommitSeq: key.asOfCommitSeq,
|
|
99
|
-
rowCount: row.row_count,
|
|
100
211
|
rowCursor: null,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
212
|
+
nowMs,
|
|
213
|
+
});
|
|
214
|
+
if (match === undefined)
|
|
215
|
+
return undefined;
|
|
216
|
+
return publicationRecord(entry.record, match);
|
|
106
217
|
}
|
|
107
218
|
async stats() {
|
|
108
219
|
const row = this.db
|
package/dist/sqlite-storage.d.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
2
2
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
3
3
|
import { type SqliteDatabase } from './sqlite-driver.js';
|
|
4
|
-
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
4
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
5
5
|
export declare class SqliteServerStorage implements ServerStorage {
|
|
6
6
|
#private;
|
|
7
7
|
readonly db: SqliteDatabase;
|
|
8
8
|
constructor(db?: SqliteDatabase | string);
|
|
9
9
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
10
10
|
table(name: string): CompiledTable;
|
|
11
|
-
ensureSchema(schema: CompiledSchema): Promise<void>;
|
|
11
|
+
ensureSchema(schema: CompiledSchema, checkpoints?: readonly CheckpointDeclaration[]): Promise<void>;
|
|
12
12
|
touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
|
|
13
13
|
rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
|
|
14
14
|
listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
|
|
@@ -16,12 +16,20 @@ export declare class SqliteServerStorage implements ServerStorage {
|
|
|
16
16
|
/** Internal: write a row + refresh its scope-index entries. */
|
|
17
17
|
writeRow(partition: string, table: string, row: StoredRow): void;
|
|
18
18
|
getMaxCommitSeq(partition: string): Promise<number>;
|
|
19
|
-
queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
|
19
|
+
queryAuthoritative(partition: string, query: AuthoritativeQueryRequest, checkpoints?: readonly CheckpointDeclaration[]): Promise<AuthoritativeQueryResult>;
|
|
20
20
|
getPartitionLogEpoch(partition: string): Promise<string | undefined>;
|
|
21
21
|
getHorizonSeq(partition: string): Promise<number>;
|
|
22
22
|
setHorizonSeq(partition: string, seq: number): Promise<void>;
|
|
23
23
|
pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
|
|
24
24
|
getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
|
|
25
|
+
readServeGate(partition: string, runningSchemaVersion: number): Promise<ServeGate>;
|
|
26
|
+
readCheckpoints(partition: string): Promise<StoredCheckpoint[]>;
|
|
27
|
+
declareCheckpoint(partition: string, name: string, schemaVersion: number, nowMs: number): Promise<StoredCheckpoint>;
|
|
28
|
+
activateCheckpoint(partition: string, name: string, ownerEpoch: number, watermark: number, sources: readonly string[], nowMs: number): Promise<'activated' | 'stale' | 'unverifiable'>;
|
|
29
|
+
claimCheckpoint(partition: string, name: string, schemaVersion: number, nowMs: number): Promise<StoredCheckpoint>;
|
|
30
|
+
advanceCheckpoint(partition: string, name: string, ownerEpoch: number, watermark: number, observedRows: number, nowMs: number): Promise<boolean>;
|
|
31
|
+
sourceCoverageSeq(partition: string, tables: readonly string[]): Promise<number>;
|
|
32
|
+
hasSourceChangesAbove(partition: string, tables: readonly string[], seq: number): Promise<'clean' | 'changed' | 'unverifiable'>;
|
|
25
33
|
getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
26
34
|
getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
|
|
27
35
|
claimReactions(partition: string, query: ReactionClaimQuery): Promise<StoredReaction[]>;
|