@syncular/server 0.8.0 → 0.10.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 +113 -0
- package/dist/context.d.ts +8 -1
- package/dist/d1-storage.d.ts +10 -1
- package/dist/d1-storage.js +0 -0
- package/dist/frame-bytes.js +15 -0
- package/dist/handler.js +15 -0
- package/dist/postgres-storage.js +62 -0
- package/dist/push.js +191 -28
- package/dist/realtime.d.ts +5 -0
- package/dist/realtime.js +6 -0
- package/dist/sqlite-dialect.js +2 -0
- package/dist/sqlite-storage.js +22 -0
- package/dist/storage.d.ts +18 -0
- package/dist/validate.d.ts +62 -2
- package/dist/validate.js +39 -1
- package/package.json +2 -2
- package/src/context.ts +8 -1
- package/src/d1-storage.ts +0 -0
- package/src/frame-bytes.ts +16 -0
- package/src/handler.ts +20 -0
- package/src/postgres-storage.ts +79 -0
- package/src/push.ts +290 -29
- package/src/realtime.ts +11 -0
- package/src/sqlite-dialect.ts +3 -0
- package/src/sqlite-storage.ts +35 -0
- package/src/storage.ts +22 -0
- package/src/validate.ts +93 -2
package/dist/sqlite-storage.js
CHANGED
|
@@ -15,6 +15,7 @@ class SqliteTransaction {
|
|
|
15
15
|
#storage;
|
|
16
16
|
#partition;
|
|
17
17
|
#open = true;
|
|
18
|
+
#commitValidationSavepoint = false;
|
|
18
19
|
constructor(storage, partition) {
|
|
19
20
|
this.#storage = storage;
|
|
20
21
|
this.#partition = partition;
|
|
@@ -28,6 +29,27 @@ class SqliteTransaction {
|
|
|
28
29
|
this.#assertOpen();
|
|
29
30
|
return this.#storage.getRow(this.#partition, table, rowId);
|
|
30
31
|
}
|
|
32
|
+
scanRows(query) {
|
|
33
|
+
this.#assertOpen();
|
|
34
|
+
return this.#storage.scanRows(this.#partition, query);
|
|
35
|
+
}
|
|
36
|
+
async lockPartitionForCommitValidation() {
|
|
37
|
+
this.#assertOpen();
|
|
38
|
+
// BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
|
|
39
|
+
this.#storage.db.exec('SAVEPOINT syncular_commit_validation_candidate');
|
|
40
|
+
this.#commitValidationSavepoint = true;
|
|
41
|
+
}
|
|
42
|
+
async commitRejectedPushResult(clientId, clientCommitId, result) {
|
|
43
|
+
this.#assertOpen();
|
|
44
|
+
if (!this.#commitValidationSavepoint) {
|
|
45
|
+
throw new Error('whole-commit rejection requires its validation savepoint');
|
|
46
|
+
}
|
|
47
|
+
this.#storage.db.exec('ROLLBACK TO SAVEPOINT syncular_commit_validation_candidate');
|
|
48
|
+
this.#storage.db.exec('RELEASE SAVEPOINT syncular_commit_validation_candidate');
|
|
49
|
+
this.#commitValidationSavepoint = false;
|
|
50
|
+
await this.putPushResult(clientId, clientCommitId, result);
|
|
51
|
+
await this.commit();
|
|
52
|
+
}
|
|
31
53
|
async upsertRow(table, row) {
|
|
32
54
|
this.#assertOpen();
|
|
33
55
|
this.#storage.writeRow(this.#partition, table, row);
|
package/dist/storage.d.ts
CHANGED
|
@@ -150,6 +150,24 @@ export interface ScopeActivityQuery {
|
|
|
150
150
|
*/
|
|
151
151
|
export interface StorageTransaction {
|
|
152
152
|
getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
153
|
+
/**
|
|
154
|
+
* Optional candidate-state scan used only by whole-commit validation.
|
|
155
|
+
* In-tree SQLite/Postgres/D1 backends implement it with read-your-own-writes
|
|
156
|
+
* semantics. A custom backend may omit it until `commitValidator` is used.
|
|
157
|
+
*/
|
|
158
|
+
scanRows?(query: RowScanQuery): Promise<StoredRow[]>;
|
|
159
|
+
/**
|
|
160
|
+
* Serialize candidate-state validation for this partition before any row
|
|
161
|
+
* read/write. Required at runtime when `commitValidator` is configured.
|
|
162
|
+
*/
|
|
163
|
+
lockPartitionForCommitValidation?(): Promise<void>;
|
|
164
|
+
/**
|
|
165
|
+
* §6.8 rejection finalization while the validation serialization lock is
|
|
166
|
+
* still held: discard every candidate write, persist the rejected
|
|
167
|
+
* idempotency result, and finish the transaction atomically. Required when
|
|
168
|
+
* `commitValidator` is configured so a concurrent duplicate cannot rerun it.
|
|
169
|
+
*/
|
|
170
|
+
commitRejectedPushResult?(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<void>;
|
|
153
171
|
upsertRow(table: string, row: StoredRow): Promise<void>;
|
|
154
172
|
deleteRow(table: string, rowId: string): Promise<void>;
|
|
155
173
|
/** Allocates the next per-partition commitSeq and appends the commit. */
|
package/dist/validate.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* path pays only an `undefined` check per operation and builds no context
|
|
14
14
|
* object — zero cost, the events-seam discipline.
|
|
15
15
|
*/
|
|
16
|
-
import type
|
|
16
|
+
import { type RejectionDetails, type RowColumn, type RowValue, type ScopeMap } from '@syncular/core';
|
|
17
17
|
/**
|
|
18
18
|
* §6.7 reserved code prefixes. A host validator code MUST NOT start with
|
|
19
19
|
* any of these: they namespace the protocol's own error families (§10.2)
|
|
@@ -49,6 +49,52 @@ export interface ValidateOperation {
|
|
|
49
49
|
/** The currently-stored row (update/delete), keyed by column name; undefined on insert. */
|
|
50
50
|
readonly stored: ValidateRow | undefined;
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* One authorized, decoded operation presented to the whole-commit validator.
|
|
54
|
+
* `row` is the final candidate row after scope stripping and CRDT merge;
|
|
55
|
+
* `stored` is the state observed immediately before this operation. Multiple
|
|
56
|
+
* operations targeting one row therefore retain their sequential evidence.
|
|
57
|
+
*/
|
|
58
|
+
export interface ValidateCommitOperation extends ValidateOperation {
|
|
59
|
+
readonly opIndex: number;
|
|
60
|
+
readonly storedServerVersion?: number;
|
|
61
|
+
readonly nextServerVersion?: number;
|
|
62
|
+
}
|
|
63
|
+
/** One candidate-state row read from inside the still-open commit transaction. */
|
|
64
|
+
export interface CommitValidationRow {
|
|
65
|
+
readonly row: ValidateRow;
|
|
66
|
+
readonly serverVersion: number;
|
|
67
|
+
}
|
|
68
|
+
export interface CommitValidationScanInput {
|
|
69
|
+
readonly table: string;
|
|
70
|
+
/** Exact scope filter, using the same AND-across-keys semantics as sync. */
|
|
71
|
+
readonly scopeFilter: ScopeMap;
|
|
72
|
+
readonly afterRowId?: string | null;
|
|
73
|
+
/** Bounded per call by the server to 1..1,000; defaults to 100. */
|
|
74
|
+
readonly limit?: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Candidate-state reads bound to the same storage transaction as the commit.
|
|
78
|
+
* Reads observe every staged sibling operation and no uncommitted competing
|
|
79
|
+
* transaction when the storage's commit-validation lock contract is honored.
|
|
80
|
+
*/
|
|
81
|
+
export interface CommitValidationReader {
|
|
82
|
+
getRow(table: string, rowId: string): Promise<CommitValidationRow | undefined>;
|
|
83
|
+
scanRows(input: CommitValidationScanInput): Promise<CommitValidationRow[]>;
|
|
84
|
+
}
|
|
85
|
+
export interface ValidateCommitInput {
|
|
86
|
+
readonly clientId: string;
|
|
87
|
+
readonly clientCommitId: string;
|
|
88
|
+
readonly actorId: string;
|
|
89
|
+
readonly partition: string;
|
|
90
|
+
readonly operations: readonly ValidateCommitOperation[];
|
|
91
|
+
readonly read: CommitValidationReader;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Runs once after every operation passed protocol/scope/row validation and was
|
|
95
|
+
* staged, but before commit-log/idempotency append and transaction commit.
|
|
96
|
+
*/
|
|
97
|
+
export type CommitValidator = (input: ValidateCommitInput) => void | Promise<void>;
|
|
52
98
|
/** Ambient context a validator may consult (§6.7). */
|
|
53
99
|
export interface ValidateContext {
|
|
54
100
|
/** Host-authenticated actor (§1.1) performing the write. */
|
|
@@ -77,7 +123,21 @@ export type ValidatorRegistry = Readonly<Record<string, Validator>>;
|
|
|
77
123
|
export declare class ValidationRejection extends Error {
|
|
78
124
|
readonly name = "ValidationRejection";
|
|
79
125
|
readonly code: string;
|
|
80
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Bounded code-like metadata explicitly safe to replicate to authorized
|
|
128
|
+
* clients. Never place diagnostic prose, secrets, or clinical values here.
|
|
129
|
+
*/
|
|
130
|
+
readonly details: RejectionDetails | undefined;
|
|
131
|
+
constructor(code: string, message?: string, details?: RejectionDetails);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* A whole-commit rejection attributed to one operation for the existing
|
|
135
|
+
* per-operation PUSH_RESULT envelope. The validator may still describe
|
|
136
|
+
* multiple affected fields in `details.fieldPaths`.
|
|
137
|
+
*/
|
|
138
|
+
export declare class CommitValidationRejection extends ValidationRejection {
|
|
139
|
+
readonly opIndex: number;
|
|
140
|
+
constructor(opIndex: number, code: string, message?: string, details?: RejectionDetails);
|
|
81
141
|
}
|
|
82
142
|
/** Build the column-keyed row object a validator inspects (§6.7). */
|
|
83
143
|
export declare function toValidateRow(columns: readonly RowColumn[], values: readonly RowValue[]): ValidateRow;
|
package/dist/validate.js
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side write-validation hooks (SPEC.md §6.7).
|
|
3
|
+
*
|
|
4
|
+
* An optional per-table `validate` callback that runs on push, AFTER the
|
|
5
|
+
* row-codec decode (§6.1) and the §3.4 scope authorization, INSIDE the
|
|
6
|
+
* commit transaction, once per operation. It is the seam for business
|
|
7
|
+
* rules that scopes cannot express ("title ≤ 200 chars", "amount ≥ 0",
|
|
8
|
+
* "status ∈ {…}"). A throw (or rejected promise) rejects the whole commit
|
|
9
|
+
* atomically (§6.4) with a host-defined code the client surfaces unchanged
|
|
10
|
+
* in its rejection record (§6.3).
|
|
11
|
+
*
|
|
12
|
+
* The feature is OFF by default (no `validators` on the config): the push
|
|
13
|
+
* path pays only an `undefined` check per operation and builds no context
|
|
14
|
+
* object — zero cost, the events-seam discipline.
|
|
15
|
+
*/
|
|
16
|
+
import { normalizeRejectionDetails, } from '@syncular/core';
|
|
1
17
|
/**
|
|
2
18
|
* §6.7 reserved code prefixes. A host validator code MUST NOT start with
|
|
3
19
|
* any of these: they namespace the protocol's own error families (§10.2)
|
|
@@ -21,7 +37,12 @@ export const RESERVED_VALIDATION_CODE_PREFIXES = [
|
|
|
21
37
|
export class ValidationRejection extends Error {
|
|
22
38
|
name = 'ValidationRejection';
|
|
23
39
|
code;
|
|
24
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Bounded code-like metadata explicitly safe to replicate to authorized
|
|
42
|
+
* clients. Never place diagnostic prose, secrets, or clinical values here.
|
|
43
|
+
*/
|
|
44
|
+
details;
|
|
45
|
+
constructor(code, message, details) {
|
|
25
46
|
super(message ?? code);
|
|
26
47
|
if (code.length === 0) {
|
|
27
48
|
throw new Error('ValidationRejection code must be non-empty (§6.7)');
|
|
@@ -32,6 +53,23 @@ export class ValidationRejection extends Error {
|
|
|
32
53
|
}
|
|
33
54
|
}
|
|
34
55
|
this.code = code;
|
|
56
|
+
this.details =
|
|
57
|
+
details === undefined ? undefined : normalizeRejectionDetails(details);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A whole-commit rejection attributed to one operation for the existing
|
|
62
|
+
* per-operation PUSH_RESULT envelope. The validator may still describe
|
|
63
|
+
* multiple affected fields in `details.fieldPaths`.
|
|
64
|
+
*/
|
|
65
|
+
export class CommitValidationRejection extends ValidationRejection {
|
|
66
|
+
opIndex;
|
|
67
|
+
constructor(opIndex, code, message, details) {
|
|
68
|
+
super(code, message, details);
|
|
69
|
+
this.opIndex = opIndex;
|
|
70
|
+
if (!Number.isSafeInteger(opIndex) || opIndex < 0) {
|
|
71
|
+
throw new Error('CommitValidationRejection opIndex must be a non-negative safe integer');
|
|
72
|
+
}
|
|
35
73
|
}
|
|
36
74
|
}
|
|
37
75
|
/** Build the column-keyed row object a validator inspects (§6.7). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"!dist/**/*.test.d.ts"
|
|
54
54
|
],
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@syncular/core": "0.
|
|
56
|
+
"@syncular/core": "0.10.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/context.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type {
|
|
|
18
18
|
} from './signed-url';
|
|
19
19
|
import type { SqliteImageBuilder } from './sqlite-image';
|
|
20
20
|
import type { ServerStorage, StoredCommit } from './storage';
|
|
21
|
-
import type { ValidatorRegistry } from './validate';
|
|
21
|
+
import type { CommitValidator, ValidatorRegistry } from './validate';
|
|
22
22
|
|
|
23
23
|
/** SSP2 body content type (§1.1). */
|
|
24
24
|
export const SSP2_CONTENT_TYPE = 'application/vnd.syncular.sync.v2';
|
|
@@ -112,6 +112,13 @@ export interface SyncServerConfig {
|
|
|
112
112
|
* process, never on the wire.
|
|
113
113
|
*/
|
|
114
114
|
readonly validators?: ValidatorRegistry;
|
|
115
|
+
/**
|
|
116
|
+
* §6.8 whole-commit validator. When present, the storage serializes this
|
|
117
|
+
* partition before operation reads/writes, then invokes the callback once
|
|
118
|
+
* over every staged decoded operation and candidate-state reader before
|
|
119
|
+
* commit-log/idempotency append. A throw rolls back the complete commit.
|
|
120
|
+
*/
|
|
121
|
+
readonly commitValidator?: CommitValidator;
|
|
115
122
|
readonly resolveScopes: ResolveScopes;
|
|
116
123
|
/**
|
|
117
124
|
* §7.3 auth leases. Absent ⇒ the feature is off: no `LEASE` frame is
|
package/src/d1-storage.ts
CHANGED
|
Binary file
|
package/src/frame-bytes.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import {
|
|
12
12
|
encodeMessage,
|
|
13
13
|
PROTOCOL_WIRE_VERSION,
|
|
14
|
+
type PushResultFrame,
|
|
14
15
|
type RespHeaderFrame,
|
|
15
16
|
type ResponseFrame,
|
|
16
17
|
type SubEndFrame,
|
|
@@ -54,6 +55,21 @@ function wrapperFor(frame: ResponseFrame): {
|
|
|
54
55
|
case 'ERROR':
|
|
55
56
|
case 'UNKNOWN':
|
|
56
57
|
return { frames: [STUB_HEADER, frame], index: 1 };
|
|
58
|
+
case 'PUSH_RESULT_DETAILS': {
|
|
59
|
+
const result: PushResultFrame = {
|
|
60
|
+
type: 'PUSH_RESULT',
|
|
61
|
+
clientCommitId: frame.clientCommitId,
|
|
62
|
+
status: 'rejected',
|
|
63
|
+
results: frame.entries.map((entry) => ({
|
|
64
|
+
opIndex: entry.opIndex,
|
|
65
|
+
status: 'error',
|
|
66
|
+
code: 'sync.constraint_violation',
|
|
67
|
+
message: '',
|
|
68
|
+
retryable: false,
|
|
69
|
+
})),
|
|
70
|
+
};
|
|
71
|
+
return { frames: [STUB_HEADER, result, frame], index: 2 };
|
|
72
|
+
}
|
|
57
73
|
case 'SUB_START':
|
|
58
74
|
return { frames: [STUB_HEADER, frame, STUB_SUB_END], index: 1 };
|
|
59
75
|
case 'SUB_END':
|
package/src/handler.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
decodeMessage,
|
|
15
15
|
type PullHeaderFrame,
|
|
16
16
|
type PushCommitFrame,
|
|
17
|
+
type PushResultDetailsFrame,
|
|
17
18
|
type PushResultFrame,
|
|
18
19
|
type ReqHeaderFrame,
|
|
19
20
|
type RequestMessage,
|
|
@@ -316,6 +317,23 @@ interface RequestReport {
|
|
|
316
317
|
errorCode?: string;
|
|
317
318
|
}
|
|
318
319
|
|
|
320
|
+
function pushResultDetailsFrame(
|
|
321
|
+
frame: PushResultFrame,
|
|
322
|
+
): PushResultDetailsFrame | undefined {
|
|
323
|
+
const entries = frame.results.flatMap((result) =>
|
|
324
|
+
result.status === 'error' && result.details !== undefined
|
|
325
|
+
? [{ opIndex: result.opIndex, details: result.details }]
|
|
326
|
+
: [],
|
|
327
|
+
);
|
|
328
|
+
return entries.length === 0
|
|
329
|
+
? undefined
|
|
330
|
+
: {
|
|
331
|
+
type: 'PUSH_RESULT_DETAILS',
|
|
332
|
+
clientCommitId: frame.clientCommitId,
|
|
333
|
+
entries,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
319
337
|
function emitPushEvent(
|
|
320
338
|
events: SyncularServerEvents,
|
|
321
339
|
ctx: SyncRequestContext,
|
|
@@ -416,6 +434,8 @@ async function* streamResponse(
|
|
|
416
434
|
emitPushEvent(events, ctx, plan.header.clientId, push, frame);
|
|
417
435
|
}
|
|
418
436
|
yield encodeResponseFrame(frame);
|
|
437
|
+
const details = pushResultDetailsFrame(frame);
|
|
438
|
+
if (details !== undefined) yield encodeResponseFrame(details);
|
|
419
439
|
}
|
|
420
440
|
|
|
421
441
|
// Pull half (§4): subscriptions echoed in request order.
|
package/src/postgres-storage.ts
CHANGED
|
@@ -165,6 +165,7 @@ interface SerializedResult {
|
|
|
165
165
|
serverVersion?: number;
|
|
166
166
|
serverRow?: string;
|
|
167
167
|
retryable?: boolean;
|
|
168
|
+
details?: import('@syncular/core').RejectionDetails;
|
|
168
169
|
}
|
|
169
170
|
|
|
170
171
|
function toBase64(bytes: Uint8Array): string {
|
|
@@ -201,6 +202,7 @@ function serializePushResult(result: StoredPushResult): unknown {
|
|
|
201
202
|
code: record.code,
|
|
202
203
|
message: record.message,
|
|
203
204
|
retryable: record.retryable,
|
|
205
|
+
...(record.details !== undefined ? { details: record.details } : {}),
|
|
204
206
|
};
|
|
205
207
|
}
|
|
206
208
|
return { opIndex: record.opIndex, status: record.status };
|
|
@@ -232,6 +234,7 @@ function deserializePushResult(value: unknown): StoredPushResult {
|
|
|
232
234
|
code: record.code ?? '',
|
|
233
235
|
message: record.message ?? '',
|
|
234
236
|
retryable: record.retryable ?? false,
|
|
237
|
+
...(record.details !== undefined ? { details: record.details } : {}),
|
|
235
238
|
};
|
|
236
239
|
}
|
|
237
240
|
return { opIndex: record.opIndex, status: 'applied' };
|
|
@@ -393,6 +396,7 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
393
396
|
#partition: string;
|
|
394
397
|
#resolveTable: (name: string) => CompiledTable;
|
|
395
398
|
#open = true;
|
|
399
|
+
#commitValidationSavepoint = false;
|
|
396
400
|
/** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
|
|
397
401
|
#resolve: () => void;
|
|
398
402
|
#reject: (error: unknown) => void;
|
|
@@ -425,6 +429,81 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
425
429
|
);
|
|
426
430
|
}
|
|
427
431
|
|
|
432
|
+
async scanRows(query: RowScanQuery): Promise<StoredRow[]> {
|
|
433
|
+
this.#assertOpen();
|
|
434
|
+
const variables = Object.keys(query.scopeFilter).sort();
|
|
435
|
+
const firstVariable = variables[0];
|
|
436
|
+
if (firstVariable === undefined) return [];
|
|
437
|
+
const firstValues = query.scopeFilter[firstVariable] ?? [];
|
|
438
|
+
if (firstValues.length === 0) return [];
|
|
439
|
+
const sql = scanRowPageSql(
|
|
440
|
+
this.#resolveTable(query.table),
|
|
441
|
+
firstValues.length,
|
|
442
|
+
'postgres',
|
|
443
|
+
);
|
|
444
|
+
const rows: StoredRow[] = [];
|
|
445
|
+
let afterRowId = query.afterRowId ?? '';
|
|
446
|
+
const batchSize = Math.max(64, query.limit);
|
|
447
|
+
while (rows.length < query.limit) {
|
|
448
|
+
const { rows: records } = await this.#client.query<RowRecord>(sql, [
|
|
449
|
+
this.#partition,
|
|
450
|
+
query.table,
|
|
451
|
+
firstVariable,
|
|
452
|
+
...firstValues,
|
|
453
|
+
afterRowId,
|
|
454
|
+
batchSize,
|
|
455
|
+
]);
|
|
456
|
+
if (records.length === 0) break;
|
|
457
|
+
for (const record of records) {
|
|
458
|
+
afterRowId = record.row_id;
|
|
459
|
+
if (record.payload === null || record.payload === undefined) continue;
|
|
460
|
+
const stored = toStoredRow(record);
|
|
461
|
+
if (!matchesEffective(stored.scopes, query.scopeFilter)) continue;
|
|
462
|
+
rows.push(stored);
|
|
463
|
+
if (rows.length >= query.limit) break;
|
|
464
|
+
}
|
|
465
|
+
if (records.length < batchSize) break;
|
|
466
|
+
}
|
|
467
|
+
return rows;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async lockPartitionForCommitValidation(): Promise<void> {
|
|
471
|
+
this.#assertOpen();
|
|
472
|
+
await this.#client.query(
|
|
473
|
+
`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
|
|
474
|
+
ON CONFLICT (partition) DO NOTHING`,
|
|
475
|
+
[this.#partition],
|
|
476
|
+
);
|
|
477
|
+
await this.#client.query(
|
|
478
|
+
'SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE',
|
|
479
|
+
[this.#partition],
|
|
480
|
+
);
|
|
481
|
+
await this.#client.query('SAVEPOINT syncular_commit_validation_candidate');
|
|
482
|
+
this.#commitValidationSavepoint = true;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async commitRejectedPushResult(
|
|
486
|
+
clientId: string,
|
|
487
|
+
clientCommitId: string,
|
|
488
|
+
result: StoredPushResult,
|
|
489
|
+
): Promise<void> {
|
|
490
|
+
this.#assertOpen();
|
|
491
|
+
if (!this.#commitValidationSavepoint) {
|
|
492
|
+
throw new Error(
|
|
493
|
+
'whole-commit rejection requires its validation savepoint',
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
await this.#client.query(
|
|
497
|
+
'ROLLBACK TO SAVEPOINT syncular_commit_validation_candidate',
|
|
498
|
+
);
|
|
499
|
+
await this.#client.query(
|
|
500
|
+
'RELEASE SAVEPOINT syncular_commit_validation_candidate',
|
|
501
|
+
);
|
|
502
|
+
this.#commitValidationSavepoint = false;
|
|
503
|
+
await this.putPushResult(clientId, clientCommitId, result);
|
|
504
|
+
await this.commit();
|
|
505
|
+
}
|
|
506
|
+
|
|
428
507
|
async upsertRow(table: string, row: StoredRow): Promise<void> {
|
|
429
508
|
this.#assertOpen();
|
|
430
509
|
await writeRowOn(
|