@syncular/server 0.20.1 → 0.21.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/dist/context.d.ts +2 -0
- package/dist/context.js +1 -0
- package/dist/d1-storage.js +27 -1
- package/dist/errors.js +16 -6
- package/dist/frame-bytes.js +16 -18
- package/dist/handler.js +0 -3
- package/dist/operations.js +2 -2
- package/dist/postgres-storage.d.ts +1 -1
- package/dist/postgres-storage.js +35 -5
- package/dist/push.js +371 -42
- package/dist/relational-rows.d.ts +17 -1
- package/dist/relational-rows.js +76 -7
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +55 -0
- package/dist/seed.js +2 -2
- package/dist/sqlite-dialect.d.ts +2 -1
- package/dist/sqlite-dialect.js +10 -0
- package/dist/sqlite-storage.js +23 -0
- package/dist/storage.d.ts +21 -0
- package/package.json +2 -2
- package/src/context.ts +3 -0
- package/src/d1-storage.ts +40 -1
- package/src/errors.ts +16 -6
- package/src/frame-bytes.ts +16 -22
- package/src/handler.ts +0 -6
- package/src/operations.ts +2 -2
- package/src/postgres-storage.ts +51 -5
- package/src/push.ts +516 -41
- package/src/relational-rows.ts +82 -6
- package/src/schema.ts +109 -0
- package/src/seed.ts +2 -2
- package/src/sqlite-dialect.ts +12 -0
- package/src/sqlite-storage.ts +36 -0
- package/src/storage.ts +21 -0
package/src/frame-bytes.ts
CHANGED
|
@@ -18,14 +18,11 @@ import {
|
|
|
18
18
|
type SubStartFrame,
|
|
19
19
|
} from '@syncular/core';
|
|
20
20
|
|
|
21
|
-
const stubHeader = (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
resetRequired: false,
|
|
27
|
-
}
|
|
28
|
-
: { type: 'RESP_HEADER' };
|
|
21
|
+
const stubHeader = (): RespHeaderFrame => ({
|
|
22
|
+
type: 'RESP_HEADER',
|
|
23
|
+
logEpoch: 'frame-probe',
|
|
24
|
+
resetRequired: false,
|
|
25
|
+
});
|
|
29
26
|
const STUB_SUB_START: SubStartFrame = {
|
|
30
27
|
type: 'SUB_START',
|
|
31
28
|
id: '',
|
|
@@ -37,9 +34,9 @@ const STUB_SUB_START: SubStartFrame = {
|
|
|
37
34
|
const STUB_SUB_END: SubEndFrame = { type: 'SUB_END', nextCursor: 0 };
|
|
38
35
|
|
|
39
36
|
const probe = encodeMessage({
|
|
40
|
-
wireVersion:
|
|
37
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
41
38
|
msgKind: 'response',
|
|
42
|
-
frames: [stubHeader(
|
|
39
|
+
frames: [stubHeader()],
|
|
43
40
|
});
|
|
44
41
|
|
|
45
42
|
/** The 8-byte SSP2 response envelope header (§1.2). */
|
|
@@ -47,7 +44,7 @@ export function responseEnvelopeHeader(wireVersion: number): Uint8Array {
|
|
|
47
44
|
const encoded = encodeMessage({
|
|
48
45
|
wireVersion,
|
|
49
46
|
msgKind: 'response',
|
|
50
|
-
frames: [stubHeader(
|
|
47
|
+
frames: [stubHeader()],
|
|
51
48
|
});
|
|
52
49
|
return encoded.slice(0, 8);
|
|
53
50
|
}
|
|
@@ -55,10 +52,7 @@ export function responseEnvelopeHeader(wireVersion: number): Uint8Array {
|
|
|
55
52
|
/** The terminating END frame (§1.2 rule 1). */
|
|
56
53
|
export const END_FRAME_BYTES: Uint8Array = probe.slice(probe.length - 5);
|
|
57
54
|
|
|
58
|
-
function wrapperFor(
|
|
59
|
-
frame: ResponseFrame,
|
|
60
|
-
wireVersion: number,
|
|
61
|
-
): {
|
|
55
|
+
function wrapperFor(frame: ResponseFrame): {
|
|
62
56
|
frames: ResponseFrame[];
|
|
63
57
|
index: number;
|
|
64
58
|
} {
|
|
@@ -67,11 +61,11 @@ function wrapperFor(
|
|
|
67
61
|
return { frames: [frame], index: 0 };
|
|
68
62
|
case 'LEASE':
|
|
69
63
|
// §7.3.2: LEASE rides immediately after RESP_HEADER.
|
|
70
|
-
return { frames: [stubHeader(
|
|
64
|
+
return { frames: [stubHeader(), frame], index: 1 };
|
|
71
65
|
case 'PUSH_RESULT':
|
|
72
66
|
case 'ERROR':
|
|
73
67
|
case 'UNKNOWN':
|
|
74
|
-
return { frames: [stubHeader(
|
|
68
|
+
return { frames: [stubHeader(), frame], index: 1 };
|
|
75
69
|
case 'PUSH_RESULT_DETAILS': {
|
|
76
70
|
const result: PushResultFrame = {
|
|
77
71
|
type: 'PUSH_RESULT',
|
|
@@ -85,23 +79,23 @@ function wrapperFor(
|
|
|
85
79
|
retryable: false,
|
|
86
80
|
})),
|
|
87
81
|
};
|
|
88
|
-
return { frames: [stubHeader(
|
|
82
|
+
return { frames: [stubHeader(), result, frame], index: 2 };
|
|
89
83
|
}
|
|
90
84
|
case 'SUB_START':
|
|
91
85
|
return {
|
|
92
|
-
frames: [stubHeader(
|
|
86
|
+
frames: [stubHeader(), frame, STUB_SUB_END],
|
|
93
87
|
index: 1,
|
|
94
88
|
};
|
|
95
89
|
case 'SUB_END':
|
|
96
90
|
return {
|
|
97
|
-
frames: [stubHeader(
|
|
91
|
+
frames: [stubHeader(), STUB_SUB_START, frame],
|
|
98
92
|
index: 2,
|
|
99
93
|
};
|
|
100
94
|
case 'COMMIT':
|
|
101
95
|
case 'SEGMENT_REF':
|
|
102
96
|
case 'SEGMENT_INLINE':
|
|
103
97
|
return {
|
|
104
|
-
frames: [stubHeader(
|
|
98
|
+
frames: [stubHeader(), STUB_SUB_START, frame, STUB_SUB_END],
|
|
105
99
|
index: 2,
|
|
106
100
|
};
|
|
107
101
|
}
|
|
@@ -115,7 +109,7 @@ export function encodeResponseFrame(
|
|
|
115
109
|
frame: ResponseFrame,
|
|
116
110
|
wireVersion = PROTOCOL_WIRE_VERSION,
|
|
117
111
|
): Uint8Array {
|
|
118
|
-
const { frames, index } = wrapperFor(frame
|
|
112
|
+
const { frames, index } = wrapperFor(frame);
|
|
119
113
|
const encoded = encodeMessage({
|
|
120
114
|
wireVersion,
|
|
121
115
|
msgKind: 'response',
|
package/src/handler.ts
CHANGED
|
@@ -211,12 +211,6 @@ async function planRequest(
|
|
|
211
211
|
else if (frame.type === 'SUBSCRIPTION') subFrames.push(frame);
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
if (request.wireVersion === 1 && registry.epochRequired) {
|
|
215
|
-
throw syncError(
|
|
216
|
-
'sync.client_wire_unsupported',
|
|
217
|
-
'this partition requires a client that validates log epochs (§2.1)',
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
214
|
if (
|
|
221
215
|
request.wireVersion >= 2 &&
|
|
222
216
|
header.logEpoch === undefined &&
|
package/src/operations.ts
CHANGED
|
@@ -5,8 +5,8 @@ import {
|
|
|
5
5
|
import {
|
|
6
6
|
decodeRow,
|
|
7
7
|
decodeRemoteOperationRequest,
|
|
8
|
-
encodeRow,
|
|
9
8
|
encodeRemoteOperationResponse,
|
|
9
|
+
encodeSparseRow,
|
|
10
10
|
type PushOperation,
|
|
11
11
|
type RemoteOperationResponse,
|
|
12
12
|
type RowValue,
|
|
@@ -526,7 +526,7 @@ function commandOperations(
|
|
|
526
526
|
table: table.name,
|
|
527
527
|
rowId,
|
|
528
528
|
op: 'upsert',
|
|
529
|
-
payload:
|
|
529
|
+
payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
|
|
530
530
|
};
|
|
531
531
|
});
|
|
532
532
|
}
|
package/src/postgres-storage.ts
CHANGED
|
@@ -213,6 +213,11 @@ CREATE TABLE IF NOT EXISTS sync_blob_refs(
|
|
|
213
213
|
);
|
|
214
214
|
CREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob
|
|
215
215
|
ON sync_blob_refs(partition, blob_id);
|
|
216
|
+
CREATE TABLE IF NOT EXISTS sync_tombstones(
|
|
217
|
+
partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
218
|
+
commit_seq BIGINT NOT NULL,
|
|
219
|
+
PRIMARY KEY(partition, tbl, row_id)
|
|
220
|
+
);
|
|
216
221
|
`;
|
|
217
222
|
|
|
218
223
|
interface SerializedResult {
|
|
@@ -222,6 +227,7 @@ interface SerializedResult {
|
|
|
222
227
|
message?: string;
|
|
223
228
|
serverVersion?: number;
|
|
224
229
|
serverRow?: string;
|
|
230
|
+
conflictColumns?: string;
|
|
225
231
|
retryable?: boolean;
|
|
226
232
|
details?: import('@syncular/core').RejectionDetails;
|
|
227
233
|
}
|
|
@@ -325,6 +331,7 @@ function serializePushResult(result: StoredPushResult): unknown {
|
|
|
325
331
|
message: record.message,
|
|
326
332
|
serverVersion: record.serverVersion,
|
|
327
333
|
serverRow: toBase64(record.serverRow),
|
|
334
|
+
conflictColumns: toBase64(record.conflictColumns),
|
|
328
335
|
};
|
|
329
336
|
}
|
|
330
337
|
if (record.status === 'error') {
|
|
@@ -359,6 +366,7 @@ function deserializePushResult(value: unknown): StoredPushResult {
|
|
|
359
366
|
message: record.message ?? '',
|
|
360
367
|
serverVersion: record.serverVersion ?? 0,
|
|
361
368
|
serverRow: fromBase64(record.serverRow ?? ''),
|
|
369
|
+
conflictColumns: fromBase64(record.conflictColumns ?? ''),
|
|
362
370
|
};
|
|
363
371
|
}
|
|
364
372
|
if (record.status === 'error') {
|
|
@@ -400,6 +408,7 @@ interface RowRecord {
|
|
|
400
408
|
server_version: unknown;
|
|
401
409
|
scopes: unknown;
|
|
402
410
|
payload: unknown;
|
|
411
|
+
column_versions: unknown;
|
|
403
412
|
}
|
|
404
413
|
|
|
405
414
|
interface ChangeRecord {
|
|
@@ -434,6 +443,9 @@ function toStoredRow(record: RowRecord): StoredRow {
|
|
|
434
443
|
serverVersion: asNumber(record.server_version),
|
|
435
444
|
scopes: asJson<Record<string, string>>(record.scopes),
|
|
436
445
|
payload: asBytes(record.payload),
|
|
446
|
+
...(record.column_versions === null || record.column_versions === undefined
|
|
447
|
+
? {}
|
|
448
|
+
: { columnVersions: asBytes(record.column_versions) }),
|
|
437
449
|
};
|
|
438
450
|
}
|
|
439
451
|
|
|
@@ -610,6 +622,27 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
610
622
|
);
|
|
611
623
|
}
|
|
612
624
|
|
|
625
|
+
async getTombstoneSeq(
|
|
626
|
+
table: string,
|
|
627
|
+
rowId: string,
|
|
628
|
+
): Promise<number | undefined> {
|
|
629
|
+
this.#assertOpen();
|
|
630
|
+
const { rows } = await this.#client.query<{ commit_seq: unknown }>(
|
|
631
|
+
'SELECT commit_seq FROM sync_tombstones WHERE partition=$1 AND tbl=$2 AND row_id=$3',
|
|
632
|
+
[this.#partition, table, rowId],
|
|
633
|
+
);
|
|
634
|
+
const seq = rows[0]?.commit_seq;
|
|
635
|
+
return seq === null || seq === undefined ? undefined : asNumber(seq);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async clearTombstone(table: string, rowId: string): Promise<void> {
|
|
639
|
+
this.#assertOpen();
|
|
640
|
+
await this.#client.query(
|
|
641
|
+
'DELETE FROM sync_tombstones WHERE partition=$1 AND tbl=$2 AND row_id=$3',
|
|
642
|
+
[this.#partition, table, rowId],
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
613
646
|
getPushResult(
|
|
614
647
|
clientId: string,
|
|
615
648
|
clientCommitId: string,
|
|
@@ -784,16 +817,24 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
784
817
|
if (change === undefined) continue;
|
|
785
818
|
// Bind serialized scopes as text before parsing JSONB. Drivers that
|
|
786
819
|
// encode JSONB parameters would otherwise store this string as a scalar.
|
|
820
|
+
// One statement per change: scopes AND the §5 delete tombstone ride the
|
|
821
|
+
// same writable-CTE chain.
|
|
787
822
|
await q.query(
|
|
788
823
|
`WITH inserted AS (
|
|
789
824
|
INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
|
|
790
825
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::text::jsonb,$9)
|
|
791
|
-
RETURNING partition, tbl, commit_seq, scopes
|
|
826
|
+
RETURNING partition, tbl, row_id, op, commit_seq, scopes
|
|
827
|
+
),
|
|
828
|
+
scoped AS (
|
|
829
|
+
INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
|
|
830
|
+
SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
|
|
831
|
+
FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
|
|
832
|
+
ON CONFLICT DO NOTHING
|
|
792
833
|
)
|
|
793
|
-
INSERT INTO
|
|
794
|
-
SELECT
|
|
795
|
-
|
|
796
|
-
|
|
834
|
+
INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
|
|
835
|
+
SELECT partition, tbl, row_id, commit_seq FROM inserted WHERE op = 2
|
|
836
|
+
ON CONFLICT (partition, tbl, row_id)
|
|
837
|
+
DO UPDATE SET commit_seq = excluded.commit_seq`,
|
|
797
838
|
[
|
|
798
839
|
p,
|
|
799
840
|
commitSeq,
|
|
@@ -1258,6 +1299,11 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1258
1299
|
'DELETE FROM sync_change_scopes WHERE partition=$1 AND commit_seq<=$2',
|
|
1259
1300
|
[partition, horizonSeq],
|
|
1260
1301
|
);
|
|
1302
|
+
// §4.6: tombstones prune in the same pass as the commit log.
|
|
1303
|
+
await client.query(
|
|
1304
|
+
'DELETE FROM sync_tombstones WHERE partition=$1 AND commit_seq<=$2',
|
|
1305
|
+
[partition, horizonSeq],
|
|
1306
|
+
);
|
|
1261
1307
|
return {
|
|
1262
1308
|
previousHorizonSeq,
|
|
1263
1309
|
horizonSeq,
|