@syncular/server 0.7.0 → 0.9.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 +49 -0
- package/dist/frame-bytes.js +15 -0
- package/dist/handler.js +15 -0
- package/dist/postgres-storage.js +2 -0
- package/dist/push.js +10 -3
- package/dist/realtime.d.ts +3 -0
- package/dist/realtime.js +3 -0
- package/dist/sqlite-dialect.js +2 -0
- package/dist/validate.d.ts +7 -2
- package/dist/validate.js +24 -1
- package/package.json +2 -2
- package/src/frame-bytes.ts +16 -0
- package/src/handler.ts +20 -0
- package/src/postgres-storage.ts +3 -0
- package/src/push.ts +17 -2
- package/src/realtime.ts +6 -0
- package/src/sqlite-dialect.ts +3 -0
- package/src/validate.ts +14 -2
package/README.md
CHANGED
|
@@ -60,6 +60,55 @@ done by a binding of the core or by in-database fanout — a relay would add a
|
|
|
60
60
|
hop, a second protocol surface, and a managed dependency for zero capability
|
|
61
61
|
the core lacks.
|
|
62
62
|
|
|
63
|
+
## Write validators and recovery metadata
|
|
64
|
+
|
|
65
|
+
`validators` is the server-authoritative seam for row business rules that
|
|
66
|
+
scope grants cannot express. A validator runs after row decode and scope
|
|
67
|
+
authorization, inside the commit transaction, for HTTP and WebSocket sync
|
|
68
|
+
rounds alike. Throw `ValidationRejection` for a deliberate host rejection:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import {
|
|
72
|
+
ValidationRejection,
|
|
73
|
+
type SyncServerConfig,
|
|
74
|
+
} from '@syncular/server';
|
|
75
|
+
|
|
76
|
+
const config: SyncServerConfig = {
|
|
77
|
+
schema,
|
|
78
|
+
storage,
|
|
79
|
+
segments,
|
|
80
|
+
resolveScopes,
|
|
81
|
+
validators: {
|
|
82
|
+
surgeries: ({ row }) => {
|
|
83
|
+
if (typeof row?.duration_minutes === 'number' && row.duration_minutes < 5) {
|
|
84
|
+
throw new ValidationRejection(
|
|
85
|
+
'surgery.duration_too_short',
|
|
86
|
+
'diagnostic only',
|
|
87
|
+
{
|
|
88
|
+
fieldPaths: ['duration_minutes'],
|
|
89
|
+
reason: 'below_minimum',
|
|
90
|
+
requiredAction: 'edit_fields',
|
|
91
|
+
references: { minimum_minutes: '5' },
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The third argument is optional. When supplied, Syncular validates and
|
|
101
|
+
normalizes a bounded `RejectionDetails` object and persists it with the
|
|
102
|
+
idempotency result. Its values replicate to the authorized client, so include
|
|
103
|
+
only non-sensitive identifiers that the host explicitly approves for recovery
|
|
104
|
+
UI. Unknown members, free-form tokens, malformed paths, and over-limit data
|
|
105
|
+
fail at construction. Diagnostic prose stays in `message`; apps should map
|
|
106
|
+
the stable code/details to localized copy instead of displaying that message.
|
|
107
|
+
|
|
108
|
+
Validators are per-operation. They do not make multi-row or multi-table
|
|
109
|
+
invariants atomic; those require a server-authoritative command or a future
|
|
110
|
+
whole-commit validation seam. A validator must not mutate the row it receives.
|
|
111
|
+
|
|
63
112
|
## Structured events (the ops seam)
|
|
64
113
|
|
|
65
114
|
One optional interface, `SyncularServerEvents`, carries every
|
package/dist/frame-bytes.js
CHANGED
|
@@ -39,6 +39,21 @@ function wrapperFor(frame) {
|
|
|
39
39
|
case 'ERROR':
|
|
40
40
|
case 'UNKNOWN':
|
|
41
41
|
return { frames: [STUB_HEADER, frame], index: 1 };
|
|
42
|
+
case 'PUSH_RESULT_DETAILS': {
|
|
43
|
+
const result = {
|
|
44
|
+
type: 'PUSH_RESULT',
|
|
45
|
+
clientCommitId: frame.clientCommitId,
|
|
46
|
+
status: 'rejected',
|
|
47
|
+
results: frame.entries.map((entry) => ({
|
|
48
|
+
opIndex: entry.opIndex,
|
|
49
|
+
status: 'error',
|
|
50
|
+
code: 'sync.constraint_violation',
|
|
51
|
+
message: '',
|
|
52
|
+
retryable: false,
|
|
53
|
+
})),
|
|
54
|
+
};
|
|
55
|
+
return { frames: [STUB_HEADER, result, frame], index: 2 };
|
|
56
|
+
}
|
|
42
57
|
case 'SUB_START':
|
|
43
58
|
return { frames: [STUB_HEADER, frame, STUB_SUB_END], index: 1 };
|
|
44
59
|
case 'SUB_END':
|
package/dist/handler.js
CHANGED
|
@@ -187,6 +187,18 @@ async function planRequest(request, ctx, schema) {
|
|
|
187
187
|
leaseToEmit,
|
|
188
188
|
};
|
|
189
189
|
}
|
|
190
|
+
function pushResultDetailsFrame(frame) {
|
|
191
|
+
const entries = frame.results.flatMap((result) => result.status === 'error' && result.details !== undefined
|
|
192
|
+
? [{ opIndex: result.opIndex, details: result.details }]
|
|
193
|
+
: []);
|
|
194
|
+
return entries.length === 0
|
|
195
|
+
? undefined
|
|
196
|
+
: {
|
|
197
|
+
type: 'PUSH_RESULT_DETAILS',
|
|
198
|
+
clientCommitId: frame.clientCommitId,
|
|
199
|
+
entries,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
190
202
|
function emitPushEvent(events, ctx, clientId, push, frame) {
|
|
191
203
|
const base = {
|
|
192
204
|
atMs: clockOf(ctx)(),
|
|
@@ -269,6 +281,9 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
269
281
|
emitPushEvent(events, ctx, plan.header.clientId, push, frame);
|
|
270
282
|
}
|
|
271
283
|
yield encodeResponseFrame(frame);
|
|
284
|
+
const details = pushResultDetailsFrame(frame);
|
|
285
|
+
if (details !== undefined)
|
|
286
|
+
yield encodeResponseFrame(details);
|
|
272
287
|
}
|
|
273
288
|
// Pull half (§4): subscriptions echoed in request order.
|
|
274
289
|
const cursors = [];
|
package/dist/postgres-storage.js
CHANGED
|
@@ -107,6 +107,7 @@ function serializePushResult(result) {
|
|
|
107
107
|
code: record.code,
|
|
108
108
|
message: record.message,
|
|
109
109
|
retryable: record.retryable,
|
|
110
|
+
...(record.details !== undefined ? { details: record.details } : {}),
|
|
110
111
|
};
|
|
111
112
|
}
|
|
112
113
|
return { opIndex: record.opIndex, status: record.status };
|
|
@@ -133,6 +134,7 @@ function deserializePushResult(value) {
|
|
|
133
134
|
code: record.code ?? '',
|
|
134
135
|
message: record.message ?? '',
|
|
135
136
|
retryable: record.retryable ?? false,
|
|
137
|
+
...(record.details !== undefined ? { details: record.details } : {}),
|
|
136
138
|
};
|
|
137
139
|
}
|
|
138
140
|
return { opIndex: record.opIndex, status: 'applied' };
|
package/dist/push.js
CHANGED
|
@@ -36,10 +36,17 @@ function blobIdsInRow(table, values) {
|
|
|
36
36
|
}
|
|
37
37
|
return ids;
|
|
38
38
|
}
|
|
39
|
-
function errorRecord(opIndex, code, message, retryable = false) {
|
|
39
|
+
function errorRecord(opIndex, code, message, retryable = false, details) {
|
|
40
40
|
return {
|
|
41
41
|
kind: 'terminate',
|
|
42
|
-
record: {
|
|
42
|
+
record: {
|
|
43
|
+
opIndex,
|
|
44
|
+
status: 'error',
|
|
45
|
+
code,
|
|
46
|
+
message,
|
|
47
|
+
retryable,
|
|
48
|
+
...(details !== undefined ? { details } : {}),
|
|
49
|
+
},
|
|
43
50
|
};
|
|
44
51
|
}
|
|
45
52
|
function conflictRecord(opIndex, serverVersion, serverRow) {
|
|
@@ -83,7 +90,7 @@ async function runValidator(validators, table, op, rowId, values, storedValues,
|
|
|
83
90
|
}
|
|
84
91
|
catch (error) {
|
|
85
92
|
if (error instanceof ValidationRejection) {
|
|
86
|
-
return errorRecord(opIndex, error.code, error.message);
|
|
93
|
+
return errorRecord(opIndex, error.code, error.message, false, error.details);
|
|
87
94
|
}
|
|
88
95
|
// §6.7: a non-ValidationRejection throw is still a rejection, mapped to
|
|
89
96
|
// the generic server-side constraint code (§10.2) — the validator's
|
package/dist/realtime.d.ts
CHANGED
|
@@ -20,10 +20,13 @@ import type { ServerSchema } from './schema.js';
|
|
|
20
20
|
import type { SegmentStore } from './segment-store.js';
|
|
21
21
|
import type { SegmentUrlConfig } from './signed-url.js';
|
|
22
22
|
import type { ServerStorage, StoredCommit } from './storage.js';
|
|
23
|
+
import type { ValidatorRegistry } from './validate.js';
|
|
23
24
|
export interface RealtimeHubConfig {
|
|
24
25
|
readonly schema: ServerSchema;
|
|
25
26
|
readonly storage: ServerStorage;
|
|
26
27
|
readonly resolveScopes: ResolveScopes;
|
|
28
|
+
/** §6.7 validators used by sync rounds carried over this socket. */
|
|
29
|
+
readonly validators?: ValidatorRegistry;
|
|
27
30
|
readonly clock?: () => number;
|
|
28
31
|
/** Deltas larger than this become `delta-too-large` wake-ups (§8.2). */
|
|
29
32
|
readonly maxDeltaBytes?: number;
|
package/dist/realtime.js
CHANGED
|
@@ -716,6 +716,9 @@ export class RealtimeHub {
|
|
|
716
716
|
storage: this.#config.storage,
|
|
717
717
|
segments,
|
|
718
718
|
resolveScopes: this.#config.resolveScopes,
|
|
719
|
+
...(this.#config.validators !== undefined
|
|
720
|
+
? { validators: this.#config.validators }
|
|
721
|
+
: {}),
|
|
719
722
|
...(this.#config.clock !== undefined
|
|
720
723
|
? { clock: this.#config.clock }
|
|
721
724
|
: {}),
|
package/dist/sqlite-dialect.js
CHANGED
|
@@ -111,6 +111,7 @@ export function serializePushResult(result) {
|
|
|
111
111
|
code: record.code,
|
|
112
112
|
message: record.message,
|
|
113
113
|
retryable: record.retryable,
|
|
114
|
+
...(record.details !== undefined ? { details: record.details } : {}),
|
|
114
115
|
};
|
|
115
116
|
}
|
|
116
117
|
return { opIndex: record.opIndex, status: record.status };
|
|
@@ -137,6 +138,7 @@ export function deserializePushResult(text) {
|
|
|
137
138
|
code: record.code ?? '',
|
|
138
139
|
message: record.message ?? '',
|
|
139
140
|
retryable: record.retryable ?? false,
|
|
141
|
+
...(record.details !== undefined ? { details: record.details } : {}),
|
|
140
142
|
};
|
|
141
143
|
}
|
|
142
144
|
return { opIndex: record.opIndex, status: 'applied' };
|
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 } 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)
|
|
@@ -77,7 +77,12 @@ export type ValidatorRegistry = Readonly<Record<string, Validator>>;
|
|
|
77
77
|
export declare class ValidationRejection extends Error {
|
|
78
78
|
readonly name = "ValidationRejection";
|
|
79
79
|
readonly code: string;
|
|
80
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Bounded code-like metadata explicitly safe to replicate to authorized
|
|
82
|
+
* clients. Never place diagnostic prose, secrets, or clinical values here.
|
|
83
|
+
*/
|
|
84
|
+
readonly details: RejectionDetails | undefined;
|
|
85
|
+
constructor(code: string, message?: string, details?: RejectionDetails);
|
|
81
86
|
}
|
|
82
87
|
/** Build the column-keyed row object a validator inspects (§6.7). */
|
|
83
88
|
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,8 @@ export class ValidationRejection extends Error {
|
|
|
32
53
|
}
|
|
33
54
|
}
|
|
34
55
|
this.code = code;
|
|
56
|
+
this.details =
|
|
57
|
+
details === undefined ? undefined : normalizeRejectionDetails(details);
|
|
35
58
|
}
|
|
36
59
|
}
|
|
37
60
|
/** 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.9.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.9.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@electric-sql/pglite": "^0.5.4"
|
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' };
|
package/src/push.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type PushOperationResult,
|
|
26
26
|
type PushResultFrame,
|
|
27
27
|
parseBlobRef,
|
|
28
|
+
type RejectionDetails,
|
|
28
29
|
type RowValue,
|
|
29
30
|
} from '@syncular/core';
|
|
30
31
|
import type { BlobStore } from './blob-store';
|
|
@@ -70,10 +71,18 @@ function errorRecord(
|
|
|
70
71
|
code: string,
|
|
71
72
|
message: string,
|
|
72
73
|
retryable = false,
|
|
74
|
+
details?: RejectionDetails,
|
|
73
75
|
): OperationOutcome {
|
|
74
76
|
return {
|
|
75
77
|
kind: 'terminate',
|
|
76
|
-
record: {
|
|
78
|
+
record: {
|
|
79
|
+
opIndex,
|
|
80
|
+
status: 'error',
|
|
81
|
+
code,
|
|
82
|
+
message,
|
|
83
|
+
retryable,
|
|
84
|
+
...(details !== undefined ? { details } : {}),
|
|
85
|
+
},
|
|
77
86
|
};
|
|
78
87
|
}
|
|
79
88
|
|
|
@@ -141,7 +150,13 @@ async function runValidator(
|
|
|
141
150
|
);
|
|
142
151
|
} catch (error) {
|
|
143
152
|
if (error instanceof ValidationRejection) {
|
|
144
|
-
return errorRecord(
|
|
153
|
+
return errorRecord(
|
|
154
|
+
opIndex,
|
|
155
|
+
error.code,
|
|
156
|
+
error.message,
|
|
157
|
+
false,
|
|
158
|
+
error.details,
|
|
159
|
+
);
|
|
145
160
|
}
|
|
146
161
|
// §6.7: a non-ValidationRejection throw is still a rejection, mapped to
|
|
147
162
|
// the generic server-side constraint code (§10.2) — the validator's
|
package/src/realtime.ts
CHANGED
|
@@ -50,11 +50,14 @@ import {
|
|
|
50
50
|
import type { SegmentStore } from './segment-store';
|
|
51
51
|
import type { SegmentUrlConfig } from './signed-url';
|
|
52
52
|
import type { ServerStorage, StoredCommit } from './storage';
|
|
53
|
+
import type { ValidatorRegistry } from './validate';
|
|
53
54
|
|
|
54
55
|
export interface RealtimeHubConfig {
|
|
55
56
|
readonly schema: ServerSchema;
|
|
56
57
|
readonly storage: ServerStorage;
|
|
57
58
|
readonly resolveScopes: ResolveScopes;
|
|
59
|
+
/** §6.7 validators used by sync rounds carried over this socket. */
|
|
60
|
+
readonly validators?: ValidatorRegistry;
|
|
58
61
|
readonly clock?: () => number;
|
|
59
62
|
/** Deltas larger than this become `delta-too-large` wake-ups (§8.2). */
|
|
60
63
|
readonly maxDeltaBytes?: number;
|
|
@@ -926,6 +929,9 @@ export class RealtimeHub {
|
|
|
926
929
|
storage: this.#config.storage,
|
|
927
930
|
segments,
|
|
928
931
|
resolveScopes: this.#config.resolveScopes,
|
|
932
|
+
...(this.#config.validators !== undefined
|
|
933
|
+
? { validators: this.#config.validators }
|
|
934
|
+
: {}),
|
|
929
935
|
...(this.#config.clock !== undefined
|
|
930
936
|
? { clock: this.#config.clock }
|
|
931
937
|
: {}),
|
package/src/sqlite-dialect.ts
CHANGED
|
@@ -126,6 +126,7 @@ interface SerializedResult {
|
|
|
126
126
|
serverVersion?: number;
|
|
127
127
|
serverRow?: string;
|
|
128
128
|
retryable?: boolean;
|
|
129
|
+
details?: import('@syncular/core').RejectionDetails;
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
/** Serialize a push result to the JSON `TEXT` stored in `sync_push_results`. */
|
|
@@ -151,6 +152,7 @@ export function serializePushResult(result: StoredPushResult): string {
|
|
|
151
152
|
code: record.code,
|
|
152
153
|
message: record.message,
|
|
153
154
|
retryable: record.retryable,
|
|
155
|
+
...(record.details !== undefined ? { details: record.details } : {}),
|
|
154
156
|
};
|
|
155
157
|
}
|
|
156
158
|
return { opIndex: record.opIndex, status: record.status };
|
|
@@ -182,6 +184,7 @@ export function deserializePushResult(text: string): StoredPushResult {
|
|
|
182
184
|
code: record.code ?? '',
|
|
183
185
|
message: record.message ?? '',
|
|
184
186
|
retryable: record.retryable ?? false,
|
|
187
|
+
...(record.details !== undefined ? { details: record.details } : {}),
|
|
185
188
|
};
|
|
186
189
|
}
|
|
187
190
|
return { opIndex: record.opIndex, status: 'applied' };
|
package/src/validate.ts
CHANGED
|
@@ -13,7 +13,12 @@
|
|
|
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
|
|
16
|
+
import {
|
|
17
|
+
normalizeRejectionDetails,
|
|
18
|
+
type RejectionDetails,
|
|
19
|
+
type RowColumn,
|
|
20
|
+
type RowValue,
|
|
21
|
+
} from '@syncular/core';
|
|
17
22
|
|
|
18
23
|
/**
|
|
19
24
|
* §6.7 reserved code prefixes. A host validator code MUST NOT start with
|
|
@@ -93,8 +98,13 @@ export type ValidatorRegistry = Readonly<Record<string, Validator>>;
|
|
|
93
98
|
export class ValidationRejection extends Error {
|
|
94
99
|
override readonly name = 'ValidationRejection';
|
|
95
100
|
readonly code: string;
|
|
101
|
+
/**
|
|
102
|
+
* Bounded code-like metadata explicitly safe to replicate to authorized
|
|
103
|
+
* clients. Never place diagnostic prose, secrets, or clinical values here.
|
|
104
|
+
*/
|
|
105
|
+
readonly details: RejectionDetails | undefined;
|
|
96
106
|
|
|
97
|
-
constructor(code: string, message?: string) {
|
|
107
|
+
constructor(code: string, message?: string, details?: RejectionDetails) {
|
|
98
108
|
super(message ?? code);
|
|
99
109
|
if (code.length === 0) {
|
|
100
110
|
throw new Error('ValidationRejection code must be non-empty (§6.7)');
|
|
@@ -107,6 +117,8 @@ export class ValidationRejection extends Error {
|
|
|
107
117
|
}
|
|
108
118
|
}
|
|
109
119
|
this.code = code;
|
|
120
|
+
this.details =
|
|
121
|
+
details === undefined ? undefined : normalizeRejectionDetails(details);
|
|
110
122
|
}
|
|
111
123
|
}
|
|
112
124
|
|