@syncular/server 0.21.0 → 0.22.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 +20 -6
- package/dist/pg-executor.d.ts +11 -1
- package/dist/postgres-storage.d.ts +9 -2
- package/dist/postgres-storage.js +79 -3
- package/dist/pull.js +9 -5
- package/dist/realtime.d.ts +40 -1
- package/dist/realtime.js +96 -18
- package/dist/relational-rows.d.ts +20 -0
- package/dist/relational-rows.js +29 -0
- package/dist/s3-segment-store.d.ts +20 -8
- package/dist/s3-segment-store.js +216 -68
- package/dist/schema.js +11 -0
- package/dist/segment-download.d.ts +1 -1
- package/dist/segment-download.js +42 -17
- package/dist/segment-store.d.ts +86 -0
- package/dist/segment-store.js +156 -17
- package/dist/signed-url.d.ts +7 -1
- package/dist/signed-url.js +10 -1
- package/dist/sqlite-dialect.d.ts +7 -1
- package/dist/sqlite-dialect.js +8 -0
- package/dist/sqlite-segment-store.js +153 -42
- package/dist/sqlite-storage.js +50 -1
- package/dist/storage.d.ts +25 -0
- package/dist/validate.d.ts +20 -1
- package/package.json +2 -2
- package/src/pg-executor.ts +11 -1
- package/src/postgres-storage.ts +90 -2
- package/src/pull.ts +9 -1
- package/src/realtime.ts +95 -17
- package/src/relational-rows.ts +36 -0
- package/src/s3-segment-store.ts +275 -84
- package/src/schema.ts +15 -0
- package/src/segment-download.ts +63 -28
- package/src/segment-store.ts +250 -18
- package/src/signed-url.ts +12 -2
- package/src/sqlite-dialect.ts +8 -0
- package/src/sqlite-segment-store.ts +245 -65
- package/src/sqlite-storage.ts +55 -0
- package/src/storage.ts +25 -0
- package/src/validate.ts +20 -1
package/README.md
CHANGED
|
@@ -660,14 +660,28 @@ which all three providers accept.
|
|
|
660
660
|
**Key layout.** Deterministic, so every lookup is a GET/HEAD — never a
|
|
661
661
|
LIST:
|
|
662
662
|
|
|
663
|
-
- `{keyPrefix}seg/sha256/{hex}` — the segment bytes, verbatim
|
|
664
|
-
object body is exactly the content-addressed bytes, so
|
|
665
|
-
serve them directly and the client's §5.1 hash check
|
|
666
|
-
|
|
667
|
-
|
|
663
|
+
- `{keyPrefix}seg/sha256/{hex}` — the segment bytes, verbatim and
|
|
664
|
+
immutable (the object body is exactly the content-addressed bytes, so
|
|
665
|
+
presigned GETs serve them directly and the client's §5.1 hash check
|
|
666
|
+
passes).
|
|
667
|
+
- `{keyPrefix}rec/sha256/{hex}.json` — the mutable `SegmentRecord`. It
|
|
668
|
+
carries the `publications` array: one entry per (partition, log epoch,
|
|
669
|
+
table, schema version, media type, scope digest, pin, cursors) context
|
|
670
|
+
the content was published under, each with its own TTL. `get` is two
|
|
671
|
+
GETs (record + bytes). A record written by 0.21 read the metadata from
|
|
672
|
+
the bytes object's user metadata; `get` still reads that shape and
|
|
673
|
+
materializes one publication per recorded digest.
|
|
668
674
|
- `{keyPrefix}find/{sha256(reuse key)}.json` — the §5.3 whole-table
|
|
669
675
|
reuse pointer, written only for `rowCursor: null` segments; `find` is
|
|
670
|
-
one GET plus a HEAD to confirm the segment object still exists
|
|
676
|
+
one GET plus a HEAD to confirm the segment object still exists, and it
|
|
677
|
+
selects the publication the key names.
|
|
678
|
+
|
|
679
|
+
A host that implements `SegmentStore` itself must return `publications`
|
|
680
|
+
and `scopeDigests` on every `SegmentRecord` (`scopeDigests` is the
|
|
681
|
+
compatibility view over `publications`). Download and `find` select a
|
|
682
|
+
publication matching the caller's partition, live log epoch, and digest;
|
|
683
|
+
they never union digests across contexts. The shared contract suite is
|
|
684
|
+
`test/segment-store-contract.ts`.
|
|
671
685
|
|
|
672
686
|
**TTL and lifecycle.** Expiry is store-side and authoritative:
|
|
673
687
|
`expiresAtMs` (`put` time + `ttlMs`, default 24 h) is recorded with the
|
package/dist/pg-executor.d.ts
CHANGED
|
@@ -39,6 +39,14 @@ export type PgRow = Record<string, unknown>;
|
|
|
39
39
|
* `params` use Postgres positional placeholders (`$1`, `$2`, ...). `text`
|
|
40
40
|
* is a single statement (no multi-statement `;`-joined batches — those are
|
|
41
41
|
* driver-specific and defeat parameterization).
|
|
42
|
+
*
|
|
43
|
+
* Syncular never overlaps statements on one `PgQueryable`. A client pinned
|
|
44
|
+
* to a connection by `PgExecutor.transaction` gets every statement chained
|
|
45
|
+
* on one FIFO by `PostgresServerStorage`, so a host validator that starts
|
|
46
|
+
* independent reads concurrently still reaches the driver one statement at
|
|
47
|
+
* a time. An adapter does not need to queue calls itself, and must not
|
|
48
|
+
* accept two in flight: node-postgres warns about that today and rejects it
|
|
49
|
+
* in node-postgres 9.
|
|
42
50
|
*/
|
|
43
51
|
export interface PgQueryable {
|
|
44
52
|
query<Row = PgRow>(text: string, params?: readonly unknown[]): Promise<{
|
|
@@ -55,7 +63,9 @@ export interface PgExecutor extends PgQueryable {
|
|
|
55
63
|
/**
|
|
56
64
|
* Run `fn` inside a single Postgres transaction on one pinned connection.
|
|
57
65
|
* The executor issues `BEGIN` before `fn`, `COMMIT` on resolve, and
|
|
58
|
-
* `ROLLBACK` on throw (rethrowing the original error).
|
|
66
|
+
* `ROLLBACK` on throw (rethrowing the original error). The caller
|
|
67
|
+
* serializes the statements `fn` issues on the provided client (see
|
|
68
|
+
* `PgQueryable`), so the driver never sees two of them in flight.
|
|
59
69
|
*/
|
|
60
70
|
transaction<T>(fn: (client: PgQueryable) => Promise<T>): Promise<T>;
|
|
61
71
|
/** Release any pooled resources. Optional (pglite/Bun.sql). */
|
|
@@ -12,7 +12,14 @@ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorI
|
|
|
12
12
|
* - `sync_row_scopes_pk (partition, tbl, var, value, row_id)` — same shape
|
|
13
13
|
* for `scanRows`, ordered by `row_id`.
|
|
14
14
|
* Both are the PRIMARY KEY, so they are the clustering/covering index for
|
|
15
|
-
* their table.
|
|
15
|
+
* their table.
|
|
16
|
+
*
|
|
17
|
+
* `sync_row_scopes_by_row (partition, tbl, row_id)` is the one secondary
|
|
18
|
+
* scope index: `writeRowOn` and `deleteRow` replace a row's scope entries
|
|
19
|
+
* with a `(partition, tbl, row_id)` predicate, and `row_id` is the LAST
|
|
20
|
+
* column of the inverted PRIMARY KEY, so without this index that predicate
|
|
21
|
+
* scans the table's whole scope range on every row written (a bulk import
|
|
22
|
+
* costs one such scan per row).
|
|
16
23
|
*
|
|
17
24
|
* Blob reference index (§5.9.4) — parity with the SQLite dialect's
|
|
18
25
|
* `sync_blob_refs`:
|
|
@@ -24,7 +31,7 @@ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorI
|
|
|
24
31
|
* set, §5.9.5) as an index range, never a scan. `postgres-explain
|
|
25
32
|
* .test.ts` asserts an `Index` node here too.
|
|
26
33
|
*/
|
|
27
|
-
export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\n";
|
|
34
|
+
export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE INDEX IF NOT EXISTS sync_row_scopes_by_row\n ON sync_row_scopes(partition, tbl, row_id);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\n";
|
|
28
35
|
export declare class PostgresServerStorage implements ServerStorage {
|
|
29
36
|
#private;
|
|
30
37
|
constructor(exec: PgExecutor);
|
package/dist/postgres-storage.js
CHANGED
|
@@ -3,7 +3,7 @@ import { StorageQueryError } from './storage-errors.js';
|
|
|
3
3
|
import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
4
4
|
import { syncError } from './errors.js';
|
|
5
5
|
import { asBytes, asNumber, } from './pg-executor.js';
|
|
6
|
-
import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
6
|
+
import { assertPhysicalColumns, commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_POSTGRES, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
|
|
7
7
|
import { matchesEffective } from './scopes.js';
|
|
8
8
|
import { isPostgresConstraintError, StorageConstraintError, } from './storage-errors.js';
|
|
9
9
|
import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js';
|
|
@@ -17,7 +17,14 @@ import { assertScopeIndexedScan, resolveIndexRowScan } from './storage-query.js'
|
|
|
17
17
|
* - `sync_row_scopes_pk (partition, tbl, var, value, row_id)` — same shape
|
|
18
18
|
* for `scanRows`, ordered by `row_id`.
|
|
19
19
|
* Both are the PRIMARY KEY, so they are the clustering/covering index for
|
|
20
|
-
* their table.
|
|
20
|
+
* their table.
|
|
21
|
+
*
|
|
22
|
+
* `sync_row_scopes_by_row (partition, tbl, row_id)` is the one secondary
|
|
23
|
+
* scope index: `writeRowOn` and `deleteRow` replace a row's scope entries
|
|
24
|
+
* with a `(partition, tbl, row_id)` predicate, and `row_id` is the LAST
|
|
25
|
+
* column of the inverted PRIMARY KEY, so without this index that predicate
|
|
26
|
+
* scans the table's whole scope range on every row written (a bulk import
|
|
27
|
+
* costs one such scan per row).
|
|
21
28
|
*
|
|
22
29
|
* Blob reference index (§5.9.4) — parity with the SQLite dialect's
|
|
23
30
|
* `sync_blob_refs`:
|
|
@@ -46,6 +53,8 @@ CREATE TABLE IF NOT EXISTS sync_row_scopes(
|
|
|
46
53
|
var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
47
54
|
PRIMARY KEY(partition, tbl, var, value, row_id)
|
|
48
55
|
);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS sync_row_scopes_by_row
|
|
57
|
+
ON sync_row_scopes(partition, tbl, row_id);
|
|
49
58
|
CREATE TABLE IF NOT EXISTS sync_commits(
|
|
50
59
|
partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,
|
|
51
60
|
client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,
|
|
@@ -339,7 +348,20 @@ class PostgresTransaction {
|
|
|
339
348
|
#resolve;
|
|
340
349
|
#reject;
|
|
341
350
|
constructor(client, partition, resolveTable, resolve, reject) {
|
|
342
|
-
|
|
351
|
+
// One pinned connection carries one statement at a time: node-postgres
|
|
352
|
+
// warns about a concurrently issued second query today and rejects it in
|
|
353
|
+
// node-postgres 9. A whole-commit validator that fans independent reads
|
|
354
|
+
// out with Promise.all must not interleave them, so every statement
|
|
355
|
+
// issued through this transaction is chained on one FIFO — the same
|
|
356
|
+
// shape as `SqliteServerStorage.queryAuthoritative`.
|
|
357
|
+
let tail = Promise.resolve();
|
|
358
|
+
this.#client = {
|
|
359
|
+
query: (text, params) => {
|
|
360
|
+
const run = tail.then(() => client.query(text, params));
|
|
361
|
+
tail = run.then(() => undefined, () => undefined);
|
|
362
|
+
return run;
|
|
363
|
+
},
|
|
364
|
+
};
|
|
343
365
|
this.#partition = partition;
|
|
344
366
|
this.#resolveTable = resolveTable;
|
|
345
367
|
this.#resolve = resolve;
|
|
@@ -615,6 +637,60 @@ export class PostgresServerStorage {
|
|
|
615
637
|
if (stored !== undefined && stored > schema.version) {
|
|
616
638
|
throw new Error(`stored schema version ${stored} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
|
|
617
639
|
}
|
|
640
|
+
if (stored !== undefined && stored === schema.version) {
|
|
641
|
+
// Version equality is not layout equality: a marker written by another
|
|
642
|
+
// build at the same version describes rows the running codec cannot
|
|
643
|
+
// decode. Compare the stored layouts instead of trusting the number.
|
|
644
|
+
const storedLayouts = parseLayouts(typeof marker.rows[0]?.layouts === 'string'
|
|
645
|
+
? marker.rows[0].layouts
|
|
646
|
+
: undefined);
|
|
647
|
+
const configuredLayouts = parseLayouts(layoutsOf(schema));
|
|
648
|
+
let mismatch;
|
|
649
|
+
for (const [tableName, columns] of Object.entries(configuredLayouts)) {
|
|
650
|
+
const storedTable = storedLayouts[tableName];
|
|
651
|
+
const columnCount = Math.max(columns.length, storedTable?.length ?? 0);
|
|
652
|
+
for (let index = 0; index < columnCount; index++) {
|
|
653
|
+
const expected = columns[index];
|
|
654
|
+
const actual = storedTable?.[index];
|
|
655
|
+
if (expected !== undefined &&
|
|
656
|
+
actual !== undefined &&
|
|
657
|
+
actual.name === expected.name &&
|
|
658
|
+
actual.type === expected.type &&
|
|
659
|
+
actual.nullable === expected.nullable) {
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
if (mismatch !== undefined)
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
if (mismatch === undefined) {
|
|
669
|
+
for (const tableName of Object.keys(storedLayouts)) {
|
|
670
|
+
if (!(tableName in configuredLayouts)) {
|
|
671
|
+
mismatch = `table ${JSON.stringify(tableName)}`;
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (mismatch !== undefined) {
|
|
677
|
+
throw new Error(`stored schema layouts disagree with the configured schema at version ${schema.version} (${mismatch}) — refusing to serve a database whose stored rows the running code cannot decode`);
|
|
678
|
+
}
|
|
679
|
+
// The persisted layouts describe the codec's app columns only: read the
|
|
680
|
+
// physical tables so a same-version database missing a
|
|
681
|
+
// storage-internal column is refused at startup instead of failing at
|
|
682
|
+
// the first write. Resolve through `to_regclass`, which follows the
|
|
683
|
+
// session's `search_path` exactly as the unqualified row queries do —
|
|
684
|
+
// `current_schema()` names only the first existing schema, so it would
|
|
685
|
+
// reject a table that resolves later in the path.
|
|
686
|
+
for (const table of schema.tables.values()) {
|
|
687
|
+
const { rows } = await this.#exec.query(`SELECT attribute.attname AS column_name
|
|
688
|
+
FROM pg_attribute attribute
|
|
689
|
+
WHERE attribute.attrelid = to_regclass($1)
|
|
690
|
+
AND attribute.attnum > 0 AND NOT attribute.attisdropped`, [table.name]);
|
|
691
|
+
assertPhysicalColumns(table, new Set(rows.map((r) => r.column_name)));
|
|
692
|
+
}
|
|
693
|
+
}
|
|
618
694
|
if (stored === undefined || stored < schema.version) {
|
|
619
695
|
// Introspect existing app tables, apply the migration subset
|
|
620
696
|
// (CREATE TABLE / ADD COLUMN / rebuild indexes), then rewrite stored
|
package/dist/pull.js
CHANGED
|
@@ -108,7 +108,7 @@ async function signedUrlFields(ctx, limits, segmentId, digest, now) {
|
|
|
108
108
|
nowMs: now,
|
|
109
109
|
});
|
|
110
110
|
}
|
|
111
|
-
function segmentRefFrame(record, extra) {
|
|
111
|
+
function segmentRefFrame(record, scopeDigest, extra) {
|
|
112
112
|
return {
|
|
113
113
|
type: 'SEGMENT_REF',
|
|
114
114
|
segmentId: record.segmentId,
|
|
@@ -117,7 +117,11 @@ function segmentRefFrame(record, extra) {
|
|
|
117
117
|
byteLength: record.byteLength,
|
|
118
118
|
rowCount: record.rowCount,
|
|
119
119
|
asOfCommitSeq: record.asOfCommitSeq,
|
|
120
|
-
|
|
120
|
+
// The frame reports the digest THIS subscription is authorized under. A
|
|
121
|
+
// stored entry can carry several digests when identical content was
|
|
122
|
+
// published under different scopes (§5.1), and the record's primary
|
|
123
|
+
// digest is not necessarily the caller's (§5.5).
|
|
124
|
+
scopeDigest,
|
|
121
125
|
...(record.rowCursor !== null ? { rowCursor: record.rowCursor } : {}),
|
|
122
126
|
...(record.nextRowCursor !== null
|
|
123
127
|
? { nextRowCursor: record.nextRowCursor }
|
|
@@ -156,7 +160,7 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
|
|
|
156
160
|
bytes: existing.byteLength,
|
|
157
161
|
rows: existing.rowCount,
|
|
158
162
|
});
|
|
159
|
-
yield segmentRefFrame(existing, await signedUrlFields(ctx, limits, existing.segmentId, digest, now));
|
|
163
|
+
yield segmentRefFrame(existing, digest, await signedUrlFields(ctx, limits, existing.segmentId, digest, now));
|
|
160
164
|
return true;
|
|
161
165
|
}
|
|
162
166
|
// Eligibility probe (§5.3): image only when the snapshot exceeds one
|
|
@@ -253,7 +257,7 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
|
|
|
253
257
|
bytes: record.byteLength,
|
|
254
258
|
rows: record.rowCount,
|
|
255
259
|
});
|
|
256
|
-
yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest, clockOf(ctx)()));
|
|
260
|
+
yield segmentRefFrame(record, digest, await signedUrlFields(ctx, limits, record.segmentId, digest, clockOf(ctx)()));
|
|
257
261
|
return true;
|
|
258
262
|
}
|
|
259
263
|
async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch) {
|
|
@@ -331,7 +335,7 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
|
|
|
331
335
|
bytes: record.byteLength,
|
|
332
336
|
rows: record.rowCount,
|
|
333
337
|
});
|
|
334
|
-
yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest, now));
|
|
338
|
+
yield segmentRefFrame(record, digest, await signedUrlFields(ctx, limits, record.segmentId, digest, now));
|
|
335
339
|
}
|
|
336
340
|
if (!hasMore)
|
|
337
341
|
return { complete: true, rowCursor: null };
|
package/dist/realtime.d.ts
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A `RealtimeSession` wraps a connected socket's send/receive callbacks:
|
|
5
5
|
* the host feeds inbound text frames to `handleMessage`, binary frames to
|
|
6
|
-
* `handleBinary`, and wires `session.close()` to socket close.
|
|
6
|
+
* `handleBinary`, and wires `session.close()` to socket close. `handleMessage`
|
|
7
|
+
* queues the ack's cursor write without awaiting it, so `drain()` is the
|
|
8
|
+
* awaitable boundary a host uses before closing its storage or ending a
|
|
9
|
+
* hibernatable event (§8.2). Initial
|
|
7
10
|
* subscription registration comes from the client's most recent pull
|
|
8
11
|
* (§8.1, loaded from the client record); a sync round completed on the
|
|
9
12
|
* connection replaces it at round end (§8.7). Deltas are complete SSP2
|
|
@@ -112,6 +115,13 @@ export declare class RealtimeSession {
|
|
|
112
115
|
/** Feed an inbound text frame (client → server control message, §8.2
|
|
113
116
|
* ack, §8.6.2 presence). */
|
|
114
117
|
handleMessage(text: string): void;
|
|
118
|
+
/**
|
|
119
|
+
* Resolve once every queued control-plane write has settled (§8.2). A host
|
|
120
|
+
* awaits this before it closes its storage or ends a hibernatable event, so
|
|
121
|
+
* an ack's cursor write is not abandoned mid-flight. A persistence failure
|
|
122
|
+
* surfaces here instead of vanishing.
|
|
123
|
+
*/
|
|
124
|
+
drain(): Promise<void>;
|
|
115
125
|
/** Deliver a fanout event to this session (called by the hub for peers,
|
|
116
126
|
* §8.6.3 receive authorization checked by the caller). */
|
|
117
127
|
receivePresence(scopeKey: string, kind: PresenceKind, actorId: string, clientId: string, doc: PresenceDoc | null): void;
|
|
@@ -135,6 +145,21 @@ export declare class RealtimeSession {
|
|
|
135
145
|
* coordinated hosts await it to retain their partition FIFO through commit.
|
|
136
146
|
*/
|
|
137
147
|
handleBinary(bytes: Uint8Array): Promise<void> | undefined;
|
|
148
|
+
/**
|
|
149
|
+
* Reload this connection's registrations and reconcile presence (§8.7).
|
|
150
|
+
* The round-end refresh and the host-initiated `RealtimeHub.refreshScopes`
|
|
151
|
+
* both route through here under one per-session generation, so a load that
|
|
152
|
+
* resolves late can never overwrite a newer result: a slow resolver may
|
|
153
|
+
* return scopes a later refresh already revoked, and the later refresh
|
|
154
|
+
* keeps the connection's current grants.
|
|
155
|
+
*
|
|
156
|
+
* `failClosed` selects the failure semantics. The round-end refresh keeps
|
|
157
|
+
* the previous registrations so a failed round changes nothing. The host
|
|
158
|
+
* refresh empties them so a revoked or unreadable grant stops receiving
|
|
159
|
+
* deltas immediately; it also survives a newer round-end refresh that
|
|
160
|
+
* failed, because that round applied nothing.
|
|
161
|
+
*/
|
|
162
|
+
refreshRegistrations(failClosed: boolean): Promise<void>;
|
|
138
163
|
sendHeartbeat(): void;
|
|
139
164
|
sendWake(reason: WakeReason): void;
|
|
140
165
|
/** Called by the hub for every applied commit notification. */
|
|
@@ -182,6 +207,20 @@ export declare class RealtimeHub {
|
|
|
182
207
|
disconnect(session: RealtimeSession): void;
|
|
183
208
|
/** RealtimeNotifier: fan an applied commit out to matching sessions. */
|
|
184
209
|
notifyCommit(partition: string, commit: StoredCommit): Promise<void>;
|
|
210
|
+
/**
|
|
211
|
+
* Host-initiated, fail-closed registration refresh (§8.7). A host calls this
|
|
212
|
+
* after it changes an actor's membership or connection state, so the
|
|
213
|
+
* partition's connected sessions stop using grants the host already revoked
|
|
214
|
+
* without waiting for the client to happen to run a round.
|
|
215
|
+
*
|
|
216
|
+
* This differs deliberately from the round-end refresh
|
|
217
|
+
* (`RealtimeSession.refreshRegistrations`), which keeps the previous
|
|
218
|
+
* registrations when the client record or resolver cannot be read so a
|
|
219
|
+
* failed round changes nothing. Here an unresolvable session is emptied:
|
|
220
|
+
* a revoked or unreadable grant must stop receiving deltas rather than live
|
|
221
|
+
* on until the next round. Presence is reconciled exactly as at round end.
|
|
222
|
+
*/
|
|
223
|
+
refreshScopes(partition: string, actorId?: string): Promise<void>;
|
|
185
224
|
/** Broadcast a wake-up (host-initiated resync, schema rollover, §8.3). */
|
|
186
225
|
wake(partition: string, reason: WakeReason): void;
|
|
187
226
|
}
|
package/dist/realtime.js
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A `RealtimeSession` wraps a connected socket's send/receive callbacks:
|
|
5
5
|
* the host feeds inbound text frames to `handleMessage`, binary frames to
|
|
6
|
-
* `handleBinary`, and wires `session.close()` to socket close.
|
|
6
|
+
* `handleBinary`, and wires `session.close()` to socket close. `handleMessage`
|
|
7
|
+
* queues the ack's cursor write without awaiting it, so `drain()` is the
|
|
8
|
+
* awaitable boundary a host uses before closing its storage or ending a
|
|
9
|
+
* hibernatable event (§8.2). Initial
|
|
7
10
|
* subscription registration comes from the client's most recent pull
|
|
8
11
|
* (§8.1, loaded from the client record); a sync round completed on the
|
|
9
12
|
* connection replaces it at round end (§8.7). Deltas are complete SSP2
|
|
@@ -147,6 +150,14 @@ export class RealtimeSession {
|
|
|
147
150
|
/** §8.6.4 rate cap: per-scope-key last-fanout time + a pending latest
|
|
148
151
|
* document coalesced while over the cap. */
|
|
149
152
|
#presenceRate = new Map();
|
|
153
|
+
/** Queued control-plane writes (ack cursor persistence, §8.2). */
|
|
154
|
+
#controlTail = Promise.resolve();
|
|
155
|
+
/** First control-plane failure not yet surfaced by `drain()`. */
|
|
156
|
+
#controlFailure = undefined;
|
|
157
|
+
/** Issue counter for registration refreshes (§8.7). */
|
|
158
|
+
#registrationGeneration = 0;
|
|
159
|
+
/** Generation of the most recent refresh whose result was applied. */
|
|
160
|
+
#registrationApplied = 0;
|
|
150
161
|
constructor(hub, options, registrations, cursor, latestSeq, clock, maxDeltaBytes, storage, logEpoch, wireVersion, events) {
|
|
151
162
|
this.sessionId = crypto.randomUUID();
|
|
152
163
|
this.partition = options.partition;
|
|
@@ -198,7 +209,21 @@ export class RealtimeSession {
|
|
|
198
209
|
if (this.cursor >= this.lastKnownSeq)
|
|
199
210
|
this.wakePending = false;
|
|
200
211
|
// §8.2: acks update the client cursor record without an HTTP pull.
|
|
201
|
-
|
|
212
|
+
this.#persistCursor();
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Resolve once every queued control-plane write has settled (§8.2). A host
|
|
216
|
+
* awaits this before it closes its storage or ends a hibernatable event, so
|
|
217
|
+
* an ack's cursor write is not abandoned mid-flight. A persistence failure
|
|
218
|
+
* surfaces here instead of vanishing.
|
|
219
|
+
*/
|
|
220
|
+
drain() {
|
|
221
|
+
return this.#controlTail.then(() => {
|
|
222
|
+
const failure = this.#controlFailure;
|
|
223
|
+
this.#controlFailure = undefined;
|
|
224
|
+
if (failure !== undefined)
|
|
225
|
+
throw failure;
|
|
226
|
+
});
|
|
202
227
|
}
|
|
203
228
|
/** §8.6.2 inbound presence publish/leave from the client. */
|
|
204
229
|
#handlePresence(text) {
|
|
@@ -439,7 +464,7 @@ export class RealtimeSession {
|
|
|
439
464
|
finishRound();
|
|
440
465
|
// §8.7 registration at round end: reload the persisted list — it
|
|
441
466
|
// only advances on success, so failed rounds change nothing.
|
|
442
|
-
await this
|
|
467
|
+
await this.refreshRegistrations(false);
|
|
443
468
|
}
|
|
444
469
|
}
|
|
445
470
|
async #sendRoundChunk(chunk) {
|
|
@@ -448,19 +473,41 @@ export class RealtimeSession {
|
|
|
448
473
|
tagged.set(chunk, 1);
|
|
449
474
|
await this.#send(tagged);
|
|
450
475
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
476
|
+
/**
|
|
477
|
+
* Reload this connection's registrations and reconcile presence (§8.7).
|
|
478
|
+
* The round-end refresh and the host-initiated `RealtimeHub.refreshScopes`
|
|
479
|
+
* both route through here under one per-session generation, so a load that
|
|
480
|
+
* resolves late can never overwrite a newer result: a slow resolver may
|
|
481
|
+
* return scopes a later refresh already revoked, and the later refresh
|
|
482
|
+
* keeps the connection's current grants.
|
|
483
|
+
*
|
|
484
|
+
* `failClosed` selects the failure semantics. The round-end refresh keeps
|
|
485
|
+
* the previous registrations so a failed round changes nothing. The host
|
|
486
|
+
* refresh empties them so a revoked or unreadable grant stops receiving
|
|
487
|
+
* deltas immediately; it also survives a newer round-end refresh that
|
|
488
|
+
* failed, because that round applied nothing.
|
|
489
|
+
*/
|
|
490
|
+
async refreshRegistrations(failClosed) {
|
|
491
|
+
const generation = ++this.#registrationGeneration;
|
|
492
|
+
let registrations;
|
|
456
493
|
try {
|
|
457
|
-
|
|
494
|
+
registrations = await this.#hub.loadRegistrations(this.partition, this.actorId, this.clientId);
|
|
458
495
|
}
|
|
459
496
|
catch {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
497
|
+
if (!failClosed)
|
|
498
|
+
return;
|
|
499
|
+
// Fail closed (host refresh): drop every grant on the connection.
|
|
500
|
+
registrations = [];
|
|
463
501
|
}
|
|
502
|
+
// A newer refresh already applied its result; this late one is stale.
|
|
503
|
+
if (generation < this.#registrationApplied)
|
|
504
|
+
return;
|
|
505
|
+
this.#registrationApplied = generation;
|
|
506
|
+
// §8.6.3: a registration change re-derives the presence grant — capture
|
|
507
|
+
// the keys before, reconcile after (leaves on lost keys, snapshots on
|
|
508
|
+
// gained keys).
|
|
509
|
+
const previousKeys = this.#currentScopeKeys();
|
|
510
|
+
this.registrations = registrations;
|
|
464
511
|
this.reconcilePresence(previousKeys);
|
|
465
512
|
}
|
|
466
513
|
/** §8.7 protocol violation: drop the connection, fail loud. The
|
|
@@ -470,13 +517,19 @@ export class RealtimeSession {
|
|
|
470
517
|
this.#closeSocket?.();
|
|
471
518
|
this.#hub.disconnect(this);
|
|
472
519
|
}
|
|
473
|
-
|
|
474
|
-
|
|
520
|
+
#persistCursor() {
|
|
521
|
+
// §8.2: start the write now and keep it on the control tail so `drain()`
|
|
522
|
+
// can await it. Two acks may write concurrently: storage folds them with
|
|
523
|
+
// `MAX(cursor, ?)`, so neither order loses the later cursor.
|
|
524
|
+
const write = (async () => {
|
|
475
525
|
await this.#storage.advanceClientCursor(this.partition, this.clientId, this.actorId, this.logEpoch, this.cursor, this.#clock());
|
|
476
|
-
}
|
|
477
|
-
catch {
|
|
478
|
-
//
|
|
479
|
-
|
|
526
|
+
})();
|
|
527
|
+
write.catch((error) => {
|
|
528
|
+
// Best-effort for the connection (the next pull repairs the cursor);
|
|
529
|
+
// `drain()` carries the first failure to the host.
|
|
530
|
+
this.#controlFailure ??= error;
|
|
531
|
+
});
|
|
532
|
+
this.#controlTail = Promise.allSettled([this.#controlTail, write]).then(() => undefined);
|
|
480
533
|
}
|
|
481
534
|
/** Fire-and-forget send for control text and deltas; a host send
|
|
482
535
|
* failure surfaces on the socket, not here. */
|
|
@@ -872,6 +925,31 @@ export class RealtimeHub {
|
|
|
872
925
|
session.deliverCommit(commit);
|
|
873
926
|
}
|
|
874
927
|
}
|
|
928
|
+
/**
|
|
929
|
+
* Host-initiated, fail-closed registration refresh (§8.7). A host calls this
|
|
930
|
+
* after it changes an actor's membership or connection state, so the
|
|
931
|
+
* partition's connected sessions stop using grants the host already revoked
|
|
932
|
+
* without waiting for the client to happen to run a round.
|
|
933
|
+
*
|
|
934
|
+
* This differs deliberately from the round-end refresh
|
|
935
|
+
* (`RealtimeSession.refreshRegistrations`), which keeps the previous
|
|
936
|
+
* registrations when the client record or resolver cannot be read so a
|
|
937
|
+
* failed round changes nothing. Here an unresolvable session is emptied:
|
|
938
|
+
* a revoked or unreadable grant must stop receiving deltas rather than live
|
|
939
|
+
* on until the next round. Presence is reconciled exactly as at round end.
|
|
940
|
+
*/
|
|
941
|
+
async refreshScopes(partition, actorId) {
|
|
942
|
+
for (const session of [...this.#sessions]) {
|
|
943
|
+
if (session.partition !== partition)
|
|
944
|
+
continue;
|
|
945
|
+
if (actorId !== undefined && session.actorId !== actorId)
|
|
946
|
+
continue;
|
|
947
|
+
// Fail closed (see the doc above): an unreadable client record drops
|
|
948
|
+
// every grant on the connection. The session's generation guard keeps
|
|
949
|
+
// this revocation from being overwritten by a slower round-end reload.
|
|
950
|
+
await session.refreshRegistrations(true);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
875
953
|
/** Broadcast a wake-up (host-initiated resync, schema rollover, §8.3). */
|
|
876
954
|
wake(partition, reason) {
|
|
877
955
|
for (const session of this.#sessions) {
|
|
@@ -75,6 +75,26 @@ export declare function columnSqlType(column: RowColumn, dialect: RelationalDial
|
|
|
75
75
|
* materialized), version, scopes, payload.
|
|
76
76
|
*/
|
|
77
77
|
export declare function tableColumnNames(table: CompiledTable): string[];
|
|
78
|
+
/**
|
|
79
|
+
* The physical column set the row codec and the scope index read for one
|
|
80
|
+
* configured table. The persisted marker's layouts describe the codec's app
|
|
81
|
+
* columns only and its version number is the schema's, not the database's, so
|
|
82
|
+
* a same-version database that is missing a storage-internal meta column (a
|
|
83
|
+
* version-only bump whose ALTER never ran) still has to be refused: compare
|
|
84
|
+
* the physical table and fail closed without writing a migration.
|
|
85
|
+
*
|
|
86
|
+
* `existingColumns` must be resolved the way the unqualified row queries
|
|
87
|
+
* resolve the table — on PostgreSQL, through the session `search_path`, not
|
|
88
|
+
* `current_schema()`. A search path whose first schema is not where the
|
|
89
|
+
* configured tables live is outside the supported contract: DDL is created in
|
|
90
|
+
* that first schema, so `ensureSchema` already writes shadow tables there.
|
|
91
|
+
*
|
|
92
|
+
* Scope: this checks that the columns EXIST. Stored types, nullability, and
|
|
93
|
+
* non-column storage internals (the tombstone bookkeeping) stay outside it,
|
|
94
|
+
* because a wrong type or nullability is a codec-compatibility question the
|
|
95
|
+
* persisted layouts answer for app columns.
|
|
96
|
+
*/
|
|
97
|
+
export declare function assertPhysicalColumns(table: CompiledTable, existingColumns: ReadonlySet<string>): void;
|
|
78
98
|
/** CREATE TABLE IF NOT EXISTS for one app table. */
|
|
79
99
|
export declare function createTableDdl(table: CompiledTable, dialect: RelationalDialect): string;
|
|
80
100
|
/**
|
package/dist/relational-rows.js
CHANGED
|
@@ -169,6 +169,35 @@ export function tableColumnNames(table) {
|
|
|
169
169
|
SYNC_COLUMN_VERSIONS_COLUMN,
|
|
170
170
|
];
|
|
171
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* The physical column set the row codec and the scope index read for one
|
|
174
|
+
* configured table. The persisted marker's layouts describe the codec's app
|
|
175
|
+
* columns only and its version number is the schema's, not the database's, so
|
|
176
|
+
* a same-version database that is missing a storage-internal meta column (a
|
|
177
|
+
* version-only bump whose ALTER never ran) still has to be refused: compare
|
|
178
|
+
* the physical table and fail closed without writing a migration.
|
|
179
|
+
*
|
|
180
|
+
* `existingColumns` must be resolved the way the unqualified row queries
|
|
181
|
+
* resolve the table — on PostgreSQL, through the session `search_path`, not
|
|
182
|
+
* `current_schema()`. A search path whose first schema is not where the
|
|
183
|
+
* configured tables live is outside the supported contract: DDL is created in
|
|
184
|
+
* that first schema, so `ensureSchema` already writes shadow tables there.
|
|
185
|
+
*
|
|
186
|
+
* Scope: this checks that the columns EXIST. Stored types, nullability, and
|
|
187
|
+
* non-column storage internals (the tombstone bookkeeping) stay outside it,
|
|
188
|
+
* because a wrong type or nullability is a codec-compatibility question the
|
|
189
|
+
* persisted layouts answer for app columns.
|
|
190
|
+
*/
|
|
191
|
+
export function assertPhysicalColumns(table, existingColumns) {
|
|
192
|
+
if (existingColumns.size === 0) {
|
|
193
|
+
throw new Error(`table ${JSON.stringify(table.name)} is missing from the database at the stored schema version — refusing to serve a database whose rows the running code cannot read`);
|
|
194
|
+
}
|
|
195
|
+
for (const name of tableColumnNames(table)) {
|
|
196
|
+
if (existingColumns.has(name))
|
|
197
|
+
continue;
|
|
198
|
+
throw new Error(`table ${JSON.stringify(table.name)} is missing column ${JSON.stringify(name)} at the stored schema version — refusing to serve a database whose rows the running code cannot read`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
172
201
|
/** CREATE TABLE IF NOT EXISTS for one app table. */
|
|
173
202
|
export function createTableDdl(table, dialect) {
|
|
174
203
|
const versionType = dialect === 'postgres' ? 'BIGINT' : 'INTEGER';
|
|
@@ -6,12 +6,19 @@
|
|
|
6
6
|
* Key layout (deterministic — every lookup is a GET/HEAD, never a LIST):
|
|
7
7
|
*
|
|
8
8
|
* {keyPrefix}seg/sha256/{hex}
|
|
9
|
-
* The segment bytes, verbatim (the object body MUST be
|
|
10
|
-
* content-addressed bytes so presigned GETs serve them
|
|
11
|
-
* the client's §5.1 hash check passes).
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* The segment bytes, verbatim and immutable (the object body MUST be
|
|
10
|
+
* exactly the content-addressed bytes so presigned GETs serve them
|
|
11
|
+
* directly and the client's §5.1 hash check passes).
|
|
12
|
+
*
|
|
13
|
+
* {keyPrefix}rec/sha256/{hex}.json
|
|
14
|
+
* The `SegmentRecord` (§5.1). It is the one MUTABLE object of the two:
|
|
15
|
+
* the scope-digest set is a union that every publication extends, so
|
|
16
|
+
* the merge is a read-modify-write. Keeping the record in the body (not
|
|
17
|
+
* in the bytes object's user metadata) is what makes that write
|
|
18
|
+
* conditional — a body ETag changes when the union changes, while the
|
|
19
|
+
* bytes ETag is a content hash and is identical for identical bytes. A
|
|
20
|
+
* `412` re-reads and merges again, so no concurrent publication can drop
|
|
21
|
+
* another one's digest. `get` therefore reads two objects.
|
|
15
22
|
*
|
|
16
23
|
* {keyPrefix}find/{sha256Hex(canonical reuse key)}.json
|
|
17
24
|
* Whole-table reuse pointer (§5.3): written only when
|
|
@@ -21,9 +28,14 @@
|
|
|
21
28
|
* confirm the segment object itself still exists — lifecycle GC may
|
|
22
29
|
* remove objects independently of pointers).
|
|
23
30
|
*
|
|
31
|
+
* Records written before the split kept the record in the bytes object's
|
|
32
|
+
* user metadata `x-amz-meta-syncular-record`; `get` still reads that shape
|
|
33
|
+
* and the next `put` materializes the record object from it, so an upgrade
|
|
34
|
+
* loses no cache entry.
|
|
35
|
+
*
|
|
24
36
|
* TTL mapping: expiry is **store-side and authoritative** — `expiresAtMs`
|
|
25
|
-
* (put-time + `ttlMs`, default 24 h) is recorded in the
|
|
26
|
-
*
|
|
37
|
+
* (put-time + `ttlMs`, default 24 h) is recorded in the record and the
|
|
38
|
+
* pointer; `get` returns expired records so §5.5 can answer
|
|
27
39
|
* `sync.segment_expired`, and `find` filters them out itself. S3 lifecycle
|
|
28
40
|
* expiration is garbage collection only: configure it comfortably ABOVE
|
|
29
41
|
* `ttlMs` (e.g. 2 days for the 24 h default) so clients normally see the
|