@rindle/api-server 0.2.0 → 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.js CHANGED
@@ -1,7 +1,19 @@
1
+ import { driveMutationAsync, isoTx } from "@rindle/client";
2
+ import { DaemonHttpError } from "@rindle/daemon-client";
3
+ import { compile as compileQueryAst } from "@rindle/query-compiler";
4
+ // Re-export the shared (generator) mutator seam so an app builds its server mutators from ONE import:
5
+ // co-locate each body with its arg schema (`shared`), bulk-drive the registry ({@link sharedApiMutators}),
6
+ // keeping only server-only authority as explicit overrides (see MUTATORS-ISOMORPHIC).
7
+ export { isoTx, shared } from "@rindle/client";
1
8
  export const DEFAULT_RINDLE_API_ROUTES = {
2
9
  query: "/api/rindle/query",
3
10
  read: "/api/rindle/read",
4
11
  mutate: "/api/rindle/mutate",
12
+ // The room write-authority host (RINDLE-REALTIME-DESIGN.md §5.3.1): the room's
13
+ // SOLE flush counterpart — the store's own handler is private ingress behind these.
14
+ applyRowChangeTxn: "/api/rindle/apply-row-change-txn",
15
+ claimRoomEpoch: "/api/rindle/claim-room-epoch",
16
+ roomLmids: "/api/rindle/room-lmids",
5
17
  };
6
18
  export class RindleApiError extends Error {
7
19
  code;
@@ -51,9 +63,53 @@ export class SplitDaemonClient {
51
63
  rejectMutation(input) {
52
64
  return this.writes.rejectMutation(input);
53
65
  }
66
+ // Interactive mutation sessions hold the MASTER's write transaction — never a replica's
67
+ // (DAEMON-INTERACTIVE-TXN-DESIGN.md §4.1; the follower write-fence enforces the same).
68
+ beginMutationSession(input) {
69
+ const begin = this.writes.beginMutationSession?.bind(this.writes);
70
+ if (!begin)
71
+ return Promise.reject(new Error("the write master lacks mutation sessions"));
72
+ return begin(input);
73
+ }
74
+ execInMutationSession(input) {
75
+ const exec = this.writes.execInMutationSession?.bind(this.writes);
76
+ if (!exec)
77
+ return Promise.reject(new Error("the write master lacks mutation sessions"));
78
+ return exec(input);
79
+ }
80
+ queryInMutationSession(input) {
81
+ const query = this.writes.queryInMutationSession?.bind(this.writes);
82
+ if (!query)
83
+ return Promise.reject(new Error("the write master lacks mutation sessions"));
84
+ return query(input);
85
+ }
86
+ commitMutationSession(input) {
87
+ const commit = this.writes.commitMutationSession?.bind(this.writes);
88
+ if (!commit)
89
+ return Promise.reject(new Error("the write master lacks mutation sessions"));
90
+ return commit(input);
91
+ }
92
+ rollbackMutationSession(input) {
93
+ const rollback = this.writes.rollbackMutationSession?.bind(this.writes);
94
+ if (!rollback)
95
+ return Promise.reject(new Error("the write master lacks mutation sessions"));
96
+ return rollback(input);
97
+ }
54
98
  applyRowChangeTxn(input) {
55
99
  return this.writes.applyRowChangeTxn(input);
56
100
  }
101
+ claimRoomEpoch(input) {
102
+ const claim = this.writes.claimRoomEpoch?.bind(this.writes);
103
+ if (!claim)
104
+ return Promise.reject(new Error("the write master lacks claimRoomEpoch"));
105
+ return claim(input);
106
+ }
107
+ roomLmids(input) {
108
+ const lmids = this.writes.roomLmids?.bind(this.writes);
109
+ if (!lmids)
110
+ return Promise.reject(new Error("the write master lacks roomLmids"));
111
+ return lmids(input);
112
+ }
57
113
  migrate(input) {
58
114
  return this.writes.migrate(input);
59
115
  }
@@ -68,6 +124,626 @@ export class SplitDaemonClient {
68
124
  return this.reads.dematerialize(input);
69
125
  }
70
126
  }
127
+ export const sqliteDialect = { name: "sqlite", placeholder: () => "?" };
128
+ export const postgresDialect = { name: "postgres", placeholder: (i) => `$${i}` };
129
+ /** Build the {@link RenderIndex} from a typed schema (`schema.tables[name]` is a `TableMeta`). */
130
+ export function buildRenderIndex(schema) {
131
+ const out = {};
132
+ const tables = schema.tables;
133
+ for (const name of Object.keys(tables)) {
134
+ const meta = tables[name];
135
+ const columns = Object.keys(meta.columns);
136
+ const types = {};
137
+ for (const c of columns)
138
+ types[c] = meta.columns[c].type;
139
+ out[name] = { columns, pkNames: [...meta.primaryKey], types };
140
+ }
141
+ return out;
142
+ }
143
+ const quoteIdent = (name) => `"${name.replace(/"/g, '""')}"`;
144
+ function tableMeta(render, table) {
145
+ const meta = render[table];
146
+ if (!meta) {
147
+ const known = Object.keys(render);
148
+ throw new Error(`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)"}`);
149
+ }
150
+ return meta;
151
+ }
152
+ /** Validate a keyed row against a table: reject unknown columns; require the pk columns; with
153
+ * `full`, require EVERY column (an insert carries a whole row — there are no defaults). Mirrors the
154
+ * client `trackingTx.checkColumns` messages so an author sees the same error on both tiers. */
155
+ function checkColumns(table, obj, meta, full) {
156
+ const unknown = Object.keys(obj).filter((k) => !meta.columns.includes(k));
157
+ if (unknown.length) {
158
+ throw new Error(`unknown column${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} on ${table} — columns: ${meta.columns.join(", ")}`);
159
+ }
160
+ const required = full ? meta.columns : meta.pkNames;
161
+ const missing = required.filter((c) => !(c in obj));
162
+ if (missing.length) {
163
+ throw new Error(`missing ${full ? "column" : "primary-key column"}${missing.length > 1 ? "s" : ""} ${missing.join(", ")} on ${table}`);
164
+ }
165
+ }
166
+ const encode = (dialect, v, type) => dialect.encodeValue ? dialect.encodeValue(v, type) : v;
167
+ /** Render one {@link MutationOp} to a `{sql, params}` for the dialect, or `null` for a no-op (an
168
+ * `update` whose row names only pk columns — nothing to SET, matching the client's no-op edit). */
169
+ export function renderOp(op, meta, dialect) {
170
+ const t = quoteIdent(op.table);
171
+ const params = [];
172
+ // Bind a value and return its placeholder at the correct 1-based index (post-push length).
173
+ const bind = (c, row) => {
174
+ params.push(encode(dialect, row[c], meta.types[c]));
175
+ return dialect.placeholder(params.length);
176
+ };
177
+ if (op.kind === "insert" || op.kind === "insertIgnore" || op.kind === "upsert") {
178
+ checkColumns(op.table, op.row, meta, true);
179
+ const cols = meta.columns;
180
+ const values = cols.map((c) => bind(c, op.row));
181
+ let sql = `INSERT INTO ${t} (${cols.map(quoteIdent).join(", ")}) VALUES (${values.join(", ")})`;
182
+ if (op.kind === "insertIgnore") {
183
+ sql += ` ON CONFLICT (${meta.pkNames.map(quoteIdent).join(", ")}) DO NOTHING`;
184
+ }
185
+ else if (op.kind === "upsert") {
186
+ const nonPk = cols.filter((c) => !meta.pkNames.includes(c));
187
+ sql += ` ON CONFLICT (${meta.pkNames.map(quoteIdent).join(", ")}) `;
188
+ sql += nonPk.length
189
+ ? `DO UPDATE SET ${nonPk.map((c) => `${quoteIdent(c)} = excluded.${quoteIdent(c)}`).join(", ")}`
190
+ : `DO NOTHING`;
191
+ }
192
+ return { sql, params };
193
+ }
194
+ if (op.kind === "update") {
195
+ checkColumns(op.table, op.row, meta, false);
196
+ const setCols = meta.columns.filter((c) => !meta.pkNames.includes(c) && c in op.row);
197
+ if (!setCols.length)
198
+ return null; // pk-only row → nothing to change (client no-op edit)
199
+ const setSql = setCols.map((c) => `${quoteIdent(c)} = ${bind(c, op.row)}`);
200
+ const whereSql = meta.pkNames.map((c) => `${quoteIdent(c)} = ${bind(c, op.row)}`);
201
+ return { sql: `UPDATE ${t} SET ${setSql.join(", ")} WHERE ${whereSql.join(" AND ")}`, params };
202
+ }
203
+ // delete
204
+ checkColumns(op.table, op.pk, meta, false);
205
+ const whereSql = meta.pkNames.map((c) => `${quoteIdent(c)} = ${bind(c, op.pk)}`);
206
+ return { sql: `DELETE FROM ${t} WHERE ${whereSql.join(" AND ")}`, params };
207
+ }
208
+ /** Render a point read (`tx.row`) — `SELECT <cols> FROM "T" WHERE <pk>` — for read-your-writes. */
209
+ export function renderPointRead(table, pk, meta, dialect) {
210
+ checkColumns(table, pk, meta, false);
211
+ const params = [];
212
+ const whereSql = meta.pkNames.map((c) => {
213
+ params.push(encode(dialect, pk[c], meta.types[c]));
214
+ return `${quoteIdent(c)} = ${dialect.placeholder(params.length)}`;
215
+ });
216
+ const cols = meta.columns.map(quoteIdent).join(", ");
217
+ return { sql: `SELECT ${cols} FROM ${quoteIdent(table)} WHERE ${whereSql.join(" AND ")}`, params };
218
+ }
219
+ /** Map a driver row (column-name keyed) to a {@link KeyedRow} over the table's known columns. */
220
+ function rowToKeyed(row, meta) {
221
+ if (!row)
222
+ return undefined;
223
+ const out = {};
224
+ for (const c of meta.columns)
225
+ out[c] = row[c];
226
+ return out;
227
+ }
228
+ // --------------------------------------------------------------------------- backends + server tx
229
+ /** Thrown (wrapping the driver error) by a server tx's DB calls, so the seam can tell an INFRA
230
+ * failure (retry) from a mutator-body throw (business rejection). */
231
+ export class BackendError extends Error {
232
+ driverError;
233
+ constructor(driverError) {
234
+ super(driverError instanceof Error ? driverError.message : String(driverError));
235
+ this.name = "BackendError";
236
+ this.driverError = driverError;
237
+ }
238
+ }
239
+ /** The rindle/daemon server tx: logical writes render to SQLite and ACCUMULATE into one batch;
240
+ * raw `exec` accumulates too; `row` reads COMMITTED state through the daemon (no read-your-writes,
241
+ * the one interactive-txn limitation of the daemon backend). */
242
+ /** Build the compiler {@link Catalog} for ONE ast from the render index: columns/pk from the
243
+ * schema; relationship cardinality from the AST ITSELF — a Rindle relationship is declared at
244
+ * the query site (`sub(alias, rel)` / `.one()`), never on the schema, so the alias→cardinality
245
+ * map is inherently per-query. `columnTypes` are stubs: the sqlite dialect binds natives and
246
+ * never consults them (DAEMON-INTERACTIVE-TXN §5.4 — no casts). */
247
+ function catalogFor(render, root) {
248
+ const tables = {};
249
+ const ensure = (table) => {
250
+ const existing = tables[table];
251
+ if (existing)
252
+ return existing;
253
+ const meta = tableMeta(render, table);
254
+ const columnTypes = {};
255
+ for (const c of meta.columns)
256
+ columnTypes[c] = { type: "text", isEnum: false, isArray: false };
257
+ return (tables[table] = {
258
+ columns: [...meta.columns],
259
+ primaryKey: [...meta.pkNames],
260
+ columnTypes,
261
+ relationships: {},
262
+ });
263
+ };
264
+ const walkCondition = (cond) => {
265
+ if (!cond)
266
+ return;
267
+ if (cond.type === "and" || cond.type === "or") {
268
+ for (const c of cond.conditions)
269
+ walkCondition(c);
270
+ }
271
+ else if (cond.type === "correlatedSubquery") {
272
+ walkAst(cond.related.subquery);
273
+ }
274
+ };
275
+ const walkAst = (ast) => {
276
+ const t = ensure(ast.table);
277
+ for (const rel of ast.related ?? []) {
278
+ const alias = rel.subquery.alias;
279
+ if (alias != null)
280
+ t.relationships[alias] = rel.subquery.one === true ? "one" : "many";
281
+ walkAst(rel.subquery);
282
+ }
283
+ walkCondition(ast.where);
284
+ };
285
+ walkAst(root);
286
+ return { tables };
287
+ }
288
+ /** The control-flow unwind for a begin-absorbed replay (DAEMON-INTERACTIVE-TXN §4.1): thrown
289
+ * from the first read so the mutator body stops re-running an already-committed envelope; the
290
+ * backend answers with the tx's latched authoritative output. Never surfaces to users. */
291
+ class AbsorbedReplay extends Error {
292
+ constructor() {
293
+ super("mutation absorbed by mid dedup at session begin");
294
+ }
295
+ }
296
+ /**
297
+ * The daemon server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
298
+ * execution strategies. It starts ACCUMULATING — a pure-write mutator ships one batch to
299
+ * `/execute-sql-txn`, byte-identical to prior behavior — and LAZILY UPGRADES to an interactive
300
+ * mutation session at the mutator's first read: `begin` carries the envelope identity, the
301
+ * accumulated statement prefix (sound to replay — nothing before the first read observed DB
302
+ * state, §5.2), and the read itself, so a one-read mutator pays exactly one extra round trip.
303
+ * From then on reads run THROUGH the open transaction (read-your-writes — PG parity, and no
304
+ * read-then-write race) and writes buffer locally, flushing before the next read/commit: k
305
+ * reads cost k+2 round trips regardless of write count.
306
+ *
307
+ * Begin-time mid dedup can ABSORB the envelope (a redelivery whose commit response was lost):
308
+ * the replay output is latched on {@link DaemonLazyTx.absorbed} and {@link AbsorbedReplay}
309
+ * unwinds the body — the latch (not the throw) is authoritative, so a mutator that swallows
310
+ * the unwind still cannot re-apply (no session opened; buffered writes are never shipped).
311
+ * A daemon client without session support keeps the LEGACY committed-state point read.
312
+ */
313
+ class DaemonLazyTx {
314
+ /** Pre-upgrade: the accumulated batch/prefix. Post-upgrade: writes buffered for the next flush. */
315
+ stmts = [];
316
+ render;
317
+ daemon;
318
+ envelope;
319
+ sessionId;
320
+ /** The begin-absorbed replay output (§4.1), latched for the backend. */
321
+ absorbed;
322
+ idempotencyKey;
323
+ constructor(render, daemon, envelope) {
324
+ this.render = render;
325
+ this.daemon = daemon;
326
+ this.envelope = envelope;
327
+ }
328
+ /** True once the tx upgraded to an interactive session (the backend then commits it). */
329
+ get session() {
330
+ return this.sessionId !== undefined;
331
+ }
332
+ get statements() {
333
+ return this.stmts;
334
+ }
335
+ exec(sql, params = []) {
336
+ this.stmts.push({ sql, params });
337
+ }
338
+ push(op) {
339
+ const rendered = renderOp(op, tableMeta(this.render, op.table), sqliteDialect);
340
+ if (rendered)
341
+ this.stmts.push(rendered);
342
+ return Promise.resolve();
343
+ }
344
+ insert(table, row) {
345
+ return this.push({ kind: "insert", table, row });
346
+ }
347
+ update(table, row) {
348
+ return this.push({ kind: "update", table, row });
349
+ }
350
+ upsert(table, row) {
351
+ return this.push({ kind: "upsert", table, row });
352
+ }
353
+ insertIgnore(table, row) {
354
+ return this.push({ kind: "insertIgnore", table, row });
355
+ }
356
+ delete(table, pk) {
357
+ return this.push({ kind: "delete", table, pk });
358
+ }
359
+ async row(table, pk) {
360
+ const read = renderPointRead(table, pk, tableMeta(this.render, table), sqliteDialect);
361
+ const out = await this.readThroughTxn(read);
362
+ const cells = out.rows[0];
363
+ if (!cells)
364
+ return undefined;
365
+ const keyed = {}; // the daemon returns positional cells — zip with `cols`
366
+ out.cols.forEach((c, i) => (keyed[c] = cells[i]));
367
+ return keyed;
368
+ }
369
+ /** A full-shape read inside the open transaction (§5.4): compile to ONE SQLite `SELECT`
370
+ * (native bind params, no casts — SQLite is the canonical store), ride the session like
371
+ * `row` (including the lazy upgrade / begin ride-along), parse the single JSON cell. */
372
+ async query(q) {
373
+ const ast = typeof q.ast === "function" ? q.ast() : q;
374
+ const compiled = compileQueryAst(ast, catalogFor(this.render, ast), { dialect: "sqlite" });
375
+ const out = await this.readThroughTxn({ sql: compiled.sql, params: compiled.params });
376
+ const cell = out.rows[0]?.[0];
377
+ if (typeof cell !== "string")
378
+ return ast.one === true ? null : [];
379
+ return JSON.parse(cell);
380
+ }
381
+ /** Run one read: upgrade to a session at the first (§5.1), ride the open one after, or fall
382
+ * back to the legacy committed-state read when the daemon client lacks sessions. */
383
+ async readThroughTxn(read) {
384
+ if (this.absorbed)
385
+ throw new AbsorbedReplay();
386
+ if (!this.daemon.beginMutationSession) {
387
+ try {
388
+ return await this.daemon.executeSqlRead({ sql: read.sql, params: read.params });
389
+ }
390
+ catch (err) {
391
+ throw new BackendError(err);
392
+ }
393
+ }
394
+ try {
395
+ if (this.sessionId === undefined) {
396
+ const opened = await this.daemon.beginMutationSession({
397
+ clientID: this.envelope.clientID,
398
+ mid: this.envelope.mid,
399
+ statements: this.stmts.splice(0),
400
+ query: read,
401
+ });
402
+ if (opened.absorbed) {
403
+ const { absorbed: _a, sessionId: _s, read: _r, ...output } = opened;
404
+ this.absorbed = output;
405
+ throw new AbsorbedReplay();
406
+ }
407
+ if (!opened.sessionId || !opened.read) {
408
+ throw new Error(`malformed mutate-session begin reply: ${JSON.stringify(opened)}`);
409
+ }
410
+ this.sessionId = opened.sessionId;
411
+ return opened.read;
412
+ }
413
+ await this.flush();
414
+ return await this.daemon.queryInMutationSession({
415
+ sessionId: this.sessionId,
416
+ sql: read.sql,
417
+ params: read.params,
418
+ });
419
+ }
420
+ catch (err) {
421
+ if (err instanceof AbsorbedReplay || err instanceof BackendError)
422
+ throw err;
423
+ throw new BackendError(err);
424
+ }
425
+ }
426
+ /** Ship buffered writes into the open session, order-preserving; a no-op when none pend. */
427
+ async flush() {
428
+ if (this.stmts.length === 0)
429
+ return;
430
+ await this.daemon.execInMutationSession({
431
+ sessionId: this.sessionId,
432
+ statements: this.stmts.splice(0),
433
+ });
434
+ }
435
+ /** Flush + commit the open session — the daemon stamps lmid co-transactionally (§4.4) and
436
+ * answers the same shape `/execute-sql-txn` does. */
437
+ async commitSession() {
438
+ try {
439
+ await this.flush();
440
+ return await this.daemon.commitMutationSession({ sessionId: this.sessionId });
441
+ }
442
+ catch (err) {
443
+ throw err instanceof BackendError ? err : new BackendError(err);
444
+ }
445
+ }
446
+ /** Best-effort rollback (the daemon's deadline is the backstop). MUST be awaited before a
447
+ * follow-up `/reject-mutation`: that lmid-only commit needs the writer this session holds. */
448
+ async rollbackSessionQuietly() {
449
+ if (this.sessionId === undefined)
450
+ return;
451
+ const sessionId = this.sessionId;
452
+ this.sessionId = undefined;
453
+ try {
454
+ await this.daemon.rollbackMutationSession({ sessionId });
455
+ }
456
+ catch {
457
+ // Unreachable daemon / already-expired session: the deadline rollback covers it.
458
+ }
459
+ }
460
+ }
461
+ /** The Postgres server tx: a REAL interactive transaction. Logical writes render to `$n` and run
462
+ * LIVE against the open txn; raw `exec` runs live too (after `rewrite`); `row` reads the open txn
463
+ * (read-your-writes). Ops append to an internally-serialized chain so order holds even when a legacy
464
+ * sync mutator does not `await`; the backend drains the chain (`settle`) before the lmid upsert. */
465
+ class PgLiveTx {
466
+ chain = Promise.resolve();
467
+ stmts = [];
468
+ q;
469
+ render;
470
+ rewrite;
471
+ idempotencyKey;
472
+ constructor(q, render, rewrite) {
473
+ this.q = q;
474
+ this.render = render;
475
+ this.rewrite = rewrite;
476
+ }
477
+ get statements() {
478
+ return this.stmts;
479
+ }
480
+ settle() {
481
+ return this.chain;
482
+ }
483
+ execLive(sql, params) {
484
+ this.chain = this.chain.then(async () => {
485
+ try {
486
+ await this.q.exec(sql, params);
487
+ }
488
+ catch (err) {
489
+ throw new BackendError(err);
490
+ }
491
+ });
492
+ }
493
+ exec(sql, params = []) {
494
+ const stmt = { sql: this.rewrite(sql), params };
495
+ this.stmts.push(stmt);
496
+ this.execLive(stmt.sql, params);
497
+ }
498
+ write(op) {
499
+ let rendered;
500
+ try {
501
+ rendered = renderOp(op, tableMeta(this.render, op.table), postgresDialect);
502
+ }
503
+ catch (err) {
504
+ return Promise.reject(err); // a validation error (business rejection), synchronous shape
505
+ }
506
+ if (rendered)
507
+ this.execLive(rendered.sql, rendered.params ?? []);
508
+ return this.chain;
509
+ }
510
+ insert(table, row) {
511
+ return this.write({ kind: "insert", table, row });
512
+ }
513
+ update(table, row) {
514
+ return this.write({ kind: "update", table, row });
515
+ }
516
+ upsert(table, row) {
517
+ return this.write({ kind: "upsert", table, row });
518
+ }
519
+ insertIgnore(table, row) {
520
+ return this.write({ kind: "insertIgnore", table, row });
521
+ }
522
+ delete(table, pk) {
523
+ return this.write({ kind: "delete", table, pk });
524
+ }
525
+ async row(table, pk) {
526
+ const meta = tableMeta(this.render, table);
527
+ const read = renderPointRead(table, pk, meta, postgresDialect); // validates before draining
528
+ await this.settle(); // read-your-writes: drain queued writes first
529
+ let rows;
530
+ try {
531
+ rows = await this.q.query(read.sql, read.params);
532
+ }
533
+ catch (err) {
534
+ throw new BackendError(err);
535
+ }
536
+ return rowToKeyed(rows[0], meta);
537
+ }
538
+ query() {
539
+ // The compiler's postgres dialect ships (@rindle/query-compiler); what remains is the §7
540
+ // static-catalog + driver-pin wiring (POSTGRES-READ-COMPILER-DESIGN.md Phase B).
541
+ return Promise.reject(new Error("tx.query is not wired on the Postgres backend yet (POSTGRES-READ-COMPILER-DESIGN.md Phase B) — use tx.row for point reads meanwhile"));
542
+ }
543
+ }
544
+ /**
545
+ * The default {@link MutationBackend}. A pure-write mutator keeps the historical shape: writes
546
+ * ACCUMULATE and ship as ONE batch to `/execute-sql-txn`, which stamps `lmid` co-transactionally
547
+ * (and `/reject-mutation` advances it past a rejected mid) — byte-identical behavior. A
548
+ * READ-bearing mutator lazily upgrades to an interactive mutation session
549
+ * (DAEMON-INTERACTIVE-TXN-DESIGN.md): reads are read-your-writes through the open transaction
550
+ * (PG parity), the commit stamps `lmid` in the same atomic unit, and a begin-absorbed replay
551
+ * short-circuits without re-running the body.
552
+ */
553
+ export function daemonBackend(daemon) {
554
+ return {
555
+ dialect: sqliteDialect,
556
+ async runMutation({ envelope, render, run }) {
557
+ const tx = new DaemonLazyTx(render, daemon, envelope);
558
+ try {
559
+ await run(tx);
560
+ }
561
+ catch (err) {
562
+ // A begin-absorbed replay: the authoritative outcome already committed — answer it,
563
+ // whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
564
+ if (tx.absorbed)
565
+ return { accepted: true, output: tx.absorbed };
566
+ if (err instanceof BackendError) {
567
+ await tx.rollbackSessionQuietly();
568
+ throw err.driverError; // infra — never a user rejection
569
+ }
570
+ const reason = errMessage(err);
571
+ // Data first, watermark second: the rollback releases the single writer that the
572
+ // `/reject-mutation` lmid-only commit needs (§2.4 on the session path).
573
+ await tx.rollbackSessionQuietly();
574
+ const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
575
+ return { accepted: false, reason, output };
576
+ }
577
+ if (tx.absorbed)
578
+ return { accepted: true, output: tx.absorbed };
579
+ if (tx.session) {
580
+ try {
581
+ return { accepted: true, output: await tx.commitSession() };
582
+ }
583
+ catch (err) {
584
+ if (err instanceof BackendError)
585
+ throw err.driverError; // infra (client retries; dedup absorbs)
586
+ throw err;
587
+ }
588
+ }
589
+ const txn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
590
+ if (tx.idempotencyKey !== undefined)
591
+ txn.idempotencyKey = tx.idempotencyKey;
592
+ return { accepted: true, output: await daemon.executeSqlTxn(txn) };
593
+ },
594
+ reject({ envelope, reason }) {
595
+ return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
596
+ },
597
+ };
598
+ }
599
+ /** The §2.3 upsert, verbatim from the contract: monotonic via GREATEST, keyed by client. The
600
+ * identifiers are lowercase so quoting is cosmetic, but quote-everything is the repo's PG rule. */
601
+ const LMID_UPSERT = `INSERT INTO "_rindle_client_mutations" ("client_id", "last_mutation_id")
602
+ VALUES ($1, $2)
603
+ ON CONFLICT ("client_id") DO UPDATE
604
+ SET "last_mutation_id" = GREATEST("_rindle_client_mutations"."last_mutation_id", EXCLUDED."last_mutation_id")`;
605
+ /**
606
+ * The BYO-Postgres {@link MutationBackend} (`BYO-POSTGRES-LMID-CONTRACT-DESIGN.md` §6.3): one PG
607
+ * transaction runs the mutator's statements and ALWAYS upserts `_rindle_client_mutations` —
608
+ * the upsert sits outside any acceptance guard by construction, so the §2.4 footgun (a rejection
609
+ * that forgets to advance `lmid` and wedges the client's pending queue) cannot be written.
610
+ *
611
+ * Confirmation does NOT come from this call's response: the lmid row rides the same PG commit
612
+ * through CDC → relay → follower and reaches the client in the same coherent release as the
613
+ * data (§8.2 relocated upstream). A rejection's `reason` still returns on the HTTP reply, but
614
+ * nothing rejection-shaped travels the replication path — the optimistic prediction snaps back
615
+ * when the advanced `lmid` arrives.
616
+ */
617
+ export function postgresBackend(plugger, opts = {}) {
618
+ const rewrite = opts.rewriteSql ?? ((sql) => sql);
619
+ // A tagged business rejection escaping `plugger.transaction` — the plugger rolls the data back on
620
+ // any throw; this marker distinguishes "mutator said no" (reject) from an infra failure (rethrow).
621
+ class RejectSignal extends Error {
622
+ }
623
+ const lmidOnly = (envelope) => plugger.transaction(async (q) => {
624
+ await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
625
+ return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
626
+ });
627
+ return {
628
+ dialect: postgresDialect,
629
+ async runMutation({ envelope, render, run }) {
630
+ try {
631
+ const output = await plugger.transaction(async (q) => {
632
+ const tx = new PgLiveTx(q, render, rewrite);
633
+ try {
634
+ await run(tx);
635
+ await tx.settle(); // drain any un-awaited queued writes before the lmid stamp
636
+ }
637
+ catch (err) {
638
+ if (err instanceof BackendError)
639
+ throw err; // infra → rollback + propagate
640
+ throw new RejectSignal(errMessage(err)); // business → rollback data, tag for §2.4
641
+ }
642
+ // ALWAYS on the accepted path, SAME transaction (§2.2): the lmid upsert commits with data.
643
+ await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
644
+ return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
645
+ });
646
+ return { accepted: true, output };
647
+ }
648
+ catch (err) {
649
+ if (err instanceof RejectSignal) {
650
+ // Data rolled back; STILL advance lmid alone (§2.4 — the client's queue must drain).
651
+ return { accepted: false, reason: err.message, output: await lmidOnly(envelope) };
652
+ }
653
+ if (err instanceof BackendError)
654
+ throw err.driverError; // infra
655
+ throw err;
656
+ }
657
+ },
658
+ reject: ({ envelope }) => lmidOnly(envelope).then(() => undefined),
659
+ };
660
+ }
661
+ /** Adapt a node-postgres `Pool` (or anything pool-shaped) to a {@link PostgresPlugger}:
662
+ * one client per transaction, `BEGIN`/`COMMIT` bracketing, `ROLLBACK` + rethrow on failure. */
663
+ export function pgPoolPlugger(pool) {
664
+ return {
665
+ async transaction(fn) {
666
+ const client = await pool.connect();
667
+ try {
668
+ await client.query("BEGIN");
669
+ const q = {
670
+ exec: async (sql, params) => {
671
+ await client.query(sql, params);
672
+ },
673
+ query: async (sql, params) => (await client.query(sql, params)).rows,
674
+ };
675
+ const out = await fn(q);
676
+ await client.query("COMMIT");
677
+ return out;
678
+ }
679
+ catch (err) {
680
+ try {
681
+ await client.query("ROLLBACK");
682
+ }
683
+ catch {
684
+ // the connection may already be unusable; the original error is the one that matters
685
+ }
686
+ throw err;
687
+ }
688
+ finally {
689
+ client.release();
690
+ }
691
+ },
692
+ };
693
+ }
694
+ /**
695
+ * Rewrite SQLite-style `?` positional placeholders to Postgres `$1..$n`, for mutators written
696
+ * once and run against either backend (pass as {@link PostgresBackendOptions.rewriteSql}).
697
+ * Skips `'…'` string literals (with `''` escapes), `"…"` quoted identifiers, `--` line comments,
698
+ * and non-nested C-style block comments. Do not mix `?` and `$n` styles in one statement.
699
+ */
700
+ export function questionToDollarParams(sql) {
701
+ let out = "";
702
+ let n = 0;
703
+ let i = 0;
704
+ while (i < sql.length) {
705
+ const c = sql[i];
706
+ if (c === "?") {
707
+ out += `$${++n}`;
708
+ i += 1;
709
+ }
710
+ else if (c === "'" || c === '"') {
711
+ // consume the quoted span; a doubled quote is an escape inside it
712
+ const quote = c;
713
+ let j = i + 1;
714
+ while (j < sql.length) {
715
+ if (sql[j] === quote) {
716
+ if (sql[j + 1] === quote)
717
+ j += 2;
718
+ else
719
+ break;
720
+ }
721
+ else {
722
+ j += 1;
723
+ }
724
+ }
725
+ out += sql.slice(i, j + 1);
726
+ i = j + 1;
727
+ }
728
+ else if (c === "-" && sql[i + 1] === "-") {
729
+ const end = sql.indexOf("\n", i);
730
+ const j = end === -1 ? sql.length : end;
731
+ out += sql.slice(i, j);
732
+ i = j;
733
+ }
734
+ else if (c === "/" && sql[i + 1] === "*") {
735
+ const end = sql.indexOf("*/", i + 2);
736
+ const j = end === -1 ? sql.length : end + 2;
737
+ out += sql.slice(i, j);
738
+ i = j;
739
+ }
740
+ else {
741
+ out += c;
742
+ i += 1;
743
+ }
744
+ }
745
+ return out;
746
+ }
71
747
  export function defineApiQueries(queries) {
72
748
  return queries;
73
749
  }
@@ -102,15 +778,124 @@ export function registerQueries(queries) {
102
778
  export function defineApiMutators(mutators) {
103
779
  return mutators;
104
780
  }
781
+ /**
782
+ * Bulk-register a SHARED (generator) mutator registry as server mutators — the mutator twin of
783
+ * {@link registerQueries} (which does the same for co-located `defineQuery` values). Each shared
784
+ * mutator carries its own arg validator (`shared(schema, gen)`), so this wraps every one with the
785
+ * UNIVERSAL server triad and nothing else: parse the UNTRUSTED wire args (its `.args`), map the server
786
+ * {@link MutationContext} to the shared {@link MutatorCtx} principal, and drive the SAME body the
787
+ * client predicts ({@link runSharedMutation}). The point is that a shared mutator whose server run
788
+ * adds NO authority beyond that triad needs no hand-written wrapper.
789
+ *
790
+ * Server-only AUTHORITY the client cannot predict (a title guard, an owner-gated cascade, a
791
+ * `NOT EXISTS` dedup) stays an explicit {@link ApiMutator} that OVERRIDES the auto-wrapped default —
792
+ * spread this first, then the overrides win by key:
793
+ *
794
+ * ```ts
795
+ * mutators: defineApiMutators({
796
+ * ...sharedApiMutators(sharedMutators, (ctx) => ({ user: requireUser(ctx.user) })),
797
+ * createIssue: withTitleGuard(sharedMutators.createIssue), // + server-only policy
798
+ * deleteIssue: async (tx, raw, ctx) => { ... }, // raw owner-gated cascade
799
+ * }),
800
+ * ```
801
+ */
802
+ export function sharedApiMutators(registry, principal) {
803
+ const out = {};
804
+ for (const [name, mutator] of Object.entries(registry)) {
805
+ out[name] = (tx, raw, ctx) => runSharedMutation(mutator, mutator.args.parse(raw), principal(ctx), tx);
806
+ }
807
+ return out;
808
+ }
105
809
  export function queryResultToAst(result) {
106
810
  if (result && typeof result === "object" && "ast" in result && typeof result.ast === "function") {
107
811
  return result.ast();
108
812
  }
109
813
  return result;
110
814
  }
815
+ /**
816
+ * Dump every registered named query's wire AST — feeder 1 ("exemplar enumeration") of
817
+ * `rindle indices suggest` (docs/INDEXING.md applied mechanically to the query set).
818
+ *
819
+ * Because named queries are FUNCTIONS of `(args, ctx)`, one query can build structurally
820
+ * different ASTs on different args; each exemplar invocation contributes its shape, and shapes
821
+ * that differ only in literal values (a limit, a filter string) dedupe to one entry. A query
822
+ * with no configured exemplars is invoked once with no args. The registry is the app's whole
823
+ * server-side query surface, so the resulting document is the complete static shape set —
824
+ * modulo arg-value-dependent branches, which need an exemplar (or runtime shape recording) to
825
+ * surface.
826
+ */
827
+ export async function dumpQueryShapes(opts) {
828
+ const tables = Object.values(opts.schema.tables)
829
+ // Local-only tables live in the browser's memory source, never a TableSource — no indexes.
830
+ .filter((t) => t.local !== true)
831
+ .map((t) => ({ name: t.name, primaryKey: [...t.primaryKey] }))
832
+ .sort((a, b) => a.name.localeCompare(b.name));
833
+ const queries = [];
834
+ for (const [name, query] of Object.entries(opts.queries).sort(([a], [b]) => a.localeCompare(b))) {
835
+ const seen = new Set();
836
+ for (const ex of opts.exemplars?.[name] ?? [{}]) {
837
+ const ast = queryResultToAst(await query({ user: ex.user }, ex.args));
838
+ const key = JSON.stringify(normalizeShape(ast));
839
+ if (seen.has(key))
840
+ continue;
841
+ seen.add(key);
842
+ queries.push({ name: seen.size > 1 ? `${name}#${seen.size}` : name, ast });
843
+ }
844
+ }
845
+ return { tables, queries };
846
+ }
847
+ /** The literal-stripped structure of an AST — the dedupe key for {@link dumpQueryShapes}. */
848
+ function normalizeShape(ast) {
849
+ return {
850
+ table: ast.table,
851
+ where: ast.where && normalizeCondition(ast.where),
852
+ related: ast.related?.map((r) => ({
853
+ correlation: r.correlation,
854
+ subquery: normalizeShape(r.subquery),
855
+ })),
856
+ orderBy: ast.orderBy,
857
+ limit: ast.limit !== undefined,
858
+ start: ast.start
859
+ ? { keys: Object.keys(ast.start.row).sort(), exclusive: ast.start.exclusive }
860
+ : undefined,
861
+ aggregate: ast.aggregate,
862
+ groupBy: ast.groupBy,
863
+ having: ast.having && normalizeCondition(ast.having),
864
+ one: ast.one,
865
+ };
866
+ }
867
+ function normalizeCondition(c) {
868
+ switch (c.type) {
869
+ case "simple":
870
+ return {
871
+ type: c.type,
872
+ op: c.op,
873
+ left: c.left,
874
+ right: c.right.type === "literal" ? { type: "literal" } : c.right,
875
+ };
876
+ case "and":
877
+ case "or":
878
+ return { type: c.type, conditions: c.conditions.map(normalizeCondition) };
879
+ case "correlatedSubquery":
880
+ return {
881
+ type: c.type,
882
+ op: c.op,
883
+ related: {
884
+ correlation: c.related.correlation,
885
+ subquery: normalizeShape(c.related.subquery),
886
+ },
887
+ };
888
+ }
889
+ }
111
890
  export function createRindleApiServer(opts) {
112
891
  const routes = { ...DEFAULT_RINDLE_API_ROUTES, ...opts.routes };
113
892
  const mode = opts.mode ?? "normalized";
893
+ // The mutation seam — defaulting to the daemon's co-transactional lmid stamp (unchanged behavior).
894
+ const backend = opts.backend ?? daemonBackend(opts.daemon);
895
+ // Schema-derived render metadata for logical mutator writes; `{}` when no schema is configured (a
896
+ // logical op then throws loudly — the tx never silently drops a write). Each backend renders in its
897
+ // own dialect (`backend.dialect`: daemon→sqlite, postgres→postgres).
898
+ const renderIndex = opts.schema ? buildRenderIndex(opts.schema) : {};
114
899
  // Names that are ALSO configured pins — a lease for one is forced to a `pinned` policy (the lazy
115
900
  // floor, §4.1) so the first viewer to route to a follower warms it for late joiners.
116
901
  const pinnedNames = new Set((opts.pinnedQueries ?? []).map((p) => p.name));
@@ -182,33 +967,37 @@ export function createRindleApiServer(opts) {
182
967
  const pushMutation = async (input) => {
183
968
  const context = { user: input.user, request: input.request };
184
969
  const mutator = opts.mutators?.[input.envelope.name];
970
+ // PRE-FLIGHT rejections — no txn, no data, `lmid` alone (the queue must still drain).
185
971
  if (!mutator)
186
- return reject(opts.daemon, input.envelope, `unknown mutator: ${input.envelope.name}`);
187
- let sql;
972
+ return reject(backend, input.envelope, `unknown mutator: ${input.envelope.name}`);
188
973
  try {
189
974
  await assertAuthorized(opts.authorizeMutation, {
190
975
  user: input.user,
191
976
  envelope: input.envelope,
192
977
  context,
193
978
  });
194
- const tx = new CollectingSqlMutationTx();
195
- const result = await mutator(tx, input.envelope.args, {
196
- user: input.user,
197
- envelope: input.envelope,
198
- daemon: opts.daemon,
199
- request: input.request,
200
- });
201
- sql = mutationResultToSqlTxn(result, tx);
202
979
  }
203
980
  catch (err) {
204
- return reject(opts.daemon, input.envelope, String(err?.message ?? err));
981
+ return reject(backend, input.envelope, errMessage(err));
205
982
  }
206
- const output = await opts.daemon.executeSqlTxn({
207
- ...sql,
208
- clientID: input.envelope.clientID,
209
- mid: input.envelope.mid,
983
+ // Run the mutator INSIDE the backend's transaction. A throw from the mutator body is a business
984
+ // rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
985
+ const outcome = await backend.runMutation({
986
+ envelope: input.envelope,
987
+ render: renderIndex,
988
+ run: async (tx) => {
989
+ const result = await mutator(tx, input.envelope.args, {
990
+ user: input.user,
991
+ envelope: input.envelope,
992
+ daemon: opts.daemon,
993
+ request: input.request,
994
+ });
995
+ applyResultToTx(result, tx);
996
+ },
210
997
  });
211
- return { accepted: true, rejected: false, output };
998
+ if (outcome.accepted)
999
+ return { accepted: true, rejected: false, output: outcome.output };
1000
+ return { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
212
1001
  };
213
1002
  const pushMutations = async (input) => {
214
1003
  const out = [];
@@ -267,6 +1056,29 @@ export function createRindleApiServer(opts) {
267
1056
  throw new Error(`assertPins: ${failures.length} failed — ${failures.join("; ")}`);
268
1057
  }
269
1058
  };
1059
+ // The room write-authority gate (§5.3.1): endpoints are disabled until the app
1060
+ // opts in with `authorizeRoom` — hosting an authority is never a default.
1061
+ const roomGate = async (context) => {
1062
+ if (!opts.authorizeRoom) {
1063
+ throw new RindleApiError("forbidden", "room authority not configured", 403);
1064
+ }
1065
+ await assertAuthorized(opts.authorizeRoom, context);
1066
+ };
1067
+ // The store's verdict rides specific statuses + body shapes (fence / conflict /
1068
+ // identity) the room decodes — pass a daemon HTTP error through VERBATIM.
1069
+ const daemonVerdict = (e) => {
1070
+ if (e instanceof DaemonHttpError) {
1071
+ let body;
1072
+ try {
1073
+ body = JSON.parse(e.body);
1074
+ }
1075
+ catch {
1076
+ body = { error: e.body };
1077
+ }
1078
+ return { status: e.status, body };
1079
+ }
1080
+ throw e;
1081
+ };
270
1082
  return {
271
1083
  routes,
272
1084
  createQueryLease,
@@ -274,6 +1086,49 @@ export function createRindleApiServer(opts) {
274
1086
  assertPins,
275
1087
  pushMutation,
276
1088
  pushMutations,
1089
+ handleApplyRowChangeTxnJson: async (body, context) => {
1090
+ await roomGate(context);
1091
+ const msg = parseObject(body, "row-change txn");
1092
+ try {
1093
+ const out = await opts.daemon.applyRowChangeTxn(msg);
1094
+ return { status: 200, body: out };
1095
+ }
1096
+ catch (e) {
1097
+ return daemonVerdict(e);
1098
+ }
1099
+ },
1100
+ handleClaimRoomEpochJson: async (body, context) => {
1101
+ await roomGate(context);
1102
+ const msg = parseObject(body, "claim-room-epoch request");
1103
+ const doc = parseString(msg.doc, "doc");
1104
+ const claim = opts.daemon.claimRoomEpoch?.bind(opts.daemon);
1105
+ if (!claim) {
1106
+ throw new Error("the configured daemon client does not implement claimRoomEpoch");
1107
+ }
1108
+ try {
1109
+ return { status: 200, body: await claim({ doc }) };
1110
+ }
1111
+ catch (e) {
1112
+ return daemonVerdict(e);
1113
+ }
1114
+ },
1115
+ handleRoomLmidsJson: async (body, context) => {
1116
+ await roomGate(context);
1117
+ const msg = parseObject(body, "room-lmids request");
1118
+ if (!Array.isArray(msg.clients) || msg.clients.some((c) => typeof c !== "string")) {
1119
+ throw new RindleApiError("bad-request", "clients must be an array of strings", 400);
1120
+ }
1121
+ const lmids = opts.daemon.roomLmids?.bind(opts.daemon);
1122
+ if (!lmids) {
1123
+ throw new Error("the configured daemon client does not implement roomLmids");
1124
+ }
1125
+ try {
1126
+ return { status: 200, body: await lmids({ clients: msg.clients }) };
1127
+ }
1128
+ catch (e) {
1129
+ return daemonVerdict(e);
1130
+ }
1131
+ },
277
1132
  handleQueryJson: (body, context) => {
278
1133
  const msg = parseObject(body, "query request");
279
1134
  return createQueryLease({
@@ -308,22 +1163,48 @@ export function createRindleApiServer(opts) {
308
1163
  },
309
1164
  };
310
1165
  }
311
- class CollectingSqlMutationTx {
312
- stmts = [];
313
- get statements() {
314
- return this.stmts;
315
- }
316
- exec(sql, params = []) {
317
- this.stmts.push({ sql, params });
1166
+ /** Run a SHARED generator mutator (the SAME body the client predicts) against a live server
1167
+ * transaction (MUTATORS-ISOMORPHIC): bind the tier-agnostic {@link isoTx} factory and drive it —
1168
+ * each yielded logical op renders + runs against `tx` (dialect SQL, per backend), each `tx.row`
1169
+ * suspends for read-your-writes, and `tx.all` fans out. A server mutator uses this to delegate its
1170
+ * write body after parsing untrusted args and applying its server-only authority (principal, policy).
1171
+ * A mutator-body throw remains a business rejection; a DB failure propagates as infra. */
1172
+ export function runSharedMutation(mutator, args, ctx, tx) {
1173
+ return driveMutationAsync(mutator(isoTx, args, ctx), {
1174
+ apply: (op) => applyOpToServerTx(tx, op),
1175
+ read: (table, pk) => tx.row(table, pk),
1176
+ });
1177
+ }
1178
+ /** Run one logical {@link MutationOp} (yielded by a shared generator mutator) against the live server
1179
+ * write surface — the SAME async methods a plain async mutator calls (they render dialect SQL and
1180
+ * execute/accumulate per backend). */
1181
+ function applyOpToServerTx(tx, op) {
1182
+ switch (op.kind) {
1183
+ case "insert":
1184
+ return tx.insert(op.table, op.row);
1185
+ case "upsert":
1186
+ return tx.upsert(op.table, op.row);
1187
+ case "insertIgnore":
1188
+ return tx.insertIgnore(op.table, op.row);
1189
+ case "update":
1190
+ return tx.update(op.table, op.row);
1191
+ case "delete":
1192
+ return tx.delete(op.table, op.pk);
318
1193
  }
319
1194
  }
320
- function mutationResultToSqlTxn(result, tx) {
321
- if (Array.isArray(result))
322
- return { statements: result };
323
- if (result && typeof result === "object" && "statements" in result) {
324
- return { statements: result.statements, idempotencyKey: result.idempotencyKey };
1195
+ /** Feed a mutator's RETURNED result (the alternative to calling `tx.exec`/logical ops directly) into
1196
+ * the backend tx: a returned `SqlStatement[]` / `SqlTxn` is exec'd onto `tx`, and a carried
1197
+ * `idempotencyKey` is stashed (the daemon backend honors it; PG ignores it). A `void` return is a
1198
+ * no-op the mutator already drove the tx. Preserves the pre-existing return-style contract. */
1199
+ function applyResultToTx(result, tx) {
1200
+ if (!result)
1201
+ return;
1202
+ const statements = Array.isArray(result) ? result : result.statements;
1203
+ for (const s of statements)
1204
+ tx.exec(s.sql, s.params);
1205
+ if (!Array.isArray(result) && result.idempotencyKey !== undefined) {
1206
+ tx.idempotencyKey = result.idempotencyKey;
325
1207
  }
326
- return { statements: [...tx.statements] };
327
1208
  }
328
1209
  async function assertAuthorized(authorizer, input) {
329
1210
  if (!authorizer)
@@ -364,8 +1245,8 @@ function queryLeaseResponse(out) {
364
1245
  function errMessage(reason) {
365
1246
  return String(reason?.message ?? reason);
366
1247
  }
367
- async function reject(daemon, envelope, reason) {
368
- const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
1248
+ async function reject(backend, envelope, reason) {
1249
+ const output = await backend.reject({ envelope, reason });
369
1250
  return { accepted: false, rejected: true, reason, output };
370
1251
  }
371
1252
  function parseObject(value, label) {