@syncular/server 0.15.47 → 0.16.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 +48 -5
- package/dist/admin.d.ts +1 -5
- package/dist/admin.js +3 -12
- package/dist/authoritative-query.d.ts +14 -6
- package/dist/authoritative-query.js +60 -82
- package/dist/blob-handlers.js +4 -1
- package/dist/context.d.ts +3 -1
- package/dist/context.js +4 -0
- package/dist/d1-storage.d.ts +8 -2
- package/dist/d1-storage.js +134 -26
- package/dist/errors.js +6 -0
- package/dist/events.d.ts +2 -1
- package/dist/frame-bytes.d.ts +2 -2
- package/dist/frame-bytes.js +33 -14
- package/dist/handler.js +58 -14
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/operations.d.ts +2 -0
- package/dist/operations.js +25 -5
- package/dist/postgres-storage.d.ts +9 -3
- package/dist/postgres-storage.js +110 -20
- package/dist/prune.d.ts +3 -1
- package/dist/prune.js +18 -14
- package/dist/pull.d.ts +1 -1
- package/dist/pull.js +74 -37
- package/dist/push.js +5 -5
- package/dist/realtime.d.ts +4 -1
- package/dist/realtime.js +33 -9
- package/dist/restore.d.ts +13 -0
- package/dist/restore.js +13 -0
- package/dist/s3-segment-store.js +10 -1
- package/dist/seed.js +36 -4
- package/dist/segment-download.js +5 -2
- package/dist/segment-store.d.ts +3 -0
- package/dist/segment-store.js +1 -0
- package/dist/sqlite-bun-driver.d.ts +2 -1
- package/dist/sqlite-bun-driver.js +5 -2
- package/dist/sqlite-bun.js +2 -2
- package/dist/sqlite-dialect.d.ts +1 -1
- package/dist/sqlite-dialect.js +7 -0
- package/dist/sqlite-image.d.ts +3 -3
- package/dist/sqlite-image.js +10 -6
- package/dist/sqlite-node.js +2 -2
- package/dist/sqlite-segment-store.js +14 -5
- package/dist/sqlite-storage.d.ts +8 -2
- package/dist/sqlite-storage.js +147 -36
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +3 -0
- package/dist/storage.d.ts +38 -10
- package/package.json +2 -2
- package/src/admin.ts +7 -15
- package/src/authoritative-query.ts +89 -94
- package/src/blob-handlers.ts +8 -1
- package/src/context.ts +16 -1
- package/src/d1-storage.ts +193 -29
- package/src/errors.ts +6 -0
- package/src/events.ts +2 -1
- package/src/frame-bytes.ts +40 -14
- package/src/handler.ts +102 -29
- package/src/index.ts +1 -0
- package/src/operations.ts +37 -4
- package/src/postgres-storage.ts +184 -38
- package/src/prune.ts +29 -15
- package/src/pull.ts +90 -41
- package/src/push.ts +5 -5
- package/src/realtime.ts +46 -7
- package/src/restore.ts +28 -0
- package/src/s3-segment-store.ts +10 -1
- package/src/seed.ts +46 -4
- package/src/segment-download.ts +11 -2
- package/src/segment-store.ts +4 -0
- package/src/sqlite-bun-driver.ts +6 -2
- package/src/sqlite-bun.ts +2 -2
- package/src/sqlite-dialect.ts +7 -0
- package/src/sqlite-image.ts +26 -15
- package/src/sqlite-node.ts +2 -2
- package/src/sqlite-segment-store.ts +18 -4
- package/src/sqlite-storage.ts +203 -39
- package/src/storage-errors.ts +10 -1
- package/src/storage.ts +57 -10
package/src/realtime.ts
CHANGED
|
@@ -31,7 +31,11 @@ import {
|
|
|
31
31
|
type WakeReason,
|
|
32
32
|
} from '@syncular/core';
|
|
33
33
|
import type { SyncRequestContext, SyncServerConfig } from './context';
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
REMOTE_COMMAND_CLIENT_ID_PREFIX,
|
|
36
|
+
RESOLVER_OUTAGE,
|
|
37
|
+
touchAuthenticatedPartition,
|
|
38
|
+
} from './context';
|
|
35
39
|
import { SyncError, syncError } from './errors';
|
|
36
40
|
import { emitEvent, type SyncularServerEvents } from './events';
|
|
37
41
|
import { createSyncResponseStream } from './handler';
|
|
@@ -215,6 +219,9 @@ export class RealtimeSession {
|
|
|
215
219
|
readonly partition: string;
|
|
216
220
|
readonly actorId: string;
|
|
217
221
|
readonly clientId: string;
|
|
222
|
+
readonly logEpoch: string;
|
|
223
|
+
/** Response/delta layout selected by the most recent socket round. */
|
|
224
|
+
wireVersion: number;
|
|
218
225
|
/** Highest contiguously applied commitSeq acknowledged by the client. */
|
|
219
226
|
cursor: number;
|
|
220
227
|
/** Suppress deltas until the client catches up via pull + ack (§8.2). */
|
|
@@ -256,12 +263,16 @@ export class RealtimeSession {
|
|
|
256
263
|
clock: () => number,
|
|
257
264
|
maxDeltaBytes: number,
|
|
258
265
|
storage: ServerStorage,
|
|
266
|
+
logEpoch: string,
|
|
267
|
+
wireVersion: number,
|
|
259
268
|
events: SyncularServerEvents | undefined,
|
|
260
269
|
) {
|
|
261
270
|
this.sessionId = crypto.randomUUID();
|
|
262
271
|
this.partition = options.partition;
|
|
263
272
|
this.actorId = options.actorId;
|
|
264
273
|
this.clientId = options.clientId;
|
|
274
|
+
this.logEpoch = logEpoch;
|
|
275
|
+
this.wireVersion = wireVersion;
|
|
265
276
|
this.cursor = cursor;
|
|
266
277
|
this.lastKnownSeq = latestSeq;
|
|
267
278
|
this.wakePending = cursor < latestSeq;
|
|
@@ -515,6 +526,11 @@ export class RealtimeSession {
|
|
|
515
526
|
if (this.#activeRound === token) this.#activeRound = undefined;
|
|
516
527
|
};
|
|
517
528
|
try {
|
|
529
|
+
const requestedWireVersion =
|
|
530
|
+
(requestBytes[4] ?? 0) | ((requestBytes[5] ?? 0) << 8);
|
|
531
|
+
if (requestedWireVersion === 1 || requestedWireVersion === 2) {
|
|
532
|
+
this.wireVersion = requestedWireVersion;
|
|
533
|
+
}
|
|
518
534
|
let stream: AsyncIterable<Uint8Array> | undefined;
|
|
519
535
|
try {
|
|
520
536
|
// §8.7: the round's clientId must match the connection's —
|
|
@@ -543,7 +559,9 @@ export class RealtimeSession {
|
|
|
543
559
|
? error
|
|
544
560
|
: syncError(error.code, error.message);
|
|
545
561
|
finishRound(); // END is in this one chunk
|
|
546
|
-
await this.#sendRoundChunk(
|
|
562
|
+
await this.#sendRoundChunk(
|
|
563
|
+
errorResponseBytes(sync, this.wireVersion, this.logEpoch),
|
|
564
|
+
);
|
|
547
565
|
return;
|
|
548
566
|
}
|
|
549
567
|
throw error;
|
|
@@ -712,7 +730,14 @@ export class RealtimeSession {
|
|
|
712
730
|
this.sendWake('catchup-required');
|
|
713
731
|
return;
|
|
714
732
|
}
|
|
715
|
-
const frames: ResponseFrame[] = [
|
|
733
|
+
const frames: ResponseFrame[] = [
|
|
734
|
+
{
|
|
735
|
+
type: 'RESP_HEADER',
|
|
736
|
+
...(this.wireVersion >= 2
|
|
737
|
+
? { logEpoch: this.logEpoch, resetRequired: false }
|
|
738
|
+
: {}),
|
|
739
|
+
},
|
|
740
|
+
];
|
|
716
741
|
for (const section of sections) {
|
|
717
742
|
frames.push({
|
|
718
743
|
type: 'SUB_START',
|
|
@@ -733,7 +758,7 @@ export class RealtimeSession {
|
|
|
733
758
|
frames.push({ type: 'SUB_END', nextCursor: commit.commitSeq });
|
|
734
759
|
}
|
|
735
760
|
const bytes = encodeMessage({
|
|
736
|
-
wireVersion:
|
|
761
|
+
wireVersion: this.wireVersion,
|
|
737
762
|
msgKind: 'response',
|
|
738
763
|
frames,
|
|
739
764
|
});
|
|
@@ -963,6 +988,11 @@ export class RealtimeHub {
|
|
|
963
988
|
async connect(options: RealtimeConnectOptions): Promise<RealtimeSession> {
|
|
964
989
|
const { storage } = this.#config;
|
|
965
990
|
const clock = this.#config.clock ?? Date.now;
|
|
991
|
+
const registry = await touchAuthenticatedPartition({
|
|
992
|
+
...this.#config,
|
|
993
|
+
partition: options.partition,
|
|
994
|
+
actorId: options.actorId,
|
|
995
|
+
});
|
|
966
996
|
if (options.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
|
|
967
997
|
throw syncError(
|
|
968
998
|
'sync.invalid_client_id',
|
|
@@ -995,6 +1025,8 @@ export class RealtimeHub {
|
|
|
995
1025
|
clock,
|
|
996
1026
|
this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES,
|
|
997
1027
|
storage,
|
|
1028
|
+
registry.logEpoch,
|
|
1029
|
+
record?.wireVersion ?? PROTOCOL_WIRE_VERSION,
|
|
998
1030
|
this.#config.events,
|
|
999
1031
|
);
|
|
1000
1032
|
this.#sessions.add(session);
|
|
@@ -1079,12 +1111,19 @@ export function createRealtimeHub(config: RealtimeHubConfig): RealtimeHub {
|
|
|
1079
1111
|
/** §8.7 failures: the socket has no HTTP status surface, so a
|
|
1080
1112
|
* request-level failure becomes a minimal RESP_HEADER/ERROR/END
|
|
1081
1113
|
* response message delivered as the round's response stream. */
|
|
1082
|
-
function errorResponseBytes(
|
|
1114
|
+
function errorResponseBytes(
|
|
1115
|
+
error: SyncError,
|
|
1116
|
+
wireVersion: number,
|
|
1117
|
+
logEpoch: string,
|
|
1118
|
+
): Uint8Array {
|
|
1083
1119
|
return encodeMessage({
|
|
1084
|
-
wireVersion
|
|
1120
|
+
wireVersion,
|
|
1085
1121
|
msgKind: 'response',
|
|
1086
1122
|
frames: [
|
|
1087
|
-
{
|
|
1123
|
+
{
|
|
1124
|
+
type: 'RESP_HEADER',
|
|
1125
|
+
...(wireVersion >= 2 ? { logEpoch, resetRequired: false } : {}),
|
|
1126
|
+
},
|
|
1088
1127
|
{
|
|
1089
1128
|
type: 'ERROR',
|
|
1090
1129
|
code: error.code,
|
package/src/restore.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PartitionRegistryEntry, ServerStorage } from './storage';
|
|
2
|
+
|
|
3
|
+
export interface RotatePartitionLogEpochOptions {
|
|
4
|
+
readonly storage: ServerStorage;
|
|
5
|
+
readonly partition: string;
|
|
6
|
+
readonly nowMs?: number;
|
|
7
|
+
/** Deterministic operator/test override. Defaults to a random UUID. */
|
|
8
|
+
readonly logEpoch?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fence clients from a prior authoritative database timeline after restore.
|
|
13
|
+
* Call while traffic and realtime delivery remain stopped (§2.1).
|
|
14
|
+
*/
|
|
15
|
+
export function rotatePartitionLogEpoch(
|
|
16
|
+
options: RotatePartitionLogEpochOptions,
|
|
17
|
+
): Promise<PartitionRegistryEntry> {
|
|
18
|
+
if (options.partition.length === 0) {
|
|
19
|
+
throw new TypeError('partition must be non-empty');
|
|
20
|
+
}
|
|
21
|
+
const logEpoch = options.logEpoch ?? crypto.randomUUID();
|
|
22
|
+
if (logEpoch.length === 0) throw new TypeError('logEpoch must be non-empty');
|
|
23
|
+
return options.storage.rotatePartitionLogEpoch(
|
|
24
|
+
options.partition,
|
|
25
|
+
logEpoch,
|
|
26
|
+
options.nowMs ?? Date.now(),
|
|
27
|
+
);
|
|
28
|
+
}
|
package/src/s3-segment-store.ts
CHANGED
|
@@ -152,7 +152,13 @@ function parseRecordJson(json: string, source: string): SegmentRecord {
|
|
|
152
152
|
throw new Error(`S3SegmentStore: corrupt record in ${source}`);
|
|
153
153
|
}
|
|
154
154
|
const r = parsed as Record<string, unknown>;
|
|
155
|
-
const strings = [
|
|
155
|
+
const strings = [
|
|
156
|
+
'segmentId',
|
|
157
|
+
'partition',
|
|
158
|
+
'logEpoch',
|
|
159
|
+
'table',
|
|
160
|
+
'scopeDigest',
|
|
161
|
+
] as const;
|
|
156
162
|
const numbers = [
|
|
157
163
|
'schemaVersion',
|
|
158
164
|
'asOfCommitSeq',
|
|
@@ -182,6 +188,7 @@ function parseRecordJson(json: string, source: string): SegmentRecord {
|
|
|
182
188
|
return {
|
|
183
189
|
segmentId: r.segmentId as string,
|
|
184
190
|
partition: r.partition as string,
|
|
191
|
+
logEpoch: r.logEpoch as string,
|
|
185
192
|
table: r.table as string,
|
|
186
193
|
schemaVersion: r.schemaVersion as number,
|
|
187
194
|
mediaType: r.mediaType,
|
|
@@ -223,6 +230,7 @@ export class S3SegmentStore implements SegmentStore {
|
|
|
223
230
|
async #findKeyFor(key: SegmentFindKey): Promise<string> {
|
|
224
231
|
const canonical = JSON.stringify([
|
|
225
232
|
key.partition,
|
|
233
|
+
key.logEpoch,
|
|
226
234
|
key.table,
|
|
227
235
|
key.schemaVersion,
|
|
228
236
|
key.mediaType,
|
|
@@ -431,6 +439,7 @@ export class S3SegmentStore implements SegmentStore {
|
|
|
431
439
|
const record = parseRecordJson(await response.text(), 'reuse pointer');
|
|
432
440
|
if (
|
|
433
441
|
record.partition !== key.partition ||
|
|
442
|
+
record.logEpoch !== key.logEpoch ||
|
|
434
443
|
record.table !== key.table ||
|
|
435
444
|
record.schemaVersion !== key.schemaVersion ||
|
|
436
445
|
record.mediaType !== key.mediaType ||
|
package/src/seed.ts
CHANGED
|
@@ -186,8 +186,52 @@ export async function seedMutations(
|
|
|
186
186
|
};
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
+
const requestContext = {
|
|
190
|
+
...config,
|
|
191
|
+
partition: target.partition,
|
|
192
|
+
actorId: target.actorId,
|
|
193
|
+
};
|
|
194
|
+
const acquisition = decodeMessage(
|
|
195
|
+
await handleSyncRequest(
|
|
196
|
+
encodeMessage({
|
|
197
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
198
|
+
msgKind: 'request',
|
|
199
|
+
frames: [
|
|
200
|
+
{
|
|
201
|
+
type: 'REQ_HEADER',
|
|
202
|
+
clientId,
|
|
203
|
+
schemaVersion: config.schema.version,
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
type: 'PULL_HEADER',
|
|
207
|
+
limitCommits: 0,
|
|
208
|
+
limitSnapshotRows: 0,
|
|
209
|
+
maxSnapshotPages: 0,
|
|
210
|
+
accept: 0b0011,
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
}),
|
|
214
|
+
requestContext,
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
const acquisitionHeader = acquisition.frames[0];
|
|
218
|
+
if (
|
|
219
|
+
acquisition.msgKind !== 'response' ||
|
|
220
|
+
acquisitionHeader?.type !== 'RESP_HEADER' ||
|
|
221
|
+
acquisitionHeader.logEpoch === undefined
|
|
222
|
+
) {
|
|
223
|
+
throw new SyncError(
|
|
224
|
+
'sync.invalid_request',
|
|
225
|
+
'seedMutations: epoch acquisition returned an invalid response',
|
|
226
|
+
);
|
|
227
|
+
}
|
|
189
228
|
const frames: RequestFrame[] = [
|
|
190
|
-
{
|
|
229
|
+
{
|
|
230
|
+
type: 'REQ_HEADER',
|
|
231
|
+
clientId,
|
|
232
|
+
schemaVersion: config.schema.version,
|
|
233
|
+
logEpoch: acquisitionHeader.logEpoch,
|
|
234
|
+
},
|
|
191
235
|
{ type: 'PUSH_COMMIT', clientCommitId, operations },
|
|
192
236
|
// A pull that asks for nothing: the round exists for its push half.
|
|
193
237
|
{
|
|
@@ -221,9 +265,7 @@ export async function seedMutations(
|
|
|
221
265
|
frames,
|
|
222
266
|
}),
|
|
223
267
|
{
|
|
224
|
-
...
|
|
225
|
-
partition: target.partition,
|
|
226
|
-
actorId: target.actorId,
|
|
268
|
+
...requestContext,
|
|
227
269
|
events:
|
|
228
270
|
config.events === undefined
|
|
229
271
|
? capture
|
package/src/segment-download.ts
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { ScopeMap } from '@syncular/core';
|
|
11
11
|
import type { SyncRequestContext } from './context';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
clockOf,
|
|
14
|
+
RESOLVER_OUTAGE,
|
|
15
|
+
touchAuthenticatedPartition,
|
|
16
|
+
} from './context';
|
|
13
17
|
import { SyncError, syncError } from './errors';
|
|
14
18
|
import { emitEvent } from './events';
|
|
15
19
|
import { compileSchema } from './schema';
|
|
@@ -96,8 +100,13 @@ async function downloadSegment(
|
|
|
96
100
|
ctx: SyncRequestContext,
|
|
97
101
|
request: SegmentDownloadRequest,
|
|
98
102
|
): Promise<SegmentDownloadResult> {
|
|
103
|
+
const registry = await touchAuthenticatedPartition(ctx);
|
|
99
104
|
const entry = await ctx.segments.get(request.segmentId);
|
|
100
|
-
if (
|
|
105
|
+
if (
|
|
106
|
+
entry === undefined ||
|
|
107
|
+
entry.record.partition !== ctx.partition ||
|
|
108
|
+
entry.record.logEpoch !== registry.logEpoch
|
|
109
|
+
) {
|
|
101
110
|
throw syncError('sync.not_found', 'unknown segment (§5.5)');
|
|
102
111
|
}
|
|
103
112
|
if (entry.record.expiresAtMs <= clockOf(ctx)()) {
|
package/src/segment-store.ts
CHANGED
|
@@ -12,6 +12,8 @@ export const DEFAULT_SEGMENT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
12
12
|
|
|
13
13
|
export interface SegmentMetadata {
|
|
14
14
|
readonly partition: string;
|
|
15
|
+
/** Partition log continuity that produced these bytes (§2.1). */
|
|
16
|
+
readonly logEpoch: string;
|
|
15
17
|
readonly table: string;
|
|
16
18
|
readonly schemaVersion: number;
|
|
17
19
|
readonly mediaType: 'rows' | 'sqlite';
|
|
@@ -36,6 +38,7 @@ export interface SegmentRecord extends SegmentMetadata {
|
|
|
36
38
|
*/
|
|
37
39
|
export interface SegmentFindKey {
|
|
38
40
|
readonly partition: string;
|
|
41
|
+
readonly logEpoch: string;
|
|
39
42
|
readonly table: string;
|
|
40
43
|
readonly schemaVersion: number;
|
|
41
44
|
readonly mediaType: 'rows' | 'sqlite';
|
|
@@ -126,6 +129,7 @@ export class MemorySegmentStore implements SegmentStore {
|
|
|
126
129
|
for (const { record } of this.#entries.values()) {
|
|
127
130
|
if (
|
|
128
131
|
record.partition === key.partition &&
|
|
132
|
+
record.logEpoch === key.logEpoch &&
|
|
129
133
|
record.table === key.table &&
|
|
130
134
|
record.schemaVersion === key.schemaVersion &&
|
|
131
135
|
record.mediaType === key.mediaType &&
|
package/src/sqlite-bun-driver.ts
CHANGED
|
@@ -9,8 +9,12 @@ import type {
|
|
|
9
9
|
export class BunSqliteDatabase implements SqliteDatabase {
|
|
10
10
|
readonly native: Database;
|
|
11
11
|
|
|
12
|
-
constructor(
|
|
13
|
-
this.native = new Database(
|
|
12
|
+
constructor(value: string | Database = ':memory:') {
|
|
13
|
+
this.native = typeof value === 'string' ? new Database(value) : value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static deserialize(bytes: Uint8Array): BunSqliteDatabase {
|
|
17
|
+
return new BunSqliteDatabase(Database.deserialize(bytes));
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
exec(sql: string): void {
|
package/src/sqlite-bun.ts
CHANGED
|
@@ -40,10 +40,10 @@ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export const buildSqliteImage: SqliteImageBuilder = (input) => {
|
|
43
|
+
export const buildSqliteImage: SqliteImageBuilder = async (input) => {
|
|
44
44
|
const db = new BunSqliteDatabase();
|
|
45
45
|
try {
|
|
46
|
-
writeSqliteImage(db, input);
|
|
46
|
+
await writeSqliteImage(db, input);
|
|
47
47
|
return db.serialize();
|
|
48
48
|
} finally {
|
|
49
49
|
db.close();
|
package/src/sqlite-dialect.ts
CHANGED
|
@@ -43,6 +43,12 @@ CREATE TABLE IF NOT EXISTS sync_partitions(
|
|
|
43
43
|
max_commit_seq INTEGER NOT NULL DEFAULT 0,
|
|
44
44
|
horizon_seq INTEGER NOT NULL DEFAULT 0
|
|
45
45
|
);
|
|
46
|
+
CREATE TABLE IF NOT EXISTS sync_partition_registry(
|
|
47
|
+
partition TEXT PRIMARY KEY,
|
|
48
|
+
log_epoch TEXT NOT NULL,
|
|
49
|
+
epoch_required INTEGER NOT NULL DEFAULT 0,
|
|
50
|
+
last_authenticated_at_ms INTEGER NOT NULL
|
|
51
|
+
);
|
|
46
52
|
CREATE TABLE IF NOT EXISTS sync_row_scopes(
|
|
47
53
|
partition TEXT NOT NULL, tbl TEXT NOT NULL,
|
|
48
54
|
var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
@@ -96,6 +102,7 @@ CREATE INDEX IF NOT EXISTS sync_reactions_dead_letter
|
|
|
96
102
|
ON sync_reactions(partition, status, available_at_ms, idempotency_key);
|
|
97
103
|
CREATE TABLE IF NOT EXISTS sync_clients(
|
|
98
104
|
partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,
|
|
105
|
+
wire_version INTEGER NOT NULL DEFAULT 1,
|
|
99
106
|
cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,
|
|
100
107
|
updated_at_ms INTEGER NOT NULL,
|
|
101
108
|
PRIMARY KEY(partition, client_id)
|
package/src/sqlite-image.ts
CHANGED
|
@@ -57,7 +57,9 @@ export interface SqliteImageInput {
|
|
|
57
57
|
readonly schemaVersion: number;
|
|
58
58
|
readonly asOfCommitSeq: number;
|
|
59
59
|
readonly scopeDigest: string;
|
|
60
|
-
readonly
|
|
60
|
+
readonly rowBatches:
|
|
61
|
+
| AsyncIterable<readonly StoredRow[]>
|
|
62
|
+
| Iterable<readonly StoredRow[]>;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
/**
|
|
@@ -68,14 +70,16 @@ export interface SqliteImageInput {
|
|
|
68
70
|
* on the pull path. A Bun or Node host passes `buildSqliteImage`; a Workers
|
|
69
71
|
* host omits it and serves the rows lane.
|
|
70
72
|
*/
|
|
71
|
-
export type SqliteImageBuilder = (
|
|
73
|
+
export type SqliteImageBuilder = (
|
|
74
|
+
input: SqliteImageInput,
|
|
75
|
+
) => Promise<Uint8Array>;
|
|
72
76
|
|
|
73
77
|
/** Populate a §5.3 image database for a whole-table snapshot. */
|
|
74
|
-
export function writeSqliteImage(
|
|
78
|
+
export async function writeSqliteImage(
|
|
75
79
|
db: SqliteDatabase,
|
|
76
80
|
input: SqliteImageInput,
|
|
77
|
-
): void {
|
|
78
|
-
const { table,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
const { table, rowBatches } = input;
|
|
79
83
|
const primaryKey = table.columns[table.primaryKeyIndex]?.name;
|
|
80
84
|
const columnDefs = table.columns.map((column) => {
|
|
81
85
|
const notNull = column.nullable ? '' : ' NOT NULL';
|
|
@@ -90,13 +94,6 @@ export function writeSqliteImage(
|
|
|
90
94
|
"schemaVersion" INTEGER NOT NULL, "asOfCommitSeq" INTEGER NOT NULL,
|
|
91
95
|
"scopeDigest" TEXT NOT NULL, "rowCount" INTEGER NOT NULL)`,
|
|
92
96
|
);
|
|
93
|
-
db.query(`INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`).run(
|
|
94
|
-
table.name,
|
|
95
|
-
input.schemaVersion,
|
|
96
|
-
input.asOfCommitSeq,
|
|
97
|
-
input.scopeDigest,
|
|
98
|
-
rows.length,
|
|
99
|
-
);
|
|
100
97
|
const names = [
|
|
101
98
|
...table.columns.map((column) => quoteIdent(column.name)),
|
|
102
99
|
quoteIdent(IMAGE_VERSION_COLUMN),
|
|
@@ -107,10 +104,24 @@ export function writeSqliteImage(
|
|
|
107
104
|
);
|
|
108
105
|
db.exec('BEGIN');
|
|
109
106
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
let rowCount = 0;
|
|
108
|
+
for await (const rows of rowBatches) {
|
|
109
|
+
for (const row of rows) {
|
|
110
|
+
const values = decodeRow(table.columns, row.payload);
|
|
111
|
+
insert.run(...values.map(toSql), row.serverVersion);
|
|
112
|
+
}
|
|
113
|
+
rowCount += rows.length;
|
|
113
114
|
}
|
|
115
|
+
db.query(
|
|
116
|
+
`INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`,
|
|
117
|
+
).run(
|
|
118
|
+
table.name,
|
|
119
|
+
input.schemaVersion,
|
|
120
|
+
input.asOfCommitSeq,
|
|
121
|
+
input.scopeDigest,
|
|
122
|
+
rowCount,
|
|
123
|
+
);
|
|
124
|
+
|
|
114
125
|
db.exec('COMMIT');
|
|
115
126
|
} catch (error) {
|
|
116
127
|
db.exec('ROLLBACK');
|
package/src/sqlite-node.ts
CHANGED
|
@@ -43,13 +43,13 @@ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export const buildSqliteImage: SqliteImageBuilder = (input) => {
|
|
46
|
+
export const buildSqliteImage: SqliteImageBuilder = async (input) => {
|
|
47
47
|
const directory = mkdtempSync(join(tmpdir(), 'syncular-server-image-'));
|
|
48
48
|
const path = join(directory, 'segment.db');
|
|
49
49
|
const db = new NodeSqliteDatabase(path);
|
|
50
50
|
try {
|
|
51
51
|
try {
|
|
52
|
-
writeSqliteImage(db, input);
|
|
52
|
+
await writeSqliteImage(db, input);
|
|
53
53
|
} finally {
|
|
54
54
|
db.close();
|
|
55
55
|
}
|
|
@@ -31,6 +31,7 @@ export class SqliteSegmentStore implements SegmentStore {
|
|
|
31
31
|
this.db.exec(`
|
|
32
32
|
CREATE TABLE IF NOT EXISTS sync_segments(
|
|
33
33
|
segment_id TEXT PRIMARY KEY, partition TEXT NOT NULL,
|
|
34
|
+
log_epoch TEXT NOT NULL,
|
|
34
35
|
tbl TEXT NOT NULL, schema_version INTEGER NOT NULL,
|
|
35
36
|
media_type TEXT NOT NULL, scope_digest TEXT NOT NULL,
|
|
36
37
|
as_of_commit_seq INTEGER NOT NULL, row_count INTEGER NOT NULL,
|
|
@@ -39,6 +40,14 @@ export class SqliteSegmentStore implements SegmentStore {
|
|
|
39
40
|
expires_at_ms INTEGER NOT NULL, bytes BLOB NOT NULL
|
|
40
41
|
);
|
|
41
42
|
`);
|
|
43
|
+
const columns = this.db
|
|
44
|
+
.query<{ name: string }, []>('PRAGMA table_info(sync_segments)')
|
|
45
|
+
.all();
|
|
46
|
+
if (!columns.some((column) => column.name === 'log_epoch')) {
|
|
47
|
+
this.db.exec(
|
|
48
|
+
"ALTER TABLE sync_segments ADD COLUMN log_epoch TEXT NOT NULL DEFAULT ''",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
async put(
|
|
@@ -57,14 +66,15 @@ export class SqliteSegmentStore implements SegmentStore {
|
|
|
57
66
|
this.db
|
|
58
67
|
.query(
|
|
59
68
|
`INSERT OR REPLACE INTO sync_segments(
|
|
60
|
-
segment_id, partition, tbl, schema_version, media_type,
|
|
69
|
+
segment_id, partition, log_epoch, tbl, schema_version, media_type,
|
|
61
70
|
scope_digest, as_of_commit_seq, row_count, row_cursor,
|
|
62
71
|
next_row_cursor, byte_length, created_at_ms, expires_at_ms, bytes
|
|
63
|
-
) VALUES (
|
|
72
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
64
73
|
)
|
|
65
74
|
.run(
|
|
66
75
|
record.segmentId,
|
|
67
76
|
record.partition,
|
|
77
|
+
record.logEpoch,
|
|
68
78
|
record.table,
|
|
69
79
|
record.schemaVersion,
|
|
70
80
|
record.mediaType,
|
|
@@ -89,6 +99,7 @@ export class SqliteSegmentStore implements SegmentStore {
|
|
|
89
99
|
{
|
|
90
100
|
segment_id: string;
|
|
91
101
|
partition: string;
|
|
102
|
+
log_epoch: string;
|
|
92
103
|
tbl: string;
|
|
93
104
|
schema_version: number;
|
|
94
105
|
media_type: string;
|
|
@@ -110,6 +121,7 @@ export class SqliteSegmentStore implements SegmentStore {
|
|
|
110
121
|
record: {
|
|
111
122
|
segmentId: row.segment_id,
|
|
112
123
|
partition: row.partition,
|
|
124
|
+
logEpoch: row.log_epoch,
|
|
113
125
|
table: row.tbl,
|
|
114
126
|
schemaVersion: row.schema_version,
|
|
115
127
|
mediaType: row.media_type === 'sqlite' ? 'sqlite' : 'rows',
|
|
@@ -140,18 +152,19 @@ export class SqliteSegmentStore implements SegmentStore {
|
|
|
140
152
|
created_at_ms: number;
|
|
141
153
|
expires_at_ms: number;
|
|
142
154
|
},
|
|
143
|
-
[string, string, number, string, string, number, number]
|
|
155
|
+
[string, string, string, number, string, string, number, number]
|
|
144
156
|
>(
|
|
145
157
|
`SELECT segment_id, row_count, next_row_cursor, byte_length,
|
|
146
158
|
created_at_ms, expires_at_ms
|
|
147
159
|
FROM sync_segments
|
|
148
|
-
WHERE partition=? AND tbl=? AND schema_version=? AND media_type=?
|
|
160
|
+
WHERE partition=? AND log_epoch=? AND tbl=? AND schema_version=? AND media_type=?
|
|
149
161
|
AND scope_digest=? AND as_of_commit_seq=? AND row_cursor IS NULL
|
|
150
162
|
AND expires_at_ms > ?
|
|
151
163
|
LIMIT 1`,
|
|
152
164
|
)
|
|
153
165
|
.get(
|
|
154
166
|
key.partition,
|
|
167
|
+
key.logEpoch,
|
|
155
168
|
key.table,
|
|
156
169
|
key.schemaVersion,
|
|
157
170
|
key.mediaType,
|
|
@@ -163,6 +176,7 @@ export class SqliteSegmentStore implements SegmentStore {
|
|
|
163
176
|
return {
|
|
164
177
|
segmentId: row.segment_id,
|
|
165
178
|
partition: key.partition,
|
|
179
|
+
logEpoch: key.logEpoch,
|
|
166
180
|
table: key.table,
|
|
167
181
|
schemaVersion: key.schemaVersion,
|
|
168
182
|
mediaType: key.mediaType,
|