@syncular/server 0.2.1 → 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 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
- **Relay does not return (decision).** v1 shipped a *relay* — a bridge that let
53
- a self-hosted server forward realtime to a managed realtime service, because
54
- v1's realtime was a separate socketed subsystem the self-hosted core couldn't
55
- serve on its own. v2 has no such gap: realtime is the **second binding of the
56
- same handler** (§8.7, Direction decision 1 the WS-native loop), so any host
57
- that runs the core serves realtime directly; multi-instance fanout is covered
58
- by **LISTEN/NOTIFY** on Postgres (below), and the Workers case is covered by
59
- the **DO design** (writes and sockets co-located per partition). Every job the
60
- relay did is now done by a binding of the core or by in-database fanout —
61
- reintroducing a relay would add a hop, a second protocol surface, and a
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 is the v2 answer to v1's full React
131
- console app: the same 80% operator value (who's connected, what's flowing,
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 the ~300-line answer to v1's console app: 5% of the code, the 80%
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. Defaults are the v1 production values; raise
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 (REVISE B2 — this was v1's production wound). The
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. This is v2's answer to v1's full React console app: the same
6
- * 80% operator value (who's connected, what's flowing, horizon health, the
7
- * event tail) as a handful of queries in the server package — no separate
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.2.1",
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": "./src/index.ts",
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": "./src/pg-executor-pglite.ts",
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"
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. This is v2's answer to v1's full React console app: the same
6
- * 80% operator value (who's connected, what's flowing, horizon health, the
7
- * event tail) as a handful of queries in the server package — no separate
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';
@@ -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. v1's production wound was
7
- * scan-before-LIMIT here, so (REVISE B2, performance-by-construction):
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
+ }