@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/src/d1-storage.ts
CHANGED
|
@@ -45,11 +45,14 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
|
45
45
|
import { decodeRow, type RowValue } from '@syncular/core';
|
|
46
46
|
import {
|
|
47
47
|
bindAuthoritativePartition,
|
|
48
|
+
type BoundAuthoritativeQuery,
|
|
48
49
|
prepareAuthoritativeQuery,
|
|
49
50
|
} from './authoritative-query';
|
|
50
51
|
import { syncError } from './errors';
|
|
51
52
|
import {
|
|
52
53
|
assertAppendOnlyMigration,
|
|
54
|
+
assertPhysicalColumns,
|
|
55
|
+
assertStoredLayouts,
|
|
53
56
|
commitWindowPageSql,
|
|
54
57
|
deleteRowSql,
|
|
55
58
|
deleteSqliteRowScopesSql,
|
|
@@ -81,6 +84,7 @@ import {
|
|
|
81
84
|
asUint8Array,
|
|
82
85
|
collectCommitWindowPage,
|
|
83
86
|
deserializePushResult,
|
|
87
|
+
placeholders,
|
|
84
88
|
type SqliteCommitWindowRecord,
|
|
85
89
|
type SqliteRowRecord,
|
|
86
90
|
serializePushResult,
|
|
@@ -202,6 +206,49 @@ function relationalValuesEqual(left: RowValue, right: RowValue): boolean {
|
|
|
202
206
|
return Object.is(left, right);
|
|
203
207
|
}
|
|
204
208
|
|
|
209
|
+
/** Run one partition-bound registered query and the cursor read as one batch. */
|
|
210
|
+
async function queryAuthoritativeOn(
|
|
211
|
+
db: Pick<D1Database, 'prepare' | 'batch'>,
|
|
212
|
+
partition: string,
|
|
213
|
+
prepared: BoundAuthoritativeQuery,
|
|
214
|
+
): Promise<AuthoritativeQueryResult> {
|
|
215
|
+
const results = await db.batch([
|
|
216
|
+
db.prepare(prepared.sql).bind(...prepared.params),
|
|
217
|
+
db
|
|
218
|
+
.prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
219
|
+
.bind(partition),
|
|
220
|
+
]);
|
|
221
|
+
const rowsResult = results[0];
|
|
222
|
+
const cursorResult = results[1];
|
|
223
|
+
if (
|
|
224
|
+
typeof rowsResult !== 'object' ||
|
|
225
|
+
rowsResult === null ||
|
|
226
|
+
!('results' in rowsResult) ||
|
|
227
|
+
!Array.isArray(rowsResult.results) ||
|
|
228
|
+
typeof cursorResult !== 'object' ||
|
|
229
|
+
cursorResult === null ||
|
|
230
|
+
!('results' in cursorResult) ||
|
|
231
|
+
!Array.isArray(cursorResult.results)
|
|
232
|
+
) {
|
|
233
|
+
throw new Error('D1 registered query returned an invalid batch result');
|
|
234
|
+
}
|
|
235
|
+
const cursor = cursorResult.results[0];
|
|
236
|
+
const maxCommitSeq =
|
|
237
|
+
typeof cursor === 'object' &&
|
|
238
|
+
cursor !== null &&
|
|
239
|
+
'max_commit_seq' in cursor &&
|
|
240
|
+
typeof cursor.max_commit_seq === 'number'
|
|
241
|
+
? cursor.max_commit_seq
|
|
242
|
+
: 0;
|
|
243
|
+
return {
|
|
244
|
+
rows: rowsResult.results.filter(
|
|
245
|
+
(row): row is Readonly<Record<string, unknown>> =>
|
|
246
|
+
typeof row === 'object' && row !== null,
|
|
247
|
+
),
|
|
248
|
+
maxCommitSeq,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
205
252
|
/** Read-your-own-writes overlay entry: a buffered upsert, or a deletion. */
|
|
206
253
|
type PendingRow =
|
|
207
254
|
| { readonly kind: 'row'; readonly row: StoredRow }
|
|
@@ -213,6 +260,10 @@ class D1Transaction implements StorageTransaction {
|
|
|
213
260
|
readonly #db: Pick<D1Database, 'prepare' | 'batch'>;
|
|
214
261
|
readonly #partition: string;
|
|
215
262
|
readonly #resolveTable: (name: string) => CompiledTable;
|
|
263
|
+
/** Storage-owned relation rewrite, bound to this transaction's partition. */
|
|
264
|
+
readonly #prepareQuery: (
|
|
265
|
+
query: AuthoritativeQueryRequest,
|
|
266
|
+
) => BoundAuthoritativeQuery;
|
|
216
267
|
readonly #pushApplySerialized: boolean;
|
|
217
268
|
readonly #buffer: BufferedStatement[] = [];
|
|
218
269
|
#open = true;
|
|
@@ -241,11 +292,13 @@ class D1Transaction implements StorageTransaction {
|
|
|
241
292
|
db: Pick<D1Database, 'prepare' | 'batch'>,
|
|
242
293
|
partition: string,
|
|
243
294
|
resolveTable: (name: string) => CompiledTable,
|
|
295
|
+
prepareQuery: (query: AuthoritativeQueryRequest) => BoundAuthoritativeQuery,
|
|
244
296
|
pushApplySerialized: boolean,
|
|
245
297
|
) {
|
|
246
298
|
this.#db = db;
|
|
247
299
|
this.#partition = partition;
|
|
248
300
|
this.#resolveTable = resolveTable;
|
|
301
|
+
this.#prepareQuery = prepareQuery;
|
|
249
302
|
this.#pushApplySerialized = pushApplySerialized;
|
|
250
303
|
}
|
|
251
304
|
|
|
@@ -438,6 +491,25 @@ class D1Transaction implements StorageTransaction {
|
|
|
438
491
|
.slice(0, query.limit);
|
|
439
492
|
}
|
|
440
493
|
|
|
494
|
+
async queryAuthoritative(
|
|
495
|
+
query: AuthoritativeQueryRequest,
|
|
496
|
+
): Promise<AuthoritativeQueryResult> {
|
|
497
|
+
this.#assertOpen();
|
|
498
|
+
// D1 buffers writes until commit() and cannot overlay them on generated
|
|
499
|
+
// SQL, so a read over a written table would return committed state
|
|
500
|
+
// without this transaction's staged rows. Refuse it instead.
|
|
501
|
+
for (const key of this.#pending.keys()) {
|
|
502
|
+
if (query.tables.includes(key.slice(0, key.indexOf('\u0000')))) {
|
|
503
|
+
throw new StorageQueryError('sync.storage.query_over_staged_writes');
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return queryAuthoritativeOn(
|
|
507
|
+
this.#db,
|
|
508
|
+
this.#partition,
|
|
509
|
+
this.#prepareQuery(query),
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
441
513
|
async lockPartitionForPush(): Promise<void> {
|
|
442
514
|
this.#assertOpen();
|
|
443
515
|
if (!this.#pushApplySerialized) {
|
|
@@ -805,6 +877,27 @@ export interface D1ServerStorageOptions {
|
|
|
805
877
|
readonly commitValidationSerialized?: boolean;
|
|
806
878
|
}
|
|
807
879
|
|
|
880
|
+
/**
|
|
881
|
+
* The core tables `D1ServerStorage` reads or writes outside schema migration.
|
|
882
|
+
* `migrateSchema` creates them in its core phase, which runs only when the
|
|
883
|
+
* version marker advances, so a same-version open refuses a database missing
|
|
884
|
+
* one. `sync_backfill_checkpoints` and `sync_writer_fence` stay out: D1
|
|
885
|
+
* declares no checkpoints and installs no fence.
|
|
886
|
+
*/
|
|
887
|
+
const D1_CORE_TABLES = [
|
|
888
|
+
'sync_partitions',
|
|
889
|
+
'sync_partition_registry',
|
|
890
|
+
'sync_row_scopes',
|
|
891
|
+
'sync_commits',
|
|
892
|
+
'sync_changes',
|
|
893
|
+
'sync_change_scopes',
|
|
894
|
+
'sync_push_results',
|
|
895
|
+
'sync_reactions',
|
|
896
|
+
'sync_clients',
|
|
897
|
+
'sync_blob_refs',
|
|
898
|
+
'sync_tombstones',
|
|
899
|
+
] as const;
|
|
900
|
+
|
|
808
901
|
interface D1MigrationState {
|
|
809
902
|
phase: 'core' | 'prepare' | 'ddl' | 'rewrite';
|
|
810
903
|
offset: number;
|
|
@@ -946,6 +1039,39 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
946
1039
|
this.#db.prepare('SELECT * FROM sync_schema_migration WHERE id=1'),
|
|
947
1040
|
);
|
|
948
1041
|
if (claim === null && marker?.schema_version === schema.version) {
|
|
1042
|
+
assertStoredLayouts(schema, marker.layouts);
|
|
1043
|
+
statementsExecuted++;
|
|
1044
|
+
const present = await this.#db
|
|
1045
|
+
.prepare(
|
|
1046
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name IN (${placeholders(D1_CORE_TABLES.length)})`,
|
|
1047
|
+
)
|
|
1048
|
+
.bind(...D1_CORE_TABLES)
|
|
1049
|
+
.all<{ name: string }>();
|
|
1050
|
+
const presentNames = new Set(present.results.map((row) => row.name));
|
|
1051
|
+
for (const name of D1_CORE_TABLES) {
|
|
1052
|
+
if (!presentNames.has(name)) {
|
|
1053
|
+
throw new StorageQueryError('sync.storage.physical_layout_mismatch', {
|
|
1054
|
+
table: name,
|
|
1055
|
+
reason: 'missing_table',
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
for (const table of tables) {
|
|
1060
|
+
statementsExecuted++;
|
|
1061
|
+
const { results } = await this.#db
|
|
1062
|
+
.prepare(`PRAGMA table_info(${quoteIdent(table.name)})`)
|
|
1063
|
+
.all<{ name: string; type: string; notnull: number; pk: number }>();
|
|
1064
|
+
assertPhysicalColumns(
|
|
1065
|
+
table,
|
|
1066
|
+
results.map((column) => ({
|
|
1067
|
+
name: column.name,
|
|
1068
|
+
type: column.type,
|
|
1069
|
+
notNull: column.notnull === 1,
|
|
1070
|
+
primaryKeyPosition: column.pk,
|
|
1071
|
+
})),
|
|
1072
|
+
'sqlite',
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
949
1075
|
this.#tables = schema.tables;
|
|
950
1076
|
this.#schemaVersion = schema.version;
|
|
951
1077
|
return { complete: true, statementsExecuted };
|
|
@@ -1448,6 +1574,7 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1448
1574
|
throw new StorageQueryError('sync.storage.schema_changed');
|
|
1449
1575
|
return table;
|
|
1450
1576
|
},
|
|
1577
|
+
(query) => this.#prepareAuthoritativeQuery(partition, query),
|
|
1451
1578
|
this.#pushApplySerialized,
|
|
1452
1579
|
);
|
|
1453
1580
|
}
|
|
@@ -1469,12 +1596,23 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1469
1596
|
// re-checks the published marker inside this same batch, so condition 2
|
|
1470
1597
|
// is already evaluated with the query (§2.4 D1 schema readiness).
|
|
1471
1598
|
const db = this.#schemaDatabase();
|
|
1599
|
+
return queryAuthoritativeOn(
|
|
1600
|
+
db,
|
|
1601
|
+
partition,
|
|
1602
|
+
this.#prepareAuthoritativeQuery(partition, query),
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
#prepareAuthoritativeQuery(
|
|
1607
|
+
partition: string,
|
|
1608
|
+
query: AuthoritativeQueryRequest,
|
|
1609
|
+
): BoundAuthoritativeQuery {
|
|
1472
1610
|
if (this.#tables === undefined) {
|
|
1473
1611
|
throw new Error(
|
|
1474
1612
|
'ensureSchema(schema) must run before registered queries',
|
|
1475
1613
|
);
|
|
1476
1614
|
}
|
|
1477
|
-
|
|
1615
|
+
return bindAuthoritativePartition(
|
|
1478
1616
|
prepareAuthoritativeQuery(
|
|
1479
1617
|
query.plan,
|
|
1480
1618
|
query.params,
|
|
@@ -1483,41 +1621,6 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1483
1621
|
),
|
|
1484
1622
|
partition,
|
|
1485
1623
|
);
|
|
1486
|
-
const results = await db.batch([
|
|
1487
|
-
db.prepare(prepared.sql).bind(...prepared.params),
|
|
1488
|
-
db
|
|
1489
|
-
.prepare('SELECT max_commit_seq FROM sync_partitions WHERE partition=?')
|
|
1490
|
-
.bind(partition),
|
|
1491
|
-
]);
|
|
1492
|
-
const rowsResult = results[0];
|
|
1493
|
-
const cursorResult = results[1];
|
|
1494
|
-
if (
|
|
1495
|
-
typeof rowsResult !== 'object' ||
|
|
1496
|
-
rowsResult === null ||
|
|
1497
|
-
!('results' in rowsResult) ||
|
|
1498
|
-
!Array.isArray(rowsResult.results) ||
|
|
1499
|
-
typeof cursorResult !== 'object' ||
|
|
1500
|
-
cursorResult === null ||
|
|
1501
|
-
!('results' in cursorResult) ||
|
|
1502
|
-
!Array.isArray(cursorResult.results)
|
|
1503
|
-
) {
|
|
1504
|
-
throw new Error('D1 registered query returned an invalid batch result');
|
|
1505
|
-
}
|
|
1506
|
-
const cursor = cursorResult.results[0];
|
|
1507
|
-
const maxCommitSeq =
|
|
1508
|
-
typeof cursor === 'object' &&
|
|
1509
|
-
cursor !== null &&
|
|
1510
|
-
'max_commit_seq' in cursor &&
|
|
1511
|
-
typeof cursor.max_commit_seq === 'number'
|
|
1512
|
-
? cursor.max_commit_seq
|
|
1513
|
-
: 0;
|
|
1514
|
-
return {
|
|
1515
|
-
rows: rowsResult.results.filter(
|
|
1516
|
-
(row): row is Readonly<Record<string, unknown>> =>
|
|
1517
|
-
typeof row === 'object' && row !== null,
|
|
1518
|
-
),
|
|
1519
|
-
maxCommitSeq,
|
|
1520
|
-
};
|
|
1521
1624
|
}
|
|
1522
1625
|
|
|
1523
1626
|
async getPartitionLogEpoch(partition: string): Promise<string | undefined> {
|
package/src/operations.ts
CHANGED
|
@@ -22,8 +22,13 @@ import { SyncError, syncError } from './errors';
|
|
|
22
22
|
import { processPushOperationsWithTrace } from './push';
|
|
23
23
|
import { compileSchema } from './schema';
|
|
24
24
|
import { authorizeWrite, type ResolvedScopes } from './scopes';
|
|
25
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
AuthoritativeQueryRequest,
|
|
27
|
+
AuthoritativeQueryResult,
|
|
28
|
+
AuthoritativeQueryValue,
|
|
29
|
+
} from './storage';
|
|
26
30
|
import type { StorageTransaction } from './storage';
|
|
31
|
+
import { transactionQuery } from './storage-query';
|
|
27
32
|
import { toValidateRow, type ValidateRow } from './validate';
|
|
28
33
|
|
|
29
34
|
export interface RemoteQueryDependency {
|
|
@@ -107,6 +112,14 @@ export interface RemoteCommandContext {
|
|
|
107
112
|
readonly operationId: string;
|
|
108
113
|
readonly requestId: string;
|
|
109
114
|
getRow(table: string, rowId: string): Promise<ValidateRow | undefined>;
|
|
115
|
+
/**
|
|
116
|
+
* Run a generated registered query on the command's push transaction
|
|
117
|
+
* (§6.7). The storage binds every relation to the partition; unlike
|
|
118
|
+
* `getRow`, the rows are not filtered by the actor's scopes.
|
|
119
|
+
*/
|
|
120
|
+
queryAuthoritative(
|
|
121
|
+
query: AuthoritativeQueryRequest,
|
|
122
|
+
): Promise<AuthoritativeQueryResult>;
|
|
110
123
|
}
|
|
111
124
|
|
|
112
125
|
export interface RemoteCommandOptions<Input> {
|
|
@@ -570,6 +583,7 @@ function commandContext(
|
|
|
570
583
|
decodeRow(table.columns, stored.payload),
|
|
571
584
|
);
|
|
572
585
|
},
|
|
586
|
+
queryAuthoritative: (query) => transactionQuery(tx, query),
|
|
573
587
|
};
|
|
574
588
|
}
|
|
575
589
|
|
package/src/postgres-storage.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { serveNotReadyError } from './readiness';
|
|
|
43
43
|
import type { PushOperationResult } from '@syncular/core';
|
|
44
44
|
import {
|
|
45
45
|
bindAuthoritativePartition,
|
|
46
|
+
type BoundAuthoritativeQuery,
|
|
46
47
|
postgresPlaceholders,
|
|
47
48
|
prepareAuthoritativeQuery,
|
|
48
49
|
} from './authoritative-query';
|
|
@@ -55,6 +56,7 @@ import {
|
|
|
55
56
|
} from './pg-executor';
|
|
56
57
|
import {
|
|
57
58
|
assertPhysicalColumns,
|
|
59
|
+
assertStoredLayouts,
|
|
58
60
|
commitWindowPageSql,
|
|
59
61
|
deleteRowSql,
|
|
60
62
|
dropTableDdl,
|
|
@@ -730,9 +732,36 @@ async function getPushResultOn(
|
|
|
730
732
|
}
|
|
731
733
|
}
|
|
732
734
|
|
|
735
|
+
/** Run one partition-bound registered query and the cursor read on `client`. */
|
|
736
|
+
async function queryAuthoritativeOn(
|
|
737
|
+
client: PgQueryable,
|
|
738
|
+
partition: string,
|
|
739
|
+
prepared: BoundAuthoritativeQuery,
|
|
740
|
+
): Promise<AuthoritativeQueryResult> {
|
|
741
|
+
const result = await client.query<Readonly<Record<string, unknown>>>(
|
|
742
|
+
postgresPlaceholders(prepared.sql),
|
|
743
|
+
prepared.params,
|
|
744
|
+
);
|
|
745
|
+
const cursor = await client.query<{ max_commit_seq: unknown }>(
|
|
746
|
+
'SELECT max_commit_seq FROM sync_partitions WHERE partition=$1',
|
|
747
|
+
[partition],
|
|
748
|
+
);
|
|
749
|
+
return {
|
|
750
|
+
rows: result.rows,
|
|
751
|
+
maxCommitSeq:
|
|
752
|
+
cursor.rows[0] === undefined
|
|
753
|
+
? 0
|
|
754
|
+
: asNumber(cursor.rows[0].max_commit_seq),
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
733
758
|
class PostgresTransaction implements StorageTransaction {
|
|
734
759
|
#client: PgQueryable;
|
|
735
760
|
#partition: string;
|
|
761
|
+
/** Storage-owned relation rewrite, bound to this transaction's partition. */
|
|
762
|
+
readonly #prepareQuery: (
|
|
763
|
+
query: AuthoritativeQueryRequest,
|
|
764
|
+
) => BoundAuthoritativeQuery;
|
|
736
765
|
/** Schema version this transaction writes; rides every appended commit row. */
|
|
737
766
|
#writerVersion: number;
|
|
738
767
|
#resolveTable: (name: string) => CompiledTable;
|
|
@@ -747,6 +776,7 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
747
776
|
partition: string,
|
|
748
777
|
writerVersion: number,
|
|
749
778
|
resolveTable: (name: string) => CompiledTable,
|
|
779
|
+
prepareQuery: (query: AuthoritativeQueryRequest) => BoundAuthoritativeQuery,
|
|
750
780
|
resolve: () => void,
|
|
751
781
|
reject: (error: unknown) => void,
|
|
752
782
|
) {
|
|
@@ -770,6 +800,7 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
770
800
|
this.#partition = partition;
|
|
771
801
|
this.#writerVersion = writerVersion;
|
|
772
802
|
this.#resolveTable = resolveTable;
|
|
803
|
+
this.#prepareQuery = prepareQuery;
|
|
773
804
|
this.#resolve = resolve;
|
|
774
805
|
this.#reject = reject;
|
|
775
806
|
}
|
|
@@ -871,6 +902,19 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
871
902
|
);
|
|
872
903
|
}
|
|
873
904
|
|
|
905
|
+
queryAuthoritative(
|
|
906
|
+
query: AuthoritativeQueryRequest,
|
|
907
|
+
): Promise<AuthoritativeQueryResult> {
|
|
908
|
+
this.#assertOpen();
|
|
909
|
+
// Runs on this transaction's pinned client, so the read observes every
|
|
910
|
+
// staged row and never waits for a second pool connection.
|
|
911
|
+
return queryAuthoritativeOn(
|
|
912
|
+
this.#client,
|
|
913
|
+
this.#partition,
|
|
914
|
+
this.#prepareQuery(query),
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
|
|
874
918
|
async lockPartitionForPush(): Promise<void> {
|
|
875
919
|
this.#assertOpen();
|
|
876
920
|
await lockPartitionOn(this.#client, this.#partition);
|
|
@@ -1229,65 +1273,47 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1229
1273
|
);
|
|
1230
1274
|
}
|
|
1231
1275
|
if (stored !== undefined && stored === schema.version) {
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
// decode. Compare the stored layouts instead of trusting the number.
|
|
1235
|
-
const storedLayouts = parseLayouts(
|
|
1276
|
+
assertStoredLayouts(
|
|
1277
|
+
schema,
|
|
1236
1278
|
typeof marker.rows[0]?.layouts === 'string'
|
|
1237
1279
|
? marker.rows[0].layouts
|
|
1238
1280
|
: undefined,
|
|
1239
1281
|
);
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
for (const [tableName, columns] of Object.entries(configuredLayouts)) {
|
|
1243
|
-
const storedTable = storedLayouts[tableName];
|
|
1244
|
-
const columnCount = Math.max(columns.length, storedTable?.length ?? 0);
|
|
1245
|
-
for (let index = 0; index < columnCount; index++) {
|
|
1246
|
-
const expected = columns[index];
|
|
1247
|
-
const actual = storedTable?.[index];
|
|
1248
|
-
if (
|
|
1249
|
-
expected !== undefined &&
|
|
1250
|
-
actual !== undefined &&
|
|
1251
|
-
actual.name === expected.name &&
|
|
1252
|
-
actual.type === expected.type &&
|
|
1253
|
-
actual.nullable === expected.nullable
|
|
1254
|
-
) {
|
|
1255
|
-
continue;
|
|
1256
|
-
}
|
|
1257
|
-
mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
|
|
1258
|
-
break;
|
|
1259
|
-
}
|
|
1260
|
-
if (mismatch !== undefined) break;
|
|
1261
|
-
}
|
|
1262
|
-
if (mismatch === undefined) {
|
|
1263
|
-
for (const tableName of Object.keys(storedLayouts)) {
|
|
1264
|
-
if (!(tableName in configuredLayouts)) {
|
|
1265
|
-
mismatch = `table ${JSON.stringify(tableName)}`;
|
|
1266
|
-
break;
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
if (mismatch !== undefined) {
|
|
1271
|
-
throw new Error(
|
|
1272
|
-
`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`,
|
|
1273
|
-
);
|
|
1274
|
-
}
|
|
1275
|
-
// The persisted layouts describe the codec's app columns only: read the
|
|
1276
|
-
// physical tables so a same-version database missing a
|
|
1277
|
-
// storage-internal column is refused at startup instead of failing at
|
|
1278
|
-
// the first write. Resolve through `to_regclass`, which follows the
|
|
1279
|
-
// session's `search_path` exactly as the unqualified row queries do —
|
|
1282
|
+
// Resolve through `to_regclass`, which follows the session's
|
|
1283
|
+
// `search_path` exactly as the unqualified row queries do:
|
|
1280
1284
|
// `current_schema()` names only the first existing schema, so it would
|
|
1281
1285
|
// reject a table that resolves later in the path.
|
|
1282
1286
|
for (const table of schema.tables.values()) {
|
|
1283
|
-
const { rows } = await this.#exec.query<{
|
|
1284
|
-
|
|
1287
|
+
const { rows } = await this.#exec.query<{
|
|
1288
|
+
name: string;
|
|
1289
|
+
type: string;
|
|
1290
|
+
not_null: boolean;
|
|
1291
|
+
pk: number | null;
|
|
1292
|
+
}>(
|
|
1293
|
+
`SELECT attribute.attname AS name,
|
|
1294
|
+
format_type(attribute.atttypid, attribute.atttypmod) AS type,
|
|
1295
|
+
attribute.attnotnull AS not_null,
|
|
1296
|
+
(SELECT key.position::int
|
|
1297
|
+
FROM pg_index pk,
|
|
1298
|
+
unnest(pk.indkey::int2[]) WITH ORDINALITY
|
|
1299
|
+
AS key(attnum, position)
|
|
1300
|
+
WHERE pk.indrelid = attribute.attrelid AND pk.indisprimary
|
|
1301
|
+
AND key.attnum = attribute.attnum) AS pk
|
|
1285
1302
|
FROM pg_attribute attribute
|
|
1286
1303
|
WHERE attribute.attrelid = to_regclass($1)
|
|
1287
1304
|
AND attribute.attnum > 0 AND NOT attribute.attisdropped`,
|
|
1288
1305
|
[table.name],
|
|
1289
1306
|
);
|
|
1290
|
-
assertPhysicalColumns(
|
|
1307
|
+
assertPhysicalColumns(
|
|
1308
|
+
table,
|
|
1309
|
+
rows.map((row) => ({
|
|
1310
|
+
name: row.name,
|
|
1311
|
+
type: row.type,
|
|
1312
|
+
notNull: row.not_null,
|
|
1313
|
+
primaryKeyPosition: row.pk ?? 0,
|
|
1314
|
+
})),
|
|
1315
|
+
'postgres',
|
|
1316
|
+
);
|
|
1291
1317
|
}
|
|
1292
1318
|
}
|
|
1293
1319
|
if (stored === undefined || stored < schema.version) {
|
|
@@ -1514,6 +1540,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1514
1540
|
partition,
|
|
1515
1541
|
this.#schemaVersion ?? 0,
|
|
1516
1542
|
(name) => this.table(name),
|
|
1543
|
+
(query) => this.#prepareAuthoritativeQuery(partition, query),
|
|
1517
1544
|
resolveScope,
|
|
1518
1545
|
rejectScope,
|
|
1519
1546
|
);
|
|
@@ -1538,17 +1565,16 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1538
1565
|
return rows[0] === undefined ? 0 : asNumber(rows[0].max_commit_seq);
|
|
1539
1566
|
}
|
|
1540
1567
|
|
|
1541
|
-
|
|
1568
|
+
#prepareAuthoritativeQuery(
|
|
1542
1569
|
partition: string,
|
|
1543
1570
|
query: AuthoritativeQueryRequest,
|
|
1544
|
-
|
|
1545
|
-
): Promise<AuthoritativeQueryResult> {
|
|
1571
|
+
): BoundAuthoritativeQuery {
|
|
1546
1572
|
if (this.#tables === undefined) {
|
|
1547
1573
|
throw new Error(
|
|
1548
1574
|
'ensureSchema(schema) must run before registered queries',
|
|
1549
1575
|
);
|
|
1550
1576
|
}
|
|
1551
|
-
|
|
1577
|
+
return bindAuthoritativePartition(
|
|
1552
1578
|
prepareAuthoritativeQuery(
|
|
1553
1579
|
query.plan,
|
|
1554
1580
|
query.params,
|
|
@@ -1557,6 +1583,14 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1557
1583
|
),
|
|
1558
1584
|
partition,
|
|
1559
1585
|
);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
async queryAuthoritative(
|
|
1589
|
+
partition: string,
|
|
1590
|
+
query: AuthoritativeQueryRequest,
|
|
1591
|
+
checkpoints?: readonly CheckpointDeclaration[],
|
|
1592
|
+
): Promise<AuthoritativeQueryResult> {
|
|
1593
|
+
const prepared = this.#prepareAuthoritativeQuery(partition, query);
|
|
1560
1594
|
return this.#exec.transaction(async (client) => {
|
|
1561
1595
|
await client.query(
|
|
1562
1596
|
'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY',
|
|
@@ -1569,21 +1603,7 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1569
1603
|
const gate = await readServeGateOn(client, partition, runningVersion);
|
|
1570
1604
|
const refusal = serveGateRefusal(gate, runningVersion, checkpoints);
|
|
1571
1605
|
if (refusal !== undefined) throw serveNotReadyError(refusal);
|
|
1572
|
-
|
|
1573
|
-
postgresPlaceholders(prepared.sql),
|
|
1574
|
-
prepared.params,
|
|
1575
|
-
);
|
|
1576
|
-
const cursor = await client.query<{ max_commit_seq: unknown }>(
|
|
1577
|
-
'SELECT max_commit_seq FROM sync_partitions WHERE partition=$1',
|
|
1578
|
-
[partition],
|
|
1579
|
-
);
|
|
1580
|
-
return {
|
|
1581
|
-
rows: result.rows,
|
|
1582
|
-
maxCommitSeq:
|
|
1583
|
-
cursor.rows[0] === undefined
|
|
1584
|
-
? 0
|
|
1585
|
-
: asNumber(cursor.rows[0].max_commit_seq),
|
|
1586
|
-
};
|
|
1606
|
+
return queryAuthoritativeOn(client, partition, prepared);
|
|
1587
1607
|
});
|
|
1588
1608
|
}
|
|
1589
1609
|
|
package/src/push.ts
CHANGED
|
@@ -54,6 +54,7 @@ import type {
|
|
|
54
54
|
} from './storage';
|
|
55
55
|
import { serveGateRefusal } from './storage';
|
|
56
56
|
import { StorageConstraintError } from './storage-errors';
|
|
57
|
+
import { transactionQuery } from './storage-query';
|
|
57
58
|
import { serveNotReadyError } from './readiness';
|
|
58
59
|
import type {
|
|
59
60
|
CommitValidationReader,
|
|
@@ -186,6 +187,7 @@ async function runValidator(
|
|
|
186
187
|
values: readonly RowValue[] | undefined,
|
|
187
188
|
storedValues: readonly RowValue[] | undefined,
|
|
188
189
|
opIndex: number,
|
|
190
|
+
tx: StorageTransaction,
|
|
189
191
|
partition: string,
|
|
190
192
|
actorId: string,
|
|
191
193
|
): Promise<TerminatingOutcome | undefined> {
|
|
@@ -206,7 +208,11 @@ async function runValidator(
|
|
|
206
208
|
? toValidateRow(table.columns, storedValues)
|
|
207
209
|
: undefined,
|
|
208
210
|
},
|
|
209
|
-
{
|
|
211
|
+
{
|
|
212
|
+
actorId,
|
|
213
|
+
partition,
|
|
214
|
+
queryAuthoritative: (query) => transactionQuery(tx, query),
|
|
215
|
+
},
|
|
210
216
|
);
|
|
211
217
|
} catch (error) {
|
|
212
218
|
if (error instanceof ValidationRejection) {
|
|
@@ -363,6 +369,7 @@ async function applyOperation(
|
|
|
363
369
|
undefined,
|
|
364
370
|
storedValues,
|
|
365
371
|
opIndex,
|
|
372
|
+
tx,
|
|
366
373
|
partition,
|
|
367
374
|
actorId,
|
|
368
375
|
);
|
|
@@ -553,6 +560,7 @@ async function applyOperation(
|
|
|
553
560
|
values,
|
|
554
561
|
storedValues,
|
|
555
562
|
opIndex,
|
|
563
|
+
tx,
|
|
556
564
|
partition,
|
|
557
565
|
actorId,
|
|
558
566
|
);
|
|
@@ -683,6 +691,7 @@ async function applyOperation(
|
|
|
683
691
|
values,
|
|
684
692
|
undefined,
|
|
685
693
|
opIndex,
|
|
694
|
+
tx,
|
|
686
695
|
partition,
|
|
687
696
|
actorId,
|
|
688
697
|
);
|
|
@@ -836,6 +845,7 @@ function commitValidationReader(
|
|
|
836
845
|
serverVersion: stored.serverVersion,
|
|
837
846
|
}));
|
|
838
847
|
},
|
|
848
|
+
queryAuthoritative: (query) => transactionQuery(tx, query),
|
|
839
849
|
};
|
|
840
850
|
}
|
|
841
851
|
|
|
@@ -1084,6 +1094,7 @@ async function enforceReferences(
|
|
|
1084
1094
|
undefined,
|
|
1085
1095
|
storedValues,
|
|
1086
1096
|
item.originOpIndex,
|
|
1097
|
+
tx,
|
|
1087
1098
|
partition,
|
|
1088
1099
|
actorId,
|
|
1089
1100
|
);
|
|
@@ -1117,6 +1128,7 @@ async function enforceReferences(
|
|
|
1117
1128
|
values,
|
|
1118
1129
|
storedValues,
|
|
1119
1130
|
item.originOpIndex,
|
|
1131
|
+
tx,
|
|
1120
1132
|
partition,
|
|
1121
1133
|
actorId,
|
|
1122
1134
|
);
|