@rindle/api-server 0.3.1 → 0.4.2
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/index.d.ts +143 -25
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +625 -41
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/src/index.ts +747 -60
package/src/index.ts
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { driveMutationAsync, isoTx } from "@rindle/client";
|
|
2
|
+
import type {
|
|
3
|
+
Ast,
|
|
4
|
+
ColType,
|
|
5
|
+
Condition,
|
|
6
|
+
KeyedRow,
|
|
7
|
+
MutationEnvelope,
|
|
8
|
+
MutationOp,
|
|
9
|
+
MutatorCtx,
|
|
10
|
+
NamedQuery,
|
|
11
|
+
Query,
|
|
12
|
+
Schema,
|
|
13
|
+
SharedMutator,
|
|
14
|
+
SharedMutatorWithArgs,
|
|
15
|
+
ServerWriteTx,
|
|
16
|
+
} from "@rindle/client";
|
|
2
17
|
import { DaemonHttpError } from "@rindle/daemon-client";
|
|
18
|
+
import { compile as compileQueryAst } from "@rindle/query-compiler";
|
|
19
|
+
import type { Catalog, ColumnType as QueryColumnType, TableSchema } from "@rindle/query-compiler";
|
|
3
20
|
import type {
|
|
4
21
|
ClaimRoomEpochInput,
|
|
5
22
|
ClaimRoomEpochOutput,
|
|
@@ -12,6 +29,11 @@ import type {
|
|
|
12
29
|
MigrateOutput,
|
|
13
30
|
MutationRejection,
|
|
14
31
|
MutationRejectionOutput,
|
|
32
|
+
MutationSessionBegin,
|
|
33
|
+
MutationSessionBeginOutput,
|
|
34
|
+
MutationSessionExec,
|
|
35
|
+
MutationSessionQuery,
|
|
36
|
+
MutationSessionRef,
|
|
15
37
|
QueryOnceInput,
|
|
16
38
|
QueryOnceOutput,
|
|
17
39
|
RindleDaemonClient,
|
|
@@ -28,6 +50,12 @@ import type {
|
|
|
28
50
|
WireValue,
|
|
29
51
|
} from "@rindle/daemon-client";
|
|
30
52
|
|
|
53
|
+
// Re-export the shared (generator) mutator seam so an app builds its server mutators from ONE import:
|
|
54
|
+
// co-locate each body with its arg schema (`shared`), bulk-drive the registry ({@link sharedApiMutators}),
|
|
55
|
+
// keeping only server-only authority as explicit overrides (see MUTATORS-ISOMORPHIC).
|
|
56
|
+
export { isoTx, shared } from "@rindle/client";
|
|
57
|
+
export type { ArgSchema, IsoTx, MutationGen, MutatorCtx, SharedMutator, SharedMutatorWithArgs } from "@rindle/client";
|
|
58
|
+
|
|
31
59
|
export const DEFAULT_RINDLE_API_ROUTES = {
|
|
32
60
|
query: "/api/rindle/query",
|
|
33
61
|
read: "/api/rindle/read",
|
|
@@ -82,14 +110,39 @@ export interface MutationContext<User> {
|
|
|
82
110
|
request?: unknown;
|
|
83
111
|
}
|
|
84
112
|
|
|
113
|
+
/** The legacy raw-SQL escape hatch (still the surface for relational/authority statements a keyed op
|
|
114
|
+
* can't express — an owner-gated cascade, a `NOT EXISTS` dedup). `exec` accumulates on the daemon
|
|
115
|
+
* backend and executes live on the Postgres backend; `statements` is the collected raw list. */
|
|
85
116
|
export interface SqlMutationTx {
|
|
86
117
|
exec(sql: string, params?: WireValue[]): void;
|
|
87
118
|
readonly statements: readonly SqlStatement[];
|
|
88
119
|
}
|
|
89
120
|
|
|
121
|
+
/** The write handle a server mutator runs against — the ASYNC twin of the client's `MutationTx`. It
|
|
122
|
+
* is both the isomorphic {@link ServerWriteTx} logical surface (insert/update/upsert/insertIgnore/
|
|
123
|
+
* delete/row, rendered to dialect SQL) AND the legacy {@link SqlMutationTx} raw escape hatch. Both
|
|
124
|
+
* backends run reads through the OPEN transaction (read-your-writes): the Postgres backend executes
|
|
125
|
+
* everything live; the daemon backend accumulates writes and lazily upgrades to an interactive
|
|
126
|
+
* mutation session at the first read (DAEMON-INTERACTIVE-TXN-DESIGN.md §5). */
|
|
127
|
+
export interface ServerMutationTx extends ServerWriteTx, SqlMutationTx {
|
|
128
|
+
/** Run a full query (a fluent `Query` or its wire `Ast`) INSIDE the open transaction —
|
|
129
|
+
* read-your-writes, like {@link ServerWriteTx.row} but for arbitrary shapes. Returns the
|
|
130
|
+
* parsed nested result tree: an array for a plural root, an object or `null` for a `.one()`
|
|
131
|
+
* root, with cells in their raw SQLite storage-class representations (the same vocabulary
|
|
132
|
+
* `row` speaks). Daemon backend: compiled by `@rindle/query-compiler`'s sqlite dialect (bind
|
|
133
|
+
* params, NO casts — §5.4) and executed through the mutation session. Postgres backend: lands
|
|
134
|
+
* with the read-compiler catalog integration (POSTGRES-READ-COMPILER-DESIGN.md Phase B). */
|
|
135
|
+
query(q: Ast | Query<any, any, any>): Promise<unknown>;
|
|
136
|
+
}
|
|
137
|
+
|
|
90
138
|
export type ApiMutatorResult = void | SqlStatement[] | SqlTxn;
|
|
139
|
+
/** A server mutator: a plain async function against the live {@link ServerMutationTx}. Two ways to
|
|
140
|
+
* write one (MUTATORS-ISOMORPHIC): drive it directly (the raw escape hatch — an owner-gated cascade,
|
|
141
|
+
* a `NOT EXISTS` dedup — plus a returned `SqlStatement[]`/`SqlTxn`), OR delegate to a SHARED generator
|
|
142
|
+
* (the SAME body the client predicts) via {@link runSharedMutation}, keeping only the server-only
|
|
143
|
+
* authority (arg parse, principal, policy) in the wrapper. */
|
|
91
144
|
export type ApiMutator<User, Args> = (
|
|
92
|
-
tx:
|
|
145
|
+
tx: ServerMutationTx,
|
|
93
146
|
args: Args,
|
|
94
147
|
ctx: MutationContext<User>,
|
|
95
148
|
) => MaybePromise<ApiMutatorResult>;
|
|
@@ -140,32 +193,44 @@ export interface QueryReadResponse {
|
|
|
140
193
|
wsEndpoint?: string;
|
|
141
194
|
}
|
|
142
195
|
|
|
143
|
-
/**
|
|
144
|
-
export interface
|
|
196
|
+
/** The context a {@link MutationBackend} needs to run one mutation inside its transaction. */
|
|
197
|
+
export interface MutationRunInput {
|
|
145
198
|
envelope: MutationEnvelope;
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
199
|
+
/** Schema-derived render metadata (from {@link RindleApiServerOptions.schema}). A logical op on a
|
|
200
|
+
* table absent here throws loudly — never a silent dropped write. `{}` when no schema is set. */
|
|
201
|
+
render: RenderIndex;
|
|
202
|
+
/** Invoke the (authorized) mutator against the backend-provided tx. A THROW that is NOT a
|
|
203
|
+
* {@link BackendError} is a BUSINESS rejection (roll the data back, then advance `lmid` alone,
|
|
204
|
+
* §2.4); a {@link BackendError} (a DB-layer failure) is INFRA and rejects the returned promise. */
|
|
205
|
+
run(tx: ServerMutationTx): Promise<void>;
|
|
150
206
|
}
|
|
151
207
|
|
|
208
|
+
/** The result of {@link MutationBackend.runMutation}: either the data+lmid committed together, or a
|
|
209
|
+
* business rejection whose data was rolled back but whose `lmid` still advanced (§2.4). */
|
|
210
|
+
export type MutationOutcome =
|
|
211
|
+
| { accepted: true; output: SqlTxnOutput }
|
|
212
|
+
| { accepted: false; reason: string; output?: unknown };
|
|
213
|
+
|
|
152
214
|
/**
|
|
153
|
-
* Where a mutation
|
|
154
|
-
*
|
|
155
|
-
* {@link daemonBackend} (
|
|
156
|
-
* {@link postgresBackend} (
|
|
157
|
-
* confirmation riding the CDC loop
|
|
215
|
+
* Where a mutation runs and who stamps `lmid` — the seam that makes the mutator authoring surface
|
|
216
|
+
* backend-agnostic (`BYO-POSTGRES-LMID-CONTRACT-DESIGN.md` §6; MUTATORS-ISOMORPHIC plan). Two impls
|
|
217
|
+
* ship: {@link daemonBackend} (the default — writes accumulate, one batch to the daemon which stamps
|
|
218
|
+
* `lmid` co-transactionally) and {@link postgresBackend} (a REAL interactive PG transaction — writes
|
|
219
|
+
* and reads execute live, `lmid` committed in the same txn, confirmation riding the CDC loop down).
|
|
158
220
|
*
|
|
159
|
-
*
|
|
160
|
-
* - `
|
|
161
|
-
* (OPTIMISTIC-WRITES-DESIGN.md §8.2)
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* -
|
|
165
|
-
*
|
|
221
|
+
* The load-bearing invariant is that a mutation ALWAYS advances the client's `last_mutation_id`:
|
|
222
|
+
* - `runMutation` runs the mutator inside the backend's transaction; on success it advances `lmid`
|
|
223
|
+
* to `envelope.mid` in the SAME atomic unit (OPTIMISTIC-WRITES-DESIGN.md §8.2); on a BUSINESS
|
|
224
|
+
* rejection it rolls the data back but STILL advances `lmid` (§2.4 — else the client's pending
|
|
225
|
+
* queue never drains and the optimistic stack wedges).
|
|
226
|
+
* - `reject` is the pre-flight path (unknown mutator, failed authorization): NO data, `lmid` alone.
|
|
227
|
+
* - An infrastructure failure THROWS from `runMutation` — never a user rejection (the client
|
|
228
|
+
* retries; mid dedup absorbs any applied prefix).
|
|
166
229
|
*/
|
|
167
230
|
export interface MutationBackend {
|
|
168
|
-
|
|
231
|
+
/** The SQL dialect this backend renders logical ops to (drives placeholder style). */
|
|
232
|
+
readonly dialect: SqlDialect;
|
|
233
|
+
runMutation(input: MutationRunInput): Promise<MutationOutcome>;
|
|
169
234
|
reject(input: { envelope: MutationEnvelope; reason: string }): Promise<unknown>;
|
|
170
235
|
}
|
|
171
236
|
|
|
@@ -223,6 +288,11 @@ export interface RindleApiServerOptions<User> {
|
|
|
223
288
|
* `daemonBackend(daemon)` — exactly today's behavior. Pass `postgresBackend(...)` when
|
|
224
289
|
* Postgres is the source of truth (the daemon leg then serves only the read side). */
|
|
225
290
|
backend?: MutationBackend;
|
|
291
|
+
/** The typed schema (`createSchema`/`refineSchema`). Required only when a mutator uses the LOGICAL
|
|
292
|
+
* write vocabulary (`tx.insert`/`update`/`upsert`/`insertIgnore`/`delete`/`row`) — it drives the
|
|
293
|
+
* dialect SQL renderer (column order, pk, quoting). A logical op with no schema configured throws
|
|
294
|
+
* loudly. Pure raw-`tx.exec` mutators do not need it. */
|
|
295
|
+
schema?: Schema;
|
|
226
296
|
queries?: ApiQueries<User>;
|
|
227
297
|
runQuery?: RunQuery<User>;
|
|
228
298
|
mutators?: ApiMutators<User>;
|
|
@@ -369,6 +439,33 @@ export class SplitDaemonClient implements RindleDaemonClient {
|
|
|
369
439
|
rejectMutation(input: MutationRejection): Promise<MutationRejectionOutput> {
|
|
370
440
|
return this.writes.rejectMutation(input);
|
|
371
441
|
}
|
|
442
|
+
// Interactive mutation sessions hold the MASTER's write transaction — never a replica's
|
|
443
|
+
// (DAEMON-INTERACTIVE-TXN-DESIGN.md §4.1; the follower write-fence enforces the same).
|
|
444
|
+
beginMutationSession(input: MutationSessionBegin): Promise<MutationSessionBeginOutput> {
|
|
445
|
+
const begin = this.writes.beginMutationSession?.bind(this.writes);
|
|
446
|
+
if (!begin) return Promise.reject(new Error("the write master lacks mutation sessions"));
|
|
447
|
+
return begin(input);
|
|
448
|
+
}
|
|
449
|
+
execInMutationSession(input: MutationSessionExec): Promise<unknown> {
|
|
450
|
+
const exec = this.writes.execInMutationSession?.bind(this.writes);
|
|
451
|
+
if (!exec) return Promise.reject(new Error("the write master lacks mutation sessions"));
|
|
452
|
+
return exec(input);
|
|
453
|
+
}
|
|
454
|
+
queryInMutationSession(input: MutationSessionQuery): Promise<SqlReadOutput> {
|
|
455
|
+
const query = this.writes.queryInMutationSession?.bind(this.writes);
|
|
456
|
+
if (!query) return Promise.reject(new Error("the write master lacks mutation sessions"));
|
|
457
|
+
return query(input);
|
|
458
|
+
}
|
|
459
|
+
commitMutationSession(input: MutationSessionRef): Promise<SqlTxnOutput> {
|
|
460
|
+
const commit = this.writes.commitMutationSession?.bind(this.writes);
|
|
461
|
+
if (!commit) return Promise.reject(new Error("the write master lacks mutation sessions"));
|
|
462
|
+
return commit(input);
|
|
463
|
+
}
|
|
464
|
+
rollbackMutationSession(input: MutationSessionRef): Promise<unknown> {
|
|
465
|
+
const rollback = this.writes.rollbackMutationSession?.bind(this.writes);
|
|
466
|
+
if (!rollback) return Promise.reject(new Error("the write master lacks mutation sessions"));
|
|
467
|
+
return rollback(input);
|
|
468
|
+
}
|
|
372
469
|
applyRowChangeTxn(input: RowChangeTxn): Promise<RowChangeTxnOutput> {
|
|
373
470
|
return this.writes.applyRowChangeTxn(input);
|
|
374
471
|
}
|
|
@@ -398,17 +495,513 @@ export class SplitDaemonClient implements RindleDaemonClient {
|
|
|
398
495
|
}
|
|
399
496
|
}
|
|
400
497
|
|
|
498
|
+
// --------------------------------------------------------------------------- dialect SQL renderer
|
|
499
|
+
//
|
|
500
|
+
// A logical {@link MutationOp} → dialect `SqlStatement`. The whole per-dialect delta is the
|
|
501
|
+
// PLACEHOLDER STYLE (`?` vs `$n`): identifiers are always double-quoted (SQLite tolerates it, PG
|
|
502
|
+
// requires it — `user` is reserved, camelCase folds), and upserts use portable `ON CONFLICT` (both
|
|
503
|
+
// engines). So `sqliteDialect`/`postgresDialect` differ only in `placeholder`.
|
|
504
|
+
|
|
505
|
+
/** A SQL dialect for the logical mutation renderer. */
|
|
506
|
+
export interface SqlDialect {
|
|
507
|
+
readonly name: "sqlite" | "postgres";
|
|
508
|
+
/** Render the i-th (1-based) bind placeholder. sqlite: `?`; postgres: `$i`. */
|
|
509
|
+
placeholder(oneBased: number): string;
|
|
510
|
+
/** Optional value coercion hook (e.g. a future SQLite `0/1` boolean). Default: identity. */
|
|
511
|
+
encodeValue?(v: WireValue, type: ColType): WireValue;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export const sqliteDialect: SqlDialect = { name: "sqlite", placeholder: () => "?" };
|
|
515
|
+
export const postgresDialect: SqlDialect = { name: "postgres", placeholder: (i) => `$${i}` };
|
|
516
|
+
|
|
517
|
+
/** Per-table metadata the renderer needs (all reachable from a `TableMeta`). */
|
|
518
|
+
export interface TableRenderMeta {
|
|
519
|
+
/** Columns in schema (wire) order — the stable INSERT column list + completeness check. */
|
|
520
|
+
columns: string[];
|
|
521
|
+
/** Primary-key column NAMES — the WHERE / ON CONFLICT target / SET partition. */
|
|
522
|
+
pkNames: string[];
|
|
523
|
+
/** Column name → declared type (only consulted by {@link SqlDialect.encodeValue}). */
|
|
524
|
+
types: Record<string, ColType>;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export type RenderIndex = Record<string, TableRenderMeta>;
|
|
528
|
+
|
|
529
|
+
/** Build the {@link RenderIndex} from a typed schema (`schema.tables[name]` is a `TableMeta`). */
|
|
530
|
+
export function buildRenderIndex(schema: Schema): RenderIndex {
|
|
531
|
+
const out: RenderIndex = {};
|
|
532
|
+
const tables = (schema as unknown as { tables: Record<string, { columns: Record<string, { type: ColType }>; primaryKey: readonly string[] }> }).tables;
|
|
533
|
+
for (const name of Object.keys(tables)) {
|
|
534
|
+
const meta = tables[name];
|
|
535
|
+
const columns = Object.keys(meta.columns);
|
|
536
|
+
const types: Record<string, ColType> = {};
|
|
537
|
+
for (const c of columns) types[c] = meta.columns[c].type;
|
|
538
|
+
out[name] = { columns, pkNames: [...meta.primaryKey], types };
|
|
539
|
+
}
|
|
540
|
+
return out;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const quoteIdent = (name: string): string => `"${name.replace(/"/g, '""')}"`;
|
|
544
|
+
|
|
545
|
+
function tableMeta(render: RenderIndex, table: string): TableRenderMeta {
|
|
546
|
+
const meta = render[table];
|
|
547
|
+
if (!meta) {
|
|
548
|
+
const known = Object.keys(render);
|
|
549
|
+
throw new Error(
|
|
550
|
+
`logical mutator write to unknown table ${JSON.stringify(table)} — did you pass \`schema\` to createRindleApiServer? known tables: ${known.length ? known.join(", ") : "(none — no schema configured)"}`,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
return meta;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Validate a keyed row against a table: reject unknown columns; require the pk columns; with
|
|
557
|
+
* `full`, require EVERY column (an insert carries a whole row — there are no defaults). Mirrors the
|
|
558
|
+
* client `trackingTx.checkColumns` messages so an author sees the same error on both tiers. */
|
|
559
|
+
function checkColumns(table: string, obj: KeyedRow, meta: TableRenderMeta, full: boolean): void {
|
|
560
|
+
const unknown = Object.keys(obj).filter((k) => !meta.columns.includes(k));
|
|
561
|
+
if (unknown.length) {
|
|
562
|
+
throw new Error(`unknown column${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} on ${table} — columns: ${meta.columns.join(", ")}`);
|
|
563
|
+
}
|
|
564
|
+
const required = full ? meta.columns : meta.pkNames;
|
|
565
|
+
const missing = required.filter((c) => !(c in obj));
|
|
566
|
+
if (missing.length) {
|
|
567
|
+
throw new Error(`missing ${full ? "column" : "primary-key column"}${missing.length > 1 ? "s" : ""} ${missing.join(", ")} on ${table}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const encode = (dialect: SqlDialect, v: WireValue, type: ColType): WireValue =>
|
|
572
|
+
dialect.encodeValue ? dialect.encodeValue(v, type) : v;
|
|
573
|
+
|
|
574
|
+
/** Render one {@link MutationOp} to a `{sql, params}` for the dialect, or `null` for a no-op (an
|
|
575
|
+
* `update` whose row names only pk columns — nothing to SET, matching the client's no-op edit). */
|
|
576
|
+
export function renderOp(op: MutationOp, meta: TableRenderMeta, dialect: SqlDialect): SqlStatement | null {
|
|
577
|
+
const t = quoteIdent(op.table);
|
|
578
|
+
const params: WireValue[] = [];
|
|
579
|
+
// Bind a value and return its placeholder at the correct 1-based index (post-push length).
|
|
580
|
+
const bind = (c: string, row: KeyedRow): string => {
|
|
581
|
+
params.push(encode(dialect, row[c], meta.types[c]));
|
|
582
|
+
return dialect.placeholder(params.length);
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
if (op.kind === "insert" || op.kind === "insertIgnore" || op.kind === "upsert") {
|
|
586
|
+
checkColumns(op.table, op.row, meta, true);
|
|
587
|
+
const cols = meta.columns;
|
|
588
|
+
const values = cols.map((c) => bind(c, op.row));
|
|
589
|
+
let sql = `INSERT INTO ${t} (${cols.map(quoteIdent).join(", ")}) VALUES (${values.join(", ")})`;
|
|
590
|
+
if (op.kind === "insertIgnore") {
|
|
591
|
+
sql += ` ON CONFLICT (${meta.pkNames.map(quoteIdent).join(", ")}) DO NOTHING`;
|
|
592
|
+
} else if (op.kind === "upsert") {
|
|
593
|
+
const nonPk = cols.filter((c) => !meta.pkNames.includes(c));
|
|
594
|
+
sql += ` ON CONFLICT (${meta.pkNames.map(quoteIdent).join(", ")}) `;
|
|
595
|
+
sql += nonPk.length
|
|
596
|
+
? `DO UPDATE SET ${nonPk.map((c) => `${quoteIdent(c)} = excluded.${quoteIdent(c)}`).join(", ")}`
|
|
597
|
+
: `DO NOTHING`;
|
|
598
|
+
}
|
|
599
|
+
return { sql, params };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (op.kind === "update") {
|
|
603
|
+
checkColumns(op.table, op.row, meta, false);
|
|
604
|
+
const setCols = meta.columns.filter((c) => !meta.pkNames.includes(c) && c in op.row);
|
|
605
|
+
if (!setCols.length) return null; // pk-only row → nothing to change (client no-op edit)
|
|
606
|
+
const setSql = setCols.map((c) => `${quoteIdent(c)} = ${bind(c, op.row)}`);
|
|
607
|
+
const whereSql = meta.pkNames.map((c) => `${quoteIdent(c)} = ${bind(c, op.row)}`);
|
|
608
|
+
return { sql: `UPDATE ${t} SET ${setSql.join(", ")} WHERE ${whereSql.join(" AND ")}`, params };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// delete
|
|
612
|
+
checkColumns(op.table, op.pk, meta, false);
|
|
613
|
+
const whereSql = meta.pkNames.map((c) => `${quoteIdent(c)} = ${bind(c, op.pk)}`);
|
|
614
|
+
return { sql: `DELETE FROM ${t} WHERE ${whereSql.join(" AND ")}`, params };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Render a point read (`tx.row`) — `SELECT <cols> FROM "T" WHERE <pk>` — for read-your-writes. */
|
|
618
|
+
export function renderPointRead(table: string, pk: KeyedRow, meta: TableRenderMeta, dialect: SqlDialect): SqlStatement {
|
|
619
|
+
checkColumns(table, pk, meta, false);
|
|
620
|
+
const params: WireValue[] = [];
|
|
621
|
+
const whereSql = meta.pkNames.map((c) => {
|
|
622
|
+
params.push(encode(dialect, pk[c], meta.types[c]));
|
|
623
|
+
return `${quoteIdent(c)} = ${dialect.placeholder(params.length)}`;
|
|
624
|
+
});
|
|
625
|
+
const cols = meta.columns.map(quoteIdent).join(", ");
|
|
626
|
+
return { sql: `SELECT ${cols} FROM ${quoteIdent(table)} WHERE ${whereSql.join(" AND ")}`, params };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Map a driver row (column-name keyed) to a {@link KeyedRow} over the table's known columns. */
|
|
630
|
+
function rowToKeyed(row: Record<string, unknown> | undefined, meta: TableRenderMeta): KeyedRow | undefined {
|
|
631
|
+
if (!row) return undefined;
|
|
632
|
+
const out: KeyedRow = {};
|
|
633
|
+
for (const c of meta.columns) out[c] = row[c] as WireValue;
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// --------------------------------------------------------------------------- backends + server tx
|
|
638
|
+
|
|
639
|
+
/** Thrown (wrapping the driver error) by a server tx's DB calls, so the seam can tell an INFRA
|
|
640
|
+
* failure (retry) from a mutator-body throw (business rejection). */
|
|
641
|
+
export class BackendError extends Error {
|
|
642
|
+
readonly driverError: unknown;
|
|
643
|
+
constructor(driverError: unknown) {
|
|
644
|
+
super(driverError instanceof Error ? driverError.message : String(driverError));
|
|
645
|
+
this.name = "BackendError";
|
|
646
|
+
this.driverError = driverError;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** The rindle/daemon server tx: logical writes render to SQLite and ACCUMULATE into one batch;
|
|
651
|
+
* raw `exec` accumulates too; `row` reads COMMITTED state through the daemon (no read-your-writes,
|
|
652
|
+
* the one interactive-txn limitation of the daemon backend). */
|
|
653
|
+
/** Build the compiler {@link Catalog} for ONE ast from the render index: columns/pk from the
|
|
654
|
+
* schema; relationship cardinality from the AST ITSELF — a Rindle relationship is declared at
|
|
655
|
+
* the query site (`sub(alias, rel)` / `.one()`), never on the schema, so the alias→cardinality
|
|
656
|
+
* map is inherently per-query. `columnTypes` are stubs: the sqlite dialect binds natives and
|
|
657
|
+
* never consults them (DAEMON-INTERACTIVE-TXN §5.4 — no casts). */
|
|
658
|
+
function catalogFor(render: RenderIndex, root: Ast): Catalog {
|
|
659
|
+
const tables: Record<string, TableSchema> = {};
|
|
660
|
+
const ensure = (table: string): TableSchema => {
|
|
661
|
+
const existing = tables[table];
|
|
662
|
+
if (existing) return existing;
|
|
663
|
+
const meta = tableMeta(render, table);
|
|
664
|
+
const columnTypes: Record<string, QueryColumnType> = {};
|
|
665
|
+
for (const c of meta.columns) columnTypes[c] = { type: "text", isEnum: false, isArray: false };
|
|
666
|
+
return (tables[table] = {
|
|
667
|
+
columns: [...meta.columns],
|
|
668
|
+
primaryKey: [...meta.pkNames],
|
|
669
|
+
columnTypes,
|
|
670
|
+
relationships: {},
|
|
671
|
+
});
|
|
672
|
+
};
|
|
673
|
+
const walkCondition = (cond: Condition | undefined): void => {
|
|
674
|
+
if (!cond) return;
|
|
675
|
+
if (cond.type === "and" || cond.type === "or") {
|
|
676
|
+
for (const c of cond.conditions) walkCondition(c);
|
|
677
|
+
} else if (cond.type === "correlatedSubquery") {
|
|
678
|
+
walkAst(cond.related.subquery);
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
const walkAst = (ast: Ast): void => {
|
|
682
|
+
const t = ensure(ast.table);
|
|
683
|
+
for (const rel of ast.related ?? []) {
|
|
684
|
+
const alias = rel.subquery.alias;
|
|
685
|
+
if (alias != null) t.relationships[alias] = rel.subquery.one === true ? "one" : "many";
|
|
686
|
+
walkAst(rel.subquery);
|
|
687
|
+
}
|
|
688
|
+
walkCondition(ast.where);
|
|
689
|
+
};
|
|
690
|
+
walkAst(root);
|
|
691
|
+
return { tables };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** The control-flow unwind for a begin-absorbed replay (DAEMON-INTERACTIVE-TXN §4.1): thrown
|
|
695
|
+
* from the first read so the mutator body stops re-running an already-committed envelope; the
|
|
696
|
+
* backend answers with the tx's latched authoritative output. Never surfaces to users. */
|
|
697
|
+
class AbsorbedReplay extends Error {
|
|
698
|
+
constructor() {
|
|
699
|
+
super("mutation absorbed by mid dedup at session begin");
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* The daemon server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
|
|
705
|
+
* execution strategies. It starts ACCUMULATING — a pure-write mutator ships one batch to
|
|
706
|
+
* `/execute-sql-txn`, byte-identical to prior behavior — and LAZILY UPGRADES to an interactive
|
|
707
|
+
* mutation session at the mutator's first read: `begin` carries the envelope identity, the
|
|
708
|
+
* accumulated statement prefix (sound to replay — nothing before the first read observed DB
|
|
709
|
+
* state, §5.2), and the read itself, so a one-read mutator pays exactly one extra round trip.
|
|
710
|
+
* From then on reads run THROUGH the open transaction (read-your-writes — PG parity, and no
|
|
711
|
+
* read-then-write race) and writes buffer locally, flushing before the next read/commit: k
|
|
712
|
+
* reads cost k+2 round trips regardless of write count.
|
|
713
|
+
*
|
|
714
|
+
* Begin-time mid dedup can ABSORB the envelope (a redelivery whose commit response was lost):
|
|
715
|
+
* the replay output is latched on {@link DaemonLazyTx.absorbed} and {@link AbsorbedReplay}
|
|
716
|
+
* unwinds the body — the latch (not the throw) is authoritative, so a mutator that swallows
|
|
717
|
+
* the unwind still cannot re-apply (no session opened; buffered writes are never shipped).
|
|
718
|
+
* A daemon client without session support keeps the LEGACY committed-state point read.
|
|
719
|
+
*/
|
|
720
|
+
class DaemonLazyTx implements ServerMutationTx {
|
|
721
|
+
/** Pre-upgrade: the accumulated batch/prefix. Post-upgrade: writes buffered for the next flush. */
|
|
722
|
+
private readonly stmts: SqlStatement[] = [];
|
|
723
|
+
private readonly render: RenderIndex;
|
|
724
|
+
private readonly daemon: RindleDaemonClient;
|
|
725
|
+
private readonly envelope: MutationEnvelope;
|
|
726
|
+
private sessionId?: string;
|
|
727
|
+
/** The begin-absorbed replay output (§4.1), latched for the backend. */
|
|
728
|
+
absorbed?: SqlTxnOutput;
|
|
729
|
+
idempotencyKey?: string;
|
|
730
|
+
|
|
731
|
+
constructor(render: RenderIndex, daemon: RindleDaemonClient, envelope: MutationEnvelope) {
|
|
732
|
+
this.render = render;
|
|
733
|
+
this.daemon = daemon;
|
|
734
|
+
this.envelope = envelope;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** True once the tx upgraded to an interactive session (the backend then commits it). */
|
|
738
|
+
get session(): boolean {
|
|
739
|
+
return this.sessionId !== undefined;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
get statements(): readonly SqlStatement[] {
|
|
743
|
+
return this.stmts;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
exec(sql: string, params: WireValue[] = []): void {
|
|
747
|
+
this.stmts.push({ sql, params });
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
private push(op: MutationOp): Promise<void> {
|
|
751
|
+
const rendered = renderOp(op, tableMeta(this.render, op.table), sqliteDialect);
|
|
752
|
+
if (rendered) this.stmts.push(rendered);
|
|
753
|
+
return Promise.resolve();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
insert(table: string, row: KeyedRow): Promise<void> {
|
|
757
|
+
return this.push({ kind: "insert", table, row });
|
|
758
|
+
}
|
|
759
|
+
update(table: string, row: KeyedRow): Promise<void> {
|
|
760
|
+
return this.push({ kind: "update", table, row });
|
|
761
|
+
}
|
|
762
|
+
upsert(table: string, row: KeyedRow): Promise<void> {
|
|
763
|
+
return this.push({ kind: "upsert", table, row });
|
|
764
|
+
}
|
|
765
|
+
insertIgnore(table: string, row: KeyedRow): Promise<void> {
|
|
766
|
+
return this.push({ kind: "insertIgnore", table, row });
|
|
767
|
+
}
|
|
768
|
+
delete(table: string, pk: KeyedRow): Promise<void> {
|
|
769
|
+
return this.push({ kind: "delete", table, pk });
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async row(table: string, pk: KeyedRow): Promise<KeyedRow | undefined> {
|
|
773
|
+
const read = renderPointRead(table, pk, tableMeta(this.render, table), sqliteDialect);
|
|
774
|
+
const out = await this.readThroughTxn(read);
|
|
775
|
+
const cells = out.rows[0];
|
|
776
|
+
if (!cells) return undefined;
|
|
777
|
+
const keyed: KeyedRow = {}; // the daemon returns positional cells — zip with `cols`
|
|
778
|
+
out.cols.forEach((c, i) => (keyed[c] = cells[i]));
|
|
779
|
+
return keyed;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** A full-shape read inside the open transaction (§5.4): compile to ONE SQLite `SELECT`
|
|
783
|
+
* (native bind params, no casts — SQLite is the canonical store), ride the session like
|
|
784
|
+
* `row` (including the lazy upgrade / begin ride-along), parse the single JSON cell. */
|
|
785
|
+
async query(q: Ast | Query<any, any, any>): Promise<unknown> {
|
|
786
|
+
const ast = typeof (q as Query<any, any, any>).ast === "function" ? (q as Query<any, any, any>).ast() : (q as Ast);
|
|
787
|
+
const compiled = compileQueryAst(ast, catalogFor(this.render, ast), { dialect: "sqlite" });
|
|
788
|
+
const out = await this.readThroughTxn({ sql: compiled.sql, params: compiled.params as WireValue[] });
|
|
789
|
+
const cell = out.rows[0]?.[0];
|
|
790
|
+
if (typeof cell !== "string") return ast.one === true ? null : [];
|
|
791
|
+
return JSON.parse(cell) as unknown;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Run one read: upgrade to a session at the first (§5.1), ride the open one after, or fall
|
|
795
|
+
* back to the legacy committed-state read when the daemon client lacks sessions. */
|
|
796
|
+
private async readThroughTxn(read: SqlStatement): Promise<SqlReadOutput> {
|
|
797
|
+
if (this.absorbed) throw new AbsorbedReplay();
|
|
798
|
+
if (!this.daemon.beginMutationSession) {
|
|
799
|
+
try {
|
|
800
|
+
return await this.daemon.executeSqlRead({ sql: read.sql, params: read.params });
|
|
801
|
+
} catch (err) {
|
|
802
|
+
throw new BackendError(err);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
if (this.sessionId === undefined) {
|
|
807
|
+
const opened = await this.daemon.beginMutationSession({
|
|
808
|
+
clientID: this.envelope.clientID,
|
|
809
|
+
mid: this.envelope.mid,
|
|
810
|
+
statements: this.stmts.splice(0),
|
|
811
|
+
query: read,
|
|
812
|
+
});
|
|
813
|
+
if (opened.absorbed) {
|
|
814
|
+
const { absorbed: _a, sessionId: _s, read: _r, ...output } = opened;
|
|
815
|
+
this.absorbed = output as SqlTxnOutput;
|
|
816
|
+
throw new AbsorbedReplay();
|
|
817
|
+
}
|
|
818
|
+
if (!opened.sessionId || !opened.read) {
|
|
819
|
+
throw new Error(`malformed mutate-session begin reply: ${JSON.stringify(opened)}`);
|
|
820
|
+
}
|
|
821
|
+
this.sessionId = opened.sessionId;
|
|
822
|
+
return opened.read;
|
|
823
|
+
}
|
|
824
|
+
await this.flush();
|
|
825
|
+
return await this.daemon.queryInMutationSession!({
|
|
826
|
+
sessionId: this.sessionId,
|
|
827
|
+
sql: read.sql,
|
|
828
|
+
params: read.params,
|
|
829
|
+
});
|
|
830
|
+
} catch (err) {
|
|
831
|
+
if (err instanceof AbsorbedReplay || err instanceof BackendError) throw err;
|
|
832
|
+
throw new BackendError(err);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** Ship buffered writes into the open session, order-preserving; a no-op when none pend. */
|
|
837
|
+
private async flush(): Promise<void> {
|
|
838
|
+
if (this.stmts.length === 0) return;
|
|
839
|
+
await this.daemon.execInMutationSession!({
|
|
840
|
+
sessionId: this.sessionId!,
|
|
841
|
+
statements: this.stmts.splice(0),
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** Flush + commit the open session — the daemon stamps lmid co-transactionally (§4.4) and
|
|
846
|
+
* answers the same shape `/execute-sql-txn` does. */
|
|
847
|
+
async commitSession(): Promise<SqlTxnOutput> {
|
|
848
|
+
try {
|
|
849
|
+
await this.flush();
|
|
850
|
+
return await this.daemon.commitMutationSession!({ sessionId: this.sessionId! });
|
|
851
|
+
} catch (err) {
|
|
852
|
+
throw err instanceof BackendError ? err : new BackendError(err);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Best-effort rollback (the daemon's deadline is the backstop). MUST be awaited before a
|
|
857
|
+
* follow-up `/reject-mutation`: that lmid-only commit needs the writer this session holds. */
|
|
858
|
+
async rollbackSessionQuietly(): Promise<void> {
|
|
859
|
+
if (this.sessionId === undefined) return;
|
|
860
|
+
const sessionId = this.sessionId;
|
|
861
|
+
this.sessionId = undefined;
|
|
862
|
+
try {
|
|
863
|
+
await this.daemon.rollbackMutationSession!({ sessionId });
|
|
864
|
+
} catch {
|
|
865
|
+
// Unreachable daemon / already-expired session: the deadline rollback covers it.
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** The Postgres server tx: a REAL interactive transaction. Logical writes render to `$n` and run
|
|
871
|
+
* LIVE against the open txn; raw `exec` runs live too (after `rewrite`); `row` reads the open txn
|
|
872
|
+
* (read-your-writes). Ops append to an internally-serialized chain so order holds even when a legacy
|
|
873
|
+
* sync mutator does not `await`; the backend drains the chain (`settle`) before the lmid upsert. */
|
|
874
|
+
class PgLiveTx implements ServerMutationTx {
|
|
875
|
+
private chain: Promise<void> = Promise.resolve();
|
|
876
|
+
private readonly stmts: SqlStatement[] = [];
|
|
877
|
+
private readonly q: PgQuery;
|
|
878
|
+
private readonly render: RenderIndex;
|
|
879
|
+
private readonly rewrite: (sql: string) => string;
|
|
880
|
+
idempotencyKey?: string;
|
|
881
|
+
|
|
882
|
+
constructor(q: PgQuery, render: RenderIndex, rewrite: (sql: string) => string) {
|
|
883
|
+
this.q = q;
|
|
884
|
+
this.render = render;
|
|
885
|
+
this.rewrite = rewrite;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
get statements(): readonly SqlStatement[] {
|
|
889
|
+
return this.stmts;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
settle(): Promise<void> {
|
|
893
|
+
return this.chain;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
private execLive(sql: string, params: WireValue[]): void {
|
|
897
|
+
this.chain = this.chain.then(async () => {
|
|
898
|
+
try {
|
|
899
|
+
await this.q.exec(sql, params as unknown[]);
|
|
900
|
+
} catch (err) {
|
|
901
|
+
throw new BackendError(err);
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
exec(sql: string, params: WireValue[] = []): void {
|
|
907
|
+
const stmt = { sql: this.rewrite(sql), params };
|
|
908
|
+
this.stmts.push(stmt);
|
|
909
|
+
this.execLive(stmt.sql, params);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
private write(op: MutationOp): Promise<void> {
|
|
913
|
+
let rendered: SqlStatement | null;
|
|
914
|
+
try {
|
|
915
|
+
rendered = renderOp(op, tableMeta(this.render, op.table), postgresDialect);
|
|
916
|
+
} catch (err) {
|
|
917
|
+
return Promise.reject(err); // a validation error (business rejection), synchronous shape
|
|
918
|
+
}
|
|
919
|
+
if (rendered) this.execLive(rendered.sql, rendered.params ?? []);
|
|
920
|
+
return this.chain;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
insert(table: string, row: KeyedRow): Promise<void> {
|
|
924
|
+
return this.write({ kind: "insert", table, row });
|
|
925
|
+
}
|
|
926
|
+
update(table: string, row: KeyedRow): Promise<void> {
|
|
927
|
+
return this.write({ kind: "update", table, row });
|
|
928
|
+
}
|
|
929
|
+
upsert(table: string, row: KeyedRow): Promise<void> {
|
|
930
|
+
return this.write({ kind: "upsert", table, row });
|
|
931
|
+
}
|
|
932
|
+
insertIgnore(table: string, row: KeyedRow): Promise<void> {
|
|
933
|
+
return this.write({ kind: "insertIgnore", table, row });
|
|
934
|
+
}
|
|
935
|
+
delete(table: string, pk: KeyedRow): Promise<void> {
|
|
936
|
+
return this.write({ kind: "delete", table, pk });
|
|
937
|
+
}
|
|
938
|
+
async row(table: string, pk: KeyedRow): Promise<KeyedRow | undefined> {
|
|
939
|
+
const meta = tableMeta(this.render, table);
|
|
940
|
+
const read = renderPointRead(table, pk, meta, postgresDialect); // validates before draining
|
|
941
|
+
await this.settle(); // read-your-writes: drain queued writes first
|
|
942
|
+
let rows: Array<Record<string, unknown>>;
|
|
943
|
+
try {
|
|
944
|
+
rows = await this.q.query(read.sql, read.params as unknown[]);
|
|
945
|
+
} catch (err) {
|
|
946
|
+
throw new BackendError(err);
|
|
947
|
+
}
|
|
948
|
+
return rowToKeyed(rows[0], meta);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
query(): Promise<unknown> {
|
|
952
|
+
// The compiler's postgres dialect ships (@rindle/query-compiler); what remains is the §7
|
|
953
|
+
// static-catalog + driver-pin wiring (POSTGRES-READ-COMPILER-DESIGN.md Phase B).
|
|
954
|
+
return Promise.reject(
|
|
955
|
+
new Error(
|
|
956
|
+
"tx.query is not wired on the Postgres backend yet (POSTGRES-READ-COMPILER-DESIGN.md Phase B) — use tx.row for point reads meanwhile",
|
|
957
|
+
),
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
401
962
|
/**
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
* it past a rejected mid)
|
|
963
|
+
* The default {@link MutationBackend}. A pure-write mutator keeps the historical shape: writes
|
|
964
|
+
* ACCUMULATE and ship as ONE batch to `/execute-sql-txn`, which stamps `lmid` co-transactionally
|
|
965
|
+
* (and `/reject-mutation` advances it past a rejected mid) — byte-identical behavior. A
|
|
966
|
+
* READ-bearing mutator lazily upgrades to an interactive mutation session
|
|
967
|
+
* (DAEMON-INTERACTIVE-TXN-DESIGN.md): reads are read-your-writes through the open transaction
|
|
968
|
+
* (PG parity), the commit stamps `lmid` in the same atomic unit, and a begin-absorbed replay
|
|
969
|
+
* short-circuits without re-running the body.
|
|
405
970
|
*/
|
|
406
971
|
export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
|
|
407
972
|
return {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
973
|
+
dialect: sqliteDialect,
|
|
974
|
+
async runMutation({ envelope, render, run }) {
|
|
975
|
+
const tx = new DaemonLazyTx(render, daemon, envelope);
|
|
976
|
+
try {
|
|
977
|
+
await run(tx);
|
|
978
|
+
} catch (err) {
|
|
979
|
+
// A begin-absorbed replay: the authoritative outcome already committed — answer it,
|
|
980
|
+
// whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
|
|
981
|
+
if (tx.absorbed) return { accepted: true, output: tx.absorbed };
|
|
982
|
+
if (err instanceof BackendError) {
|
|
983
|
+
await tx.rollbackSessionQuietly();
|
|
984
|
+
throw err.driverError; // infra — never a user rejection
|
|
985
|
+
}
|
|
986
|
+
const reason = errMessage(err);
|
|
987
|
+
// Data first, watermark second: the rollback releases the single writer that the
|
|
988
|
+
// `/reject-mutation` lmid-only commit needs (§2.4 on the session path).
|
|
989
|
+
await tx.rollbackSessionQuietly();
|
|
990
|
+
const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
|
|
991
|
+
return { accepted: false, reason, output };
|
|
992
|
+
}
|
|
993
|
+
if (tx.absorbed) return { accepted: true, output: tx.absorbed };
|
|
994
|
+
if (tx.session) {
|
|
995
|
+
try {
|
|
996
|
+
return { accepted: true, output: await tx.commitSession() };
|
|
997
|
+
} catch (err) {
|
|
998
|
+
if (err instanceof BackendError) throw err.driverError; // infra (client retries; dedup absorbs)
|
|
999
|
+
throw err;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
const txn: SqlTxn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
|
|
1003
|
+
if (tx.idempotencyKey !== undefined) txn.idempotencyKey = tx.idempotencyKey;
|
|
1004
|
+
return { accepted: true, output: await daemon.executeSqlTxn(txn) };
|
|
412
1005
|
},
|
|
413
1006
|
reject({ envelope, reason }) {
|
|
414
1007
|
return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
|
|
@@ -458,16 +1051,42 @@ ON CONFLICT ("client_id") DO UPDATE
|
|
|
458
1051
|
*/
|
|
459
1052
|
export function postgresBackend(plugger: PostgresPlugger, opts: PostgresBackendOptions = {}): MutationBackend {
|
|
460
1053
|
const rewrite = opts.rewriteSql ?? ((sql: string) => sql);
|
|
461
|
-
|
|
1054
|
+
// A tagged business rejection escaping `plugger.transaction` — the plugger rolls the data back on
|
|
1055
|
+
// any throw; this marker distinguishes "mutator said no" (reject) from an infra failure (rethrow).
|
|
1056
|
+
class RejectSignal extends Error {}
|
|
1057
|
+
const lmidOnly = (envelope: MutationEnvelope): Promise<SqlTxnOutput> =>
|
|
462
1058
|
plugger.transaction(async (q) => {
|
|
463
|
-
for (const s of statements) await q.exec(rewrite(s.sql), (s.params ?? []) as unknown[]);
|
|
464
|
-
// ALWAYS — accepted or rejected — in this same transaction (§2.2, §2.4).
|
|
465
1059
|
await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
|
|
466
1060
|
return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
|
|
467
1061
|
});
|
|
468
1062
|
return {
|
|
469
|
-
|
|
470
|
-
|
|
1063
|
+
dialect: postgresDialect,
|
|
1064
|
+
async runMutation({ envelope, render, run }) {
|
|
1065
|
+
try {
|
|
1066
|
+
const output = await plugger.transaction(async (q) => {
|
|
1067
|
+
const tx = new PgLiveTx(q, render, rewrite);
|
|
1068
|
+
try {
|
|
1069
|
+
await run(tx);
|
|
1070
|
+
await tx.settle(); // drain any un-awaited queued writes before the lmid stamp
|
|
1071
|
+
} catch (err) {
|
|
1072
|
+
if (err instanceof BackendError) throw err; // infra → rollback + propagate
|
|
1073
|
+
throw new RejectSignal(errMessage(err)); // business → rollback data, tag for §2.4
|
|
1074
|
+
}
|
|
1075
|
+
// ALWAYS on the accepted path, SAME transaction (§2.2): the lmid upsert commits with data.
|
|
1076
|
+
await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
|
|
1077
|
+
return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
|
|
1078
|
+
});
|
|
1079
|
+
return { accepted: true, output };
|
|
1080
|
+
} catch (err) {
|
|
1081
|
+
if (err instanceof RejectSignal) {
|
|
1082
|
+
// Data rolled back; STILL advance lmid alone (§2.4 — the client's queue must drain).
|
|
1083
|
+
return { accepted: false, reason: err.message, output: await lmidOnly(envelope) };
|
|
1084
|
+
}
|
|
1085
|
+
if (err instanceof BackendError) throw err.driverError; // infra
|
|
1086
|
+
throw err;
|
|
1087
|
+
}
|
|
1088
|
+
},
|
|
1089
|
+
reject: ({ envelope }) => lmidOnly(envelope).then(() => undefined),
|
|
471
1090
|
};
|
|
472
1091
|
}
|
|
473
1092
|
|
|
@@ -595,6 +1214,38 @@ export function defineApiMutators<User, M extends ApiMutators<User>>(mutators: M
|
|
|
595
1214
|
return mutators;
|
|
596
1215
|
}
|
|
597
1216
|
|
|
1217
|
+
/**
|
|
1218
|
+
* Bulk-register a SHARED (generator) mutator registry as server mutators — the mutator twin of
|
|
1219
|
+
* {@link registerQueries} (which does the same for co-located `defineQuery` values). Each shared
|
|
1220
|
+
* mutator carries its own arg validator (`shared(schema, gen)`), so this wraps every one with the
|
|
1221
|
+
* UNIVERSAL server triad and nothing else: parse the UNTRUSTED wire args (its `.args`), map the server
|
|
1222
|
+
* {@link MutationContext} to the shared {@link MutatorCtx} principal, and drive the SAME body the
|
|
1223
|
+
* client predicts ({@link runSharedMutation}). The point is that a shared mutator whose server run
|
|
1224
|
+
* adds NO authority beyond that triad needs no hand-written wrapper.
|
|
1225
|
+
*
|
|
1226
|
+
* Server-only AUTHORITY the client cannot predict (a title guard, an owner-gated cascade, a
|
|
1227
|
+
* `NOT EXISTS` dedup) stays an explicit {@link ApiMutator} that OVERRIDES the auto-wrapped default —
|
|
1228
|
+
* spread this first, then the overrides win by key:
|
|
1229
|
+
*
|
|
1230
|
+
* ```ts
|
|
1231
|
+
* mutators: defineApiMutators({
|
|
1232
|
+
* ...sharedApiMutators(sharedMutators, (ctx) => ({ user: requireUser(ctx.user) })),
|
|
1233
|
+
* createIssue: withTitleGuard(sharedMutators.createIssue), // + server-only policy
|
|
1234
|
+
* deleteIssue: async (tx, raw, ctx) => { ... }, // raw owner-gated cascade
|
|
1235
|
+
* }),
|
|
1236
|
+
* ```
|
|
1237
|
+
*/
|
|
1238
|
+
export function sharedApiMutators<User>(
|
|
1239
|
+
registry: Record<string, SharedMutatorWithArgs<any>>,
|
|
1240
|
+
principal: (ctx: MutationContext<User>) => MutatorCtx,
|
|
1241
|
+
): ApiMutators<User> {
|
|
1242
|
+
const out: ApiMutators<User> = {};
|
|
1243
|
+
for (const [name, mutator] of Object.entries(registry)) {
|
|
1244
|
+
out[name] = (tx, raw, ctx) => runSharedMutation(mutator, mutator.args.parse(raw), principal(ctx), tx);
|
|
1245
|
+
}
|
|
1246
|
+
return out;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
598
1249
|
export function queryResultToAst(result: ApiQueryResult): Ast {
|
|
599
1250
|
if (result && typeof result === "object" && "ast" in result && typeof result.ast === "function") {
|
|
600
1251
|
return result.ast();
|
|
@@ -704,6 +1355,10 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
704
1355
|
const mode = opts.mode ?? "normalized";
|
|
705
1356
|
// The mutation seam — defaulting to the daemon's co-transactional lmid stamp (unchanged behavior).
|
|
706
1357
|
const backend = opts.backend ?? daemonBackend(opts.daemon);
|
|
1358
|
+
// Schema-derived render metadata for logical mutator writes; `{}` when no schema is configured (a
|
|
1359
|
+
// logical op then throws loudly — the tx never silently drops a write). Each backend renders in its
|
|
1360
|
+
// own dialect (`backend.dialect`: daemon→sqlite, postgres→postgres).
|
|
1361
|
+
const renderIndex: RenderIndex = opts.schema ? buildRenderIndex(opts.schema) : {};
|
|
707
1362
|
// Names that are ALSO configured pins — a lease for one is forced to a `pinned` policy (the lazy
|
|
708
1363
|
// floor, §4.1) so the first viewer to route to a follower warms it for late joiners.
|
|
709
1364
|
const pinnedNames = new Set((opts.pinnedQueries ?? []).map((p) => p.name));
|
|
@@ -777,31 +1432,34 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
777
1432
|
const pushMutation = async (input: PushMutationRequest<User>): Promise<PushMutationResponse> => {
|
|
778
1433
|
const context: ApiContext<User> = { user: input.user, request: input.request };
|
|
779
1434
|
const mutator = opts.mutators?.[input.envelope.name];
|
|
1435
|
+
// PRE-FLIGHT rejections — no txn, no data, `lmid` alone (the queue must still drain).
|
|
780
1436
|
if (!mutator) return reject(backend, input.envelope, `unknown mutator: ${input.envelope.name}`);
|
|
781
|
-
let sql: Pick<SqlTxn, "statements" | "idempotencyKey">;
|
|
782
1437
|
try {
|
|
783
1438
|
await assertAuthorized(opts.authorizeMutation, {
|
|
784
1439
|
user: input.user,
|
|
785
1440
|
envelope: input.envelope,
|
|
786
1441
|
context,
|
|
787
1442
|
});
|
|
788
|
-
const tx = new CollectingSqlMutationTx();
|
|
789
|
-
const result = await mutator(tx, input.envelope.args as never, {
|
|
790
|
-
user: input.user,
|
|
791
|
-
envelope: input.envelope,
|
|
792
|
-
daemon: opts.daemon,
|
|
793
|
-
request: input.request,
|
|
794
|
-
});
|
|
795
|
-
sql = mutationResultToSqlTxn(result, tx);
|
|
796
1443
|
} catch (err) {
|
|
797
|
-
return reject(backend, input.envelope,
|
|
1444
|
+
return reject(backend, input.envelope, errMessage(err));
|
|
798
1445
|
}
|
|
799
|
-
|
|
1446
|
+
// Run the mutator INSIDE the backend's transaction. A throw from the mutator body is a business
|
|
1447
|
+
// rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
|
|
1448
|
+
const outcome = await backend.runMutation({
|
|
800
1449
|
envelope: input.envelope,
|
|
801
|
-
|
|
802
|
-
|
|
1450
|
+
render: renderIndex,
|
|
1451
|
+
run: async (tx) => {
|
|
1452
|
+
const result = await mutator(tx, input.envelope.args as never, {
|
|
1453
|
+
user: input.user,
|
|
1454
|
+
envelope: input.envelope,
|
|
1455
|
+
daemon: opts.daemon,
|
|
1456
|
+
request: input.request,
|
|
1457
|
+
});
|
|
1458
|
+
applyResultToTx(result, tx);
|
|
1459
|
+
},
|
|
803
1460
|
});
|
|
804
|
-
return { accepted: true, rejected: false, output };
|
|
1461
|
+
if (outcome.accepted) return { accepted: true, rejected: false, output: outcome.output };
|
|
1462
|
+
return { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
|
|
805
1463
|
};
|
|
806
1464
|
|
|
807
1465
|
const pushMutations = async (input: PushMutationsRequest<User>): Promise<PushMutationResponse[]> => {
|
|
@@ -965,24 +1623,53 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
965
1623
|
};
|
|
966
1624
|
}
|
|
967
1625
|
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1626
|
+
/** Run a SHARED generator mutator (the SAME body the client predicts) against a live server
|
|
1627
|
+
* transaction (MUTATORS-ISOMORPHIC): bind the tier-agnostic {@link isoTx} factory and drive it —
|
|
1628
|
+
* each yielded logical op renders + runs against `tx` (dialect SQL, per backend), each `tx.row`
|
|
1629
|
+
* suspends for read-your-writes, and `tx.all` fans out. A server mutator uses this to delegate its
|
|
1630
|
+
* write body after parsing untrusted args and applying its server-only authority (principal, policy).
|
|
1631
|
+
* A mutator-body throw remains a business rejection; a DB failure propagates as infra. */
|
|
1632
|
+
export function runSharedMutation<Args, Ctx extends MutatorCtx>(
|
|
1633
|
+
mutator: SharedMutator<Args, Ctx>,
|
|
1634
|
+
args: Args,
|
|
1635
|
+
ctx: Ctx,
|
|
1636
|
+
tx: ServerMutationTx,
|
|
1637
|
+
): Promise<void> {
|
|
1638
|
+
return driveMutationAsync(mutator(isoTx, args, ctx), {
|
|
1639
|
+
apply: (op) => applyOpToServerTx(tx, op),
|
|
1640
|
+
read: (table, pk) => tx.row(table, pk),
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
974
1643
|
|
|
975
|
-
|
|
976
|
-
|
|
1644
|
+
/** Run one logical {@link MutationOp} (yielded by a shared generator mutator) against the live server
|
|
1645
|
+
* write surface — the SAME async methods a plain async mutator calls (they render dialect SQL and
|
|
1646
|
+
* execute/accumulate per backend). */
|
|
1647
|
+
function applyOpToServerTx(tx: ServerWriteTx, op: MutationOp): Promise<void> {
|
|
1648
|
+
switch (op.kind) {
|
|
1649
|
+
case "insert":
|
|
1650
|
+
return tx.insert(op.table, op.row);
|
|
1651
|
+
case "upsert":
|
|
1652
|
+
return tx.upsert(op.table, op.row);
|
|
1653
|
+
case "insertIgnore":
|
|
1654
|
+
return tx.insertIgnore(op.table, op.row);
|
|
1655
|
+
case "update":
|
|
1656
|
+
return tx.update(op.table, op.row);
|
|
1657
|
+
case "delete":
|
|
1658
|
+
return tx.delete(op.table, op.pk);
|
|
977
1659
|
}
|
|
978
1660
|
}
|
|
979
1661
|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1662
|
+
/** Feed a mutator's RETURNED result (the alternative to calling `tx.exec`/logical ops directly) into
|
|
1663
|
+
* the backend tx: a returned `SqlStatement[]` / `SqlTxn` is exec'd onto `tx`, and a carried
|
|
1664
|
+
* `idempotencyKey` is stashed (the daemon backend honors it; PG ignores it). A `void` return is a
|
|
1665
|
+
* no-op — the mutator already drove the tx. Preserves the pre-existing return-style contract. */
|
|
1666
|
+
function applyResultToTx(result: ApiMutatorResult, tx: ServerMutationTx): void {
|
|
1667
|
+
if (!result) return;
|
|
1668
|
+
const statements = Array.isArray(result) ? result : result.statements;
|
|
1669
|
+
for (const s of statements) tx.exec(s.sql, s.params);
|
|
1670
|
+
if (!Array.isArray(result) && result.idempotencyKey !== undefined) {
|
|
1671
|
+
(tx as { idempotencyKey?: string }).idempotencyKey = result.idempotencyKey;
|
|
984
1672
|
}
|
|
985
|
-
return { statements: [...tx.statements] };
|
|
986
1673
|
}
|
|
987
1674
|
|
|
988
1675
|
async function assertAuthorized<T>(authorizer: Authorizer<T> | undefined, input: T): Promise<void> {
|