@syncular/server 0.26.1 → 0.26.4
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/dist/d1-storage.js +75 -14
- package/dist/handler.js +7 -3
- package/dist/postgres-storage.js +79 -37
- package/dist/pull.d.ts +19 -0
- package/dist/pull.js +87 -33
- package/dist/relational-rows.d.ts +24 -0
- package/dist/relational-rows.js +45 -0
- package/package.json +2 -2
- package/src/d1-storage.ts +93 -33
- package/src/handler.ts +14 -4
- package/src/postgres-storage.ts +112 -50
- package/src/pull.ts +144 -39
- package/src/relational-rows.ts +47 -0
package/dist/d1-storage.js
CHANGED
|
@@ -583,6 +583,13 @@ export class D1ServerStorage {
|
|
|
583
583
|
/** Set by `ensureSchema`: app-table lookup for the relational row store. */
|
|
584
584
|
#tables;
|
|
585
585
|
#schemaVersion;
|
|
586
|
+
/**
|
|
587
|
+
* Page reads waiting for the end of the current microtask turn. A pull
|
|
588
|
+
* starts the window read (or first snapshot page) of every subscription
|
|
589
|
+
* before awaiting any of them, so they leave as ONE `db.batch` round trip
|
|
590
|
+
* instead of one per subscription (SYNCULAR-PULL-ROUNDTRIPS-001).
|
|
591
|
+
*/
|
|
592
|
+
#pendingReads;
|
|
586
593
|
constructor(db, options = {}) {
|
|
587
594
|
this.#db = db;
|
|
588
595
|
this.#pushApplySerialized =
|
|
@@ -944,6 +951,47 @@ export class D1ServerStorage {
|
|
|
944
951
|
return { complete: false, statementsExecuted };
|
|
945
952
|
}
|
|
946
953
|
/** Every protected read or write shares a transaction with its schema check. */
|
|
954
|
+
/** Queue one read into the current turn's `db.batch` (see `#pendingReads`). */
|
|
955
|
+
#batchedAll(sql, params) {
|
|
956
|
+
let pending = this.#pendingReads;
|
|
957
|
+
if (pending === undefined) {
|
|
958
|
+
const created = {
|
|
959
|
+
db: this.#schemaDatabase(),
|
|
960
|
+
entries: [],
|
|
961
|
+
};
|
|
962
|
+
pending = created;
|
|
963
|
+
this.#pendingReads = created;
|
|
964
|
+
queueMicrotask(() => {
|
|
965
|
+
this.#pendingReads = undefined;
|
|
966
|
+
created.db.batch(created.entries.map((entry) => entry.statement)).then((results) => {
|
|
967
|
+
created.entries.forEach((entry, i) => {
|
|
968
|
+
const result = results[i];
|
|
969
|
+
if (typeof result !== 'object' ||
|
|
970
|
+
result === null ||
|
|
971
|
+
!('results' in result) ||
|
|
972
|
+
!Array.isArray(result.results)) {
|
|
973
|
+
entry.reject(new Error('D1 query returned an invalid batch result'));
|
|
974
|
+
}
|
|
975
|
+
else {
|
|
976
|
+
entry.resolve(result.results);
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
}, (error) => {
|
|
980
|
+
for (const entry of created.entries)
|
|
981
|
+
entry.reject(error);
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
const target = pending;
|
|
986
|
+
const statement = target.db.prepare(sql).bind(...params);
|
|
987
|
+
return new Promise((resolve, reject) => {
|
|
988
|
+
target.entries.push({
|
|
989
|
+
statement,
|
|
990
|
+
resolve: (rows) => resolve(rows),
|
|
991
|
+
reject,
|
|
992
|
+
});
|
|
993
|
+
});
|
|
994
|
+
}
|
|
947
995
|
#schemaDatabase() {
|
|
948
996
|
if (this.#schemaVersion === undefined)
|
|
949
997
|
throw new StorageQueryError('sync.storage.schema_changed');
|
|
@@ -1392,7 +1440,6 @@ export class D1ServerStorage {
|
|
|
1392
1440
|
};
|
|
1393
1441
|
}
|
|
1394
1442
|
async readCommitWindow(partition, query) {
|
|
1395
|
-
const db = this.#schemaDatabase();
|
|
1396
1443
|
const variables = Object.keys(query.scopeFilter).sort();
|
|
1397
1444
|
const firstVariable = variables[0];
|
|
1398
1445
|
if (firstVariable === undefined)
|
|
@@ -1401,19 +1448,28 @@ export class D1ServerStorage {
|
|
|
1401
1448
|
if (firstValues.length === 0)
|
|
1402
1449
|
return [];
|
|
1403
1450
|
// Candidates via the inverted index (one variable) LEFT JOINed to the
|
|
1404
|
-
// commit meta + the table's changes — one
|
|
1405
|
-
//
|
|
1406
|
-
//
|
|
1451
|
+
// commit meta + the table's changes — one statement per page, never two
|
|
1452
|
+
// per candidate (see `commitWindowPageSql`), and concurrent pages share
|
|
1453
|
+
// one D1 round trip. Exact multi-variable verification against the
|
|
1454
|
+
// stored scope map in `collectCommitWindowPage`.
|
|
1407
1455
|
const sql = commitWindowPageSql(firstValues.length, 'sqlite');
|
|
1408
1456
|
const commits = [];
|
|
1409
1457
|
let deliveredChanges = 0;
|
|
1410
1458
|
let afterSeq = query.afterSeq;
|
|
1411
1459
|
const batchSize = Math.max(64, query.limitChanges);
|
|
1412
1460
|
while (deliveredChanges < query.limitChanges) {
|
|
1413
|
-
const
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1461
|
+
const records = await this.#batchedAll(sql, [
|
|
1462
|
+
partition,
|
|
1463
|
+
query.table,
|
|
1464
|
+
firstVariable,
|
|
1465
|
+
...firstValues,
|
|
1466
|
+
afterSeq,
|
|
1467
|
+
query.throughSeq,
|
|
1468
|
+
batchSize,
|
|
1469
|
+
partition,
|
|
1470
|
+
partition,
|
|
1471
|
+
query.table,
|
|
1472
|
+
]);
|
|
1417
1473
|
if (records.length === 0)
|
|
1418
1474
|
break;
|
|
1419
1475
|
const page = collectCommitWindowPage(records, query.scopeFilter, query.limitChanges - deliveredChanges);
|
|
@@ -1426,23 +1482,28 @@ export class D1ServerStorage {
|
|
|
1426
1482
|
return commits;
|
|
1427
1483
|
}
|
|
1428
1484
|
async scanRows(partition, query) {
|
|
1429
|
-
const db = this.#schemaDatabase();
|
|
1430
1485
|
const firstVariable = assertScopeIndexedScan(query);
|
|
1431
1486
|
const firstValues = query.scopeFilter[firstVariable] ?? [];
|
|
1432
1487
|
if (firstValues.length === 0)
|
|
1433
1488
|
return [];
|
|
1434
1489
|
// Candidates via the inverted index LEFT JOINed to the row table — one
|
|
1435
|
-
//
|
|
1490
|
+
// statement per page, never one per row (see `scanRowPageSql`), and
|
|
1491
|
+
// concurrent pages share one D1 round trip.
|
|
1436
1492
|
// Exact multi-variable verification against the stored scope map below.
|
|
1437
1493
|
const sql = scanRowPageSql(this.table(query.table), firstValues.length, 'sqlite');
|
|
1438
1494
|
const rows = [];
|
|
1439
1495
|
let afterRowId = query.afterRowId ?? '';
|
|
1440
1496
|
const batchSize = Math.max(64, query.limit);
|
|
1441
1497
|
while (rows.length < query.limit) {
|
|
1442
|
-
const
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1498
|
+
const records = await this.#batchedAll(sql, [
|
|
1499
|
+
partition,
|
|
1500
|
+
query.table,
|
|
1501
|
+
firstVariable,
|
|
1502
|
+
...firstValues,
|
|
1503
|
+
afterRowId,
|
|
1504
|
+
batchSize,
|
|
1505
|
+
partition,
|
|
1506
|
+
]);
|
|
1446
1507
|
if (records.length === 0)
|
|
1447
1508
|
break;
|
|
1448
1509
|
for (const record of records) {
|
package/dist/handler.js
CHANGED
|
@@ -14,7 +14,7 @@ import { clockOf, limitsOf, REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, to
|
|
|
14
14
|
import { SyncError, syncError } from './errors.js';
|
|
15
15
|
import { emitEvent, } from './events.js';
|
|
16
16
|
import { END_FRAME_BYTES, encodeResponseFrame, responseEnvelopeHeader, } from './frame-bytes.js';
|
|
17
|
-
import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits,
|
|
17
|
+
import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, prepareSections, preparedSection, } from './pull.js';
|
|
18
18
|
import { processPushCommitWithTrace } from './push.js';
|
|
19
19
|
import { serveNotReadyError } from './readiness.js';
|
|
20
20
|
import { compileSchema } from './schema.js';
|
|
@@ -353,9 +353,13 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
353
353
|
const limits = clampPullLimits(plan.pull);
|
|
354
354
|
const maxSeq = await ctx.storage.getMaxCommitSeq(ctx.partition);
|
|
355
355
|
const horizonSeq = await ctx.storage.getHorizonSeq(ctx.partition);
|
|
356
|
-
|
|
356
|
+
const prepared = await prepareSections(ctx, limits, plan.subscriptions, maxSeq, horizonSeq);
|
|
357
|
+
for (const [index, subscription] of plan.subscriptions.entries()) {
|
|
357
358
|
const trace = summaries !== undefined ? { segments: [] } : undefined;
|
|
358
|
-
const
|
|
359
|
+
const reads = prepared[index];
|
|
360
|
+
if (reads === undefined)
|
|
361
|
+
throw new Error('prepareSections lost a plan');
|
|
362
|
+
const section = preparedSection(ctx, schema, limits, subscription, maxSeq, reads, trace, plan.logEpoch);
|
|
359
363
|
let status = 'active';
|
|
360
364
|
let bootstrap = false;
|
|
361
365
|
let commits = 0;
|
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, assertStoredLayouts,
|
|
8
|
+
import { assertPhysicalColumns, assertStoredLayouts, COMMIT_WINDOW_BATCH_SQL, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowBatchSql, 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';
|
|
@@ -171,19 +171,21 @@ async function installCheckpointOn(client, partition, name, schemaVersion, nowMs
|
|
|
171
171
|
}
|
|
172
172
|
/** RFC 0007 serve gate on either the pool or a transaction's client. */
|
|
173
173
|
async function readServeGateOn(client, partition, runningSchemaVersion) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
174
|
+
// One statement: every request reads the gate twice (entry and verify),
|
|
175
|
+
// so its parts share one round trip and one snapshot.
|
|
176
|
+
const { rows } = await client.query(`SELECT (SELECT schema_version FROM sync_schema_meta WHERE id=1) AS schema_version,
|
|
177
|
+
(SELECT log_epoch FROM sync_partition_registry WHERE partition=$1) AS log_epoch,
|
|
178
|
+
(SELECT json_agg(json_build_object('partition', partition, 'name', name))
|
|
179
|
+
FROM sync_backfill_checkpoints
|
|
180
|
+
WHERE schema_version=$2 AND state<>'activated') AS pending`, [partition, runningSchemaVersion]);
|
|
181
|
+
const row = rows[0];
|
|
178
182
|
return {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
name: row.name,
|
|
186
|
-
})),
|
|
183
|
+
// asNumber maps the NULL of a missing marker row to 0.
|
|
184
|
+
storedSchemaVersion: asNumber(row?.schema_version),
|
|
185
|
+
logEpoch: row?.log_epoch ?? undefined,
|
|
186
|
+
pending: row?.pending === null || row?.pending === undefined
|
|
187
|
+
? []
|
|
188
|
+
: asJson(row.pending),
|
|
187
189
|
};
|
|
188
190
|
}
|
|
189
191
|
async function hasSourceChangesAboveOn(client, partition, tables, seq) {
|
|
@@ -725,9 +727,53 @@ export class PostgresServerStorage {
|
|
|
725
727
|
/** Set by `ensureSchema`: app-table lookup for the relational row store. */
|
|
726
728
|
#tables;
|
|
727
729
|
#schemaVersion;
|
|
730
|
+
/**
|
|
731
|
+
* Page reads waiting for the end of the current microtask turn, keyed by
|
|
732
|
+
* statement and fixed params. A pull starts the window read (or first
|
|
733
|
+
* snapshot page) of every subscription before awaiting any of them, so the
|
|
734
|
+
* reads of one table leave as ONE set-based statement instead of one round
|
|
735
|
+
* trip per subscription (SYNCULAR-PULL-ROUNDTRIPS-001).
|
|
736
|
+
*/
|
|
737
|
+
#pageBatches = new Map();
|
|
728
738
|
constructor(exec) {
|
|
729
739
|
this.#exec = exec;
|
|
730
740
|
}
|
|
741
|
+
/**
|
|
742
|
+
* Queue one page read into the batch for (`sql`, `partition`, `table`).
|
|
743
|
+
* The batch runs as a single statement with the queued specs bound as a
|
|
744
|
+
* JSON array (`$2`); each spec gets its position as `i`, and the result
|
|
745
|
+
* rows are split back by their `sub` column.
|
|
746
|
+
*/
|
|
747
|
+
#batchedPage(sql, partition, table, spec) {
|
|
748
|
+
const key = `${sql}\u0000${partition}\u0000${table}`;
|
|
749
|
+
let batch = this.#pageBatches.get(key);
|
|
750
|
+
if (batch === undefined) {
|
|
751
|
+
const queued = [];
|
|
752
|
+
batch = queued;
|
|
753
|
+
this.#pageBatches.set(key, queued);
|
|
754
|
+
queueMicrotask(() => {
|
|
755
|
+
this.#pageBatches.delete(key);
|
|
756
|
+
const specs = JSON.stringify(queued.map((entry, i) => ({ ...entry.spec, i })));
|
|
757
|
+
this.#exec.query(sql, [partition, specs, table]).then(({ rows }) => {
|
|
758
|
+
const grouped = queued.map(() => []);
|
|
759
|
+
for (const row of rows)
|
|
760
|
+
grouped[asNumber(row.sub)]?.push(row);
|
|
761
|
+
queued.forEach((entry, i) => entry.resolve(grouped[i] ?? []));
|
|
762
|
+
}, (error) => {
|
|
763
|
+
for (const entry of queued)
|
|
764
|
+
entry.reject(error);
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
const target = batch;
|
|
769
|
+
return new Promise((resolve, reject) => {
|
|
770
|
+
target.push({
|
|
771
|
+
spec,
|
|
772
|
+
resolve: (rows) => resolve(rows),
|
|
773
|
+
reject,
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
}
|
|
731
777
|
/** Apply the schema DDL (idempotent). Call once before use. */
|
|
732
778
|
async migrate() {
|
|
733
779
|
// Split on the statement boundary so drivers that reject multi-statement
|
|
@@ -1313,25 +1359,22 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1313
1359
|
// Candidates via the inverted index (one variable): the (partition, tbl,
|
|
1314
1360
|
// var, value, commit_seq) PK makes the candidate subquery an index range
|
|
1315
1361
|
// scan returning commit_seq already ascending (see postgres-explain
|
|
1316
|
-
// .test.ts), LEFT JOINed to the commit meta + the table's changes
|
|
1317
|
-
//
|
|
1318
|
-
// `
|
|
1319
|
-
// stored scope map happens below.
|
|
1320
|
-
const sql = commitWindowPageSql(firstValues.length, 'postgres');
|
|
1362
|
+
// .test.ts), LEFT JOINed to the commit meta + the table's changes. Pages
|
|
1363
|
+
// of concurrent window reads on the same table share one statement (see
|
|
1364
|
+
// `COMMIT_WINDOW_BATCH_SQL`). Exact multi-variable verification against
|
|
1365
|
+
// the stored scope map happens below.
|
|
1321
1366
|
const commits = [];
|
|
1322
1367
|
let deliveredChanges = 0;
|
|
1323
1368
|
let afterSeq = query.afterSeq;
|
|
1324
1369
|
const batchSize = Math.max(64, query.limitChanges);
|
|
1325
1370
|
while (deliveredChanges < query.limitChanges) {
|
|
1326
|
-
const
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
batchSize,
|
|
1334
|
-
]);
|
|
1371
|
+
const records = await this.#batchedPage(COMMIT_WINDOW_BATCH_SQL, partition, query.table, {
|
|
1372
|
+
var: firstVariable,
|
|
1373
|
+
vals: firstValues,
|
|
1374
|
+
after: afterSeq,
|
|
1375
|
+
through: query.throughSeq,
|
|
1376
|
+
lim: batchSize,
|
|
1377
|
+
});
|
|
1335
1378
|
if (records.length === 0)
|
|
1336
1379
|
break;
|
|
1337
1380
|
// Fold the page: rows arrive ordered (commit_seq, idx); consecutive
|
|
@@ -1397,21 +1440,20 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
|
|
|
1397
1440
|
return [];
|
|
1398
1441
|
// Candidates via the inverted index (ordered + LIMITed at the covering
|
|
1399
1442
|
// PK) LEFT JOINed to the row table — one round trip per page, never one
|
|
1400
|
-
// per row
|
|
1443
|
+
// per row, and pages of concurrent scans on the same table share one
|
|
1444
|
+
// statement (see `scanRowBatchSql`). Exact multi-variable verification
|
|
1401
1445
|
// against the stored scope map happens below.
|
|
1402
|
-
const sql =
|
|
1446
|
+
const sql = scanRowBatchSql(this.table(query.table));
|
|
1403
1447
|
const rows = [];
|
|
1404
1448
|
let afterRowId = query.afterRowId ?? '';
|
|
1405
1449
|
const batchSize = Math.max(64, query.limit);
|
|
1406
1450
|
while (rows.length < query.limit) {
|
|
1407
|
-
const
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
batchSize,
|
|
1414
|
-
]);
|
|
1451
|
+
const records = await this.#batchedPage(sql, partition, query.table, {
|
|
1452
|
+
var: firstVariable,
|
|
1453
|
+
vals: firstValues,
|
|
1454
|
+
after: afterRowId,
|
|
1455
|
+
lim: batchSize,
|
|
1456
|
+
});
|
|
1415
1457
|
if (records.length === 0)
|
|
1416
1458
|
break;
|
|
1417
1459
|
for (const record of records) {
|
package/dist/pull.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { type PullHeaderFrame, type ResponseFrame, type ScopeMap, type Subscript
|
|
|
6
6
|
import type { SyncRequestContext } from './context.js';
|
|
7
7
|
import type { PullSegmentSummary } from './events.js';
|
|
8
8
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
9
|
+
import type { StoredCommit, StoredRow } from './storage.js';
|
|
9
10
|
/** §4.2 accept bitmask. */
|
|
10
11
|
export declare const ACCEPT_INLINE_ROWS: number;
|
|
11
12
|
export declare const ACCEPT_EXTERNAL_ROWS: number;
|
|
@@ -37,8 +38,26 @@ export interface SubscriptionResult {
|
|
|
37
38
|
export interface PullSectionTrace {
|
|
38
39
|
readonly segments: PullSegmentSummary[];
|
|
39
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* The storage reads a section needs before its first frame. A pull starts
|
|
43
|
+
* them for every subscription before awaiting any, so a storage that batches
|
|
44
|
+
* concurrent page reads (PostgreSQL) answers all subscriptions of one table
|
|
45
|
+
* with one statement, and the horizon re-check runs once per pull.
|
|
46
|
+
*/
|
|
47
|
+
export interface PreparedSection {
|
|
48
|
+
/** §4.5 window read for an incremental cursor; empty when none was read. */
|
|
49
|
+
readonly commits: StoredCommit[];
|
|
50
|
+
/** Horizon to judge the cursor against, re-read after any window read. */
|
|
51
|
+
readonly horizonSeq: number;
|
|
52
|
+
/** First rows-lane snapshot page, read when the sqlite lane is excluded. */
|
|
53
|
+
readonly firstPage: StoredRow[] | undefined;
|
|
54
|
+
}
|
|
55
|
+
/** Run the pre-frame storage reads of every subscription concurrently. */
|
|
56
|
+
export declare function prepareSections(ctx: SyncRequestContext, limits: PullLimits, plans: readonly SubscriptionPlan[], maxSeq: number, horizonSeq: number): Promise<PreparedSection[]>;
|
|
40
57
|
/**
|
|
41
58
|
* Produce the `SUB_START … SUB_END` section for one subscription (§1.6),
|
|
42
59
|
* returning the cursor recorded for the retention watermark (§4.5).
|
|
43
60
|
*/
|
|
44
61
|
export declare function subscriptionSection(ctx: SyncRequestContext, schema: CompiledSchema, limits: PullLimits, plan: SubscriptionPlan, maxSeq: number, horizonSeq: number, trace?: PullSectionTrace, logEpoch?: string): AsyncGenerator<ResponseFrame, SubscriptionResult>;
|
|
62
|
+
/** The section of one subscription whose reads `prepareSections` ran. */
|
|
63
|
+
export declare function preparedSection(ctx: SyncRequestContext, schema: CompiledSchema, limits: PullLimits, plan: SubscriptionPlan, maxSeq: number, prepared: PreparedSection, trace?: PullSectionTrace, logEpoch?: string): AsyncGenerator<ResponseFrame, SubscriptionResult>;
|
package/dist/pull.js
CHANGED
|
@@ -132,6 +132,19 @@ function segmentRefFrame(record, scopeDigest, extra) {
|
|
|
132
132
|
: {}),
|
|
133
133
|
};
|
|
134
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* §5.3: the sqlite-image lane — whole-table, chosen only at the start of a
|
|
137
|
+
* table (a mid-table resume stays on the rows lane, never switching lanes),
|
|
138
|
+
* and only when the client advertised bit 2. §5.11: a table with any
|
|
139
|
+
* encrypted column is image-INELIGIBLE — an image copies ciphertext
|
|
140
|
+
* wholesale with no per-row decrypt pass, so it MUST be served on the rows
|
|
141
|
+
* lane (which decrypts per row on the client).
|
|
142
|
+
*/
|
|
143
|
+
function sqliteLaneEligible(limits, plan, startRowCursor) {
|
|
144
|
+
return ((limits.accept & ACCEPT_SQLITE) !== 0 &&
|
|
145
|
+
startRowCursor === null &&
|
|
146
|
+
plan.table.encryptedColumnIndices.length === 0);
|
|
147
|
+
}
|
|
135
148
|
/**
|
|
136
149
|
* §5.3 sqlite-image lane: only at the start of a table, only when the
|
|
137
150
|
* client advertised bit 2, and only when the snapshot exceeds one rows
|
|
@@ -260,32 +273,26 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
|
|
|
260
273
|
yield segmentRefFrame(record, digest, await signedUrlFields(ctx, limits, record.segmentId, digest, clockOf(ctx)()));
|
|
261
274
|
return true;
|
|
262
275
|
}
|
|
263
|
-
async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch) {
|
|
276
|
+
async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch, firstPage) {
|
|
264
277
|
const { storage, segments, partition } = ctx;
|
|
265
278
|
const serverLimits = limitsOf(ctx);
|
|
266
279
|
const digest = await scopeDigest(plan.effective);
|
|
267
280
|
const now = clockOf(ctx)();
|
|
268
|
-
|
|
269
|
-
// of a table (a mid-table resume stays on the rows lane, never
|
|
270
|
-
// switching lanes), and only when the client advertised bit 2.
|
|
271
|
-
// §5.11: a table with any encrypted column is image-INELIGIBLE — an image
|
|
272
|
-
// copies ciphertext wholesale with no per-row decrypt pass, so it MUST be
|
|
273
|
-
// served on the rows lane (which decrypts per row on the client).
|
|
274
|
-
if ((limits.accept & ACCEPT_SQLITE) !== 0 &&
|
|
275
|
-
startRowCursor === null &&
|
|
276
|
-
plan.table.encryptedColumnIndices.length === 0) {
|
|
281
|
+
if (sqliteLaneEligible(limits, plan, startRowCursor)) {
|
|
277
282
|
const imaged = yield* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch);
|
|
278
283
|
if (imaged)
|
|
279
284
|
return { complete: true, rowCursor: null };
|
|
280
285
|
}
|
|
281
286
|
let rowCursor = startRowCursor;
|
|
282
287
|
for (let page = 0; page < limits.maxSnapshotPages; page++) {
|
|
283
|
-
const scanned =
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
288
|
+
const scanned = page === 0 && firstPage !== undefined
|
|
289
|
+
? firstPage
|
|
290
|
+
: await storage.scanRows(partition, {
|
|
291
|
+
table: plan.table.name,
|
|
292
|
+
scopeFilter: plan.effective,
|
|
293
|
+
afterRowId: rowCursor,
|
|
294
|
+
limit: limits.limitSnapshotRows + 1,
|
|
295
|
+
});
|
|
289
296
|
const pageRows = scanned.slice(0, limits.limitSnapshotRows);
|
|
290
297
|
const hasMore = scanned.length > limits.limitSnapshotRows;
|
|
291
298
|
// §5.2: every row record carries the row's current server_version.
|
|
@@ -343,11 +350,73 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
|
|
|
343
350
|
}
|
|
344
351
|
return { complete: false, rowCursor };
|
|
345
352
|
}
|
|
353
|
+
/** Run the pre-frame storage reads of every subscription concurrently. */
|
|
354
|
+
export async function prepareSections(ctx, limits, plans, maxSeq, horizonSeq) {
|
|
355
|
+
const reads = plans.map((plan) => {
|
|
356
|
+
const sub = plan.frame;
|
|
357
|
+
if (plan.status === 'revoked')
|
|
358
|
+
return {};
|
|
359
|
+
const token = parseBootstrapToken(sub.bootstrapState, sub.table);
|
|
360
|
+
if (token === undefined &&
|
|
361
|
+
sub.cursor >= 0 &&
|
|
362
|
+
sub.cursor >= horizonSeq &&
|
|
363
|
+
sub.cursor <= maxSeq) {
|
|
364
|
+
return {
|
|
365
|
+
commits: ctx.storage.readCommitWindow(ctx.partition, {
|
|
366
|
+
table: sub.table,
|
|
367
|
+
scopeFilter: plan.effective,
|
|
368
|
+
afterSeq: sub.cursor,
|
|
369
|
+
throughSeq: maxSeq,
|
|
370
|
+
limitChanges: limits.limitCommits + 1,
|
|
371
|
+
}),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
// §4.6 reset: no reads.
|
|
375
|
+
if (token === undefined && sub.cursor >= 0 && sub.cursor < horizonSeq) {
|
|
376
|
+
return {};
|
|
377
|
+
}
|
|
378
|
+
// Bootstrapping: the same resume decision `preparedSection` makes (§4.7).
|
|
379
|
+
const startCursor = token !== undefined && token.asOfCommitSeq >= horizonSeq
|
|
380
|
+
? token.rowCursor
|
|
381
|
+
: null;
|
|
382
|
+
if (sqliteLaneEligible(limits, plan, startCursor))
|
|
383
|
+
return {};
|
|
384
|
+
return {
|
|
385
|
+
firstPage: ctx.storage.scanRows(ctx.partition, {
|
|
386
|
+
table: plan.table.name,
|
|
387
|
+
scopeFilter: plan.effective,
|
|
388
|
+
afterRowId: startCursor,
|
|
389
|
+
limit: limits.limitSnapshotRows + 1,
|
|
390
|
+
}),
|
|
391
|
+
};
|
|
392
|
+
});
|
|
393
|
+
const settled = await Promise.all(reads.map(async (read) => ({
|
|
394
|
+
commits: read.commits === undefined ? undefined : await read.commits,
|
|
395
|
+
firstPage: await read.firstPage,
|
|
396
|
+
})));
|
|
397
|
+
// Validate continuity before committing to an active section. A prune
|
|
398
|
+
// during a paged read can otherwise make an incomplete window look empty.
|
|
399
|
+
const windowHorizon = settled.some((read) => read.commits !== undefined)
|
|
400
|
+
? Math.max(horizonSeq, await ctx.storage.getHorizonSeq(ctx.partition))
|
|
401
|
+
: horizonSeq;
|
|
402
|
+
return settled.map((read) => ({
|
|
403
|
+
commits: read.commits ?? [],
|
|
404
|
+
horizonSeq: read.commits === undefined ? horizonSeq : windowHorizon,
|
|
405
|
+
firstPage: read.firstPage,
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
346
408
|
/**
|
|
347
409
|
* Produce the `SUB_START … SUB_END` section for one subscription (§1.6),
|
|
348
410
|
* returning the cursor recorded for the retention watermark (§4.5).
|
|
349
411
|
*/
|
|
350
412
|
export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, horizonSeq, trace, logEpoch) {
|
|
413
|
+
const [prepared] = await prepareSections(ctx, limits, [plan], maxSeq, horizonSeq);
|
|
414
|
+
if (prepared === undefined)
|
|
415
|
+
throw new Error('prepareSections lost a plan');
|
|
416
|
+
return yield* preparedSection(ctx, schema, limits, plan, maxSeq, prepared, trace, logEpoch);
|
|
417
|
+
}
|
|
418
|
+
/** The section of one subscription whose reads `prepareSections` ran. */
|
|
419
|
+
export async function* preparedSection(ctx, schema, limits, plan, maxSeq, prepared, trace, logEpoch) {
|
|
351
420
|
const sub = plan.frame;
|
|
352
421
|
if (logEpoch === undefined || logEpoch.length === 0) {
|
|
353
422
|
throw new Error('subscriptionSection requires a non-empty log epoch');
|
|
@@ -365,22 +434,7 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
|
|
|
365
434
|
return { nextCursor: sub.cursor, active: false };
|
|
366
435
|
}
|
|
367
436
|
const token = parseBootstrapToken(sub.bootstrapState, sub.table);
|
|
368
|
-
|
|
369
|
-
if (token === undefined &&
|
|
370
|
-
sub.cursor >= 0 &&
|
|
371
|
-
sub.cursor >= horizonSeq &&
|
|
372
|
-
sub.cursor <= maxSeq) {
|
|
373
|
-
commits = await ctx.storage.readCommitWindow(ctx.partition, {
|
|
374
|
-
table: sub.table,
|
|
375
|
-
scopeFilter: plan.effective,
|
|
376
|
-
afterSeq: sub.cursor,
|
|
377
|
-
throughSeq: maxSeq,
|
|
378
|
-
limitChanges: limits.limitCommits + 1,
|
|
379
|
-
});
|
|
380
|
-
// Validate continuity before committing to an active section. A prune
|
|
381
|
-
// during a paged read can otherwise make an incomplete window look empty.
|
|
382
|
-
horizonSeq = Math.max(horizonSeq, await ctx.storage.getHorizonSeq(ctx.partition));
|
|
383
|
-
}
|
|
437
|
+
const { commits, horizonSeq } = prepared;
|
|
384
438
|
// §4.6: a cursor behind the horizon (and not resuming a bootstrap)
|
|
385
439
|
// cannot compute deltas — answer `reset` and echo the cursor.
|
|
386
440
|
if (token === undefined && sub.cursor >= 0 && sub.cursor < horizonSeq) {
|
|
@@ -412,7 +466,7 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
|
|
|
412
466
|
effectiveScopes: plan.effective,
|
|
413
467
|
bootstrap: true,
|
|
414
468
|
};
|
|
415
|
-
const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace, logEpoch);
|
|
469
|
+
const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace, logEpoch, prepared.firstPage);
|
|
416
470
|
if (outcome.complete) {
|
|
417
471
|
yield { type: 'SUB_END', nextCursor: asOf };
|
|
418
472
|
}
|
|
@@ -225,6 +225,30 @@ export declare function scanRowPageSql(table: CompiledTable, valueCount: number,
|
|
|
225
225
|
* again for the joins).
|
|
226
226
|
*/
|
|
227
227
|
export declare function commitWindowPageSql(valueCount: number, dialect: RelationalDialect): string;
|
|
228
|
+
/**
|
|
229
|
+
* PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
|
|
230
|
+
* page of every subscription scanning `table` in a pull. `$2` is a JSON array
|
|
231
|
+
* of `{i, var, vals, after, lim}` records; each record runs the same
|
|
232
|
+
* index-first candidate subquery through a LATERAL join, so every
|
|
233
|
+
* subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
|
|
234
|
+
* by (i, row_id).
|
|
235
|
+
*
|
|
236
|
+
* Params: [partition, specs (JSON text), tbl]. The specs bind as text before
|
|
237
|
+
* the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
|
|
238
|
+
* parameter (Bun.sql) cannot turn the array into a JSON string.
|
|
239
|
+
*/
|
|
240
|
+
export declare function scanRowBatchSql(table: CompiledTable): string;
|
|
241
|
+
/**
|
|
242
|
+
* PostgreSQL set-based form of `commitWindowPageSql`: one statement answers
|
|
243
|
+
* the window page of every subscription reading `tbl` in a pull. `$2` is a
|
|
244
|
+
* JSON array of `{i, var, vals, after, through, lim}` records, each running
|
|
245
|
+
* the same index-first candidate subquery through a LATERAL join. Rows
|
|
246
|
+
* arrive ordered by (i, commit_seq, idx).
|
|
247
|
+
*
|
|
248
|
+
* Params: [partition, specs (JSON text), tbl], the specs bound as text like
|
|
249
|
+
* `scanRowBatchSql`.
|
|
250
|
+
*/
|
|
251
|
+
export declare const COMMIT_WINDOW_BATCH_SQL = "SELECT s.i AS sub, c.commit_seq AS commit_seq, m.actor_id AS actor_id, m.created_at_ms AS created_at_ms,\n ch.tbl AS tbl, ch.row_id AS row_id, ch.op AS op, ch.row_version AS row_version, ch.scopes AS scopes, ch.payload AS payload\n FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after bigint, through bigint, lim int)\n CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes\n WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)\n AND commit_seq>s.after AND commit_seq<=s.through\n ORDER BY commit_seq LIMIT s.lim) c\n LEFT JOIN sync_commits m\n ON m.partition=$1 AND m.commit_seq=c.commit_seq\n LEFT JOIN sync_changes ch\n ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=$3\n ORDER BY s.i, c.commit_seq, ch.idx";
|
|
228
252
|
/** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
|
|
229
253
|
export declare function selectRowScopesSql(table: CompiledTable, dialect: RelationalDialect): string;
|
|
230
254
|
/** DELETE one stored row. Params: [partition, rowId]. */
|
package/dist/relational-rows.js
CHANGED
|
@@ -548,6 +548,51 @@ export function commitWindowPageSql(valueCount, dialect) {
|
|
|
548
548
|
ON ch.partition=${joinPartition} AND ch.commit_seq=c.commit_seq AND ch.tbl=${joinTbl}
|
|
549
549
|
ORDER BY c.commit_seq, ch.idx`;
|
|
550
550
|
}
|
|
551
|
+
/**
|
|
552
|
+
* PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
|
|
553
|
+
* page of every subscription scanning `table` in a pull. `$2` is a JSON array
|
|
554
|
+
* of `{i, var, vals, after, lim}` records; each record runs the same
|
|
555
|
+
* index-first candidate subquery through a LATERAL join, so every
|
|
556
|
+
* subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
|
|
557
|
+
* by (i, row_id).
|
|
558
|
+
*
|
|
559
|
+
* Params: [partition, specs (JSON text), tbl]. The specs bind as text before
|
|
560
|
+
* the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
|
|
561
|
+
* parameter (Bun.sql) cannot turn the array into a JSON string.
|
|
562
|
+
*/
|
|
563
|
+
export function scanRowBatchSql(table) {
|
|
564
|
+
return `SELECT s.i AS sub, c.row_id AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, r.${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions
|
|
565
|
+
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after text, lim int)
|
|
566
|
+
CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
|
|
567
|
+
WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)
|
|
568
|
+
AND row_id>s.after
|
|
569
|
+
ORDER BY row_id LIMIT s.lim) c
|
|
570
|
+
LEFT JOIN ${quoteIdent(table.name)} r
|
|
571
|
+
ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=c.row_id
|
|
572
|
+
ORDER BY s.i, c.row_id`;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* PostgreSQL set-based form of `commitWindowPageSql`: one statement answers
|
|
576
|
+
* the window page of every subscription reading `tbl` in a pull. `$2` is a
|
|
577
|
+
* JSON array of `{i, var, vals, after, through, lim}` records, each running
|
|
578
|
+
* the same index-first candidate subquery through a LATERAL join. Rows
|
|
579
|
+
* arrive ordered by (i, commit_seq, idx).
|
|
580
|
+
*
|
|
581
|
+
* Params: [partition, specs (JSON text), tbl], the specs bound as text like
|
|
582
|
+
* `scanRowBatchSql`.
|
|
583
|
+
*/
|
|
584
|
+
export const COMMIT_WINDOW_BATCH_SQL = `SELECT s.i AS sub, c.commit_seq AS commit_seq, m.actor_id AS actor_id, m.created_at_ms AS created_at_ms,
|
|
585
|
+
ch.tbl AS tbl, ch.row_id AS row_id, ch.op AS op, ch.row_version AS row_version, ch.scopes AS scopes, ch.payload AS payload
|
|
586
|
+
FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, var text, vals text[], after bigint, through bigint, lim int)
|
|
587
|
+
CROSS JOIN LATERAL (SELECT DISTINCT commit_seq FROM sync_change_scopes
|
|
588
|
+
WHERE partition=$1 AND tbl=$3 AND var=s.var AND value=ANY(s.vals)
|
|
589
|
+
AND commit_seq>s.after AND commit_seq<=s.through
|
|
590
|
+
ORDER BY commit_seq LIMIT s.lim) c
|
|
591
|
+
LEFT JOIN sync_commits m
|
|
592
|
+
ON m.partition=$1 AND m.commit_seq=c.commit_seq
|
|
593
|
+
LEFT JOIN sync_changes ch
|
|
594
|
+
ON ch.partition=$1 AND ch.commit_seq=c.commit_seq AND ch.tbl=$3
|
|
595
|
+
ORDER BY s.i, c.commit_seq, ch.idx`;
|
|
551
596
|
/** SELECT a row's version + scope map (admin/blob authz). Params: [partition, rowId]. */
|
|
552
597
|
export function selectRowScopesSql(table, dialect) {
|
|
553
598
|
const p = dialect === 'sqlite' ? ['?', '?'] : ['$1', '$2'];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.4",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@syncular/core": "0.26.
|
|
71
|
+
"@syncular/core": "0.26.4"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|