@syncular/server 0.46.3 → 0.48.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 +5 -2
- package/dist/errors.js +9 -0
- package/dist/operations.d.ts +10 -1
- package/dist/operations.js +17 -0
- package/dist/push.js +71 -5
- package/dist/validate.d.ts +25 -0
- package/package.json +2 -2
- package/src/errors.ts +9 -0
- package/src/operations.ts +40 -0
- package/src/push.ts +83 -3
- package/src/validate.ts +26 -0
package/README.md
CHANGED
|
@@ -256,7 +256,8 @@ const config: SyncServerConfig = {
|
|
|
256
256
|
```
|
|
257
257
|
|
|
258
258
|
The callback also receives `read.getRow()` and its batch form
|
|
259
|
-
`read.getRows(table, rowIds)`, bounded `read.scanRows()`,
|
|
259
|
+
`read.getRows(table, rowIds)`, bounded `read.scanRows()`,
|
|
260
|
+
`read.scanRowsByIndex()` for an exact declared-index lookup, and
|
|
260
261
|
`read.queryAuthoritative()` APIs. Those reads see the final candidate state, including staged sibling upserts and
|
|
261
262
|
deletes. Syncular serializes the partition before any operation read so two
|
|
262
263
|
aggregate validators cannot both accept mutually invalid candidates. It also
|
|
@@ -359,7 +360,9 @@ SQLite, PostgreSQL, and D1.
|
|
|
359
360
|
|
|
360
361
|
An authoritative command that needs an exact alternate lookup can instead use
|
|
361
362
|
the optional `storage.scanRowsByIndex(partition, query)` or transactional
|
|
362
|
-
`tx.scanRowsByIndex(query)` capability
|
|
363
|
+
`tx.scanRowsByIndex(query)` capability, or `command.scanRowsByIndex()` on its
|
|
364
|
+
push transaction. The whole-commit validator reaches the same transaction
|
|
365
|
+
lookup as `read.scanRowsByIndex()`; both readers default `limit` to 100. The query names one declared
|
|
363
366
|
`TableSchema.indexes` entry, supplies one exact value per index column, uses an
|
|
364
367
|
exclusive `afterRowId`, and has a required limit from 1 through 1,000. All
|
|
365
368
|
shipped adapters implement it; transaction reads see staged writes and deletes.
|
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/operations.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type AuthoritativeRelationPlan } from './authoritative-query.js';
|
|
|
2
2
|
import { type RemoteOperationRequest, type RemoteOperationResponse, type RowValue } from '@syncular/core';
|
|
3
3
|
import type { ResolveScopes, SyncRequestContext } from './context.js';
|
|
4
4
|
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, AuthoritativeQueryValue } from './storage.js';
|
|
5
|
-
import { type ValidateRow } from './validate.js';
|
|
5
|
+
import { type IndexScanInput, type ValidateRow } from './validate.js';
|
|
6
6
|
export interface RemoteQueryDependency {
|
|
7
7
|
readonly table: string;
|
|
8
8
|
readonly scopeKeys?: readonly string[];
|
|
@@ -76,6 +76,15 @@ export interface RemoteCommandContext {
|
|
|
76
76
|
* D1 issue one lookup per id.
|
|
77
77
|
*/
|
|
78
78
|
getRows(table: string, rowIds: readonly string[]): Promise<ReadonlyMap<string, ValidateRow>>;
|
|
79
|
+
/**
|
|
80
|
+
* Exact lookup through one declared relational index on the command's push
|
|
81
|
+
* transaction. The values form the complete, order-sensitive index tuple
|
|
82
|
+
* and `afterRowId` is an exclusive keyset cursor. Unlike `getRow` and
|
|
83
|
+
* `getRows`, the rows are not filtered by the actor's scopes: `authorize`
|
|
84
|
+
* gates the command, and this is the same trusted server-host lookup
|
|
85
|
+
* `ServerStorage.scanRowsByIndex` performs.
|
|
86
|
+
*/
|
|
87
|
+
scanRowsByIndex(input: IndexScanInput): Promise<ValidateRow[]>;
|
|
79
88
|
/**
|
|
80
89
|
* Run a generated registered query on the command's push transaction
|
|
81
90
|
* (§6.7). The storage binds every relation to the partition; unlike
|
package/dist/operations.js
CHANGED
|
@@ -314,6 +314,23 @@ function commandContext(ctx, tx, resolved, schema, clientId, operationId, reques
|
|
|
314
314
|
}
|
|
315
315
|
return rows;
|
|
316
316
|
},
|
|
317
|
+
scanRowsByIndex: async ({ table: tableName, index, values, afterRowId = null, limit = 100, }) => {
|
|
318
|
+
const table = schema.tables.get(tableName);
|
|
319
|
+
if (table === undefined) {
|
|
320
|
+
throw syncError('operation.invalid_request', 'command read targets an unknown table');
|
|
321
|
+
}
|
|
322
|
+
if (tx.scanRowsByIndex === undefined) {
|
|
323
|
+
throw new Error('storage transaction does not support command index scans');
|
|
324
|
+
}
|
|
325
|
+
const rows = await tx.scanRowsByIndex({
|
|
326
|
+
table: tableName,
|
|
327
|
+
index,
|
|
328
|
+
values,
|
|
329
|
+
afterRowId,
|
|
330
|
+
limit,
|
|
331
|
+
});
|
|
332
|
+
return rows.map((stored) => toValidateRow(table.columns, decodeRow(table.columns, stored.payload)));
|
|
333
|
+
},
|
|
317
334
|
queryAuthoritative: (query) => transactionQuery(tx, query),
|
|
318
335
|
};
|
|
319
336
|
}
|
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';
|
|
@@ -696,10 +696,27 @@ function commitValidationReader(tx, schema) {
|
|
|
696
696
|
serverVersion: stored.serverVersion,
|
|
697
697
|
}));
|
|
698
698
|
},
|
|
699
|
+
scanRowsByIndex: async ({ table: tableName, index, values, afterRowId = null, limit = 100, }) => {
|
|
700
|
+
const table = tableFor(tableName);
|
|
701
|
+
if (tx.scanRowsByIndex === undefined) {
|
|
702
|
+
throw new Error('storage transaction does not support commit-validator index scans');
|
|
703
|
+
}
|
|
704
|
+
const rows = await tx.scanRowsByIndex({
|
|
705
|
+
table: tableName,
|
|
706
|
+
index,
|
|
707
|
+
values,
|
|
708
|
+
afterRowId,
|
|
709
|
+
limit,
|
|
710
|
+
});
|
|
711
|
+
return rows.map((stored) => ({
|
|
712
|
+
row: toValidateRow(table.columns, decodeRow(table.columns, stored.payload)),
|
|
713
|
+
serverVersion: stored.serverVersion,
|
|
714
|
+
}));
|
|
715
|
+
},
|
|
699
716
|
queryAuthoritative: (query) => transactionQuery(tx, query),
|
|
700
717
|
};
|
|
701
718
|
}
|
|
702
|
-
async function runCommitValidator(validator, tx, schema, clientId, clientCommitId, actorId, partition, operations) {
|
|
719
|
+
async function runCommitValidator(validator, onError, tx, schema, clientId, clientCommitId, actorId, partition, operations) {
|
|
703
720
|
if (validator === undefined)
|
|
704
721
|
return undefined;
|
|
705
722
|
try {
|
|
@@ -722,7 +739,30 @@ async function runCommitValidator(validator, tx, schema, clientId, clientCommitI
|
|
|
722
739
|
if (error instanceof ValidationRejection) {
|
|
723
740
|
return errorRecord(operations[0]?.opIndex ?? 0, error.code, error.message, false, error.details);
|
|
724
741
|
}
|
|
725
|
-
|
|
742
|
+
// §6.8: a throw that is not a deliberate rejection is a host fault, not a
|
|
743
|
+
// rule refusal. The host's observation hook receives the original (its
|
|
744
|
+
// stack included); the client gets a distinct code and, in the §6.3.1
|
|
745
|
+
// details, the bounded thrown message. Stacks and thrown values stay off
|
|
746
|
+
// the wire.
|
|
747
|
+
reportError(error, onError, 'sync');
|
|
748
|
+
let text = '';
|
|
749
|
+
let bytes = 0;
|
|
750
|
+
const encoder = new TextEncoder();
|
|
751
|
+
const thrown = error instanceof Error ? error.message : String(error);
|
|
752
|
+
for (const char of thrown.replace(/\p{Cc}+/gu, ' ').trim()) {
|
|
753
|
+
const size = encoder.encode(char).length;
|
|
754
|
+
if (bytes + size > 256)
|
|
755
|
+
break;
|
|
756
|
+
bytes += size;
|
|
757
|
+
text += char;
|
|
758
|
+
}
|
|
759
|
+
text = text.trimEnd();
|
|
760
|
+
return errorRecord(operations[0]?.opIndex ?? 0, 'sync.validator_error', 'whole-commit validator failed', false, {
|
|
761
|
+
reason: 'validator_threw',
|
|
762
|
+
...(text === ''
|
|
763
|
+
? {}
|
|
764
|
+
: { references: { validator: 'commit', message: text } }),
|
|
765
|
+
});
|
|
726
766
|
}
|
|
727
767
|
return undefined;
|
|
728
768
|
}
|
|
@@ -768,6 +808,32 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
768
808
|
last: write,
|
|
769
809
|
});
|
|
770
810
|
}
|
|
811
|
+
// One candidate-state read per parent table, not one per reference: a
|
|
812
|
+
// commit that points several rows at the same parent (a fight's
|
|
813
|
+
// combatants, its chronicle) reads that parent once. Postgres binds the
|
|
814
|
+
// ids as one array per table; SQLite and D1 answer with one lookup per id.
|
|
815
|
+
const parentIds = new Map();
|
|
816
|
+
for (const { last: write } of finalByRow.values()) {
|
|
817
|
+
if (write.op !== 'upsert' || write.values === undefined)
|
|
818
|
+
continue;
|
|
819
|
+
for (const reference of write.table.references) {
|
|
820
|
+
const value = write.values[reference.columnIndex];
|
|
821
|
+
if (value === null || value === undefined)
|
|
822
|
+
continue;
|
|
823
|
+
const parentRowId = renderScopeValue(value);
|
|
824
|
+
if (parentRowId === undefined)
|
|
825
|
+
continue;
|
|
826
|
+
const ids = parentIds.get(reference.parentTable);
|
|
827
|
+
if (ids === undefined)
|
|
828
|
+
parentIds.set(reference.parentTable, new Set([parentRowId]));
|
|
829
|
+
else
|
|
830
|
+
ids.add(parentRowId);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
const parents = new Map();
|
|
834
|
+
for (const [parentTable, ids] of parentIds) {
|
|
835
|
+
parents.set(parentTable, await readCandidateRows(tx, parentTable, [...ids]));
|
|
836
|
+
}
|
|
771
837
|
for (const { last: write } of finalByRow.values()) {
|
|
772
838
|
if (write.op !== 'upsert' || write.values === undefined)
|
|
773
839
|
continue;
|
|
@@ -778,7 +844,7 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
778
844
|
const parentRowId = renderScopeValue(value);
|
|
779
845
|
if (parentRowId === undefined)
|
|
780
846
|
continue;
|
|
781
|
-
if (
|
|
847
|
+
if (parents.get(reference.parentTable)?.has(parentRowId) === true) {
|
|
782
848
|
continue;
|
|
783
849
|
}
|
|
784
850
|
return {
|
|
@@ -1129,7 +1195,7 @@ export async function processPushOperationsWithTrace(ctx, schema, resolved, clie
|
|
|
1129
1195
|
}
|
|
1130
1196
|
}
|
|
1131
1197
|
if (terminated === undefined) {
|
|
1132
|
-
const commitReject = await runCommitValidator(commitValidator, tx, schema, clientId, clientCommitId, ctx.actorId, partition, validatedOperations);
|
|
1198
|
+
const commitReject = await runCommitValidator(commitValidator, ctx.onError, tx, schema, clientId, clientCommitId, ctx.actorId, partition, validatedOperations);
|
|
1133
1199
|
if (commitReject?.kind === 'terminate') {
|
|
1134
1200
|
// An appended §6.11 cascade operation carries a synthetic opIndex;
|
|
1135
1201
|
// surface the originating client operation instead.
|
package/dist/validate.d.ts
CHANGED
|
@@ -74,6 +74,21 @@ export interface CommitValidationScanInput {
|
|
|
74
74
|
/** Bounded per call by the server to 1..1,000; defaults to 100. */
|
|
75
75
|
readonly limit?: number;
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* One exact declared-index lookup a push-transaction reader runs (§6.8): the
|
|
79
|
+
* whole-commit reader and a registered command share this shape.
|
|
80
|
+
*/
|
|
81
|
+
export interface IndexScanInput {
|
|
82
|
+
readonly table: string;
|
|
83
|
+
/** `TableSchema.indexes[].name`; never exposed as a client subscription. */
|
|
84
|
+
readonly index: string;
|
|
85
|
+
/** Exact values in the index declaration's column order. */
|
|
86
|
+
readonly values: readonly RowValue[];
|
|
87
|
+
/** Resume after this rowId (exclusive); `null` = start of the match set. */
|
|
88
|
+
readonly afterRowId?: string | null;
|
|
89
|
+
/** Bounded per call by the server to 1..1,000; defaults to 100. */
|
|
90
|
+
readonly limit?: number;
|
|
91
|
+
}
|
|
77
92
|
/**
|
|
78
93
|
* Candidate-state reads bound to the same storage transaction as the commit.
|
|
79
94
|
* Reads observe every staged sibling operation and no uncommitted competing
|
|
@@ -91,6 +106,16 @@ export interface CommitValidationReader {
|
|
|
91
106
|
*/
|
|
92
107
|
getRows(table: string, rowIds: readonly string[]): Promise<ReadonlyMap<string, CommitValidationRow>>;
|
|
93
108
|
scanRows(input: CommitValidationScanInput): Promise<CommitValidationRow[]>;
|
|
109
|
+
/**
|
|
110
|
+
* Candidate-state exact lookup through one declared relational index, with
|
|
111
|
+
* the same read-your-own-writes guarantee as `scanRows`: staged sibling
|
|
112
|
+
* upserts are present and staged deletes are absent. The values form the
|
|
113
|
+
* complete, order-sensitive index tuple and `afterRowId` is an exclusive
|
|
114
|
+
* keyset cursor, so a caller pages without a scope declaration. There is no
|
|
115
|
+
* scope filter and no actor scope authorization: the caller already holds
|
|
116
|
+
* partition authority, exactly as with `scanRows`.
|
|
117
|
+
*/
|
|
118
|
+
scanRowsByIndex(input: IndexScanInput): Promise<CommitValidationRow[]>;
|
|
94
119
|
/**
|
|
95
120
|
* Run a generated registered query on the commit transaction (§6.8). The
|
|
96
121
|
* request has the shape `ServerStorage.queryAuthoritative` takes; the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.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.
|
|
71
|
+
"@syncular/core": "0.48.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/operations.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { transactionQuery } from './storage-query';
|
|
|
37
37
|
import {
|
|
38
38
|
toValidateRow,
|
|
39
39
|
ValidationRejection,
|
|
40
|
+
type IndexScanInput,
|
|
40
41
|
type ValidateRow,
|
|
41
42
|
} from './validate';
|
|
42
43
|
|
|
@@ -138,6 +139,15 @@ export interface RemoteCommandContext {
|
|
|
138
139
|
table: string,
|
|
139
140
|
rowIds: readonly string[],
|
|
140
141
|
): Promise<ReadonlyMap<string, ValidateRow>>;
|
|
142
|
+
/**
|
|
143
|
+
* Exact lookup through one declared relational index on the command's push
|
|
144
|
+
* transaction. The values form the complete, order-sensitive index tuple
|
|
145
|
+
* and `afterRowId` is an exclusive keyset cursor. Unlike `getRow` and
|
|
146
|
+
* `getRows`, the rows are not filtered by the actor's scopes: `authorize`
|
|
147
|
+
* gates the command, and this is the same trusted server-host lookup
|
|
148
|
+
* `ServerStorage.scanRowsByIndex` performs.
|
|
149
|
+
*/
|
|
150
|
+
scanRowsByIndex(input: IndexScanInput): Promise<ValidateRow[]>;
|
|
141
151
|
/**
|
|
142
152
|
* Run a generated registered query on the command's push transaction
|
|
143
153
|
* (§6.7). The storage binds every relation to the partition; unlike
|
|
@@ -639,6 +649,36 @@ function commandContext(
|
|
|
639
649
|
}
|
|
640
650
|
return rows;
|
|
641
651
|
},
|
|
652
|
+
scanRowsByIndex: async ({
|
|
653
|
+
table: tableName,
|
|
654
|
+
index,
|
|
655
|
+
values,
|
|
656
|
+
afterRowId = null,
|
|
657
|
+
limit = 100,
|
|
658
|
+
}) => {
|
|
659
|
+
const table = schema.tables.get(tableName);
|
|
660
|
+
if (table === undefined) {
|
|
661
|
+
throw syncError(
|
|
662
|
+
'operation.invalid_request',
|
|
663
|
+
'command read targets an unknown table',
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
if (tx.scanRowsByIndex === undefined) {
|
|
667
|
+
throw new Error(
|
|
668
|
+
'storage transaction does not support command index scans',
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
const rows = await tx.scanRowsByIndex({
|
|
672
|
+
table: tableName,
|
|
673
|
+
index,
|
|
674
|
+
values,
|
|
675
|
+
afterRowId,
|
|
676
|
+
limit,
|
|
677
|
+
});
|
|
678
|
+
return rows.map((stored) =>
|
|
679
|
+
toValidateRow(table.columns, decodeRow(table.columns, stored.payload)),
|
|
680
|
+
);
|
|
681
|
+
},
|
|
642
682
|
queryAuthoritative: (query) => transactionQuery(tx, query),
|
|
643
683
|
};
|
|
644
684
|
}
|
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,
|
|
@@ -1110,12 +1110,41 @@ function commitValidationReader(
|
|
|
1110
1110
|
serverVersion: stored.serverVersion,
|
|
1111
1111
|
}));
|
|
1112
1112
|
},
|
|
1113
|
+
scanRowsByIndex: async ({
|
|
1114
|
+
table: tableName,
|
|
1115
|
+
index,
|
|
1116
|
+
values,
|
|
1117
|
+
afterRowId = null,
|
|
1118
|
+
limit = 100,
|
|
1119
|
+
}) => {
|
|
1120
|
+
const table = tableFor(tableName);
|
|
1121
|
+
if (tx.scanRowsByIndex === undefined) {
|
|
1122
|
+
throw new Error(
|
|
1123
|
+
'storage transaction does not support commit-validator index scans',
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
const rows = await tx.scanRowsByIndex({
|
|
1127
|
+
table: tableName,
|
|
1128
|
+
index,
|
|
1129
|
+
values,
|
|
1130
|
+
afterRowId,
|
|
1131
|
+
limit,
|
|
1132
|
+
});
|
|
1133
|
+
return rows.map((stored) => ({
|
|
1134
|
+
row: toValidateRow(
|
|
1135
|
+
table.columns,
|
|
1136
|
+
decodeRow(table.columns, stored.payload),
|
|
1137
|
+
),
|
|
1138
|
+
serverVersion: stored.serverVersion,
|
|
1139
|
+
}));
|
|
1140
|
+
},
|
|
1113
1141
|
queryAuthoritative: (query) => transactionQuery(tx, query),
|
|
1114
1142
|
};
|
|
1115
1143
|
}
|
|
1116
1144
|
|
|
1117
1145
|
async function runCommitValidator(
|
|
1118
1146
|
validator: CommitValidator | undefined,
|
|
1147
|
+
onError: SyncularErrorHandler | undefined,
|
|
1119
1148
|
tx: StorageTransaction,
|
|
1120
1149
|
schema: CompiledSchema,
|
|
1121
1150
|
clientId: string,
|
|
@@ -1160,10 +1189,34 @@ async function runCommitValidator(
|
|
|
1160
1189
|
error.details,
|
|
1161
1190
|
);
|
|
1162
1191
|
}
|
|
1192
|
+
// §6.8: a throw that is not a deliberate rejection is a host fault, not a
|
|
1193
|
+
// rule refusal. The host's observation hook receives the original (its
|
|
1194
|
+
// stack included); the client gets a distinct code and, in the §6.3.1
|
|
1195
|
+
// details, the bounded thrown message. Stacks and thrown values stay off
|
|
1196
|
+
// the wire.
|
|
1197
|
+
reportError(error, onError, 'sync');
|
|
1198
|
+
let text = '';
|
|
1199
|
+
let bytes = 0;
|
|
1200
|
+
const encoder = new TextEncoder();
|
|
1201
|
+
const thrown = error instanceof Error ? error.message : String(error);
|
|
1202
|
+
for (const char of thrown.replace(/\p{Cc}+/gu, ' ').trim()) {
|
|
1203
|
+
const size = encoder.encode(char).length;
|
|
1204
|
+
if (bytes + size > 256) break;
|
|
1205
|
+
bytes += size;
|
|
1206
|
+
text += char;
|
|
1207
|
+
}
|
|
1208
|
+
text = text.trimEnd();
|
|
1163
1209
|
return errorRecord(
|
|
1164
1210
|
operations[0]?.opIndex ?? 0,
|
|
1165
|
-
'sync.
|
|
1211
|
+
'sync.validator_error',
|
|
1166
1212
|
'whole-commit validator failed',
|
|
1213
|
+
false,
|
|
1214
|
+
{
|
|
1215
|
+
reason: 'validator_threw',
|
|
1216
|
+
...(text === ''
|
|
1217
|
+
? {}
|
|
1218
|
+
: { references: { validator: 'commit', message: text } }),
|
|
1219
|
+
},
|
|
1167
1220
|
);
|
|
1168
1221
|
}
|
|
1169
1222
|
return undefined;
|
|
@@ -1243,6 +1296,32 @@ async function enforceReferences(
|
|
|
1243
1296
|
});
|
|
1244
1297
|
}
|
|
1245
1298
|
|
|
1299
|
+
// One candidate-state read per parent table, not one per reference: a
|
|
1300
|
+
// commit that points several rows at the same parent (a fight's
|
|
1301
|
+
// combatants, its chronicle) reads that parent once. Postgres binds the
|
|
1302
|
+
// ids as one array per table; SQLite and D1 answer with one lookup per id.
|
|
1303
|
+
const parentIds = new Map<string, Set<string>>();
|
|
1304
|
+
for (const { last: write } of finalByRow.values()) {
|
|
1305
|
+
if (write.op !== 'upsert' || write.values === undefined) continue;
|
|
1306
|
+
for (const reference of write.table.references) {
|
|
1307
|
+
const value = write.values[reference.columnIndex];
|
|
1308
|
+
if (value === null || value === undefined) continue;
|
|
1309
|
+
const parentRowId = renderScopeValue(value);
|
|
1310
|
+
if (parentRowId === undefined) continue;
|
|
1311
|
+
const ids = parentIds.get(reference.parentTable);
|
|
1312
|
+
if (ids === undefined)
|
|
1313
|
+
parentIds.set(reference.parentTable, new Set([parentRowId]));
|
|
1314
|
+
else ids.add(parentRowId);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
const parents = new Map<string, ReadonlyMap<string, StoredRow>>();
|
|
1318
|
+
for (const [parentTable, ids] of parentIds) {
|
|
1319
|
+
parents.set(
|
|
1320
|
+
parentTable,
|
|
1321
|
+
await readCandidateRows(tx, parentTable, [...ids]),
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1246
1325
|
for (const { last: write } of finalByRow.values()) {
|
|
1247
1326
|
if (write.op !== 'upsert' || write.values === undefined) continue;
|
|
1248
1327
|
for (const reference of write.table.references) {
|
|
@@ -1250,7 +1329,7 @@ async function enforceReferences(
|
|
|
1250
1329
|
if (value === null || value === undefined) continue;
|
|
1251
1330
|
const parentRowId = renderScopeValue(value);
|
|
1252
1331
|
if (parentRowId === undefined) continue;
|
|
1253
|
-
if (
|
|
1332
|
+
if (parents.get(reference.parentTable)?.has(parentRowId) === true) {
|
|
1254
1333
|
continue;
|
|
1255
1334
|
}
|
|
1256
1335
|
return {
|
|
@@ -1769,6 +1848,7 @@ export async function processPushOperationsWithTrace(
|
|
|
1769
1848
|
if (terminated === undefined) {
|
|
1770
1849
|
const commitReject = await runCommitValidator(
|
|
1771
1850
|
commitValidator,
|
|
1851
|
+
ctx.onError,
|
|
1772
1852
|
tx,
|
|
1773
1853
|
schema,
|
|
1774
1854
|
clientId,
|
package/src/validate.ts
CHANGED
|
@@ -97,6 +97,22 @@ export interface CommitValidationScanInput {
|
|
|
97
97
|
readonly limit?: number;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* One exact declared-index lookup a push-transaction reader runs (§6.8): the
|
|
102
|
+
* whole-commit reader and a registered command share this shape.
|
|
103
|
+
*/
|
|
104
|
+
export interface IndexScanInput {
|
|
105
|
+
readonly table: string;
|
|
106
|
+
/** `TableSchema.indexes[].name`; never exposed as a client subscription. */
|
|
107
|
+
readonly index: string;
|
|
108
|
+
/** Exact values in the index declaration's column order. */
|
|
109
|
+
readonly values: readonly RowValue[];
|
|
110
|
+
/** Resume after this rowId (exclusive); `null` = start of the match set. */
|
|
111
|
+
readonly afterRowId?: string | null;
|
|
112
|
+
/** Bounded per call by the server to 1..1,000; defaults to 100. */
|
|
113
|
+
readonly limit?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
100
116
|
/**
|
|
101
117
|
* Candidate-state reads bound to the same storage transaction as the commit.
|
|
102
118
|
* Reads observe every staged sibling operation and no uncommitted competing
|
|
@@ -120,6 +136,16 @@ export interface CommitValidationReader {
|
|
|
120
136
|
rowIds: readonly string[],
|
|
121
137
|
): Promise<ReadonlyMap<string, CommitValidationRow>>;
|
|
122
138
|
scanRows(input: CommitValidationScanInput): Promise<CommitValidationRow[]>;
|
|
139
|
+
/**
|
|
140
|
+
* Candidate-state exact lookup through one declared relational index, with
|
|
141
|
+
* the same read-your-own-writes guarantee as `scanRows`: staged sibling
|
|
142
|
+
* upserts are present and staged deletes are absent. The values form the
|
|
143
|
+
* complete, order-sensitive index tuple and `afterRowId` is an exclusive
|
|
144
|
+
* keyset cursor, so a caller pages without a scope declaration. There is no
|
|
145
|
+
* scope filter and no actor scope authorization: the caller already holds
|
|
146
|
+
* partition authority, exactly as with `scanRows`.
|
|
147
|
+
*/
|
|
148
|
+
scanRowsByIndex(input: IndexScanInput): Promise<CommitValidationRow[]>;
|
|
123
149
|
/**
|
|
124
150
|
* Run a generated registered query on the commit transaction (§6.8). The
|
|
125
151
|
* request has the shape `ServerStorage.queryAuthoritative` takes; the
|