@syncular/server 0.2.0 → 0.3.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 +16 -19
- package/dist/admin.d.ts +3 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/seed.d.ts +34 -0
- package/dist/seed.js +92 -0
- package/package.json +4 -4
- package/src/admin.ts +3 -3
- package/src/index.ts +1 -0
- package/src/postgres-storage.ts +2 -2
- package/src/seed.ts +168 -0
package/README.md
CHANGED
|
@@ -49,18 +49,16 @@ landing in a plain isolate wakes the partition's DO (the in-platform
|
|
|
49
49
|
LISTEN/NOTIFY analogue). Full shape, wiring, hibernation semantics, and the
|
|
50
50
|
manual real-workerd smoke recipe in `@syncular/server-workers/README.md`.
|
|
51
51
|
|
|
52
|
-
**
|
|
53
|
-
a self-hosted server
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
managed dependency for zero capability the core lacks. So it is retired, not
|
|
63
|
-
ported.
|
|
52
|
+
**No relay (decision).** There is deliberately no *relay* — no bridge that
|
|
53
|
+
forwards realtime from a self-hosted server to a managed realtime service.
|
|
54
|
+
Realtime is the **second binding of the same handler** (§8.7, Direction
|
|
55
|
+
decision 1 — the WS-native loop), so any host that runs the core serves
|
|
56
|
+
realtime directly; multi-instance fanout is covered by **LISTEN/NOTIFY** on
|
|
57
|
+
Postgres (below), and the Workers case is covered by the **DO design**
|
|
58
|
+
(writes and sockets co-located per partition). Every job a relay would do is
|
|
59
|
+
done by a binding of the core or by in-database fanout — a relay would add a
|
|
60
|
+
hop, a second protocol surface, and a managed dependency for zero capability
|
|
61
|
+
the core lacks.
|
|
64
62
|
|
|
65
63
|
## Structured events (the ops seam)
|
|
66
64
|
|
|
@@ -127,8 +125,8 @@ exists) `partition` / `actorId`.
|
|
|
127
125
|
The operator-facing read surface over the server core. It is a module in
|
|
128
126
|
this package — **not** a separate UI package — and adds **zero** wire
|
|
129
127
|
protocol: SPEC.md says nothing about it, because authorization for these
|
|
130
|
-
reads is entirely the host's. It
|
|
131
|
-
|
|
128
|
+
reads is entirely the host's. It delivers the 80% operator value (who's
|
|
129
|
+
connected, what's flowing,
|
|
132
130
|
horizon health, the event tail) as a handful of read-only, partition-scoped,
|
|
133
131
|
JSON-able queries.
|
|
134
132
|
|
|
@@ -215,8 +213,8 @@ framework, no build step, no React. It fetches the sibling JSON endpoints
|
|
|
215
213
|
(relative to its own mount path, so it works under any prefix and the same
|
|
216
214
|
guard covers its XHRs), renders tables for horizon, store stats, clients,
|
|
217
215
|
recent commits, and the event tail, with an auto-refresh toggle (2 s poll).
|
|
218
|
-
This is
|
|
219
|
-
operator value.
|
|
216
|
+
This is a deliberate ~300-line console: 5% of the code a full console app
|
|
217
|
+
would cost, the 80% operator value.
|
|
220
218
|
|
|
221
219
|
**No SSE (yet).** `GET /events` is a polled ring query; the page's
|
|
222
220
|
auto-refresh polls it. Server-Sent-Events streaming was deliberately
|
|
@@ -571,8 +569,7 @@ whose cursor record was touched within `activeWindowMs` (default 14
|
|
|
571
569
|
days). Two escape hatches keep laggards from pinning the log forever:
|
|
572
570
|
commits older than `ageForceMs` (default 30 days) may be pruned
|
|
573
571
|
regardless, and at least the newest `minRetainedCommits` (default 1000)
|
|
574
|
-
commits are always kept.
|
|
575
|
-
them freely, lower them with care.
|
|
572
|
+
commits are always kept. Raise the defaults freely, lower them with care.
|
|
576
573
|
|
|
577
574
|
**What `sync.cursor_expired` means operationally.** A client whose
|
|
578
575
|
cursor fell behind the horizon gets `SUB_START.status = reset` and
|
|
@@ -624,7 +621,7 @@ same table+scope during a storm, your TTL is shorter than the storm.
|
|
|
624
621
|
production, `PostgresServerStorage` implements the same `ServerStorage`
|
|
625
622
|
contract against Postgres, with the inverted scope index carried through
|
|
626
623
|
as **covering indexes** so scope fanout is an index range scan, never a
|
|
627
|
-
scan-before-LIMIT
|
|
624
|
+
scan-before-LIMIT. The
|
|
628
625
|
schema (`POSTGRES_DDL`) and its index design live in
|
|
629
626
|
`src/postgres-storage.ts`; `storage.migrate()` applies it idempotently
|
|
630
627
|
(every DDL is `CREATE … IF NOT EXISTS`, run statement-by-statement, so
|
package/dist/admin.d.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* `SyncularAdmin` — the operator-facing read surface over the server core
|
|
3
3
|
* (TODO §2.5). A read-only, partition-scoped, JSON-able query layer over
|
|
4
4
|
* `ServerStorage`, the optional segment/blob store stats, and an in-memory
|
|
5
|
-
* event ring.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* event ring. It delivers the 80% operator value (who's connected, what's
|
|
6
|
+
* flowing, horizon health, the event tail) as a handful of queries in the
|
|
7
|
+
* server package — no separate
|
|
8
8
|
* UI package, no framework, no wire-protocol surface (SPEC.md is untouched;
|
|
9
9
|
* this is host surface, mirrored in the server README).
|
|
10
10
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export * from './s3-blob-store.js';
|
|
|
32
32
|
export * from './s3-segment-store.js';
|
|
33
33
|
export * from './schema.js';
|
|
34
34
|
export * from './scopes.js';
|
|
35
|
+
export * from './seed.js';
|
|
35
36
|
export * from './segment-download.js';
|
|
36
37
|
export * from './segment-store.js';
|
|
37
38
|
export * from './signed-url.js';
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,7 @@ export * from './s3-blob-store.js';
|
|
|
36
36
|
export * from './s3-segment-store.js';
|
|
37
37
|
export * from './schema.js';
|
|
38
38
|
export * from './scopes.js';
|
|
39
|
+
export * from './seed.js';
|
|
39
40
|
export * from './segment-download.js';
|
|
40
41
|
export * from './segment-store.js';
|
|
41
42
|
export * from './signed-url.js';
|
package/dist/seed.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { SyncServerConfig } from './context.js';
|
|
2
|
+
/** One app-shaped seed mutation — the same vocabulary as client mutations. */
|
|
3
|
+
export type SeedMutation = {
|
|
4
|
+
readonly table: string;
|
|
5
|
+
readonly op: 'upsert';
|
|
6
|
+
/**
|
|
7
|
+
* Full-row values keyed by column name (§6.1). Keys are accepted in
|
|
8
|
+
* the SQL-truth snake_case or the generated row types' camelCase;
|
|
9
|
+
* missing nullable columns become NULL.
|
|
10
|
+
*/
|
|
11
|
+
readonly values: Readonly<Record<string, unknown>>;
|
|
12
|
+
} | {
|
|
13
|
+
readonly table: string;
|
|
14
|
+
readonly op: 'delete';
|
|
15
|
+
readonly rowId: string;
|
|
16
|
+
};
|
|
17
|
+
export interface SeedTarget {
|
|
18
|
+
readonly partition: string;
|
|
19
|
+
readonly actorId: string;
|
|
20
|
+
/**
|
|
21
|
+
* The synthetic client identity of the seed (default `'seed'`). Together
|
|
22
|
+
* with `commitId` it forms the §2.3 idempotency key — keep both stable
|
|
23
|
+
* for a re-runnable seed, vary `commitId` to seed additional batches.
|
|
24
|
+
*/
|
|
25
|
+
readonly clientId?: string;
|
|
26
|
+
/** The client commit id (default `'seed-commit-1'`). */
|
|
27
|
+
readonly commitId?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Seed `mutations` into a partition through the real push path. Throws a
|
|
31
|
+
* `SyncError` when the push is rejected or any operation fails, so a broken
|
|
32
|
+
* seed fails loud at boot instead of silently serving an empty database.
|
|
33
|
+
*/
|
|
34
|
+
export declare function seedMutations(config: SyncServerConfig, target: SeedTarget, mutations: readonly SeedMutation[]): Promise<void>;
|
package/dist/seed.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public seeding helper (RFC 0002 §2.5): push app-shaped mutations
|
|
3
|
+
* through the REAL push path — one `handleSyncRequest` round built from the
|
|
4
|
+
* server schema — so demos, dev servers, and ops scripts seed data with one
|
|
5
|
+
* supported call. This is the same §6 pipeline every client write takes
|
|
6
|
+
* (authorization, validation, idempotency, realtime fanout), so seeded rows
|
|
7
|
+
* behave exactly like synced rows.
|
|
8
|
+
*
|
|
9
|
+
* Idempotent by construction (§2.3): the commit id is stable, so re-running
|
|
10
|
+
* the seed replays the cached push result and writes nothing twice.
|
|
11
|
+
*/
|
|
12
|
+
import { decodeMessage, encodeMessage, encodeRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
|
|
13
|
+
import { SyncError } from './errors.js';
|
|
14
|
+
import { handleSyncRequest } from './handler.js';
|
|
15
|
+
function camelToSnake(name) {
|
|
16
|
+
return name.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Seed `mutations` into a partition through the real push path. Throws a
|
|
20
|
+
* `SyncError` when the push is rejected or any operation fails, so a broken
|
|
21
|
+
* seed fails loud at boot instead of silently serving an empty database.
|
|
22
|
+
*/
|
|
23
|
+
export async function seedMutations(config, target, mutations) {
|
|
24
|
+
const clientId = target.clientId ?? 'seed';
|
|
25
|
+
const clientCommitId = target.commitId ?? 'seed-commit-1';
|
|
26
|
+
const operations = mutations.map((mutation) => {
|
|
27
|
+
const table = config.schema.tables.find((t) => t.name === mutation.table);
|
|
28
|
+
if (table === undefined) {
|
|
29
|
+
throw new SyncError('sync.invalid_request', `seedMutations: unknown table ${JSON.stringify(mutation.table)}`);
|
|
30
|
+
}
|
|
31
|
+
if (mutation.op === 'delete') {
|
|
32
|
+
return { table: mutation.table, rowId: mutation.rowId, op: 'delete' };
|
|
33
|
+
}
|
|
34
|
+
// Normalize record keys to column positions (snake_case or camelCase,
|
|
35
|
+
// the two casings the client tier accepts).
|
|
36
|
+
const byColumn = new Map();
|
|
37
|
+
for (const [key, value] of Object.entries(mutation.values)) {
|
|
38
|
+
const name = table.columns.some((c) => c.name === key)
|
|
39
|
+
? key
|
|
40
|
+
: camelToSnake(key);
|
|
41
|
+
if (!table.columns.some((c) => c.name === name)) {
|
|
42
|
+
throw new SyncError('sync.invalid_request', `seedMutations: table ${table.name}: unknown column ${JSON.stringify(key)}`);
|
|
43
|
+
}
|
|
44
|
+
if (byColumn.has(name)) {
|
|
45
|
+
throw new SyncError('sync.invalid_request', `seedMutations: table ${table.name}: column ${JSON.stringify(name)} appears twice (snake_case and camelCase) — pass it once`);
|
|
46
|
+
}
|
|
47
|
+
byColumn.set(name, value);
|
|
48
|
+
}
|
|
49
|
+
const values = table.columns.map((column) => (byColumn.get(column.name) ?? null));
|
|
50
|
+
const pkIndex = table.columns.findIndex((c) => c.name === table.primaryKey);
|
|
51
|
+
const rowId = values[pkIndex];
|
|
52
|
+
if (typeof rowId !== 'string' || rowId.length === 0) {
|
|
53
|
+
throw new SyncError('sync.invalid_request', `seedMutations: table ${table.name}: upsert requires a non-empty string primary key`);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
table: mutation.table,
|
|
57
|
+
rowId,
|
|
58
|
+
op: 'upsert',
|
|
59
|
+
payload: encodeRow(table.columns, values),
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
const frames = [
|
|
63
|
+
{ type: 'REQ_HEADER', clientId, schemaVersion: config.schema.version },
|
|
64
|
+
{ type: 'PUSH_COMMIT', clientCommitId, operations },
|
|
65
|
+
// A pull that asks for nothing: the round exists for its push half.
|
|
66
|
+
{
|
|
67
|
+
type: 'PULL_HEADER',
|
|
68
|
+
limitCommits: 0,
|
|
69
|
+
limitSnapshotRows: 0,
|
|
70
|
+
maxSnapshotPages: 0,
|
|
71
|
+
accept: 0b0011,
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
const response = await handleSyncRequest(encodeMessage({
|
|
75
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
76
|
+
msgKind: 'request',
|
|
77
|
+
frames,
|
|
78
|
+
}), { ...config, partition: target.partition, actorId: target.actorId });
|
|
79
|
+
// Fail loud: surface the first rejected/failed operation.
|
|
80
|
+
const message = decodeMessage(response);
|
|
81
|
+
const result = message.frames.find((frame) => frame.type === 'PUSH_RESULT' && frame.clientCommitId === clientCommitId);
|
|
82
|
+
if (result === undefined) {
|
|
83
|
+
throw new SyncError('sync.invalid_request', 'seedMutations: the sync response carried no push result');
|
|
84
|
+
}
|
|
85
|
+
if (result.status === 'rejected') {
|
|
86
|
+
const failed = result.results.find((r) => r.status !== 'applied');
|
|
87
|
+
const detail = failed !== undefined && 'code' in failed
|
|
88
|
+
? ` (op ${failed.opIndex}: ${failed.code} — ${failed.message})`
|
|
89
|
+
: '';
|
|
90
|
+
throw new SyncError('sync.invalid_request', `seedMutations: the seed commit was rejected${detail}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"exports": {
|
|
29
29
|
".": {
|
|
30
30
|
"bun": "./src/index.ts",
|
|
31
|
-
"browser": "./
|
|
31
|
+
"browser": "./dist/index.js",
|
|
32
32
|
"import": {
|
|
33
33
|
"types": "./dist/index.d.ts",
|
|
34
34
|
"default": "./dist/index.js"
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"./pglite": {
|
|
38
38
|
"bun": "./src/pg-executor-pglite.ts",
|
|
39
|
-
"browser": "./
|
|
39
|
+
"browser": "./dist/pg-executor-pglite.js",
|
|
40
40
|
"import": {
|
|
41
41
|
"types": "./dist/pg-executor-pglite.d.ts",
|
|
42
42
|
"default": "./dist/pg-executor-pglite.js"
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"!dist/**/*.test.d.ts"
|
|
54
54
|
],
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@syncular/core": "0.2.
|
|
56
|
+
"@syncular/core": "0.2.1"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/admin.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* `SyncularAdmin` — the operator-facing read surface over the server core
|
|
3
3
|
* (TODO §2.5). A read-only, partition-scoped, JSON-able query layer over
|
|
4
4
|
* `ServerStorage`, the optional segment/blob store stats, and an in-memory
|
|
5
|
-
* event ring.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* event ring. It delivers the 80% operator value (who's connected, what's
|
|
6
|
+
* flowing, horizon health, the event tail) as a handful of queries in the
|
|
7
|
+
* server package — no separate
|
|
8
8
|
* UI package, no framework, no wire-protocol surface (SPEC.md is untouched;
|
|
9
9
|
* this is host surface, mirrored in the server README).
|
|
10
10
|
*
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ export * from './s3-blob-store';
|
|
|
36
36
|
export * from './s3-segment-store';
|
|
37
37
|
export * from './schema';
|
|
38
38
|
export * from './scopes';
|
|
39
|
+
export * from './seed';
|
|
39
40
|
export * from './segment-download';
|
|
40
41
|
export * from './segment-store';
|
|
41
42
|
export * from './signed-url';
|
package/src/postgres-storage.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Semantics mirror `SqliteServerStorage` exactly (both run the shared
|
|
5
5
|
* storage contract in `test/storage-contract.ts`); the difference is that
|
|
6
|
-
* scope fanout MUST survive contact with Postgres
|
|
7
|
-
*
|
|
6
|
+
* scope fanout MUST survive contact with Postgres — no scan-before-LIMIT,
|
|
7
|
+
* ever (performance-by-construction):
|
|
8
8
|
*
|
|
9
9
|
* - both the commit log and the current-row table carry a (table, var,
|
|
10
10
|
* value) inverted scope index;
|
package/src/seed.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public seeding helper (RFC 0002 §2.5): push app-shaped mutations
|
|
3
|
+
* through the REAL push path — one `handleSyncRequest` round built from the
|
|
4
|
+
* server schema — so demos, dev servers, and ops scripts seed data with one
|
|
5
|
+
* supported call. This is the same §6 pipeline every client write takes
|
|
6
|
+
* (authorization, validation, idempotency, realtime fanout), so seeded rows
|
|
7
|
+
* behave exactly like synced rows.
|
|
8
|
+
*
|
|
9
|
+
* Idempotent by construction (§2.3): the commit id is stable, so re-running
|
|
10
|
+
* the seed replays the cached push result and writes nothing twice.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
decodeMessage,
|
|
14
|
+
encodeMessage,
|
|
15
|
+
encodeRow,
|
|
16
|
+
PROTOCOL_WIRE_VERSION,
|
|
17
|
+
type PushOperation,
|
|
18
|
+
type PushResultFrame,
|
|
19
|
+
type RequestFrame,
|
|
20
|
+
type RowValue,
|
|
21
|
+
} from '@syncular/core';
|
|
22
|
+
import type { SyncServerConfig } from './context';
|
|
23
|
+
import { SyncError } from './errors';
|
|
24
|
+
import { handleSyncRequest } from './handler';
|
|
25
|
+
|
|
26
|
+
/** One app-shaped seed mutation — the same vocabulary as client mutations. */
|
|
27
|
+
export type SeedMutation =
|
|
28
|
+
| {
|
|
29
|
+
readonly table: string;
|
|
30
|
+
readonly op: 'upsert';
|
|
31
|
+
/**
|
|
32
|
+
* Full-row values keyed by column name (§6.1). Keys are accepted in
|
|
33
|
+
* the SQL-truth snake_case or the generated row types' camelCase;
|
|
34
|
+
* missing nullable columns become NULL.
|
|
35
|
+
*/
|
|
36
|
+
readonly values: Readonly<Record<string, unknown>>;
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
readonly table: string;
|
|
40
|
+
readonly op: 'delete';
|
|
41
|
+
readonly rowId: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface SeedTarget {
|
|
45
|
+
readonly partition: string;
|
|
46
|
+
readonly actorId: string;
|
|
47
|
+
/**
|
|
48
|
+
* The synthetic client identity of the seed (default `'seed'`). Together
|
|
49
|
+
* with `commitId` it forms the §2.3 idempotency key — keep both stable
|
|
50
|
+
* for a re-runnable seed, vary `commitId` to seed additional batches.
|
|
51
|
+
*/
|
|
52
|
+
readonly clientId?: string;
|
|
53
|
+
/** The client commit id (default `'seed-commit-1'`). */
|
|
54
|
+
readonly commitId?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function camelToSnake(name: string): string {
|
|
58
|
+
return name.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Seed `mutations` into a partition through the real push path. Throws a
|
|
63
|
+
* `SyncError` when the push is rejected or any operation fails, so a broken
|
|
64
|
+
* seed fails loud at boot instead of silently serving an empty database.
|
|
65
|
+
*/
|
|
66
|
+
export async function seedMutations(
|
|
67
|
+
config: SyncServerConfig,
|
|
68
|
+
target: SeedTarget,
|
|
69
|
+
mutations: readonly SeedMutation[],
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
const clientId = target.clientId ?? 'seed';
|
|
72
|
+
const clientCommitId = target.commitId ?? 'seed-commit-1';
|
|
73
|
+
const operations: PushOperation[] = mutations.map((mutation) => {
|
|
74
|
+
const table = config.schema.tables.find((t) => t.name === mutation.table);
|
|
75
|
+
if (table === undefined) {
|
|
76
|
+
throw new SyncError(
|
|
77
|
+
'sync.invalid_request',
|
|
78
|
+
`seedMutations: unknown table ${JSON.stringify(mutation.table)}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (mutation.op === 'delete') {
|
|
82
|
+
return { table: mutation.table, rowId: mutation.rowId, op: 'delete' };
|
|
83
|
+
}
|
|
84
|
+
// Normalize record keys to column positions (snake_case or camelCase,
|
|
85
|
+
// the two casings the client tier accepts).
|
|
86
|
+
const byColumn = new Map<string, unknown>();
|
|
87
|
+
for (const [key, value] of Object.entries(mutation.values)) {
|
|
88
|
+
const name = table.columns.some((c) => c.name === key)
|
|
89
|
+
? key
|
|
90
|
+
: camelToSnake(key);
|
|
91
|
+
if (!table.columns.some((c) => c.name === name)) {
|
|
92
|
+
throw new SyncError(
|
|
93
|
+
'sync.invalid_request',
|
|
94
|
+
`seedMutations: table ${table.name}: unknown column ${JSON.stringify(key)}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (byColumn.has(name)) {
|
|
98
|
+
throw new SyncError(
|
|
99
|
+
'sync.invalid_request',
|
|
100
|
+
`seedMutations: table ${table.name}: column ${JSON.stringify(name)} appears twice (snake_case and camelCase) — pass it once`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
byColumn.set(name, value);
|
|
104
|
+
}
|
|
105
|
+
const values = table.columns.map(
|
|
106
|
+
(column) => (byColumn.get(column.name) ?? null) as RowValue,
|
|
107
|
+
);
|
|
108
|
+
const pkIndex = table.columns.findIndex((c) => c.name === table.primaryKey);
|
|
109
|
+
const rowId = values[pkIndex];
|
|
110
|
+
if (typeof rowId !== 'string' || rowId.length === 0) {
|
|
111
|
+
throw new SyncError(
|
|
112
|
+
'sync.invalid_request',
|
|
113
|
+
`seedMutations: table ${table.name}: upsert requires a non-empty string primary key`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
table: mutation.table,
|
|
118
|
+
rowId,
|
|
119
|
+
op: 'upsert',
|
|
120
|
+
payload: encodeRow(table.columns, values),
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const frames: RequestFrame[] = [
|
|
125
|
+
{ type: 'REQ_HEADER', clientId, schemaVersion: config.schema.version },
|
|
126
|
+
{ type: 'PUSH_COMMIT', clientCommitId, operations },
|
|
127
|
+
// A pull that asks for nothing: the round exists for its push half.
|
|
128
|
+
{
|
|
129
|
+
type: 'PULL_HEADER',
|
|
130
|
+
limitCommits: 0,
|
|
131
|
+
limitSnapshotRows: 0,
|
|
132
|
+
maxSnapshotPages: 0,
|
|
133
|
+
accept: 0b0011,
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
const response = await handleSyncRequest(
|
|
137
|
+
encodeMessage({
|
|
138
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
139
|
+
msgKind: 'request',
|
|
140
|
+
frames,
|
|
141
|
+
}),
|
|
142
|
+
{ ...config, partition: target.partition, actorId: target.actorId },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Fail loud: surface the first rejected/failed operation.
|
|
146
|
+
const message = decodeMessage(response);
|
|
147
|
+
const result = message.frames.find(
|
|
148
|
+
(frame): frame is PushResultFrame =>
|
|
149
|
+
frame.type === 'PUSH_RESULT' && frame.clientCommitId === clientCommitId,
|
|
150
|
+
);
|
|
151
|
+
if (result === undefined) {
|
|
152
|
+
throw new SyncError(
|
|
153
|
+
'sync.invalid_request',
|
|
154
|
+
'seedMutations: the sync response carried no push result',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
if (result.status === 'rejected') {
|
|
158
|
+
const failed = result.results.find((r) => r.status !== 'applied');
|
|
159
|
+
const detail =
|
|
160
|
+
failed !== undefined && 'code' in failed
|
|
161
|
+
? ` (op ${failed.opIndex}: ${failed.code} — ${failed.message})`
|
|
162
|
+
: '';
|
|
163
|
+
throw new SyncError(
|
|
164
|
+
'sync.invalid_request',
|
|
165
|
+
`seedMutations: the seed commit was rejected${detail}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|