@syncular/server 0.47.0 → 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/operations.d.ts +10 -1
- package/dist/operations.js +17 -0
- package/dist/push.js +44 -1
- package/dist/validate.d.ts +25 -0
- package/package.json +2 -2
- package/src/operations.ts +40 -0
- package/src/push.ts +55 -1
- 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/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
|
@@ -696,6 +696,23 @@ 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
|
}
|
|
@@ -791,6 +808,32 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
791
808
|
last: write,
|
|
792
809
|
});
|
|
793
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
|
+
}
|
|
794
837
|
for (const { last: write } of finalByRow.values()) {
|
|
795
838
|
if (write.op !== 'upsert' || write.values === undefined)
|
|
796
839
|
continue;
|
|
@@ -801,7 +844,7 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
801
844
|
const parentRowId = renderScopeValue(value);
|
|
802
845
|
if (parentRowId === undefined)
|
|
803
846
|
continue;
|
|
804
|
-
if (
|
|
847
|
+
if (parents.get(reference.parentTable)?.has(parentRowId) === true) {
|
|
805
848
|
continue;
|
|
806
849
|
}
|
|
807
850
|
return {
|
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/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
|
@@ -1110,6 +1110,34 @@ 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
|
}
|
|
@@ -1268,6 +1296,32 @@ async function enforceReferences(
|
|
|
1268
1296
|
});
|
|
1269
1297
|
}
|
|
1270
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
|
+
|
|
1271
1325
|
for (const { last: write } of finalByRow.values()) {
|
|
1272
1326
|
if (write.op !== 'upsert' || write.values === undefined) continue;
|
|
1273
1327
|
for (const reference of write.table.references) {
|
|
@@ -1275,7 +1329,7 @@ async function enforceReferences(
|
|
|
1275
1329
|
if (value === null || value === undefined) continue;
|
|
1276
1330
|
const parentRowId = renderScopeValue(value);
|
|
1277
1331
|
if (parentRowId === undefined) continue;
|
|
1278
|
-
if (
|
|
1332
|
+
if (parents.get(reference.parentTable)?.has(parentRowId) === true) {
|
|
1279
1333
|
continue;
|
|
1280
1334
|
}
|
|
1281
1335
|
return {
|
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
|