@syncular/server 0.46.3 → 0.47.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/dist/errors.js CHANGED
@@ -139,6 +139,15 @@ export const ERROR_CATALOG = {
139
139
  recommendedAction: 'fixRequest',
140
140
  httpStatus: 400,
141
141
  },
142
+ // §6.8: the whole-commit validator threw an exception that is not a
143
+ // deliberate rejection. A host fault; the thrown message rides the §6.3.1
144
+ // details and the original reaches the host's `onError`.
145
+ 'sync.validator_error': {
146
+ category: 'internal',
147
+ retryable: false,
148
+ recommendedAction: 'inspectServer',
149
+ httpStatus: 500,
150
+ },
142
151
  'sync.missing_scopes': {
143
152
  category: 'internal',
144
153
  retryable: false,
package/dist/push.js CHANGED
@@ -20,7 +20,7 @@ import { retainRollbackFailure } from './storage-errors.js';
20
20
  */
21
21
  import { decodeRow, decodeSparseRow, encodeRow, parseBlobRef, } from '@syncular/core';
22
22
  import { clockOf, limitsOf } from './context.js';
23
- import { SyncError } from './errors.js';
23
+ import { reportError, SyncError } from './errors.js';
24
24
  import { emitEvent } from './events.js';
25
25
  import { prepareReactions, toNewReactions, } from './reactions.js';
26
26
  import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes.js';
@@ -699,7 +699,7 @@ function commitValidationReader(tx, schema) {
699
699
  queryAuthoritative: (query) => transactionQuery(tx, query),
700
700
  };
701
701
  }
702
- async function runCommitValidator(validator, tx, schema, clientId, clientCommitId, actorId, partition, operations) {
702
+ async function runCommitValidator(validator, onError, tx, schema, clientId, clientCommitId, actorId, partition, operations) {
703
703
  if (validator === undefined)
704
704
  return undefined;
705
705
  try {
@@ -722,7 +722,30 @@ async function runCommitValidator(validator, tx, schema, clientId, clientCommitI
722
722
  if (error instanceof ValidationRejection) {
723
723
  return errorRecord(operations[0]?.opIndex ?? 0, error.code, error.message, false, error.details);
724
724
  }
725
- return errorRecord(operations[0]?.opIndex ?? 0, 'sync.constraint_violation', 'whole-commit validator failed');
725
+ // §6.8: a throw that is not a deliberate rejection is a host fault, not a
726
+ // rule refusal. The host's observation hook receives the original (its
727
+ // stack included); the client gets a distinct code and, in the §6.3.1
728
+ // details, the bounded thrown message. Stacks and thrown values stay off
729
+ // the wire.
730
+ reportError(error, onError, 'sync');
731
+ let text = '';
732
+ let bytes = 0;
733
+ const encoder = new TextEncoder();
734
+ const thrown = error instanceof Error ? error.message : String(error);
735
+ for (const char of thrown.replace(/\p{Cc}+/gu, ' ').trim()) {
736
+ const size = encoder.encode(char).length;
737
+ if (bytes + size > 256)
738
+ break;
739
+ bytes += size;
740
+ text += char;
741
+ }
742
+ text = text.trimEnd();
743
+ return errorRecord(operations[0]?.opIndex ?? 0, 'sync.validator_error', 'whole-commit validator failed', false, {
744
+ reason: 'validator_threw',
745
+ ...(text === ''
746
+ ? {}
747
+ : { references: { validator: 'commit', message: text } }),
748
+ });
726
749
  }
727
750
  return undefined;
728
751
  }
@@ -1129,7 +1152,7 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
1129
1152
  }
1130
1153
  }
1131
1154
  if (terminated === undefined) {
1132
- const commitReject = await runCommitValidator(commitValidator, tx, schema, clientId, clientCommitId, ctx.actorId, partition, validatedOperations);
1155
+ const commitReject = await runCommitValidator(commitValidator, ctx.onError, tx, schema, clientId, clientCommitId, ctx.actorId, partition, validatedOperations);
1133
1156
  if (commitReject?.kind === 'terminate') {
1134
1157
  // An appended §6.11 cascade operation carries a synthetic opIndex;
1135
1158
  // surface the originating client operation instead.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.46.3",
3
+ "version": "0.47.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",
@@ -68,7 +68,7 @@
68
68
  "verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
69
69
  },
70
70
  "dependencies": {
71
- "@syncular/core": "0.46.3"
71
+ "@syncular/core": "0.47.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
package/src/errors.ts CHANGED
@@ -147,6 +147,15 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
147
147
  recommendedAction: 'fixRequest',
148
148
  httpStatus: 400,
149
149
  },
150
+ // §6.8: the whole-commit validator threw an exception that is not a
151
+ // deliberate rejection. A host fault; the thrown message rides the §6.3.1
152
+ // details and the original reaches the host's `onError`.
153
+ 'sync.validator_error': {
154
+ category: 'internal',
155
+ retryable: false,
156
+ recommendedAction: 'inspectServer',
157
+ httpStatus: 500,
158
+ },
150
159
  'sync.missing_scopes': {
151
160
  category: 'internal',
152
161
  retryable: false,
package/src/push.ts CHANGED
@@ -35,7 +35,7 @@ import type { BlobStore } from './blob-store';
35
35
  import type { SyncRequestContext } from './context';
36
36
  import { clockOf, limitsOf } from './context';
37
37
  import type { CrdtMergerRegistry } from './crdt-merger';
38
- import { SyncError } from './errors';
38
+ import { reportError, SyncError, type SyncularErrorHandler } from './errors';
39
39
  import { emitEvent } from './events';
40
40
  import {
41
41
  prepareReactions,
@@ -1116,6 +1116,7 @@ function commitValidationReader(
1116
1116
 
1117
1117
  async function runCommitValidator(
1118
1118
  validator: CommitValidator | undefined,
1119
+ onError: SyncularErrorHandler | undefined,
1119
1120
  tx: StorageTransaction,
1120
1121
  schema: CompiledSchema,
1121
1122
  clientId: string,
@@ -1160,10 +1161,34 @@ async function runCommitValidator(
1160
1161
  error.details,
1161
1162
  );
1162
1163
  }
1164
+ // §6.8: a throw that is not a deliberate rejection is a host fault, not a
1165
+ // rule refusal. The host's observation hook receives the original (its
1166
+ // stack included); the client gets a distinct code and, in the §6.3.1
1167
+ // details, the bounded thrown message. Stacks and thrown values stay off
1168
+ // the wire.
1169
+ reportError(error, onError, 'sync');
1170
+ let text = '';
1171
+ let bytes = 0;
1172
+ const encoder = new TextEncoder();
1173
+ const thrown = error instanceof Error ? error.message : String(error);
1174
+ for (const char of thrown.replace(/\p{Cc}+/gu, ' ').trim()) {
1175
+ const size = encoder.encode(char).length;
1176
+ if (bytes + size > 256) break;
1177
+ bytes += size;
1178
+ text += char;
1179
+ }
1180
+ text = text.trimEnd();
1163
1181
  return errorRecord(
1164
1182
  operations[0]?.opIndex ?? 0,
1165
- 'sync.constraint_violation',
1183
+ 'sync.validator_error',
1166
1184
  'whole-commit validator failed',
1185
+ false,
1186
+ {
1187
+ reason: 'validator_threw',
1188
+ ...(text === ''
1189
+ ? {}
1190
+ : { references: { validator: 'commit', message: text } }),
1191
+ },
1167
1192
  );
1168
1193
  }
1169
1194
  return undefined;
@@ -1769,6 +1794,7 @@ export async function processPushOperationsWithTrace(
1769
1794
  if (terminated === undefined) {
1770
1795
  const commitReject = await runCommitValidator(
1771
1796
  commitValidator,
1797
+ ctx.onError,
1772
1798
  tx,
1773
1799
  schema,
1774
1800
  clientId,