@syncular/server 0.22.0 → 0.24.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/admin.d.ts +3 -1
- package/dist/admin.js +7 -1
- package/dist/blob-handlers.js +1 -1
- package/dist/context.d.ts +8 -1
- package/dist/d1-storage.d.ts +11 -3
- package/dist/d1-storage.js +186 -36
- package/dist/errors.js +10 -0
- package/dist/handler.js +50 -4
- package/dist/operations.d.ts +7 -1
- package/dist/operations.js +5 -3
- package/dist/postgres-storage.d.ts +12 -4
- package/dist/postgres-storage.js +310 -59
- package/dist/push.js +31 -7
- package/dist/readiness.d.ts +9 -2
- package/dist/readiness.js +27 -2
- package/dist/realtime.js +20 -1
- package/dist/relational-rows.d.ts +31 -12
- package/dist/relational-rows.js +121 -26
- package/dist/segment-download.js +8 -0
- package/dist/sqlite-dialect.d.ts +1 -1
- package/dist/sqlite-dialect.js +15 -0
- package/dist/sqlite-storage.d.ts +18 -3
- package/dist/sqlite-storage.js +327 -82
- package/dist/storage-errors.d.ts +5 -3
- package/dist/storage-errors.js +13 -2
- package/dist/storage-query.d.ts +8 -1
- package/dist/storage-query.js +12 -0
- package/dist/storage.d.ts +153 -14
- package/dist/storage.js +40 -1
- package/dist/validate.d.ts +33 -15
- package/package.json +2 -2
- package/src/admin.ts +13 -1
- package/src/blob-handlers.ts +1 -1
- package/src/context.ts +8 -0
- package/src/d1-storage.ts +282 -37
- package/src/errors.ts +10 -0
- package/src/handler.ts +62 -4
- package/src/operations.ts +32 -14
- package/src/postgres-storage.ts +515 -68
- package/src/push.ts +31 -1
- package/src/readiness.ts +39 -4
- package/src/realtime.ts +22 -1
- package/src/relational-rows.ts +145 -30
- package/src/segment-download.ts +16 -0
- package/src/sqlite-dialect.ts +15 -0
- package/src/sqlite-storage.ts +491 -96
- package/src/storage-errors.ts +33 -3
- package/src/storage-query.ts +23 -1
- package/src/storage.ts +245 -12
- package/src/validate.ts +40 -15
package/src/postgres-storage.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { validateCommitPruneQuery } from './prune';
|
|
2
2
|
import { StorageQueryError } from './storage-errors';
|
|
3
3
|
import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
4
|
+
import { serveGateRefusal } from './storage';
|
|
5
|
+
import { serveNotReadyError } from './readiness';
|
|
4
6
|
/**
|
|
5
7
|
* Postgres server storage: the production database path.
|
|
6
8
|
*
|
|
@@ -41,6 +43,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
|
41
43
|
import type { PushOperationResult } from '@syncular/core';
|
|
42
44
|
import {
|
|
43
45
|
bindAuthoritativePartition,
|
|
46
|
+
type BoundAuthoritativeQuery,
|
|
44
47
|
postgresPlaceholders,
|
|
45
48
|
prepareAuthoritativeQuery,
|
|
46
49
|
} from './authoritative-query';
|
|
@@ -53,6 +56,7 @@ import {
|
|
|
53
56
|
} from './pg-executor';
|
|
54
57
|
import {
|
|
55
58
|
assertPhysicalColumns,
|
|
59
|
+
assertStoredLayouts,
|
|
56
60
|
commitWindowPageSql,
|
|
57
61
|
deleteRowSql,
|
|
58
62
|
dropTableDdl,
|
|
@@ -79,6 +83,7 @@ import { matchesEffective } from './scopes';
|
|
|
79
83
|
import type {
|
|
80
84
|
AuthoritativeQueryRequest,
|
|
81
85
|
AuthoritativeQueryResult,
|
|
86
|
+
CheckpointDeclaration,
|
|
82
87
|
ClientCursorInfo,
|
|
83
88
|
ClientRecord,
|
|
84
89
|
ClientSubscription,
|
|
@@ -100,8 +105,10 @@ import type {
|
|
|
100
105
|
ScopeActivityQuery,
|
|
101
106
|
ScopeCommitActivity,
|
|
102
107
|
ServerStorage,
|
|
108
|
+
ServeGate,
|
|
103
109
|
StorageTransaction,
|
|
104
110
|
StoredChange,
|
|
111
|
+
StoredCheckpoint,
|
|
105
112
|
StoredCommit,
|
|
106
113
|
StoredPushResult,
|
|
107
114
|
StoredReaction,
|
|
@@ -165,6 +172,7 @@ CREATE TABLE IF NOT EXISTS sync_commits(
|
|
|
165
172
|
partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,
|
|
166
173
|
client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,
|
|
167
174
|
actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,
|
|
175
|
+
writer_version BIGINT,
|
|
168
176
|
PRIMARY KEY(partition, commit_seq)
|
|
169
177
|
);
|
|
170
178
|
CREATE INDEX IF NOT EXISTS sync_commits_by_time
|
|
@@ -228,6 +236,20 @@ CREATE TABLE IF NOT EXISTS sync_tombstones(
|
|
|
228
236
|
commit_seq BIGINT NOT NULL,
|
|
229
237
|
PRIMARY KEY(partition, tbl, row_id)
|
|
230
238
|
);
|
|
239
|
+
CREATE TABLE IF NOT EXISTS sync_backfill_checkpoints(
|
|
240
|
+
partition TEXT NOT NULL, name TEXT NOT NULL,
|
|
241
|
+
schema_version INTEGER NOT NULL,
|
|
242
|
+
state TEXT NOT NULL CHECK(state IN ('declared','backfilling','activated')),
|
|
243
|
+
watermark BIGINT NOT NULL DEFAULT 0,
|
|
244
|
+
owner_epoch BIGINT NOT NULL DEFAULT 0,
|
|
245
|
+
observed_rows BIGINT NOT NULL DEFAULT 0,
|
|
246
|
+
updated_at_ms BIGINT NOT NULL,
|
|
247
|
+
PRIMARY KEY(partition, name)
|
|
248
|
+
);
|
|
249
|
+
CREATE TABLE IF NOT EXISTS sync_writer_fence(
|
|
250
|
+
partition TEXT PRIMARY KEY,
|
|
251
|
+
required_writer_version INTEGER NOT NULL
|
|
252
|
+
);
|
|
231
253
|
`;
|
|
232
254
|
|
|
233
255
|
interface SerializedResult {
|
|
@@ -264,6 +286,96 @@ async function lockPartitionOn(
|
|
|
264
286
|
);
|
|
265
287
|
}
|
|
266
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Insert the `declared` row and raise the fence, on the caller's open
|
|
291
|
+
* transaction. Shared by `declareCheckpoint` and the `ensureSchema`
|
|
292
|
+
* migration so the schema bump and the fence commit together. Never lowers a
|
|
293
|
+
* fence and never re-declares an existing row.
|
|
294
|
+
*/
|
|
295
|
+
async function installCheckpointOn(
|
|
296
|
+
client: PgQueryable,
|
|
297
|
+
partition: string,
|
|
298
|
+
name: string,
|
|
299
|
+
schemaVersion: number,
|
|
300
|
+
nowMs: number,
|
|
301
|
+
): Promise<void> {
|
|
302
|
+
await client.query(
|
|
303
|
+
`INSERT INTO sync_backfill_checkpoints(
|
|
304
|
+
partition, name, schema_version, state, watermark,
|
|
305
|
+
owner_epoch, observed_rows, updated_at_ms)
|
|
306
|
+
VALUES ($1,$2,$3,'declared',0,0,0,$4)
|
|
307
|
+
ON CONFLICT (partition, name) DO NOTHING`,
|
|
308
|
+
[partition, name, schemaVersion, nowMs],
|
|
309
|
+
);
|
|
310
|
+
await client.query(
|
|
311
|
+
`INSERT INTO sync_writer_fence(partition, required_writer_version)
|
|
312
|
+
VALUES ($1,$2)
|
|
313
|
+
ON CONFLICT (partition) DO UPDATE SET
|
|
314
|
+
required_writer_version=GREATEST(
|
|
315
|
+
sync_writer_fence.required_writer_version,
|
|
316
|
+
EXCLUDED.required_writer_version)`,
|
|
317
|
+
[partition, schemaVersion],
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** RFC 0007 serve gate on either the pool or a transaction's client. */
|
|
322
|
+
async function readServeGateOn(
|
|
323
|
+
client: PgQueryable,
|
|
324
|
+
partition: string,
|
|
325
|
+
runningSchemaVersion: number,
|
|
326
|
+
): Promise<ServeGate> {
|
|
327
|
+
const marker = await client.query<{ schema_version: unknown }>(
|
|
328
|
+
'SELECT schema_version FROM sync_schema_meta WHERE id=1',
|
|
329
|
+
);
|
|
330
|
+
const epoch = await client.query<{ log_epoch: string }>(
|
|
331
|
+
'SELECT log_epoch FROM sync_partition_registry WHERE partition=$1',
|
|
332
|
+
[partition],
|
|
333
|
+
);
|
|
334
|
+
const pending = await client.query<{ partition: string; name: string }>(
|
|
335
|
+
`SELECT partition, name FROM sync_backfill_checkpoints
|
|
336
|
+
WHERE schema_version=$1 AND state<>'activated'`,
|
|
337
|
+
[runningSchemaVersion],
|
|
338
|
+
);
|
|
339
|
+
return {
|
|
340
|
+
storedSchemaVersion:
|
|
341
|
+
marker.rows[0] === undefined
|
|
342
|
+
? 0
|
|
343
|
+
: asNumber(marker.rows[0].schema_version),
|
|
344
|
+
logEpoch: epoch.rows[0]?.log_epoch,
|
|
345
|
+
pending: pending.rows.map((row) => ({
|
|
346
|
+
partition: row.partition,
|
|
347
|
+
name: row.name,
|
|
348
|
+
})),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function hasSourceChangesAboveOn(
|
|
353
|
+
client: PgQueryable,
|
|
354
|
+
partition: string,
|
|
355
|
+
tables: readonly string[],
|
|
356
|
+
seq: number,
|
|
357
|
+
): Promise<'clean' | 'changed' | 'unverifiable'> {
|
|
358
|
+
// Read the horizon before the window scan: a horizon past `seq` means the
|
|
359
|
+
// history that would answer the question is gone, so an empty scan proves
|
|
360
|
+
// nothing and must not be reported as `clean`.
|
|
361
|
+
const horizon = await client.query<{ horizon_seq: unknown }>(
|
|
362
|
+
'SELECT horizon_seq FROM sync_partitions WHERE partition=$1',
|
|
363
|
+
[partition],
|
|
364
|
+
);
|
|
365
|
+
const horizonSeq =
|
|
366
|
+
horizon.rows[0] === undefined ? 0 : asNumber(horizon.rows[0].horizon_seq);
|
|
367
|
+
if (tables.length === 0) return 'clean';
|
|
368
|
+
const tableParams = tables.map((_, index) => `$${index + 2}`).join(',');
|
|
369
|
+
const hit = await client.query<{ hit: number }>(
|
|
370
|
+
`SELECT 1 AS hit FROM sync_changes
|
|
371
|
+
WHERE partition=$1 AND tbl IN (${tableParams}) AND commit_seq>$${tables.length + 2}
|
|
372
|
+
LIMIT 1`,
|
|
373
|
+
[partition, ...tables, seq],
|
|
374
|
+
);
|
|
375
|
+
if (hit.rows[0] !== undefined) return 'changed';
|
|
376
|
+
return horizonSeq > seq ? 'unverifiable' : 'clean';
|
|
377
|
+
}
|
|
378
|
+
|
|
267
379
|
function toBase64(bytes: Uint8Array): string {
|
|
268
380
|
return Buffer.from(bytes).toString('base64');
|
|
269
381
|
}
|
|
@@ -272,6 +384,32 @@ function fromBase64(text: string): Uint8Array {
|
|
|
272
384
|
return new Uint8Array(Buffer.from(text, 'base64'));
|
|
273
385
|
}
|
|
274
386
|
|
|
387
|
+
interface PostgresCheckpointRecord {
|
|
388
|
+
partition: string;
|
|
389
|
+
name: string;
|
|
390
|
+
schema_version: unknown;
|
|
391
|
+
state: StoredCheckpoint['state'];
|
|
392
|
+
watermark: unknown;
|
|
393
|
+
owner_epoch: unknown;
|
|
394
|
+
observed_rows: unknown;
|
|
395
|
+
updated_at_ms: unknown;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function toStoredCheckpoint(
|
|
399
|
+
record: PostgresCheckpointRecord,
|
|
400
|
+
): StoredCheckpoint {
|
|
401
|
+
return {
|
|
402
|
+
partition: record.partition,
|
|
403
|
+
name: record.name,
|
|
404
|
+
schemaVersion: asNumber(record.schema_version),
|
|
405
|
+
state: record.state,
|
|
406
|
+
watermark: asNumber(record.watermark),
|
|
407
|
+
ownerEpoch: asNumber(record.owner_epoch),
|
|
408
|
+
observedRows: asNumber(record.observed_rows),
|
|
409
|
+
updatedAtMs: asNumber(record.updated_at_ms),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
275
413
|
interface PostgresReactionRecord {
|
|
276
414
|
idempotency_key: string;
|
|
277
415
|
type: string;
|
|
@@ -594,9 +732,38 @@ async function getPushResultOn(
|
|
|
594
732
|
}
|
|
595
733
|
}
|
|
596
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
|
+
|
|
597
758
|
class PostgresTransaction implements StorageTransaction {
|
|
598
759
|
#client: PgQueryable;
|
|
599
760
|
#partition: string;
|
|
761
|
+
/** Storage-owned relation rewrite, bound to this transaction's partition. */
|
|
762
|
+
readonly #prepareQuery: (
|
|
763
|
+
query: AuthoritativeQueryRequest,
|
|
764
|
+
) => BoundAuthoritativeQuery;
|
|
765
|
+
/** Schema version this transaction writes; rides every appended commit row. */
|
|
766
|
+
#writerVersion: number;
|
|
600
767
|
#resolveTable: (name: string) => CompiledTable;
|
|
601
768
|
#open = true;
|
|
602
769
|
#pushApplySavepoint = false;
|
|
@@ -607,7 +774,9 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
607
774
|
constructor(
|
|
608
775
|
client: PgQueryable,
|
|
609
776
|
partition: string,
|
|
777
|
+
writerVersion: number,
|
|
610
778
|
resolveTable: (name: string) => CompiledTable,
|
|
779
|
+
prepareQuery: (query: AuthoritativeQueryRequest) => BoundAuthoritativeQuery,
|
|
611
780
|
resolve: () => void,
|
|
612
781
|
reject: (error: unknown) => void,
|
|
613
782
|
) {
|
|
@@ -629,7 +798,9 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
629
798
|
},
|
|
630
799
|
};
|
|
631
800
|
this.#partition = partition;
|
|
801
|
+
this.#writerVersion = writerVersion;
|
|
632
802
|
this.#resolveTable = resolveTable;
|
|
803
|
+
this.#prepareQuery = prepareQuery;
|
|
633
804
|
this.#resolve = resolve;
|
|
634
805
|
this.#reject = reject;
|
|
635
806
|
}
|
|
@@ -731,6 +902,19 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
731
902
|
);
|
|
732
903
|
}
|
|
733
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
|
+
|
|
734
918
|
async lockPartitionForPush(): Promise<void> {
|
|
735
919
|
this.#assertOpen();
|
|
736
920
|
await lockPartitionOn(this.#client, this.#partition);
|
|
@@ -738,6 +922,35 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
738
922
|
this.#pushApplySavepoint = true;
|
|
739
923
|
}
|
|
740
924
|
|
|
925
|
+
async advanceCheckpoint(
|
|
926
|
+
name: string,
|
|
927
|
+
ownerEpoch: number,
|
|
928
|
+
watermark: number,
|
|
929
|
+
observedRows: number,
|
|
930
|
+
nowMs: number,
|
|
931
|
+
): Promise<boolean> {
|
|
932
|
+
this.#assertOpen();
|
|
933
|
+
// `RETURNING`: the affected-row check must not depend on a driver's
|
|
934
|
+
// `rowCount` for a statement with no result rows. Bun.sql's canonical
|
|
935
|
+
// adapter derives `rowCount` from the returned rows, so a bare UPDATE
|
|
936
|
+
// reports 0 and the CAS would never advance on a real Bun.sql host.
|
|
937
|
+
const result = await this.#client.query<{ name: string }>(
|
|
938
|
+
`UPDATE sync_backfill_checkpoints
|
|
939
|
+
SET watermark=$1, observed_rows=$2, updated_at_ms=$3
|
|
940
|
+
WHERE partition=$4 AND name=$5 AND owner_epoch=$6
|
|
941
|
+
RETURNING name`,
|
|
942
|
+
[watermark, observedRows, nowMs, this.#partition, name, ownerEpoch],
|
|
943
|
+
);
|
|
944
|
+
return result.rows.length === 1;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
readServeGate(runningSchemaVersion: number): Promise<ServeGate> {
|
|
948
|
+
this.#assertOpen();
|
|
949
|
+
// The transaction's own client: the gate and the write share one
|
|
950
|
+
// transaction, so a migration cannot interleave between them.
|
|
951
|
+
return readServeGateOn(this.#client, this.#partition, runningSchemaVersion);
|
|
952
|
+
}
|
|
953
|
+
|
|
741
954
|
async commitRejectedPushResult(
|
|
742
955
|
clientId: string,
|
|
743
956
|
clientCommitId: string,
|
|
@@ -826,8 +1039,8 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
826
1039
|
SET max_commit_seq = sync_partitions.max_commit_seq + 1
|
|
827
1040
|
RETURNING max_commit_seq
|
|
828
1041
|
)
|
|
829
|
-
INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms)
|
|
830
|
-
SELECT $1, max_commit_seq, $2, $3, $4, $5 FROM allocated
|
|
1042
|
+
INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms, writer_version)
|
|
1043
|
+
SELECT $1, max_commit_seq, $2, $3, $4, $5, $6 FROM allocated
|
|
831
1044
|
RETURNING commit_seq`,
|
|
832
1045
|
[
|
|
833
1046
|
p,
|
|
@@ -835,6 +1048,7 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
835
1048
|
commit.clientCommitId,
|
|
836
1049
|
commit.actorId,
|
|
837
1050
|
commit.createdAtMs,
|
|
1051
|
+
this.#writerVersion,
|
|
838
1052
|
],
|
|
839
1053
|
);
|
|
840
1054
|
const commitSeq = asNumber(rows[0]?.commit_seq);
|
|
@@ -972,6 +1186,33 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
972
1186
|
for (const statement of statements) {
|
|
973
1187
|
await this.#exec.query(statement);
|
|
974
1188
|
}
|
|
1189
|
+
await this.#exec.query(
|
|
1190
|
+
'ALTER TABLE sync_commits ADD COLUMN IF NOT EXISTS writer_version BIGINT',
|
|
1191
|
+
);
|
|
1192
|
+
// Database-side fence: an old binary's INSERT never reaches JS. An absent
|
|
1193
|
+
// `sync_writer_fence` row makes the EXISTS false, so a partition with no
|
|
1194
|
+
// barrier behaves exactly as before. Sent as separate statements because
|
|
1195
|
+
// the dollar-quoted body contains `;`.
|
|
1196
|
+
await this.#exec.query(`CREATE OR REPLACE FUNCTION syncular_writer_fence()
|
|
1197
|
+
RETURNS trigger AS $$
|
|
1198
|
+
BEGIN
|
|
1199
|
+
IF EXISTS (
|
|
1200
|
+
SELECT 1 FROM sync_writer_fence
|
|
1201
|
+
WHERE partition = NEW.partition
|
|
1202
|
+
AND (NEW.writer_version IS NULL
|
|
1203
|
+
OR NEW.writer_version < required_writer_version)
|
|
1204
|
+
) THEN
|
|
1205
|
+
RAISE EXCEPTION 'sync.storage.writer_fence_rejected';
|
|
1206
|
+
END IF;
|
|
1207
|
+
RETURN NEW;
|
|
1208
|
+
END;
|
|
1209
|
+
$$ LANGUAGE plpgsql`);
|
|
1210
|
+
await this.#exec.query(
|
|
1211
|
+
'DROP TRIGGER IF EXISTS sync_commits_writer_fence ON sync_commits',
|
|
1212
|
+
);
|
|
1213
|
+
await this.#exec.query(`CREATE TRIGGER sync_commits_writer_fence
|
|
1214
|
+
BEFORE INSERT ON sync_commits
|
|
1215
|
+
FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
975
1216
|
}
|
|
976
1217
|
|
|
977
1218
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
@@ -985,10 +1226,38 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
985
1226
|
return table;
|
|
986
1227
|
}
|
|
987
1228
|
|
|
988
|
-
async ensureSchema(
|
|
1229
|
+
async ensureSchema(
|
|
1230
|
+
schema: CompiledSchema,
|
|
1231
|
+
checkpoints?: readonly CheckpointDeclaration[],
|
|
1232
|
+
): Promise<void> {
|
|
989
1233
|
// Memoized fast path: same instance, same schema version.
|
|
990
1234
|
if (this.#schemaVersion === schema.version) return;
|
|
991
1235
|
await this.migrate();
|
|
1236
|
+
// The barrier reads stored state: a checkpoint at this schema version that
|
|
1237
|
+
// is not activated and that this caller did not declare means this process
|
|
1238
|
+
// must not serve. An omitted or empty declaration set declares nothing and
|
|
1239
|
+
// is refused here, never treated as a bypass.
|
|
1240
|
+
const declarations = checkpoints ?? [];
|
|
1241
|
+
const incomplete = await this.#exec.query<{
|
|
1242
|
+
partition: string;
|
|
1243
|
+
name: string;
|
|
1244
|
+
}>(
|
|
1245
|
+
`SELECT partition, name FROM sync_backfill_checkpoints
|
|
1246
|
+
WHERE schema_version=$1 AND state<>'activated'`,
|
|
1247
|
+
[schema.version],
|
|
1248
|
+
);
|
|
1249
|
+
if (
|
|
1250
|
+
incomplete.rows.some(
|
|
1251
|
+
(row) =>
|
|
1252
|
+
!declarations.some(
|
|
1253
|
+
(declaration) =>
|
|
1254
|
+
declaration.partition === row.partition &&
|
|
1255
|
+
declaration.name === row.name,
|
|
1256
|
+
),
|
|
1257
|
+
)
|
|
1258
|
+
) {
|
|
1259
|
+
throw new StorageQueryError('sync.storage.checkpoint_incomplete');
|
|
1260
|
+
}
|
|
992
1261
|
await this.#exec.query(SCHEMA_META_DDL_POSTGRES);
|
|
993
1262
|
const marker = await this.#exec.query<{
|
|
994
1263
|
schema_version: unknown;
|
|
@@ -1004,65 +1273,47 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1004
1273
|
);
|
|
1005
1274
|
}
|
|
1006
1275
|
if (stored !== undefined && stored === schema.version) {
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
// decode. Compare the stored layouts instead of trusting the number.
|
|
1010
|
-
const storedLayouts = parseLayouts(
|
|
1276
|
+
assertStoredLayouts(
|
|
1277
|
+
schema,
|
|
1011
1278
|
typeof marker.rows[0]?.layouts === 'string'
|
|
1012
1279
|
? marker.rows[0].layouts
|
|
1013
1280
|
: undefined,
|
|
1014
1281
|
);
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
for (const [tableName, columns] of Object.entries(configuredLayouts)) {
|
|
1018
|
-
const storedTable = storedLayouts[tableName];
|
|
1019
|
-
const columnCount = Math.max(columns.length, storedTable?.length ?? 0);
|
|
1020
|
-
for (let index = 0; index < columnCount; index++) {
|
|
1021
|
-
const expected = columns[index];
|
|
1022
|
-
const actual = storedTable?.[index];
|
|
1023
|
-
if (
|
|
1024
|
-
expected !== undefined &&
|
|
1025
|
-
actual !== undefined &&
|
|
1026
|
-
actual.name === expected.name &&
|
|
1027
|
-
actual.type === expected.type &&
|
|
1028
|
-
actual.nullable === expected.nullable
|
|
1029
|
-
) {
|
|
1030
|
-
continue;
|
|
1031
|
-
}
|
|
1032
|
-
mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
|
|
1033
|
-
break;
|
|
1034
|
-
}
|
|
1035
|
-
if (mismatch !== undefined) break;
|
|
1036
|
-
}
|
|
1037
|
-
if (mismatch === undefined) {
|
|
1038
|
-
for (const tableName of Object.keys(storedLayouts)) {
|
|
1039
|
-
if (!(tableName in configuredLayouts)) {
|
|
1040
|
-
mismatch = `table ${JSON.stringify(tableName)}`;
|
|
1041
|
-
break;
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
if (mismatch !== undefined) {
|
|
1046
|
-
throw new Error(
|
|
1047
|
-
`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`,
|
|
1048
|
-
);
|
|
1049
|
-
}
|
|
1050
|
-
// The persisted layouts describe the codec's app columns only: read the
|
|
1051
|
-
// physical tables so a same-version database missing a
|
|
1052
|
-
// storage-internal column is refused at startup instead of failing at
|
|
1053
|
-
// the first write. Resolve through `to_regclass`, which follows the
|
|
1054
|
-
// 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:
|
|
1055
1284
|
// `current_schema()` names only the first existing schema, so it would
|
|
1056
1285
|
// reject a table that resolves later in the path.
|
|
1057
1286
|
for (const table of schema.tables.values()) {
|
|
1058
|
-
const { rows } = await this.#exec.query<{
|
|
1059
|
-
|
|
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
|
|
1060
1302
|
FROM pg_attribute attribute
|
|
1061
1303
|
WHERE attribute.attrelid = to_regclass($1)
|
|
1062
1304
|
AND attribute.attnum > 0 AND NOT attribute.attisdropped`,
|
|
1063
1305
|
[table.name],
|
|
1064
1306
|
);
|
|
1065
|
-
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
|
+
);
|
|
1066
1317
|
}
|
|
1067
1318
|
}
|
|
1068
1319
|
if (stored === undefined || stored < schema.version) {
|
|
@@ -1079,6 +1330,17 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1079
1330
|
);
|
|
1080
1331
|
const retiredTables = retiredTableNames(schema, layouts);
|
|
1081
1332
|
await this.#exec.transaction(async (client) => {
|
|
1333
|
+
// RFC 0007 migration barrier: `LOCK TABLE ... EXCLUSIVE` is the FIRST
|
|
1334
|
+
// statement, before any read, DDL, rewrite, marker or checkpoint
|
|
1335
|
+
// install. EXCLUSIVE conflicts with the ROW SHARE that every writer's
|
|
1336
|
+
// `lockPartitionOn` takes (`SELECT … FOR UPDATE`) and with the ROW
|
|
1337
|
+
// EXCLUSIVE of its allocation (`UPDATE sync_partitions` / `INSERT … ON
|
|
1338
|
+
// CONFLICT DO UPDATE`), so an unaware old writer blocks at its first
|
|
1339
|
+
// statement with no cooperation; plain `SELECT` (ACCESS SHARE) still
|
|
1340
|
+
// works. The lock order is acyclic: the migration holds nothing else
|
|
1341
|
+
// when it takes this lock, so a writer already inside completes while
|
|
1342
|
+
// the migration waits.
|
|
1343
|
+
await client.query('LOCK TABLE sync_partitions IN EXCLUSIVE MODE');
|
|
1082
1344
|
const existing = new Map<string, ReadonlySet<string>>();
|
|
1083
1345
|
const existingIndexes = new Map<string, ReadonlySet<string>>();
|
|
1084
1346
|
for (const table of schema.tables.values()) {
|
|
@@ -1152,6 +1414,19 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1152
1414
|
SET schema_version=EXCLUDED.schema_version, layouts=EXCLUDED.layouts`,
|
|
1153
1415
|
[schema.version, layoutsOf(schema)],
|
|
1154
1416
|
);
|
|
1417
|
+
// The host's declared checkpoints install in the schema-bump
|
|
1418
|
+
// transaction, so no observer sees the bumped marker without the
|
|
1419
|
+
// fence, or the fence without its declaration row.
|
|
1420
|
+
const installedAtMs = Date.now();
|
|
1421
|
+
for (const declaration of checkpoints ?? []) {
|
|
1422
|
+
await installCheckpointOn(
|
|
1423
|
+
client,
|
|
1424
|
+
declaration.partition,
|
|
1425
|
+
declaration.name,
|
|
1426
|
+
declaration.schemaVersion,
|
|
1427
|
+
installedAtMs,
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1155
1430
|
});
|
|
1156
1431
|
}
|
|
1157
1432
|
this.#tables = schema.tables;
|
|
@@ -1263,7 +1538,9 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1263
1538
|
const tx = new PostgresTransaction(
|
|
1264
1539
|
client,
|
|
1265
1540
|
partition,
|
|
1541
|
+
this.#schemaVersion ?? 0,
|
|
1266
1542
|
(name) => this.table(name),
|
|
1543
|
+
(query) => this.#prepareAuthoritativeQuery(partition, query),
|
|
1267
1544
|
resolveScope,
|
|
1268
1545
|
rejectScope,
|
|
1269
1546
|
);
|
|
@@ -1288,16 +1565,16 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1288
1565
|
return rows[0] === undefined ? 0 : asNumber(rows[0].max_commit_seq);
|
|
1289
1566
|
}
|
|
1290
1567
|
|
|
1291
|
-
|
|
1568
|
+
#prepareAuthoritativeQuery(
|
|
1292
1569
|
partition: string,
|
|
1293
1570
|
query: AuthoritativeQueryRequest,
|
|
1294
|
-
):
|
|
1571
|
+
): BoundAuthoritativeQuery {
|
|
1295
1572
|
if (this.#tables === undefined) {
|
|
1296
1573
|
throw new Error(
|
|
1297
1574
|
'ensureSchema(schema) must run before registered queries',
|
|
1298
1575
|
);
|
|
1299
1576
|
}
|
|
1300
|
-
|
|
1577
|
+
return bindAuthoritativePartition(
|
|
1301
1578
|
prepareAuthoritativeQuery(
|
|
1302
1579
|
query.plan,
|
|
1303
1580
|
query.params,
|
|
@@ -1306,25 +1583,27 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1306
1583
|
),
|
|
1307
1584
|
partition,
|
|
1308
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);
|
|
1309
1594
|
return this.#exec.transaction(async (client) => {
|
|
1310
1595
|
await client.query(
|
|
1311
1596
|
'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY',
|
|
1312
1597
|
);
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
const
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
);
|
|
1321
|
-
return
|
|
1322
|
-
rows: result.rows,
|
|
1323
|
-
maxCommitSeq:
|
|
1324
|
-
cursor.rows[0] === undefined
|
|
1325
|
-
? 0
|
|
1326
|
-
: asNumber(cursor.rows[0].max_commit_seq),
|
|
1327
|
-
};
|
|
1598
|
+
// RFC 0007: evaluate the gate inside this pinned snapshot, so a
|
|
1599
|
+
// migration or an epoch rotation cannot land between the gate read and
|
|
1600
|
+
// the query's own read. The registered-query path uses the pinned
|
|
1601
|
+
// snapshot, not a request-entry check.
|
|
1602
|
+
const runningVersion = this.#schemaVersion ?? 0;
|
|
1603
|
+
const gate = await readServeGateOn(client, partition, runningVersion);
|
|
1604
|
+
const refusal = serveGateRefusal(gate, runningVersion, checkpoints);
|
|
1605
|
+
if (refusal !== undefined) throw serveNotReadyError(refusal);
|
|
1606
|
+
return queryAuthoritativeOn(client, partition, prepared);
|
|
1328
1607
|
});
|
|
1329
1608
|
}
|
|
1330
1609
|
|
|
@@ -1412,6 +1691,174 @@ export class PostgresServerStorage implements ServerStorage {
|
|
|
1412
1691
|
return seq === null || seq === undefined ? 0 : asNumber(seq);
|
|
1413
1692
|
}
|
|
1414
1693
|
|
|
1694
|
+
async readCheckpoints(partition: string): Promise<StoredCheckpoint[]> {
|
|
1695
|
+
const { rows } = await this.#exec.query<PostgresCheckpointRecord>(
|
|
1696
|
+
`SELECT partition, name, schema_version, state, watermark,
|
|
1697
|
+
owner_epoch, observed_rows, updated_at_ms
|
|
1698
|
+
FROM sync_backfill_checkpoints WHERE partition=$1 ORDER BY name`,
|
|
1699
|
+
[partition],
|
|
1700
|
+
);
|
|
1701
|
+
return rows.map(toStoredCheckpoint);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
async readServeGate(
|
|
1705
|
+
partition: string,
|
|
1706
|
+
runningSchemaVersion: number,
|
|
1707
|
+
): Promise<ServeGate> {
|
|
1708
|
+
return readServeGateOn(this.#exec, partition, runningSchemaVersion);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
async declareCheckpoint(
|
|
1712
|
+
partition: string,
|
|
1713
|
+
name: string,
|
|
1714
|
+
schemaVersion: number,
|
|
1715
|
+
nowMs: number,
|
|
1716
|
+
): Promise<StoredCheckpoint> {
|
|
1717
|
+
// One transaction: the checkpoint becomes visible and the fence that
|
|
1718
|
+
// protects it is raised together. The fence is raised at declaration, not
|
|
1719
|
+
// at activation, so old writers are rejected for the whole backfill.
|
|
1720
|
+
//
|
|
1721
|
+
// RFC 0007 lock decision: this transaction writes only the checkpoint and
|
|
1722
|
+
// fence rows, no application rows and no schema marker, so it does not
|
|
1723
|
+
// take the migration table lock or a partition lock. The fence it raises is
|
|
1724
|
+
// enforced by the `sync_commits` trigger on every append from the commit
|
|
1725
|
+
// of this transaction onward; a writer that committed before the fence was
|
|
1726
|
+
// visible is pre-barrier by the design's own "from declaration onward"
|
|
1727
|
+
// rule, not a bypass of it.
|
|
1728
|
+
return this.#exec.transaction(async (client) => {
|
|
1729
|
+
await installCheckpointOn(client, partition, name, schemaVersion, nowMs);
|
|
1730
|
+
const { rows } = await client.query<PostgresCheckpointRecord>(
|
|
1731
|
+
`SELECT partition, name, schema_version, state, watermark,
|
|
1732
|
+
owner_epoch, observed_rows, updated_at_ms
|
|
1733
|
+
FROM sync_backfill_checkpoints WHERE partition=$1 AND name=$2`,
|
|
1734
|
+
[partition, name],
|
|
1735
|
+
);
|
|
1736
|
+
const row = rows[0];
|
|
1737
|
+
if (row === undefined) {
|
|
1738
|
+
throw new StorageQueryError('sync.storage.checkpoint_not_declared');
|
|
1739
|
+
}
|
|
1740
|
+
return toStoredCheckpoint(row);
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
async activateCheckpoint(
|
|
1745
|
+
partition: string,
|
|
1746
|
+
name: string,
|
|
1747
|
+
ownerEpoch: number,
|
|
1748
|
+
watermark: number,
|
|
1749
|
+
sources: readonly string[],
|
|
1750
|
+
nowMs: number,
|
|
1751
|
+
): Promise<'activated' | 'stale' | 'unverifiable'> {
|
|
1752
|
+
return this.#exec.transaction(async (client) => {
|
|
1753
|
+
// The same `SELECT … FOR UPDATE` partition row lock that
|
|
1754
|
+
// `lockPartitionForPush` takes; a bare `BEGIN` would race a push.
|
|
1755
|
+
await lockPartitionOn(client, partition);
|
|
1756
|
+
const fence = await client.query<{ required_writer_version: unknown }>(
|
|
1757
|
+
'SELECT required_writer_version FROM sync_writer_fence WHERE partition=$1',
|
|
1758
|
+
[partition],
|
|
1759
|
+
);
|
|
1760
|
+
const checkpoint = await client.query<{ schema_version: unknown }>(
|
|
1761
|
+
'SELECT schema_version FROM sync_backfill_checkpoints WHERE partition=$1 AND name=$2',
|
|
1762
|
+
[partition, name],
|
|
1763
|
+
);
|
|
1764
|
+
const fenceRow = fence.rows[0];
|
|
1765
|
+
const checkpointRow = checkpoint.rows[0];
|
|
1766
|
+
if (
|
|
1767
|
+
fenceRow === undefined ||
|
|
1768
|
+
checkpointRow === undefined ||
|
|
1769
|
+
asNumber(fenceRow.required_writer_version) <
|
|
1770
|
+
asNumber(checkpointRow.schema_version)
|
|
1771
|
+
) {
|
|
1772
|
+
// The barrier is not installed. Activating would certify a state old
|
|
1773
|
+
// writers can still decay; refuse instead of silently proceeding.
|
|
1774
|
+
throw new StorageQueryError('sync.storage.checkpoint_fence_missing');
|
|
1775
|
+
}
|
|
1776
|
+
const coverage = await hasSourceChangesAboveOn(
|
|
1777
|
+
client,
|
|
1778
|
+
partition,
|
|
1779
|
+
sources,
|
|
1780
|
+
watermark,
|
|
1781
|
+
);
|
|
1782
|
+
if (coverage !== 'clean') {
|
|
1783
|
+
return coverage === 'changed' ? 'stale' : 'unverifiable';
|
|
1784
|
+
}
|
|
1785
|
+
const updated = await client.query<{ partition: string }>(
|
|
1786
|
+
`UPDATE sync_backfill_checkpoints
|
|
1787
|
+
SET state='activated', watermark=$1, updated_at_ms=$2
|
|
1788
|
+
WHERE partition=$3 AND name=$4 AND owner_epoch=$5
|
|
1789
|
+
AND state<>'activated'
|
|
1790
|
+
RETURNING partition`,
|
|
1791
|
+
[watermark, nowMs, partition, name, ownerEpoch],
|
|
1792
|
+
);
|
|
1793
|
+
return updated.rows.length === 1 ? 'activated' : 'stale';
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
async claimCheckpoint(
|
|
1798
|
+
partition: string,
|
|
1799
|
+
name: string,
|
|
1800
|
+
schemaVersion: number,
|
|
1801
|
+
nowMs: number,
|
|
1802
|
+
): Promise<StoredCheckpoint> {
|
|
1803
|
+
// The UPDATE's own row lock serializes concurrent claims; owner_epoch+1
|
|
1804
|
+
// is evaluated under it, so two claimers never observe the same epoch.
|
|
1805
|
+
const { rows } = await this.#exec.query<PostgresCheckpointRecord>(
|
|
1806
|
+
`UPDATE sync_backfill_checkpoints
|
|
1807
|
+
SET owner_epoch=owner_epoch+1,
|
|
1808
|
+
state=CASE WHEN state='declared' THEN 'backfilling' ELSE state END,
|
|
1809
|
+
schema_version=$1, updated_at_ms=$2
|
|
1810
|
+
WHERE partition=$3 AND name=$4 AND state<>'activated'
|
|
1811
|
+
RETURNING partition, name, schema_version, state, watermark,
|
|
1812
|
+
owner_epoch, observed_rows, updated_at_ms`,
|
|
1813
|
+
[schemaVersion, nowMs, partition, name],
|
|
1814
|
+
);
|
|
1815
|
+
if (rows[0] === undefined) {
|
|
1816
|
+
throw new StorageQueryError('sync.storage.checkpoint_not_declared');
|
|
1817
|
+
}
|
|
1818
|
+
return toStoredCheckpoint(rows[0]);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
async advanceCheckpoint(
|
|
1822
|
+
partition: string,
|
|
1823
|
+
name: string,
|
|
1824
|
+
ownerEpoch: number,
|
|
1825
|
+
watermark: number,
|
|
1826
|
+
observedRows: number,
|
|
1827
|
+
nowMs: number,
|
|
1828
|
+
): Promise<boolean> {
|
|
1829
|
+
const result = await this.#exec.query<{ name: string }>(
|
|
1830
|
+
`UPDATE sync_backfill_checkpoints
|
|
1831
|
+
SET watermark=$1, observed_rows=$2, updated_at_ms=$3
|
|
1832
|
+
WHERE partition=$4 AND name=$5 AND owner_epoch=$6
|
|
1833
|
+
RETURNING name`,
|
|
1834
|
+
[watermark, observedRows, nowMs, partition, name, ownerEpoch],
|
|
1835
|
+
);
|
|
1836
|
+
return result.rows.length === 1;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
async sourceCoverageSeq(
|
|
1840
|
+
partition: string,
|
|
1841
|
+
tables: readonly string[],
|
|
1842
|
+
): Promise<number> {
|
|
1843
|
+
if (tables.length === 0) return 0;
|
|
1844
|
+
const tableParams = tables.map((_, index) => `$${index + 2}`).join(',');
|
|
1845
|
+
const { rows } = await this.#exec.query<{ seq: unknown }>(
|
|
1846
|
+
`SELECT max(commit_seq) AS seq FROM sync_changes
|
|
1847
|
+
WHERE partition=$1 AND tbl IN (${tableParams})`,
|
|
1848
|
+
[partition, ...tables],
|
|
1849
|
+
);
|
|
1850
|
+
const seq = rows[0]?.seq;
|
|
1851
|
+
return seq === null || seq === undefined ? 0 : asNumber(seq);
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
hasSourceChangesAbove(
|
|
1855
|
+
partition: string,
|
|
1856
|
+
tables: readonly string[],
|
|
1857
|
+
seq: number,
|
|
1858
|
+
): Promise<'clean' | 'changed' | 'unverifiable'> {
|
|
1859
|
+
return hasSourceChangesAboveOn(this.#exec, partition, tables, seq);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1415
1862
|
getRow(
|
|
1416
1863
|
partition: string,
|
|
1417
1864
|
table: string,
|