@syncular/server 0.23.0 → 0.25.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 +14 -2
- package/dist/d1-storage.js +102 -34
- package/dist/operations.d.ts +7 -1
- package/dist/operations.js +2 -0
- package/dist/postgres-storage.js +47 -56
- package/dist/push.js +13 -7
- package/dist/relational-rows.d.ts +31 -12
- package/dist/relational-rows.js +121 -26
- package/dist/sqlite-storage.d.ts +7 -0
- package/dist/sqlite-storage.js +32 -50
- package/dist/storage-errors.d.ts +5 -3
- package/dist/storage-errors.js +9 -2
- package/dist/storage-query.d.ts +8 -1
- package/dist/storage-query.js +12 -0
- package/dist/storage.d.ts +25 -11
- package/dist/validate.d.ts +33 -15
- package/package.json +2 -2
- package/src/d1-storage.ts +139 -36
- package/src/operations.ts +15 -1
- package/src/postgres-storage.ts +86 -66
- package/src/push.ts +13 -1
- package/src/relational-rows.ts +145 -30
- package/src/sqlite-storage.ts +53 -61
- package/src/storage-errors.ts +21 -3
- package/src/storage-query.ts +23 -1
- package/src/storage.ts +27 -11
- package/src/validate.ts +40 -15
package/README.md
CHANGED
|
@@ -168,6 +168,18 @@ the stable code/details to localized copy instead of displaying that message.
|
|
|
168
168
|
|
|
169
169
|
Validators are per-operation. A validator must not mutate the row it receives.
|
|
170
170
|
|
|
171
|
+
A validator that decides from other rows (a membership, a role, a grant) calls
|
|
172
|
+
`context.queryAuthoritative` with the request `storage.queryAuthoritative`
|
|
173
|
+
takes, built from a generated query descriptor. The storage binds every
|
|
174
|
+
relation to the commit's partition and runs the statement on the push
|
|
175
|
+
transaction's connection, so the read sees the operations staged before this
|
|
176
|
+
one and needs no second pool connection. Never call
|
|
177
|
+
`storage.queryAuthoritative` from a validator: on SQLite and PGlite it waits
|
|
178
|
+
for the push transaction and the push never completes, and on a pool it reads
|
|
179
|
+
committed state. The whole-commit reader, the reaction planner reader, and the
|
|
180
|
+
remote command context expose the same method. See
|
|
181
|
+
[Read a generated query inside a push transaction](https://syncular.dev/guide-remote-operations/#read-a-generated-query-inside-a-push-transaction).
|
|
182
|
+
|
|
171
183
|
For a multi-row or multi-table invariant, install `commitValidator`. It runs
|
|
172
184
|
once after every operation is staged in the same transaction, and can inspect
|
|
173
185
|
both the decoded sibling operations and final candidate state:
|
|
@@ -215,8 +227,8 @@ const config: SyncServerConfig = {
|
|
|
215
227
|
};
|
|
216
228
|
```
|
|
217
229
|
|
|
218
|
-
The callback also receives `read.getRow()
|
|
219
|
-
Those reads see the final candidate state, including staged sibling upserts and
|
|
230
|
+
The callback also receives `read.getRow()`, bounded `read.scanRows()`, and
|
|
231
|
+
`read.queryAuthoritative()` APIs. Those reads see the final candidate state, including staged sibling upserts and
|
|
220
232
|
deletes. Syncular serializes the partition before any operation read so two
|
|
221
233
|
aggregate validators cannot both accept mutually invalid candidates. It also
|
|
222
234
|
re-checks idempotency after taking that lock and persists a rejected outcome
|
package/dist/d1-storage.js
CHANGED
|
@@ -45,9 +45,9 @@ import { StorageQueryError } from './storage-errors.js';
|
|
|
45
45
|
import { decodeRow } from '@syncular/core';
|
|
46
46
|
import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
47
47
|
import { syncError } from './errors.js';
|
|
48
|
-
import { assertAppendOnlyMigration, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
48
|
+
import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
|
|
49
49
|
import { matchesEffective } from './scopes.js';
|
|
50
|
-
import { asUint8Array, collectCommitWindowPage, deserializePushResult, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
|
|
50
|
+
import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, serializePushResult, sqliteDdlStatements, toStoredRow, } from './sqlite-dialect.js';
|
|
51
51
|
import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
|
|
52
52
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
53
53
|
function toStoredReaction(record) {
|
|
@@ -83,10 +83,44 @@ function relationalValuesEqual(left, right) {
|
|
|
83
83
|
}
|
|
84
84
|
return Object.is(left, right);
|
|
85
85
|
}
|
|
86
|
+
/** Run one partition-bound registered query and the cursor read as one batch. */
|
|
87
|
+
async function queryAuthoritativeOn(db, partition, prepared) {
|
|
88
|
+
const results = await db.batch([
|
|
89
|
+
db.prepare(prepared.sql).bind(...prepared.params),
|
|
90
|
+
db
|
|
91
|
+
.prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
92
|
+
.bind(partition),
|
|
93
|
+
]);
|
|
94
|
+
const rowsResult = results[0];
|
|
95
|
+
const cursorResult = results[1];
|
|
96
|
+
if (typeof rowsResult !== 'object' ||
|
|
97
|
+
rowsResult === null ||
|
|
98
|
+
!('results' in rowsResult) ||
|
|
99
|
+
!Array.isArray(rowsResult.results) ||
|
|
100
|
+
typeof cursorResult !== 'object' ||
|
|
101
|
+
cursorResult === null ||
|
|
102
|
+
!('results' in cursorResult) ||
|
|
103
|
+
!Array.isArray(cursorResult.results)) {
|
|
104
|
+
throw new Error('D1 registered query returned an invalid batch result');
|
|
105
|
+
}
|
|
106
|
+
const cursor = cursorResult.results[0];
|
|
107
|
+
const maxCommitSeq = typeof cursor === 'object' &&
|
|
108
|
+
cursor !== null &&
|
|
109
|
+
'max_commit_seq' in cursor &&
|
|
110
|
+
typeof cursor.max_commit_seq === 'number'
|
|
111
|
+
? cursor.max_commit_seq
|
|
112
|
+
: 0;
|
|
113
|
+
return {
|
|
114
|
+
rows: rowsResult.results.filter((row) => typeof row === 'object' && row !== null),
|
|
115
|
+
maxCommitSeq,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
86
118
|
class D1Transaction {
|
|
87
119
|
#db;
|
|
88
120
|
#partition;
|
|
89
121
|
#resolveTable;
|
|
122
|
+
/** Storage-owned relation rewrite, bound to this transaction's partition. */
|
|
123
|
+
#prepareQuery;
|
|
90
124
|
#pushApplySerialized;
|
|
91
125
|
#buffer = [];
|
|
92
126
|
#open = true;
|
|
@@ -110,10 +144,11 @@ class D1Transaction {
|
|
|
110
144
|
* `tbl\u0000rowId` → the pending state, consulted by `getRow` before D1.
|
|
111
145
|
*/
|
|
112
146
|
#pending = new Map();
|
|
113
|
-
constructor(db, partition, resolveTable, pushApplySerialized) {
|
|
147
|
+
constructor(db, partition, resolveTable, prepareQuery, pushApplySerialized) {
|
|
114
148
|
this.#db = db;
|
|
115
149
|
this.#partition = partition;
|
|
116
150
|
this.#resolveTable = resolveTable;
|
|
151
|
+
this.#prepareQuery = prepareQuery;
|
|
117
152
|
this.#pushApplySerialized = pushApplySerialized;
|
|
118
153
|
}
|
|
119
154
|
#assertOpen() {
|
|
@@ -255,6 +290,18 @@ class D1Transaction {
|
|
|
255
290
|
.sort((left, right) => left.rowId.localeCompare(right.rowId))
|
|
256
291
|
.slice(0, query.limit);
|
|
257
292
|
}
|
|
293
|
+
async queryAuthoritative(query) {
|
|
294
|
+
this.#assertOpen();
|
|
295
|
+
// D1 buffers writes until commit() and cannot overlay them on generated
|
|
296
|
+
// SQL, so a read over a written table would return committed state
|
|
297
|
+
// without this transaction's staged rows. Refuse it instead.
|
|
298
|
+
for (const key of this.#pending.keys()) {
|
|
299
|
+
if (query.tables.includes(key.slice(0, key.indexOf('\u0000')))) {
|
|
300
|
+
throw new StorageQueryError('sync.storage.query_over_staged_writes');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return queryAuthoritativeOn(this.#db, this.#partition, this.#prepareQuery(query));
|
|
304
|
+
}
|
|
258
305
|
async lockPartitionForPush() {
|
|
259
306
|
this.#assertOpen();
|
|
260
307
|
if (!this.#pushApplySerialized) {
|
|
@@ -510,6 +557,26 @@ _a = D1Transaction;
|
|
|
510
557
|
* binds one per app column plus the five `_sync_*` meta columns.
|
|
511
558
|
*/
|
|
512
559
|
const D1_MAX_BIND_PARAMS = 100;
|
|
560
|
+
/**
|
|
561
|
+
* The core tables `D1ServerStorage` reads or writes outside schema migration.
|
|
562
|
+
* `migrateSchema` creates them in its core phase, which runs only when the
|
|
563
|
+
* version marker advances, so a same-version open refuses a database missing
|
|
564
|
+
* one. `sync_backfill_checkpoints` and `sync_writer_fence` stay out: D1
|
|
565
|
+
* declares no checkpoints and installs no fence.
|
|
566
|
+
*/
|
|
567
|
+
const D1_CORE_TABLES = [
|
|
568
|
+
'sync_partitions',
|
|
569
|
+
'sync_partition_registry',
|
|
570
|
+
'sync_row_scopes',
|
|
571
|
+
'sync_commits',
|
|
572
|
+
'sync_changes',
|
|
573
|
+
'sync_change_scopes',
|
|
574
|
+
'sync_push_results',
|
|
575
|
+
'sync_reactions',
|
|
576
|
+
'sync_clients',
|
|
577
|
+
'sync_blob_refs',
|
|
578
|
+
'sync_tombstones',
|
|
579
|
+
];
|
|
513
580
|
export class D1ServerStorage {
|
|
514
581
|
#db;
|
|
515
582
|
#pushApplySerialized;
|
|
@@ -602,6 +669,33 @@ export class D1ServerStorage {
|
|
|
602
669
|
const marker = await read(this.#db.prepare('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1'));
|
|
603
670
|
let claim = await read(this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'));
|
|
604
671
|
if (claim === null && marker?.schema_version === schema.version) {
|
|
672
|
+
assertStoredLayouts(schema, marker.layouts);
|
|
673
|
+
statementsExecuted++;
|
|
674
|
+
const present = await this.#db
|
|
675
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name IN (${placeholders(D1_CORE_TABLES.length)})`)
|
|
676
|
+
.bind(...D1_CORE_TABLES)
|
|
677
|
+
.all();
|
|
678
|
+
const presentNames = new Set(present.results.map((row) => row.name));
|
|
679
|
+
for (const name of D1_CORE_TABLES) {
|
|
680
|
+
if (!presentNames.has(name)) {
|
|
681
|
+
throw new StorageQueryError('sync.storage.physical_layout_mismatch', {
|
|
682
|
+
table: name,
|
|
683
|
+
reason: 'missing_table',
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
for (const table of tables) {
|
|
688
|
+
statementsExecuted++;
|
|
689
|
+
const { results } = await this.#db
|
|
690
|
+
.prepare(`PRAGMA table_info(${quoteIdent(table.name)})`)
|
|
691
|
+
.all();
|
|
692
|
+
assertPhysicalColumns(table, results.map((column) => ({
|
|
693
|
+
name: column.name,
|
|
694
|
+
type: column.type,
|
|
695
|
+
notNull: column.notnull === 1,
|
|
696
|
+
primaryKeyPosition: column.pk,
|
|
697
|
+
})), 'sqlite');
|
|
698
|
+
}
|
|
605
699
|
this.#tables = schema.tables;
|
|
606
700
|
this.#schemaVersion = schema.version;
|
|
607
701
|
return { complete: true, statementsExecuted };
|
|
@@ -983,7 +1077,7 @@ export class D1ServerStorage {
|
|
|
983
1077
|
if (table === undefined)
|
|
984
1078
|
throw new StorageQueryError('sync.storage.schema_changed');
|
|
985
1079
|
return table;
|
|
986
|
-
}, this.#pushApplySerialized);
|
|
1080
|
+
}, (query) => this.#prepareAuthoritativeQuery(partition, query), this.#pushApplySerialized);
|
|
987
1081
|
}
|
|
988
1082
|
async getMaxCommitSeq(partition) {
|
|
989
1083
|
const row = await this.#db
|
|
@@ -997,39 +1091,13 @@ export class D1ServerStorage {
|
|
|
997
1091
|
// re-checks the published marker inside this same batch, so condition 2
|
|
998
1092
|
// is already evaluated with the query (§2.4 D1 schema readiness).
|
|
999
1093
|
const db = this.#schemaDatabase();
|
|
1094
|
+
return queryAuthoritativeOn(db, partition, this.#prepareAuthoritativeQuery(partition, query));
|
|
1095
|
+
}
|
|
1096
|
+
#prepareAuthoritativeQuery(partition, query) {
|
|
1000
1097
|
if (this.#tables === undefined) {
|
|
1001
1098
|
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
1002
1099
|
}
|
|
1003
|
-
|
|
1004
|
-
const results = await db.batch([
|
|
1005
|
-
db.prepare(prepared.sql).bind(...prepared.params),
|
|
1006
|
-
db
|
|
1007
|
-
.prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
1008
|
-
.bind(partition),
|
|
1009
|
-
]);
|
|
1010
|
-
const rowsResult = results[0];
|
|
1011
|
-
const cursorResult = results[1];
|
|
1012
|
-
if (typeof rowsResult !== 'object' ||
|
|
1013
|
-
rowsResult === null ||
|
|
1014
|
-
!('results' in rowsResult) ||
|
|
1015
|
-
!Array.isArray(rowsResult.results) ||
|
|
1016
|
-
typeof cursorResult !== 'object' ||
|
|
1017
|
-
cursorResult === null ||
|
|
1018
|
-
!('results' in cursorResult) ||
|
|
1019
|
-
!Array.isArray(cursorResult.results)) {
|
|
1020
|
-
throw new Error('D1 registered query returned an invalid batch result');
|
|
1021
|
-
}
|
|
1022
|
-
const cursor = cursorResult.results[0];
|
|
1023
|
-
const maxCommitSeq = typeof cursor === 'object' &&
|
|
1024
|
-
cursor !== null &&
|
|
1025
|
-
'max_commit_seq' in cursor &&
|
|
1026
|
-
typeof cursor.max_commit_seq === 'number'
|
|
1027
|
-
? cursor.max_commit_seq
|
|
1028
|
-
: 0;
|
|
1029
|
-
return {
|
|
1030
|
-
rows: rowsResult.results.filter((row) => typeof row === 'object' && row !== null),
|
|
1031
|
-
maxCommitSeq,
|
|
1032
|
-
};
|
|
1100
|
+
return bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
|
|
1033
1101
|
}
|
|
1034
1102
|
async getPartitionLogEpoch(partition) {
|
|
1035
1103
|
const row = await this.#db
|
package/dist/operations.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type AuthoritativeRelationPlan } from './authoritative-query.js';
|
|
2
2
|
import { type RemoteOperationResponse, type RowValue } from '@syncular/core';
|
|
3
3
|
import type { SyncRequestContext } from './context.js';
|
|
4
|
-
import type { AuthoritativeQueryValue } from './storage.js';
|
|
4
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, AuthoritativeQueryValue } from './storage.js';
|
|
5
5
|
import { type ValidateRow } from './validate.js';
|
|
6
6
|
export interface RemoteQueryDependency {
|
|
7
7
|
readonly table: string;
|
|
@@ -63,6 +63,12 @@ export interface RemoteCommandContext {
|
|
|
63
63
|
readonly operationId: string;
|
|
64
64
|
readonly requestId: string;
|
|
65
65
|
getRow(table: string, rowId: string): Promise<ValidateRow | undefined>;
|
|
66
|
+
/**
|
|
67
|
+
* Run a generated registered query on the command's push transaction
|
|
68
|
+
* (§6.7). The storage binds every relation to the partition; unlike
|
|
69
|
+
* `getRow`, the rows are not filtered by the actor's scopes.
|
|
70
|
+
*/
|
|
71
|
+
queryAuthoritative(query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
|
66
72
|
}
|
|
67
73
|
export interface RemoteCommandOptions<Input> {
|
|
68
74
|
readonly authorize: (context: RemoteOperationAuthContext, input: Input) => boolean | Promise<boolean>;
|
package/dist/operations.js
CHANGED
|
@@ -5,6 +5,7 @@ import { SyncError, syncError } from './errors.js';
|
|
|
5
5
|
import { processPushOperationsWithTrace } from './push.js';
|
|
6
6
|
import { compileSchema } from './schema.js';
|
|
7
7
|
import { authorizeWrite } from './scopes.js';
|
|
8
|
+
import { transactionQuery } from './storage-query.js';
|
|
8
9
|
import { toValidateRow } from './validate.js';
|
|
9
10
|
function scopeAllowed(allowed, variable, value) {
|
|
10
11
|
const values = allowed[variable];
|
|
@@ -293,6 +294,7 @@ function commandContext(ctx, tx, resolved, schema, clientId, operationId, reques
|
|
|
293
294
|
}
|
|
294
295
|
return toValidateRow(table.columns, decodeRow(table.columns, stored.payload));
|
|
295
296
|
},
|
|
297
|
+
queryAuthoritative: (query) => transactionQuery(tx, query),
|
|
296
298
|
};
|
|
297
299
|
}
|
|
298
300
|
/** Register custom command code that plans one ordinary Syncular commit. */
|
package/dist/postgres-storage.js
CHANGED
|
@@ -5,7 +5,7 @@ import { serveNotReadyError } from './readiness.js';
|
|
|
5
5
|
import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
6
6
|
import { syncError } from './errors.js';
|
|
7
7
|
import { asBytes, asNumber, } from './pg-executor.js';
|
|
8
|
-
import { assertPhysicalColumns, commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
8
|
+
import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
9
9
|
import { matchesEffective } from './scopes.js';
|
|
10
10
|
import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
11
11
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
@@ -419,9 +419,22 @@ async function getPushResultOn(q, partition, clientId, clientCommitId) {
|
|
|
419
419
|
throw syncError('sync.idempotency_cache_miss', 'persisted push result unreadable (§6.3)');
|
|
420
420
|
}
|
|
421
421
|
}
|
|
422
|
+
/** Run one partition-bound registered query and the cursor read on `client`. */
|
|
423
|
+
async function queryAuthoritativeOn(client, partition, prepared) {
|
|
424
|
+
const result = await client.query(postgresPlaceholders(prepared.sql), prepared.params);
|
|
425
|
+
const cursor = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
|
|
426
|
+
return {
|
|
427
|
+
rows: result.rows,
|
|
428
|
+
maxCommitSeq: cursor.rows[0] === undefined
|
|
429
|
+
? 0
|
|
430
|
+
: asNumber(cursor.rows[0].max_commit_seq),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
422
433
|
class PostgresTransaction {
|
|
423
434
|
#client;
|
|
424
435
|
#partition;
|
|
436
|
+
/** Storage-owned relation rewrite, bound to this transaction's partition. */
|
|
437
|
+
#prepareQuery;
|
|
425
438
|
/** Schema version this transaction writes; rides every appended commit row. */
|
|
426
439
|
#writerVersion;
|
|
427
440
|
#resolveTable;
|
|
@@ -430,7 +443,7 @@ class PostgresTransaction {
|
|
|
430
443
|
/** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
|
|
431
444
|
#resolve;
|
|
432
445
|
#reject;
|
|
433
|
-
constructor(client, partition, writerVersion, resolveTable, resolve, reject) {
|
|
446
|
+
constructor(client, partition, writerVersion, resolveTable, prepareQuery, resolve, reject) {
|
|
434
447
|
// One pinned connection carries one statement at a time: node-postgres
|
|
435
448
|
// warns about a concurrently issued second query today and rejects it in
|
|
436
449
|
// node-postgres 9. A whole-commit validator that fans independent reads
|
|
@@ -448,6 +461,7 @@ class PostgresTransaction {
|
|
|
448
461
|
this.#partition = partition;
|
|
449
462
|
this.#writerVersion = writerVersion;
|
|
450
463
|
this.#resolveTable = resolveTable;
|
|
464
|
+
this.#prepareQuery = prepareQuery;
|
|
451
465
|
this.#resolve = resolve;
|
|
452
466
|
this.#reject = reject;
|
|
453
467
|
}
|
|
@@ -517,6 +531,12 @@ class PostgresTransaction {
|
|
|
517
531
|
this.#assertOpen();
|
|
518
532
|
return scanRowsByIndexOn(this.#client, this.#resolveTable(query.table), this.#partition, query);
|
|
519
533
|
}
|
|
534
|
+
queryAuthoritative(query) {
|
|
535
|
+
this.#assertOpen();
|
|
536
|
+
// Runs on this transaction's pinned client, so the read observes every
|
|
537
|
+
// staged row and never waits for a second pool connection.
|
|
538
|
+
return queryAuthoritativeOn(this.#client, this.#partition, this.#prepareQuery(query));
|
|
539
|
+
}
|
|
520
540
|
async lockPartitionForPush() {
|
|
521
541
|
this.#assertOpen();
|
|
522
542
|
await lockPartitionOn(this.#client, this.#partition);
|
|
@@ -775,57 +795,32 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
775
795
|
throw new Error(`stored schema version ${stored} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
|
|
776
796
|
}
|
|
777
797
|
if (stored !== undefined && stored === schema.version) {
|
|
778
|
-
|
|
779
|
-
// build at the same version describes rows the running codec cannot
|
|
780
|
-
// decode. Compare the stored layouts instead of trusting the number.
|
|
781
|
-
const storedLayouts = parseLayouts(typeof marker.rows[0]?.layouts === 'string'
|
|
798
|
+
assertStoredLayouts(schema, typeof marker.rows[0]?.layouts === 'string'
|
|
782
799
|
? marker.rows[0].layouts
|
|
783
800
|
: undefined);
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
for (const [tableName, columns] of Object.entries(configuredLayouts)) {
|
|
787
|
-
const storedTable = storedLayouts[tableName];
|
|
788
|
-
const columnCount = Math.max(columns.length, storedTable?.length ?? 0);
|
|
789
|
-
for (let index = 0; index < columnCount; index++) {
|
|
790
|
-
const expected = columns[index];
|
|
791
|
-
const actual = storedTable?.[index];
|
|
792
|
-
if (expected !== undefined &&
|
|
793
|
-
actual !== undefined &&
|
|
794
|
-
actual.name === expected.name &&
|
|
795
|
-
actual.type === expected.type &&
|
|
796
|
-
actual.nullable === expected.nullable) {
|
|
797
|
-
continue;
|
|
798
|
-
}
|
|
799
|
-
mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
|
|
800
|
-
break;
|
|
801
|
-
}
|
|
802
|
-
if (mismatch !== undefined)
|
|
803
|
-
break;
|
|
804
|
-
}
|
|
805
|
-
if (mismatch === undefined) {
|
|
806
|
-
for (const tableName of Object.keys(storedLayouts)) {
|
|
807
|
-
if (!(tableName in configuredLayouts)) {
|
|
808
|
-
mismatch = `table ${JSON.stringify(tableName)}`;
|
|
809
|
-
break;
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
if (mismatch !== undefined) {
|
|
814
|
-
throw new Error(`stored schema layouts disagree with the configured schema at version ${schema.version} (${mismatch}) — refusing to serve a database whose stored rows the running code cannot decode`);
|
|
815
|
-
}
|
|
816
|
-
// The persisted layouts describe the codec's app columns only: read the
|
|
817
|
-
// physical tables so a same-version database missing a
|
|
818
|
-
// storage-internal column is refused at startup instead of failing at
|
|
819
|
-
// the first write. Resolve through `to_regclass`, which follows the
|
|
820
|
-
// session's `search_path` exactly as the unqualified row queries do —
|
|
801
|
+
// Resolve through `to_regclass`, which follows the session's
|
|
802
|
+
// `search_path` exactly as the unqualified row queries do:
|
|
821
803
|
// `current_schema()` names only the first existing schema, so it would
|
|
822
804
|
// reject a table that resolves later in the path.
|
|
823
805
|
for (const table of schema.tables.values()) {
|
|
824
|
-
const { rows } = await this.#exec.query(`SELECT attribute.attname AS
|
|
806
|
+
const { rows } = await this.#exec.query(`SELECT attribute.attname AS name,
|
|
807
|
+
format_type(attribute.atttypid, attribute.atttypmod) AS type,
|
|
808
|
+
attribute.attnotnull AS not_null,
|
|
809
|
+
(SELECT key.position::int
|
|
810
|
+
FROM pg_index pk,
|
|
811
|
+
unnest(pk.indkey::int2[]) WITH ORDINALITY
|
|
812
|
+
AS key(attnum, position)
|
|
813
|
+
WHERE pk.indrelid = attribute.attrelid AND pk.indisprimary
|
|
814
|
+
AND key.attnum = attribute.attnum) AS pk
|
|
825
815
|
FROM pg_attribute attribute
|
|
826
816
|
WHERE attribute.attrelid = to_regclass($1)
|
|
827
817
|
AND attribute.attnum > 0 AND NOT attribute.attisdropped`, [table.name]);
|
|
828
|
-
assertPhysicalColumns(table,
|
|
818
|
+
assertPhysicalColumns(table, rows.map((row) => ({
|
|
819
|
+
name: row.name,
|
|
820
|
+
type: row.type,
|
|
821
|
+
notNull: row.not_null,
|
|
822
|
+
primaryKeyPosition: row.pk ?? 0,
|
|
823
|
+
})), 'postgres');
|
|
829
824
|
}
|
|
830
825
|
}
|
|
831
826
|
if (stored === undefined || stored < schema.version) {
|
|
@@ -990,7 +985,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
990
985
|
});
|
|
991
986
|
const done = this.#exec
|
|
992
987
|
.transaction(async (client) => {
|
|
993
|
-
const tx = new PostgresTransaction(client, partition, this.#schemaVersion ?? 0, (name) => this.table(name), resolveScope, rejectScope);
|
|
988
|
+
const tx = new PostgresTransaction(client, partition, this.#schemaVersion ?? 0, (name) => this.table(name), (query) => this.#prepareAuthoritativeQuery(partition, query), resolveScope, rejectScope);
|
|
994
989
|
resolveReady(tx);
|
|
995
990
|
// Hold the transaction open until commit()/rollback() settles `scope`.
|
|
996
991
|
await scope;
|
|
@@ -1008,11 +1003,14 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1008
1003
|
const { rows } = await this.#exec.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
|
|
1009
1004
|
return rows[0] === undefined ? 0 : asNumber(rows[0].max_commit_seq);
|
|
1010
1005
|
}
|
|
1011
|
-
|
|
1006
|
+
#prepareAuthoritativeQuery(partition, query) {
|
|
1012
1007
|
if (this.#tables === undefined) {
|
|
1013
1008
|
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
1014
1009
|
}
|
|
1015
|
-
|
|
1010
|
+
return bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
|
|
1011
|
+
}
|
|
1012
|
+
async queryAuthoritative(partition, query, checkpoints) {
|
|
1013
|
+
const prepared = this.#prepareAuthoritativeQuery(partition, query);
|
|
1016
1014
|
return this.#exec.transaction(async (client) => {
|
|
1017
1015
|
await client.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
|
1018
1016
|
// RFC 0007: evaluate the gate inside this pinned snapshot, so a
|
|
@@ -1024,14 +1022,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1024
1022
|
const refusal = serveGateRefusal(gate, runningVersion, checkpoints);
|
|
1025
1023
|
if (refusal !== undefined)
|
|
1026
1024
|
throw serveNotReadyError(refusal);
|
|
1027
|
-
|
|
1028
|
-
const cursor = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1', [partition]);
|
|
1029
|
-
return {
|
|
1030
|
-
rows: result.rows,
|
|
1031
|
-
maxCommitSeq: cursor.rows[0] === undefined
|
|
1032
|
-
? 0
|
|
1033
|
-
: asNumber(cursor.rows[0].max_commit_seq),
|
|
1034
|
-
};
|
|
1025
|
+
return queryAuthoritativeOn(client, partition, prepared);
|
|
1035
1026
|
});
|
|
1036
1027
|
}
|
|
1037
1028
|
async getPartitionLogEpoch(partition) {
|
package/dist/push.js
CHANGED
|
@@ -26,6 +26,7 @@ import { authorizeWrite, renderScopeValue, storedScopesForRow } from './scopes.j
|
|
|
26
26
|
import { decodeColumnVersions, encodeColumnVersions } from './relational-rows.js';
|
|
27
27
|
import { serveGateRefusal } from './storage.js';
|
|
28
28
|
import { StorageConstraintError } from './storage-errors.js';
|
|
29
|
+
import { transactionQuery } from './storage-query.js';
|
|
29
30
|
import { serveNotReadyError } from './readiness.js';
|
|
30
31
|
import { CommitValidationRejection, toValidateRow, ValidationRejection, } from './validate.js';
|
|
31
32
|
/**
|
|
@@ -86,7 +87,7 @@ function conflictColumnsBitmap(columnCount, marked) {
|
|
|
86
87
|
* A no-op for tables with no validator — the `undefined` short-circuit
|
|
87
88
|
* keeps the feature zero-cost when off.
|
|
88
89
|
*/
|
|
89
|
-
async function runValidator(validators, table, op, rowId, values, storedValues, opIndex, partition, actorId) {
|
|
90
|
+
async function runValidator(validators, table, op, rowId, values, storedValues, opIndex, tx, partition, actorId) {
|
|
90
91
|
const validator = validators?.[table.name];
|
|
91
92
|
if (validator === undefined)
|
|
92
93
|
return undefined;
|
|
@@ -101,7 +102,11 @@ async function runValidator(validators, table, op, rowId, values, storedValues,
|
|
|
101
102
|
stored: storedValues !== undefined
|
|
102
103
|
? toValidateRow(table.columns, storedValues)
|
|
103
104
|
: undefined,
|
|
104
|
-
}, {
|
|
105
|
+
}, {
|
|
106
|
+
actorId,
|
|
107
|
+
partition,
|
|
108
|
+
queryAuthoritative: (query) => transactionQuery(tx, query),
|
|
109
|
+
});
|
|
105
110
|
}
|
|
106
111
|
catch (error) {
|
|
107
112
|
if (error instanceof ValidationRejection) {
|
|
@@ -199,7 +204,7 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
199
204
|
// stored = the row about to be removed). Only reached for an existing
|
|
200
205
|
// row — an absent-row delete is an idempotent no-op above.
|
|
201
206
|
const storedValues = decodeRow(table.columns, stored.payload);
|
|
202
|
-
const deleteReject = await runValidator(validators, table, 'delete', op.rowId, undefined, storedValues, opIndex, partition, actorId);
|
|
207
|
+
const deleteReject = await runValidator(validators, table, 'delete', op.rowId, undefined, storedValues, opIndex, tx, partition, actorId);
|
|
203
208
|
if (deleteReject !== undefined)
|
|
204
209
|
return deleteReject;
|
|
205
210
|
await tx.deleteRow(op.table, op.rowId);
|
|
@@ -330,7 +335,7 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
330
335
|
// §6.7: validate the merged row that will persist — for a crdt column
|
|
331
336
|
// the validator sees the MERGED value (§5.10.3), the state the store
|
|
332
337
|
// holds, not the raw pushed update.
|
|
333
|
-
const updateReject = await runValidator(validators, table, 'upsert', op.rowId, values, storedValues, opIndex, partition, actorId);
|
|
338
|
+
const updateReject = await runValidator(validators, table, 'upsert', op.rowId, values, storedValues, opIndex, tx, partition, actorId);
|
|
334
339
|
if (updateReject !== undefined)
|
|
335
340
|
return updateReject;
|
|
336
341
|
const columnVersions = encodeColumnVersions(nextVersions);
|
|
@@ -412,7 +417,7 @@ async function applyOperation(tx, schema, resolved, op, opIndex, blobCtx, crdtMe
|
|
|
412
417
|
// §6.7: validate the insert row (stored = undefined, so a validator can
|
|
413
418
|
// distinguish create from update); crdt columns are already merged
|
|
414
419
|
// against the empty document.
|
|
415
|
-
const insertReject = await runValidator(validators, table, 'upsert', op.rowId, values, undefined, opIndex, partition, actorId);
|
|
420
|
+
const insertReject = await runValidator(validators, table, 'upsert', op.rowId, values, undefined, opIndex, tx, partition, actorId);
|
|
416
421
|
if (insertReject !== undefined)
|
|
417
422
|
return insertReject;
|
|
418
423
|
// §5.2: an explicit insert recreates — the tombstone goes with it.
|
|
@@ -526,6 +531,7 @@ function commitValidationReader(tx, schema) {
|
|
|
526
531
|
serverVersion: stored.serverVersion,
|
|
527
532
|
}));
|
|
528
533
|
},
|
|
534
|
+
queryAuthoritative: (query) => transactionQuery(tx, query),
|
|
529
535
|
};
|
|
530
536
|
}
|
|
531
537
|
async function runCommitValidator(validator, tx, schema, clientId, clientCommitId, actorId, partition, operations) {
|
|
@@ -682,7 +688,7 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
682
688
|
const syntheticIndex = clientOpCount + originByIndex.length;
|
|
683
689
|
const storedValues = decodeRow(child.columns, childRow.payload);
|
|
684
690
|
if (reference.onDelete === 'CASCADE') {
|
|
685
|
-
const reject = await runValidator(validators, child, 'delete', childRow.rowId, undefined, storedValues, item.originOpIndex, partition, actorId);
|
|
691
|
+
const reject = await runValidator(validators, child, 'delete', childRow.rowId, undefined, storedValues, item.originOpIndex, tx, partition, actorId);
|
|
686
692
|
if (reject !== undefined) {
|
|
687
693
|
return { kind: 'terminate', record: reject.record };
|
|
688
694
|
}
|
|
@@ -706,7 +712,7 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
|
|
|
706
712
|
else {
|
|
707
713
|
const values = [...storedValues];
|
|
708
714
|
values[reference.columnIndex] = null;
|
|
709
|
-
const reject = await runValidator(validators, child, 'upsert', childRow.rowId, values, storedValues, item.originOpIndex, partition, actorId);
|
|
715
|
+
const reject = await runValidator(validators, child, 'upsert', childRow.rowId, values, storedValues, item.originOpIndex, tx, partition, actorId);
|
|
710
716
|
if (reject !== undefined) {
|
|
711
717
|
return { kind: 'terminate', record: reject.record };
|
|
712
718
|
}
|
|
@@ -75,26 +75,38 @@ export declare function columnSqlType(column: RowColumn, dialect: RelationalDial
|
|
|
75
75
|
* materialized), version, scopes, payload.
|
|
76
76
|
*/
|
|
77
77
|
export declare function tableColumnNames(table: CompiledTable): string[];
|
|
78
|
+
/** One physical column of a synced table, read from the database catalog. */
|
|
79
|
+
export interface PhysicalColumn {
|
|
80
|
+
readonly name: string;
|
|
81
|
+
/** Declared type (SQLite) or `format_type` (PostgreSQL); compared case-insensitively. */
|
|
82
|
+
readonly type: string;
|
|
83
|
+
readonly notNull: boolean;
|
|
84
|
+
/** 1-based position in the primary key; 0 when the column is not in it. */
|
|
85
|
+
readonly primaryKeyPosition: number;
|
|
86
|
+
}
|
|
78
87
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
88
|
+
* Refuse a same-version database whose physical table the row store cannot
|
|
89
|
+
* read or write. The persisted marker's layouts describe the codec's app
|
|
81
90
|
* columns only and its version number is the schema's, not the database's, so
|
|
82
|
-
* a same-version database
|
|
83
|
-
* version-only bump whose ALTER never ran
|
|
84
|
-
*
|
|
91
|
+
* a same-version database whose storage-internal meta columns drifted (a
|
|
92
|
+
* version-only bump whose ALTER never ran, or a table written by another
|
|
93
|
+
* build) is only visible in the catalog. Every configured column must exist;
|
|
94
|
+
* the meta columns must also carry the type, nullability, and primary-key
|
|
95
|
+
* position `createTableDdl` declares. App-column types and nullability stay
|
|
96
|
+
* with the stored layouts: migration-added app columns are nullable by design.
|
|
97
|
+
* Fails closed without writing a migration.
|
|
85
98
|
*
|
|
86
|
-
* `
|
|
87
|
-
*
|
|
99
|
+
* `columns` must be resolved the way the unqualified row queries resolve the
|
|
100
|
+
* table: on PostgreSQL, through the session `search_path`, not
|
|
88
101
|
* `current_schema()`. A search path whose first schema is not where the
|
|
89
102
|
* configured tables live is outside the supported contract: DDL is created in
|
|
90
103
|
* that first schema, so `ensureSchema` already writes shadow tables there.
|
|
91
104
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* persisted layouts answer for app columns.
|
|
105
|
+
* Core storage tables (`sync_tombstones`, `sync_commits`, ...) are outside
|
|
106
|
+
* this check: every open re-applies their `CREATE TABLE IF NOT EXISTS` DDL
|
|
107
|
+
* and column additions, independently of the version marker.
|
|
96
108
|
*/
|
|
97
|
-
export declare function assertPhysicalColumns(table: CompiledTable,
|
|
109
|
+
export declare function assertPhysicalColumns(table: CompiledTable, columns: readonly PhysicalColumn[], dialect: RelationalDialect): void;
|
|
98
110
|
/** CREATE TABLE IF NOT EXISTS for one app table. */
|
|
99
111
|
export declare function createTableDdl(table: CompiledTable, dialect: RelationalDialect): string;
|
|
100
112
|
/**
|
|
@@ -249,6 +261,13 @@ export type StoredLayouts = Record<string, readonly StoredColumnLayout[]>;
|
|
|
249
261
|
/** The layouts JSON persisted alongside the schema version marker. */
|
|
250
262
|
export declare function layoutsOf(schema: CompiledSchema): string;
|
|
251
263
|
export declare function parseLayouts(json: string | null | undefined): StoredLayouts;
|
|
264
|
+
/**
|
|
265
|
+
* Version equality is not layout equality: a marker written by another build
|
|
266
|
+
* at the same version describes rows the running codec cannot decode. Refuse
|
|
267
|
+
* when the stored layouts disagree with the configured schema, naming the
|
|
268
|
+
* first table (and column) that differs.
|
|
269
|
+
*/
|
|
270
|
+
export declare function assertStoredLayouts(schema: CompiledSchema, storedJson: string | null | undefined): void;
|
|
252
271
|
/**
|
|
253
272
|
* Tables present in the stored layout but absent from the configured head
|
|
254
273
|
* schema. A schema-version bump retires their relational current-row tables;
|