@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.
@@ -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. */
@@ -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, 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
- constructor(code: string, message?: string);
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
- 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,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.8.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.8.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
@@ -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' };
@@ -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(