@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 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
@@ -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 = [];
@@ -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: { opIndex, status: 'error', code, message, retryable },
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
@@ -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
  : {}),
@@ -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' };
@@ -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 { RowColumn, RowValue } from '@syncular/core';
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
- constructor(code: string, message?: string);
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
- constructor(code, message) {
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.7.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.7.0"
56
+ "@syncular/core": "0.9.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@electric-sql/pglite": "^0.5.4"
@@ -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.
@@ -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: { opIndex, status: 'error', code, message, retryable },
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(opIndex, error.code, error.message);
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
  : {}),
@@ -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 type { RowColumn, RowValue } from '@syncular/core';
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