@syncular/server 0.20.1 → 0.21.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/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/dist/context.d.ts
CHANGED
|
@@ -61,6 +61,8 @@ export interface ServerLimits {
|
|
|
61
61
|
readonly maxOperationsPerRequest: number;
|
|
62
62
|
/** Inline rows segments up to this size (§5.7 SHOULD: 256 KiB). */
|
|
63
63
|
readonly inlineSegmentMaxBytes: number;
|
|
64
|
+
/** §6.11 declared-reference cascade cap per commit (reference default). */
|
|
65
|
+
readonly maxCascadeOperationsPerCommit: number;
|
|
64
66
|
}
|
|
65
67
|
export declare const DEFAULT_LIMITS: ServerLimits;
|
|
66
68
|
/** Receives applied commits for realtime fanout (§8.2). */
|
package/dist/context.js
CHANGED
|
@@ -14,6 +14,7 @@ export const RESOLVER_OUTAGE = Symbol.for('syncular.resolver_outage');
|
|
|
14
14
|
export const DEFAULT_LIMITS = {
|
|
15
15
|
maxOperationsPerRequest: 500,
|
|
16
16
|
inlineSegmentMaxBytes: 256 * 1024,
|
|
17
|
+
maxCascadeOperationsPerCommit: 1_000,
|
|
17
18
|
};
|
|
18
19
|
export function clockOf(ctx) {
|
|
19
20
|
return ctx.clock ?? Date.now;
|
package/dist/d1-storage.js
CHANGED
|
@@ -138,6 +138,21 @@ class D1Transaction {
|
|
|
138
138
|
.first();
|
|
139
139
|
return record === null ? undefined : toStoredRow(record);
|
|
140
140
|
}
|
|
141
|
+
async getTombstoneSeq(table, rowId) {
|
|
142
|
+
this.#assertOpen();
|
|
143
|
+
// Persisted read, no #pending overlay: a delete buffered by this commit
|
|
144
|
+
// records its tombstone at appendCommit, matching the SQLite/Postgres
|
|
145
|
+
// adapters where the tombstone insert also runs at commit append.
|
|
146
|
+
const record = await this.#db
|
|
147
|
+
.prepare('SELECT commit_seq FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?')
|
|
148
|
+
.bind(this.#partition, table, rowId)
|
|
149
|
+
.first();
|
|
150
|
+
return record?.commit_seq;
|
|
151
|
+
}
|
|
152
|
+
async clearTombstone(table, rowId) {
|
|
153
|
+
this.#assertOpen();
|
|
154
|
+
this.#buffer_('DELETE FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?', [this.#partition, table, rowId]);
|
|
155
|
+
}
|
|
141
156
|
async getPushResult(clientId, clientCommitId) {
|
|
142
157
|
this.#assertOpen();
|
|
143
158
|
// D1 reads run in autocommit; the push layer's duplicate re-check happens
|
|
@@ -405,6 +420,12 @@ class D1Transaction {
|
|
|
405
420
|
JSON.stringify(change.scopes),
|
|
406
421
|
change.payload ?? null,
|
|
407
422
|
]);
|
|
423
|
+
if (change.op === 'delete') {
|
|
424
|
+
// §5 delete precedence: every applied delete leaves a tombstone,
|
|
425
|
+
// pruned with the commit log (§4.6).
|
|
426
|
+
this.#buffer_(`INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
|
|
427
|
+
ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`, [p, change.table, change.rowId, commitSeq]);
|
|
428
|
+
}
|
|
408
429
|
for (const [variable, value] of Object.entries(change.scopes)) {
|
|
409
430
|
this.#buffer_('INSERT OR IGNORE INTO sync_change_scopes(partition, tbl, var, value, commit_seq) VALUES (?,?,?,?,?)', [p, change.table, variable, value, commitSeq]);
|
|
410
431
|
}
|
|
@@ -1024,7 +1045,12 @@ export class D1ServerStorage {
|
|
|
1024
1045
|
this.#db
|
|
1025
1046
|
.prepare(`SELECT horizon_seq, (SELECT count(*) FROM sync_commits WHERE partition=? AND commit_seq<=${horizon}) AS removed_commits FROM sync_partitions WHERE partition=?`)
|
|
1026
1047
|
.bind(partition, partition, partition),
|
|
1027
|
-
...[
|
|
1048
|
+
...[
|
|
1049
|
+
'sync_commits',
|
|
1050
|
+
'sync_changes',
|
|
1051
|
+
'sync_change_scopes',
|
|
1052
|
+
'sync_tombstones',
|
|
1053
|
+
].map((table) => this.#db
|
|
1028
1054
|
.prepare(`DELETE FROM ${table} WHERE partition=? AND commit_seq<=${horizon} AND ${epochGuard}`)
|
|
1029
1055
|
.bind(partition, partition, partition, query.logEpoch)),
|
|
1030
1056
|
]);
|
package/dist/errors.js
CHANGED
|
@@ -111,6 +111,22 @@ export const ERROR_CATALOG = {
|
|
|
111
111
|
recommendedAction: 'forceResync',
|
|
112
112
|
httpStatus: 404,
|
|
113
113
|
},
|
|
114
|
+
// §5 delete precedence: an unversioned upsert lost to a delete inside the
|
|
115
|
+
// tombstone horizon. The client drops the operation on rebuild.
|
|
116
|
+
'sync.row_deleted': {
|
|
117
|
+
category: 'not-found',
|
|
118
|
+
retryable: false,
|
|
119
|
+
recommendedAction: 'fixRequest',
|
|
120
|
+
httpStatus: 404,
|
|
121
|
+
},
|
|
122
|
+
// §6.11 declared references: absent parent, RESTRICT delete with live
|
|
123
|
+
// children, or the cascade cap. A push operation-result error only.
|
|
124
|
+
'sync.reference_violation': {
|
|
125
|
+
category: 'invalid-request',
|
|
126
|
+
retryable: false,
|
|
127
|
+
recommendedAction: 'fixRequest',
|
|
128
|
+
httpStatus: 400,
|
|
129
|
+
},
|
|
114
130
|
'sync.version_conflict': {
|
|
115
131
|
category: 'conflict',
|
|
116
132
|
retryable: false,
|
|
@@ -191,12 +207,6 @@ export const ERROR_CATALOG = {
|
|
|
191
207
|
recommendedAction: 'upgradeClient',
|
|
192
208
|
httpStatus: 400,
|
|
193
209
|
},
|
|
194
|
-
'sync.client_wire_unsupported': {
|
|
195
|
-
category: 'schema-mismatch',
|
|
196
|
-
retryable: false,
|
|
197
|
-
recommendedAction: 'upgradeClient',
|
|
198
|
-
httpStatus: 400,
|
|
199
|
-
},
|
|
200
210
|
'sync.websocket_connection_limit': {
|
|
201
211
|
category: 'rate-limited',
|
|
202
212
|
retryable: true,
|
package/dist/frame-bytes.js
CHANGED
|
@@ -9,13 +9,11 @@
|
|
|
9
9
|
* bytes; the reference codec remains the single source of wire bytes.
|
|
10
10
|
*/
|
|
11
11
|
import { encodeMessage, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
|
|
12
|
-
const stubHeader = (
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
: { type: 'RESP_HEADER' };
|
|
12
|
+
const stubHeader = () => ({
|
|
13
|
+
type: 'RESP_HEADER',
|
|
14
|
+
logEpoch: 'frame-probe',
|
|
15
|
+
resetRequired: false,
|
|
16
|
+
});
|
|
19
17
|
const STUB_SUB_START = {
|
|
20
18
|
type: 'SUB_START',
|
|
21
19
|
id: '',
|
|
@@ -26,32 +24,32 @@ const STUB_SUB_START = {
|
|
|
26
24
|
};
|
|
27
25
|
const STUB_SUB_END = { type: 'SUB_END', nextCursor: 0 };
|
|
28
26
|
const probe = encodeMessage({
|
|
29
|
-
wireVersion:
|
|
27
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
30
28
|
msgKind: 'response',
|
|
31
|
-
frames: [stubHeader(
|
|
29
|
+
frames: [stubHeader()],
|
|
32
30
|
});
|
|
33
31
|
/** The 8-byte SSP2 response envelope header (§1.2). */
|
|
34
32
|
export function responseEnvelopeHeader(wireVersion) {
|
|
35
33
|
const encoded = encodeMessage({
|
|
36
34
|
wireVersion,
|
|
37
35
|
msgKind: 'response',
|
|
38
|
-
frames: [stubHeader(
|
|
36
|
+
frames: [stubHeader()],
|
|
39
37
|
});
|
|
40
38
|
return encoded.slice(0, 8);
|
|
41
39
|
}
|
|
42
40
|
/** The terminating END frame (§1.2 rule 1). */
|
|
43
41
|
export const END_FRAME_BYTES = probe.slice(probe.length - 5);
|
|
44
|
-
function wrapperFor(frame
|
|
42
|
+
function wrapperFor(frame) {
|
|
45
43
|
switch (frame.type) {
|
|
46
44
|
case 'RESP_HEADER':
|
|
47
45
|
return { frames: [frame], index: 0 };
|
|
48
46
|
case 'LEASE':
|
|
49
47
|
// §7.3.2: LEASE rides immediately after RESP_HEADER.
|
|
50
|
-
return { frames: [stubHeader(
|
|
48
|
+
return { frames: [stubHeader(), frame], index: 1 };
|
|
51
49
|
case 'PUSH_RESULT':
|
|
52
50
|
case 'ERROR':
|
|
53
51
|
case 'UNKNOWN':
|
|
54
|
-
return { frames: [stubHeader(
|
|
52
|
+
return { frames: [stubHeader(), frame], index: 1 };
|
|
55
53
|
case 'PUSH_RESULT_DETAILS': {
|
|
56
54
|
const result = {
|
|
57
55
|
type: 'PUSH_RESULT',
|
|
@@ -65,23 +63,23 @@ function wrapperFor(frame, wireVersion) {
|
|
|
65
63
|
retryable: false,
|
|
66
64
|
})),
|
|
67
65
|
};
|
|
68
|
-
return { frames: [stubHeader(
|
|
66
|
+
return { frames: [stubHeader(), result, frame], index: 2 };
|
|
69
67
|
}
|
|
70
68
|
case 'SUB_START':
|
|
71
69
|
return {
|
|
72
|
-
frames: [stubHeader(
|
|
70
|
+
frames: [stubHeader(), frame, STUB_SUB_END],
|
|
73
71
|
index: 1,
|
|
74
72
|
};
|
|
75
73
|
case 'SUB_END':
|
|
76
74
|
return {
|
|
77
|
-
frames: [stubHeader(
|
|
75
|
+
frames: [stubHeader(), STUB_SUB_START, frame],
|
|
78
76
|
index: 2,
|
|
79
77
|
};
|
|
80
78
|
case 'COMMIT':
|
|
81
79
|
case 'SEGMENT_REF':
|
|
82
80
|
case 'SEGMENT_INLINE':
|
|
83
81
|
return {
|
|
84
|
-
frames: [stubHeader(
|
|
82
|
+
frames: [stubHeader(), STUB_SUB_START, frame, STUB_SUB_END],
|
|
85
83
|
index: 2,
|
|
86
84
|
};
|
|
87
85
|
}
|
|
@@ -91,7 +89,7 @@ function wrapperFor(frame, wireVersion) {
|
|
|
91
89
|
* reference codec.
|
|
92
90
|
*/
|
|
93
91
|
export function encodeResponseFrame(frame, wireVersion = PROTOCOL_WIRE_VERSION) {
|
|
94
|
-
const { frames, index } = wrapperFor(frame
|
|
92
|
+
const { frames, index } = wrapperFor(frame);
|
|
95
93
|
const encoded = encodeMessage({
|
|
96
94
|
wireVersion,
|
|
97
95
|
msgKind: 'response',
|
package/dist/handler.js
CHANGED
|
@@ -112,9 +112,6 @@ async function planRequest(request, ctx, schema, registry) {
|
|
|
112
112
|
else if (frame.type === 'SUBSCRIPTION')
|
|
113
113
|
subFrames.push(frame);
|
|
114
114
|
}
|
|
115
|
-
if (request.wireVersion === 1 && registry.epochRequired) {
|
|
116
|
-
throw syncError('sync.client_wire_unsupported', 'this partition requires a client that validates log epochs (§2.1)');
|
|
117
|
-
}
|
|
118
115
|
if (request.wireVersion >= 2 &&
|
|
119
116
|
header.logEpoch === undefined &&
|
|
120
117
|
pushes.length > 0) {
|
package/dist/operations.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { validateAuthoritativeRelationPlan, } from './authoritative-query.js';
|
|
2
|
-
import { decodeRow, decodeRemoteOperationRequest,
|
|
2
|
+
import { decodeRow, decodeRemoteOperationRequest, encodeRemoteOperationResponse, encodeSparseRow, } from '@syncular/core';
|
|
3
3
|
import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
|
|
4
4
|
import { SyncError, syncError } from './errors.js';
|
|
5
5
|
import { processPushOperationsWithTrace } from './push.js';
|
|
@@ -270,7 +270,7 @@ function commandOperations(mutations, schema) {
|
|
|
270
270
|
table: table.name,
|
|
271
271
|
rowId,
|
|
272
272
|
op: 'upsert',
|
|
273
|
-
payload:
|
|
273
|
+
payload: encodeSparseRow(table.columns, table.primaryKeyIndex, values),
|
|
274
274
|
};
|
|
275
275
|
});
|
|
276
276
|
}
|
|
@@ -24,7 +24,7 @@ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorI
|
|
|
24
24
|
* set, §5.9.5) as an index range, never a scan. `postgres-explain
|
|
25
25
|
* .test.ts` asserts an `Index` node here too.
|
|
26
26
|
*/
|
|
27
|
-
export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT 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 BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT 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 BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT 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 BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\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 BIGINT 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 JSONB 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 JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\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 BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\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);\n";
|
|
27
|
+
export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT 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 BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT 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 BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT 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 BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\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 BIGINT 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 JSONB 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 JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\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 BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\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 BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\n";
|
|
28
28
|
export declare class PostgresServerStorage implements ServerStorage {
|
|
29
29
|
#private;
|
|
30
30
|
constructor(exec: PgExecutor);
|
package/dist/postgres-storage.js
CHANGED
|
@@ -108,6 +108,11 @@ CREATE TABLE IF NOT EXISTS sync_blob_refs(
|
|
|
108
108
|
);
|
|
109
109
|
CREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob
|
|
110
110
|
ON sync_blob_refs(partition, blob_id);
|
|
111
|
+
CREATE TABLE IF NOT EXISTS sync_tombstones(
|
|
112
|
+
partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
113
|
+
commit_seq BIGINT NOT NULL,
|
|
114
|
+
PRIMARY KEY(partition, tbl, row_id)
|
|
115
|
+
);
|
|
111
116
|
`;
|
|
112
117
|
async function lockPartitionOn(client, partition) {
|
|
113
118
|
const locked = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
|
|
@@ -174,6 +179,7 @@ function serializePushResult(result) {
|
|
|
174
179
|
message: record.message,
|
|
175
180
|
serverVersion: record.serverVersion,
|
|
176
181
|
serverRow: toBase64(record.serverRow),
|
|
182
|
+
conflictColumns: toBase64(record.conflictColumns),
|
|
177
183
|
};
|
|
178
184
|
}
|
|
179
185
|
if (record.status === 'error') {
|
|
@@ -201,6 +207,7 @@ function deserializePushResult(value) {
|
|
|
201
207
|
message: record.message ?? '',
|
|
202
208
|
serverVersion: record.serverVersion ?? 0,
|
|
203
209
|
serverRow: fromBase64(record.serverRow ?? ''),
|
|
210
|
+
conflictColumns: fromBase64(record.conflictColumns ?? ''),
|
|
204
211
|
};
|
|
205
212
|
}
|
|
206
213
|
if (record.status === 'error') {
|
|
@@ -242,6 +249,9 @@ function toStoredRow(record) {
|
|
|
242
249
|
serverVersion: asNumber(record.server_version),
|
|
243
250
|
scopes: asJson(record.scopes),
|
|
244
251
|
payload: asBytes(record.payload),
|
|
252
|
+
...(record.column_versions === null || record.column_versions === undefined
|
|
253
|
+
? {}
|
|
254
|
+
: { columnVersions: asBytes(record.column_versions) }),
|
|
245
255
|
};
|
|
246
256
|
}
|
|
247
257
|
function toStoredChange(record) {
|
|
@@ -343,6 +353,16 @@ class PostgresTransaction {
|
|
|
343
353
|
this.#assertOpen();
|
|
344
354
|
return getRowOn(this.#client, this.#resolveTable(table), this.#partition, rowId);
|
|
345
355
|
}
|
|
356
|
+
async getTombstoneSeq(table, rowId) {
|
|
357
|
+
this.#assertOpen();
|
|
358
|
+
const { rows } = await this.#client.query('SELECT commit_seq FROM sync_tombstones WHERE partition=$1 AND tbl=$2 AND row_id=$3', [this.#partition, table, rowId]);
|
|
359
|
+
const seq = rows[0]?.commit_seq;
|
|
360
|
+
return seq === null || seq === undefined ? undefined : asNumber(seq);
|
|
361
|
+
}
|
|
362
|
+
async clearTombstone(table, rowId) {
|
|
363
|
+
this.#assertOpen();
|
|
364
|
+
await this.#client.query('DELETE FROM sync_tombstones WHERE partition=$1 AND tbl=$2 AND row_id=$3', [this.#partition, table, rowId]);
|
|
365
|
+
}
|
|
346
366
|
getPushResult(clientId, clientCommitId) {
|
|
347
367
|
this.#assertOpen();
|
|
348
368
|
// Runs on this transaction's pinned client: the push layer's duplicate
|
|
@@ -465,15 +485,23 @@ class PostgresTransaction {
|
|
|
465
485
|
continue;
|
|
466
486
|
// Bind serialized scopes as text before parsing JSONB. Drivers that
|
|
467
487
|
// encode JSONB parameters would otherwise store this string as a scalar.
|
|
488
|
+
// One statement per change: scopes AND the §5 delete tombstone ride the
|
|
489
|
+
// same writable-CTE chain.
|
|
468
490
|
await q.query(`WITH inserted AS (
|
|
469
491
|
INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
|
|
470
492
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::text::jsonb,$9)
|
|
471
|
-
RETURNING partition, tbl, commit_seq, scopes
|
|
493
|
+
RETURNING partition, tbl, row_id, op, commit_seq, scopes
|
|
494
|
+
),
|
|
495
|
+
scoped AS (
|
|
496
|
+
INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
|
|
497
|
+
SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
|
|
498
|
+
FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
|
|
499
|
+
ON CONFLICT DO NOTHING
|
|
472
500
|
)
|
|
473
|
-
INSERT INTO
|
|
474
|
-
SELECT
|
|
475
|
-
|
|
476
|
-
|
|
501
|
+
INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq)
|
|
502
|
+
SELECT partition, tbl, row_id, commit_seq FROM inserted WHERE op = 2
|
|
503
|
+
ON CONFLICT (partition, tbl, row_id)
|
|
504
|
+
DO UPDATE SET commit_seq = excluded.commit_seq`, [
|
|
477
505
|
p,
|
|
478
506
|
commitSeq,
|
|
479
507
|
idx,
|
|
@@ -792,6 +820,8 @@ export class PostgresServerStorage {
|
|
|
792
820
|
const removed = await client.query('DELETE FROM sync_commits WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
|
|
793
821
|
await client.query('DELETE FROM sync_changes WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
|
|
794
822
|
await client.query('DELETE FROM sync_change_scopes WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
|
|
823
|
+
// §4.6: tombstones prune in the same pass as the commit log.
|
|
824
|
+
await client.query('DELETE FROM sync_tombstones WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
|
|
795
825
|
return {
|
|
796
826
|
previousHorizonSeq,
|
|
797
827
|
horizonSeq,
|