botversion-sdk 2.3.0 → 2.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botversion-sdk",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "description": "BotVersion SDK: auto-detect and register your API endpoints, with undo for AI actions",
5
5
  "main": "index.js",
6
6
  "bin": {
package/undo/index.js CHANGED
@@ -50,7 +50,7 @@ function init(options) {
50
50
  // 2. Look at the host project.
51
51
  const scan = scanner.scan();
52
52
 
53
- // 3. Database hooks. Only the ones that match what the host has.
53
+ // 3. Database hooks. Only the ones that match what the host has..
54
54
  // (Final SDK: more hooks are added to this same list.)
55
55
  const pgCopies = pgHook.attach(scan.hostRoot);
56
56
  const mysql2Copies = mysql2Hook.attach(scan.hostRoot);
@@ -602,13 +602,6 @@ async function runCapture(client, run, stmt, ref, kind, st, execHost) {
602
602
  let phase = "before"; // "before" = the write has not run yet, so it can still be blocked
603
603
  let failedWhy = null;
604
604
  const notSaved = (why) => {
605
- log.debug(
606
- "mysql notSaved:",
607
- "table=" + name,
608
- "phase=" + phase,
609
- "reason=" + why,
610
- "confirmed=" + st.confirmed,
611
- );
612
605
  if (phase === "before") failedWhy = failedWhy || why;
613
606
  if (phase !== "before" || st.confirmed) st.unsaved = true; // the write runs (or already ran) without a saved snapshot
614
607
  };
@@ -640,17 +633,6 @@ async function runCapture(client, run, stmt, ref, kind, st, execHost) {
640
633
  else plan = planInsert(stmt, pk);
641
634
  }
642
635
  } catch (e) {
643
- // TEMP DEBUG: real cause behind "capture error"
644
- log.debug(
645
- "mysql capture threw:",
646
- "table=" + name,
647
- "kind=" + kind,
648
- "code=" + ((e && e.code) || "none"),
649
- "name=" + (e && e.name),
650
- !(e && e.code)
651
- ? "msg=" + String((e && e.message) || "").slice(0, 150)
652
- : "",
653
- );
654
636
  notSaved(
655
637
  e instanceof Skip
656
638
  ? e.message
@@ -811,12 +793,6 @@ function maybeCapture(client, args, original, queryOriginal) {
811
793
 
812
794
  // A write that cannot be saved: blocked until the user confirms (or runs unsaved if they already did).
813
795
  const refuse = (table, why) => {
814
- log.debug(
815
- "mysql refuse (statement shape):",
816
- "table=" + table,
817
- "reason=" + why,
818
- "confirmed=" + st.confirmed,
819
- );
820
796
  if (st.confirmed) {
821
797
  st.unsaved = true; // runs without a saved snapshot, so Undo will not cover it
822
798
  return null;
@@ -121,6 +121,8 @@ const pkWhere = (info, row) => {
121
121
 
122
122
  // A top-level create or update whose relation fields only use connect (or disconnect, update only), and whose
123
123
  // links the database undoes by itself or that are plain columns on the written row. Anything unclear = false.
124
+ // A top-level create or update whose relation fields only use connect (or disconnect, update only), where the
125
+ // link is a plain column on the written row, or (create only) a hidden join table that cascades away. Anything unclear = false.
124
126
  async function nestedConnectOnly(
125
127
  base,
126
128
  model,
@@ -161,17 +163,22 @@ async function nestedConnectOnly(
161
163
  !(operation === "update" && keys[0] === "disconnect")
162
164
  )
163
165
  return false;
164
- // A hidden join table is only safe for create (the database removes its rows when the new row is deleted).
165
- if (
166
- operation === "create" &&
167
- structure.hiddenJoinIsCascade(
168
- base,
169
- f.relationName,
170
- tableOf(model),
171
- tableOf(f.type),
166
+ // Decided per relation: a hidden join table (or an unknown answer) is safe only for a create whose join rows cascade away.
167
+ const joined = structure.hasHiddenJoin(base, f.relationName);
168
+ if (joined !== false) {
169
+ if (
170
+ joined === true &&
171
+ operation === "create" &&
172
+ structure.hiddenJoinIsCascade(
173
+ base,
174
+ f.relationName,
175
+ tableOf(model),
176
+ tableOf(f.type),
177
+ )
172
178
  )
173
- )
174
- continue;
179
+ continue;
180
+ return false;
181
+ }
175
182
  const here = (g.get(f.type) || []).some((l) => l.child === model);
176
183
  const there = (g.get(model) || []).some((l) => l.child === f.type);
177
184
  if (here && !there) continue; // link is only a column on the written row
@@ -200,24 +207,6 @@ async function capture({
200
207
  }) {
201
208
  const st = tagger.getCurrent();
202
209
  if (!st) return null;
203
- // TEMP DEBUG: what the Prisma hook handed to capture() (names and types only, never values)
204
- log.debug(
205
- "prisma capture called:",
206
- "model=" + model,
207
- "op=" + operation,
208
- "dialect=" + String(dialect) + " (type " + typeof dialect + ")",
209
- "nested=" + !!nested,
210
- "internalType=" + typeof internal,
211
- "internalKeys=" +
212
- (internal && typeof internal === "object"
213
- ? Object.keys(internal).join("|")
214
- : "none"),
215
- "inHostTransaction=" + !!(internal && internal.transaction),
216
- "baseActiveProvider=" + String(base && base._activeProvider),
217
- "baseEngineProvider=" +
218
- String(base && base._engineConfig && base._engineConfig.activeProvider),
219
- "baseHasDataModel=" + !!(base && base._runtimeDataModel),
220
- );
221
210
  // FIX: take the database type from the live client when the hook did not supply one (a schema file read from disk can be missing or elsewhere).
222
211
  if (!dialect) dialect = dialectFromPrismaClient(base);
223
212
  // FIX: some Prisma versions carry no key markers or delete rules at runtime; ask the database (once per client) for them.
@@ -225,9 +214,7 @@ async function capture({
225
214
  const m0 = modelInfo(base, model);
226
215
  const isDelete = operation === "delete" || operation === "deleteMany";
227
216
  if ((m0 && m0.pkFields.length === 0) || (isDelete && !getGraph(base))) {
228
- log.debug("prisma structure load starting:", "model=" + model);
229
217
  const loadedOk = await structure.load(base, dialect);
230
- log.debug("prisma structure load result:", "ok=" + loadedOk);
231
218
  }
232
219
  }
233
220
  // TEMP DEBUG: writes only, to avoid noise. Shows the type finally used, and whether relation rules exist on the live client.
@@ -241,13 +228,6 @@ async function capture({
241
228
  rm && Array.isArray(rm.fields)
242
229
  ? rm.fields.find((f) => f.kind === "object")
243
230
  : null;
244
- log.debug(
245
- "prisma write seen:",
246
- "model=" + model,
247
- "op=" + operation,
248
- "dialectUsed=" + String(dialect),
249
- "relationFieldKeys=" + (rel ? Object.keys(rel).join("|") : "none"),
250
- );
251
231
  // Logs the hook's nested flag next to the shape of the call (names only, never values).
252
232
  try {
253
233
  const d1 = args && args.data;
@@ -266,15 +246,6 @@ async function capture({
266
246
  )
267
247
  .map((f) => f.name)
268
248
  : [];
269
- log.debug(
270
- "prisma nested flag vs call shape:",
271
- "model=" + model,
272
- "op=" + operation,
273
- "nested=" + !!nested,
274
- "relationKeysInData=" + (relKeys.join("|") || "none"),
275
- "hasInclude=" + !!(args && args.include),
276
- "hasSelect=" + !!(args && args.select),
277
- );
278
249
  } catch (_) {
279
250
  /* never break the host */
280
251
  }
@@ -285,30 +256,6 @@ async function capture({
285
256
  (f) => f.kind === "scalar" || f.kind === "enum",
286
257
  );
287
258
  const mi = modelInfo(base, model);
288
- log.debug(
289
- "prisma model detail:",
290
- "model=" + model,
291
- "modelKeys=" + (rm ? Object.keys(rm).join("|") : "none"),
292
- "primaryKeyProp=" +
293
- (rm && rm.primaryKey ? JSON.stringify(rm.primaryKey) : "none"),
294
- "uniqueFields=" +
295
- (rm && rm.uniqueFields ? JSON.stringify(rm.uniqueFields) : "none"),
296
- "fieldCount=" + fields.length,
297
- "scalarCount=" + scalarFields.length,
298
- "fieldsMarkedIsId=" + fields.filter((f) => f.isId).length,
299
- "fieldNamedId=" + (idNamed ? Object.keys(idNamed).join("|") : "none"),
300
- "firstScalarKeys=" +
301
- (scalarFields[0] ? Object.keys(scalarFields[0]).join("|") : "none"),
302
- "modelInfoPk=" + (mi ? JSON.stringify(mi.pkFields) : "no model info"),
303
- );
304
- // TEMP DEBUG: also reads the delete links now, so the next possible block shows up in this same run
305
- if (operation === "delete" || operation === "deleteMany") {
306
- const g0 = getGraph(base);
307
- log.debug(
308
- "prisma delete graph:",
309
- g0 ? "readOk models=" + g0.size : "COULD NOT BE READ",
310
- );
311
- }
312
259
  // TEMP DEBUG (once per process): primary-key detection for EVERY model, so no second model blocks the same action later
313
260
  if (!capture.audited) {
314
261
  capture.audited = true;
@@ -319,25 +266,11 @@ async function capture({
319
266
  const info2 = modelInfo(base, n);
320
267
  return !info2 || info2.pkFields.length === 0;
321
268
  });
322
- log.debug(
323
- "prisma model audit:",
324
- "models=" + names.length,
325
- "withoutPrimaryKeyDetected=" + noPk.length,
326
- "names=" + noPk.slice(0, 60).join("|"),
327
- "anyFieldMarkedIsId=" +
328
- names.some((n) => (all[n].fields || []).some((f) => f.isId)),
329
- );
330
269
  }
331
270
  // TEMP DEBUG: does the Undo side find the same primary key for this table?
332
271
  try {
333
272
  const ui = require("./undo-prisma").infoFor(mi ? mi.table : model);
334
- log.debug(
335
- "prisma undo-side model info:",
336
- ui ? "pk=" + JSON.stringify(ui.pkFields) : "NOT FOUND",
337
- );
338
- } catch (e) {
339
- log.debug("prisma undo-side model info failed:", "name=" + (e && e.name));
340
- }
273
+ } catch (e) {}
341
274
  }
342
275
  if (
343
276
  UNSAVABLE.has(operation) ||
@@ -352,15 +285,10 @@ async function capture({
352
285
  );
353
286
  return null;
354
287
  }
355
- if (!model || !OPS.has(operation)) return null;
288
+ if (!model || !OPS.has(operation)) {
289
+ return null;
290
+ }
356
291
  const skip = async (why) => {
357
- log.debug(
358
- "prisma skip:",
359
- "model=" + model,
360
- "op=" + operation,
361
- "reason=" + why,
362
- "confirmed=" + st.confirmed,
363
- );
364
292
  if (st.confirmed) st.unsaved = true; // runs without a saved snapshot, so Undo will not cover it
365
293
  if (!st.confirmed) await gate.block(st, `${model} ${operation}`, why);
366
294
  return null;
@@ -398,12 +326,6 @@ async function capture({
398
326
  Object.keys(d[k]).some((v) => verbs.includes(v)),
399
327
  )
400
328
  .map((k) => k + ":" + Object.keys(d[k]).join("+"));
401
- log.debug(
402
- "prisma nested write shape:",
403
- "model=" + model,
404
- "op=" + operation,
405
- shape.join(" | ") || "none found in data",
406
- );
407
329
  } catch (_) {
408
330
  /* never break the host */
409
331
  }
@@ -432,13 +354,6 @@ async function capture({
432
354
  onOther
433
355
  );
434
356
  });
435
- log.debug(
436
- "prisma nested relation detail:",
437
- "model=" + model,
438
- "argKeys=" + Object.keys(args || {}).join("|"),
439
- out.join(" | ") || "none",
440
- "graphAvailable=" + !!g,
441
- );
442
357
  } catch (_) {
443
358
  /* never break the host */
444
359
  }
@@ -524,47 +439,8 @@ async function capture({
524
439
  "]"
525
440
  );
526
441
  });
527
- log.debug(
528
- "prisma nested relation detail:",
529
- "model=" + model,
530
- "op=" + operation,
531
- "graphAvailable=" + !!g,
532
- "argKeys=" + Object.keys(args || {}).join("|"),
533
- "includeKeys=" +
534
- (plain(args && args.include)
535
- ? Object.keys(args.include).join("|")
536
- : "none"),
537
- "selectKeys=" +
538
- (plain(args && args.select)
539
- ? Object.keys(args.select).join("|")
540
- : "none"),
541
- );
542
442
  lines.forEach((l) => log.debug("prisma nested relation field:", l));
543
- // the call details the hook passed in, in case they explain why nested=true
544
- log.debug(
545
- "prisma nested hook inputs:",
546
- "dataPathIsArray=" + Array.isArray(internal && internal.dataPath),
547
- "dataPathLength=" +
548
- (Array.isArray(internal && internal.dataPath)
549
- ? internal.dataPath.length
550
- : "n/a"),
551
- "dataPath=" +
552
- (Array.isArray(internal && internal.dataPath)
553
- ? internal.dataPath.join(">")
554
- : "n/a"),
555
- "action=" + String(internal && internal.action),
556
- "clientMethod=" + String(internal && internal.clientMethod),
557
- "internalArgsKeys=" +
558
- (plain(internal && internal.args)
559
- ? Object.keys(internal.args).join("|")
560
- : "n/a"),
561
- );
562
- } catch (e) {
563
- log.debug(
564
- "prisma nested relation detail failed:",
565
- "name=" + (e && e.name),
566
- );
567
- }
443
+ } catch (e) {}
568
444
  // Logs the foreign keys of any hidden join table behind each relation field in the write (names and rules only).
569
445
  try {
570
446
  const relFields = base._runtimeDataModel.models[model].fields.filter(
@@ -576,21 +452,8 @@ async function capture({
576
452
  );
577
453
  relFields.forEach((f) => {
578
454
  const links = structure.linksOfTablesMentioning(base, f.relationName);
579
- log.debug(
580
- "prisma nested join table links:",
581
- "field=" + f.name,
582
- "relationName=" + f.relationName,
583
- links.length
584
- ? links.join(" ; ")
585
- : "no table named like this relation has foreign keys",
586
- );
587
455
  });
588
- } catch (e) {
589
- log.debug(
590
- "prisma nested join table links failed:",
591
- "name=" + (e && e.name),
592
- );
593
- }
456
+ } catch (e) {}
594
457
  if (
595
458
  !(await nestedConnectOnly(
596
459
  base,
@@ -882,16 +745,6 @@ async function capture({
882
745
 
883
746
  return { result: out };
884
747
  } catch (e) {
885
- // TEMP DEBUG: real cause of a capture failure (our own messages only; database messages can contain values)
886
- log.debug(
887
- "prisma capture threw:",
888
- "model=" + model,
889
- "op=" + operation,
890
- "name=" + (e && e.name),
891
- "code=" + ((e && e.code) || "none"),
892
- e instanceof Skip ? "msg=" + e.message : "",
893
- "hostError=" + !!hostError,
894
- );
895
748
  if (hostError) throw hostError; // the host's own write failed: the host must see its own error
896
749
  return skip(e instanceof Skip ? e.message : "capture error");
897
750
  }
@@ -479,13 +479,6 @@ async function runCapture(client, rawRun, stmt, values, st, hostCall) {
479
479
  let phase = "before"; // "before" = the write has not run yet, so it can still be blocked
480
480
  let failedWhy = null;
481
481
  const notSaved = (why) => {
482
- log.debug(
483
- "pg notSaved:",
484
- "table=" + name,
485
- "phase=" + phase,
486
- "reason=" + why,
487
- "confirmed=" + st.confirmed,
488
- );
489
482
  if (phase === "before") failedWhy = failedWhy || why;
490
483
  if (phase !== "before" || st.confirmed) st.unsaved = true; // the write runs (or already ran) without a saved snapshot
491
484
  };
@@ -499,17 +492,6 @@ async function runCapture(client, rawRun, stmt, values, st, hostCall) {
499
492
  else if (stmt.type === "insert")
500
493
  await beforeInsert(rawRun, stmt, inTx, notSaved);
501
494
  } catch (e) {
502
- // TEMP DEBUG: real cause behind "capture error" (message only for non-database errors, since database messages can contain values)
503
- log.debug(
504
- "pg capture threw:",
505
- "table=" + name,
506
- "type=" + stmt.type,
507
- "code=" + ((e && e.code) || "none"),
508
- "name=" + (e && e.name),
509
- !(e && e.code)
510
- ? "msg=" + String((e && e.message) || "").slice(0, 150)
511
- : "",
512
- );
513
495
  notSaved("capture error " + ((e && e.code) || "unknown"));
514
496
  }
515
497
 
@@ -586,12 +568,6 @@ function maybeCapture(client, args, original) {
586
568
 
587
569
  // A write that cannot be saved: blocked until the user confirms (or runs unsaved if they already did).
588
570
  const refuse = (table, why) => {
589
- log.debug(
590
- "pg refuse (statement shape):",
591
- "table=" + table,
592
- "reason=" + why,
593
- "confirmed=" + st.confirmed,
594
- );
595
571
  if (st.confirmed) {
596
572
  st.unsaved = true; // runs without a saved snapshot, so Undo will not cover it
597
573
  return null;
package/undo/lib/gate.js CHANGED
@@ -50,11 +50,6 @@ async function rollbackAction(st) {
50
50
  (e.code === "unsupported" ||
51
51
  e.code === "no-client" ||
52
52
  e.code === "not-found");
53
- log.debug(
54
- "gate rollback: undoer error",
55
- (e && e.code) || (e && e.name) || "unknown",
56
- next ? "(moving to next database)" : "(stopping)",
57
- );
58
53
  if (!next) throw e;
59
54
  if (e.code === "unsupported" && !unsupported) unsupported = e;
60
55
  if (e.code === "not-found" && !notFound) notFound = e;
@@ -66,12 +61,6 @@ async function rollbackAction(st) {
66
61
 
67
62
  // Answers the browser request itself, then silences the host so it cannot answer a second time.
68
63
  function reply(st, info) {
69
- log.debug(
70
- "GATE REPLY:",
71
- "rolledBack=" + info.rolledBack,
72
- "undone=" + info.undone,
73
- "headersAlreadySent=" + !!(st.res && st.res.headersSent),
74
- );
75
64
  const res = st.res;
76
65
  if (!res || res.headersSent || res.writableEnded) return;
77
66
  const body = JSON.stringify({
@@ -108,14 +97,6 @@ function reply(st, info) {
108
97
 
109
98
  // Always throws Blocked (so the host's write does not run).
110
99
  async function block(st, where, why) {
111
- log.debug(
112
- "GATE BLOCK:",
113
- "table=" + where,
114
- "reason=" + why,
115
- "visual=" + st.visual,
116
- "confirmed=" + st.confirmed,
117
- "alreadyBlocked=" + !!st.blocked,
118
- );
119
100
  try {
120
101
  if (!st.blocked) {
121
102
  const info = {
@@ -146,13 +127,6 @@ async function block(st, where, why) {
146
127
  // Only better-sqlite3 writes can be rolled back this way; writes made earlier in other databases are reported as not rolled back.
147
128
  // Always throws Blocked (so the host's write does not run).
148
129
  function blockSync(st, where, why) {
149
- log.debug(
150
- "GATE BLOCK (sync):",
151
- "table=" + where,
152
- "reason=" + why,
153
- "visual=" + st.visual,
154
- "confirmed=" + st.confirmed,
155
- );
156
130
  try {
157
131
  if (!st.blocked) {
158
132
  const info = {
@@ -90,6 +90,24 @@ function hasNestedWrites(node, depth) {
90
90
  return false;
91
91
  }
92
92
 
93
+ // True when the write's data sets a relation field of the model (read from the model itself, no word list).
94
+ // If the model cannot be read, falls back to the old word-list check.
95
+ function writesRelationFields(base, model, args) {
96
+ const d = args && typeof args === "object" ? args.data : null;
97
+ if (!d || typeof d !== "object") return false;
98
+ try {
99
+ const models = base._runtimeDataModel && base._runtimeDataModel.models;
100
+ const m = models && models[model];
101
+ if (m && Array.isArray(m.fields) && !Array.isArray(d))
102
+ return m.fields.some(
103
+ (f) => f.kind === "object" && d[f.name] !== undefined,
104
+ );
105
+ } catch (_) {
106
+ /* fall through */
107
+ }
108
+ return hasNestedWrites(d, 0);
109
+ }
110
+
93
111
  function isEmptyFilter(where) {
94
112
  return (
95
113
  where === undefined ||
@@ -215,12 +233,7 @@ function extendClient(base, options) {
215
233
  /* observation problems must never affect the host's operation */
216
234
  }
217
235
  // Tagged writes run inside our own transaction together with their snapshot.
218
- const nested = !!(
219
- args &&
220
- typeof args === "object" &&
221
- args.data &&
222
- hasNestedWrites(args.data, 0)
223
- );
236
+ const nested = writesRelationFields(base, model, args);
224
237
  const handled = await guard.runUnhooked(() =>
225
238
  capturePrisma.capture({
226
239
  base,
@@ -239,7 +252,8 @@ function extendClient(base, options) {
239
252
  },
240
253
  });
241
254
  state.clients++;
242
- if (state.clients === 1) return extended;
255
+ // hooks every client the host creates, not only the first one
256
+ return extended;
243
257
  }
244
258
 
245
259
  // Returns a subclass of Prisma's generated client class that hooks every new instance.
@@ -27,11 +27,6 @@ function install() {
27
27
  // TEMP DEBUG: which module requests the database hooks matched, and who asked for them
28
28
  if (!seenLoads.has(request)) {
29
29
  seenLoads.add(request);
30
- log.debug(
31
- "loader matched:",
32
- request,
33
- "from=" + (parent && parent.filename ? parent.filename : "?"),
34
- );
35
30
  }
36
31
  const replacement = h.handle(exp, request, parent);
37
32
  if (replacement !== undefined) exp = replacement; // a hook may hand back a wrapped version
package/undo/lib/log.js CHANGED
@@ -1,7 +1,6 @@
1
1
  // lib/log.js
2
2
  "use strict";
3
- // Tiny logger. Rule from the plan: logs may contain table names, operation
4
- // types, counts and ids only - NEVER row values or SQL text.
3
+ // Tiny logger. Logs may contain table names, operation types, counts and ids only - NEVER row values or SQL text.
5
4
 
6
5
  const PREFIX = "[botversion-undo-exp]";
7
6
  let quiet = false;
@@ -14,6 +13,8 @@ function info(...args) {
14
13
  if (quiet) return;
15
14
  }
16
15
 
16
+ function debug(...args) {}
17
+
17
18
  function warn(...args) {
18
19
  try {
19
20
  console.warn(PREFIX, "WARNING:", ...args);
@@ -22,13 +23,13 @@ function warn(...args) {
22
23
  }
23
24
  }
24
25
 
25
- // TEMP DEBUG: always prints (log.info is silent). Reasons, names and codes only, never row values or SQL.
26
- function debug(...args) {
26
+ // TEMP DIAGNOSTIC: always prints, so we can trace why Undo is not offered. Remove after the cause is fixed.
27
+ function diag(...args) {
27
28
  try {
28
- console.log(PREFIX, "[DEBUG]", ...args);
29
+ console.log("[bv-diag]", ...args);
29
30
  } catch (_) {
30
31
  /* logging must never break the host */
31
32
  }
32
33
  }
33
34
 
34
- module.exports = { info, warn, debug, setQuiet, PREFIX };
35
+ module.exports = { info, warn, debug, diag, setQuiet, PREFIX };
@@ -129,10 +129,6 @@ const byOrd = (a, b) => a.ord - b.ord;
129
129
  async function read(base, dialect, st) {
130
130
  const sql = SQL[dialect];
131
131
  if (!sql) {
132
- log.debug(
133
- "prisma-structure: reading keys from the database is not available for",
134
- String(dialect),
135
- );
136
132
  return false; // stays cached: asking again would give the same answer
137
133
  }
138
134
  try {
@@ -183,22 +179,9 @@ async function read(base, dialect, st) {
183
179
  });
184
180
  st.badRule = badRule;
185
181
  st.ready = true;
186
- log.debug(
187
- "prisma-structure loaded:",
188
- "dialect=" + dialect,
189
- "tablesWithKeys=" + st.pk.size,
190
- "foreignKeys=" + st.fks.length,
191
- "unknownRules=" + badRule,
192
- );
193
182
  return true;
194
183
  } catch (e) {
195
184
  st.promise = null; // try again on the next write
196
- log.debug(
197
- "prisma-structure failed:",
198
- "name=" + (e && e.name),
199
- "code=" + ((e && e.code) || "none"),
200
- "dbCode=" + ((e && e.meta && e.meta.code) || "none"),
201
- );
202
185
  return false;
203
186
  }
204
187
  }
@@ -207,11 +190,6 @@ async function read(base, dialect, st) {
207
190
  function load(base, dialect) {
208
191
  try {
209
192
  if (!base || typeof base.$queryRawUnsafe !== "function") {
210
- log.debug(
211
- "prisma-structure: client unusable:",
212
- "hasBase=" + !!base,
213
- "hasQueryRaw=" + !!(base && typeof base.$queryRawUnsafe === "function"),
214
- );
215
193
  return Promise.resolve(false);
216
194
  }
217
195
  let st = cache.get(base);
@@ -228,7 +206,6 @@ function load(base, dialect) {
228
206
  if (!st.promise) st.promise = read(base, dialect, st);
229
207
  return st.promise;
230
208
  } catch (e) {
231
- log.debug("prisma-structure: load threw:", "name=" + (e && e.name));
232
209
  return Promise.resolve(false);
233
210
  }
234
211
  }
@@ -300,12 +277,6 @@ function deleteGraph(base) {
300
277
  }
301
278
  if (!st.graphLogged) {
302
279
  st.graphLogged = true;
303
- log.debug(
304
- "prisma-structure delete links built:",
305
- "parents=" + graph.size,
306
- "links=" + links,
307
- "skippedNoModel=" + skipped,
308
- );
309
280
  }
310
281
  return graph;
311
282
  } catch (_) {
@@ -326,6 +297,17 @@ function tablesMentioning(base, text) {
326
297
  }
327
298
  }
328
299
 
300
+ // True/false: is this relation stored in a hidden join table ("_" + relation name)? null = cannot tell.
301
+ function hasHiddenJoin(base, relationName) {
302
+ try {
303
+ const st = cache.get(base);
304
+ if (!st || !st.ready || !relationName) return null;
305
+ return st.fks.some((fk) => fk.child === "_" + relationName);
306
+ } catch (_) {
307
+ return null;
308
+ }
309
+ }
310
+
329
311
  // True when the relation is stored in a hidden join table whose two foreign keys both cascade on delete.
330
312
  function hiddenJoinIsCascade(base, relationName, tableA, tableB) {
331
313
  try {
@@ -394,4 +376,5 @@ module.exports = {
394
376
  tablesMentioning,
395
377
  linksOfTablesMentioning,
396
378
  hiddenJoinIsCascade,
379
+ hasHiddenJoin,
397
380
  };
@@ -197,14 +197,6 @@ function detectPrisma(hostRoot) {
197
197
  "Prisma's own engine - NOT visible to the pg hook (the Prisma hook covers it at model level)";
198
198
  }
199
199
  const ds = readPrismaDatasource(hostRoot);
200
- // TEMP DEBUG: where the schema was looked for and whether a database provider was read
201
- log.debug(
202
- "scanner prisma schema:",
203
- "hostRoot=" + hostRoot,
204
- ds
205
- ? "provider=" + ds.provider + " file=" + ds.file
206
- : "no schema file found in the usual places",
207
- );
208
200
  const database = ds
209
201
  ? {
210
202
  name: PROVIDER_LABELS[ds.provider] || ds.provider,
@@ -354,21 +346,6 @@ function scan() {
354
346
 
355
347
  const prisma = detectPrisma(hostRoot);
356
348
  const snapshotTable = detectSnapshotDialect(prisma, drivers);
357
- // TEMP DEBUG: what detection found (names and paths only)
358
- log.debug(
359
- "scanner result:",
360
- "hostRoot=" + hostRoot,
361
- "prismaFound=" + !!prisma,
362
- "prismaDatabase=" +
363
- (prisma && prisma.database
364
- ? prisma.database.provider + " from " + prisma.database.file
365
- : "NOT FOUND"),
366
- "snapshotDialect=" +
367
- (snapshotTable
368
- ? snapshotTable.dialect + " via " + snapshotTable.source
369
- : "NONE"),
370
- "drivers=" + drivers.map((d) => d.name).join("|"),
371
- );
372
349
 
373
350
  return {
374
351
  hostRoot,
@@ -82,12 +82,6 @@ function readDeleteGraph(hostRoot) {
82
82
  const Prisma = loadPrismaNamespace(hostRoot);
83
83
  const dm = Prisma && Prisma.dmmf && Prisma.dmmf.datamodel;
84
84
  if (!dm || !Array.isArray(dm.models)) {
85
- // TEMP DEBUG: why the delete links could not be read
86
- log.debug(
87
- "schema-reader delete graph: no data model",
88
- "hasPrismaNamespace=" + !!Prisma,
89
- "hasDmmf=" + !!(Prisma && Prisma.dmmf),
90
- );
91
85
  return null;
92
86
  }
93
87
  const graph = new Map();
@@ -115,13 +109,6 @@ function readDeleteGraph(hostRoot) {
115
109
  }
116
110
  return graph;
117
111
  } catch (e) {
118
- // TEMP DEBUG: why Prisma's data model could not be loaded
119
- log.debug(
120
- "schema-reader delete graph failed:",
121
- "name=" + (e && e.name),
122
- "code=" + ((e && e.code) || "none"),
123
- "msg=" + String((e && e.message) || "").slice(0, 150),
124
- );
125
112
  return null;
126
113
  }
127
114
  }
@@ -116,14 +116,16 @@ function isOwnTable(name) {
116
116
 
117
117
  // info = { rowCount, primaryKeyColumns }. Tells a hook whether a write may be captured, before it reads anything.
118
118
  function checkCapturable(info) {
119
- if (!snapshotCrypto.hasKey())
120
- return { ok: false, reason: "no-encryption-key" };
121
- const pk = info && info.primaryKeyColumns;
122
- if (!Array.isArray(pk) || pk.length === 0)
123
- return { ok: false, reason: "no-primary-key" };
124
- if (!(info.rowCount <= ROW_LIMIT))
125
- return { ok: false, reason: "too-many-rows" };
126
- return { ok: true };
119
+ let r = { ok: true };
120
+ if (!snapshotCrypto.hasKey()) r = { ok: false, reason: "no-encryption-key" };
121
+ else {
122
+ const pk = info && info.primaryKeyColumns;
123
+ if (!Array.isArray(pk) || pk.length === 0)
124
+ r = { ok: false, reason: "no-primary-key" };
125
+ else if (!(info.rowCount <= ROW_LIMIT))
126
+ r = { ok: false, reason: "too-many-rows" };
127
+ }
128
+ return r;
127
129
  }
128
130
 
129
131
  // Order number of a write inside one AI action (1, 2, 3...). Undo reverses them last to first.
@@ -212,21 +214,12 @@ async function saveSnapshot(run, store, entry, opts) {
212
214
  await run(sqlFor(dialect).insertSql, params);
213
215
  return { ok: true };
214
216
  });
215
- if (!result.ok) return result;
217
+ if (!result.ok) {
218
+ return result;
219
+ }
216
220
  (store.savedIds || (store.savedIds = [])).push(id); // remembered so a visual run can roll back only this request
217
221
  return { ok: true, id, seq };
218
222
  } catch (e) {
219
- // TEMP DEBUG: why saving a snapshot failed (message only for non-database errors)
220
- log.debug(
221
- "saveSnapshot failed:",
222
- "table=" + (entry && entry.table),
223
- "op=" + (entry && entry.operation),
224
- "code=" + ((e && e.code) || "none"),
225
- "name=" + (e && e.name),
226
- !(e && e.code)
227
- ? "msg=" + String((e && e.message) || "").slice(0, 150)
228
- : "",
229
- );
230
223
  warnOnce(
231
224
  "save-failed",
232
225
  `Undo setup problem: a snapshot could not be saved (database error code ${(e && e.code) || "unknown"}). Check the snapshot table and that the login can INSERT, SELECT, UPDATE and DELETE on it.`,
@@ -69,6 +69,15 @@ function buildStore(req, res) {
69
69
  if (!headers) return null;
70
70
  const raw = headers[headerName];
71
71
  const first = Array.isArray(raw) ? raw[0] : raw;
72
+ if (
73
+ !first &&
74
+ Object.keys(headers).some((k) => k.indexOf("x-botversion-") === 0)
75
+ )
76
+ noteIgnored(
77
+ "request has x-botversion-* headers but not the action-id header named '" +
78
+ headerName +
79
+ "'",
80
+ );
72
81
  // Only a plain id shape is accepted, so odd or hostile values never reach logs or later storage.
73
82
  if (typeof first !== "string" || !/^[A-Za-z0-9_-]{8,64}$/.test(first))
74
83
  return null;
@@ -29,14 +29,6 @@ function jsonNull() {
29
29
  let base = null; // the app's own PrismaClient (not the extended one, so our writes are never captured)
30
30
  let dialect = "postgres"; // database type of the app's Prisma client
31
31
  function remember(client, d) {
32
- // TEMP DEBUG: was the Prisma client and database type remembered, and when?
33
- log.debug(
34
- "undoPrisma.remember called:",
35
- "dialectPassed=" + String(d),
36
- "hasClient=" + !!client,
37
- "hasTransactionFn=" +
38
- !!(client && typeof client.$transaction === "function"),
39
- );
40
32
  base = client;
41
33
  // FIX: the hook's value first, then what the live client reports, then Postgres (the old default).
42
34
  dialect = d || dialectFromPrismaClient(client) || "postgres";
@@ -377,7 +369,6 @@ async function statusAction(actionId, requesterHash) {
377
369
  `SELECT requester_hash, status, ${LIVE()} AS live FROM ${TABLE} WHERE action_id = ${PH()}`,
378
370
  actionId,
379
371
  );
380
- // Same safety checks as Undo itself: same browser session, still captured, still inside the window.
381
372
  return (
382
373
  rows.length > 0 &&
383
374
  rows.every(
@@ -20,18 +20,20 @@ const STATUS_PATH = "/botversion-undo-status";
20
20
  // true only when something saved for this action can still be undone by this browser session.
21
21
  // Any problem answers false, so the widget simply does not draw the button.
22
22
  async function statusOf(id, hash) {
23
+ const tag = String(id).slice(0, 6);
23
24
  try {
24
25
  // Ask each database in turn; the first one that has something to undo answers true.
25
- for (const u of [
26
- undoPrisma,
27
- undoPg,
28
- undoMysql,
29
- undoMongo,
30
- undoSqlite,
31
- undoMssql,
26
+ for (const [name, u] of [
27
+ ["prisma", undoPrisma],
28
+ ["pg", undoPg],
29
+ ["mysql", undoMysql],
30
+ ["mongo", undoMongo],
31
+ ["sqlite", undoSqlite],
32
+ ["mssql", undoMssql],
32
33
  ]) {
33
34
  try {
34
- if (await u.statusAction(id, hash)) return true;
35
+ const ok = await u.statusAction(id, hash);
36
+ if (ok) return true;
35
37
  } catch (e) {
36
38
  if (!(e instanceof undoPrisma.Refuse && e.code === "no-client"))
37
39
  throw e;
@@ -104,7 +106,8 @@ function handle(req, res, isValidId) {
104
106
  } catch (_) {
105
107
  /* bad JSON */
106
108
  }
107
- if (typeof id !== "string" || !isValidId(id)) {
109
+ const idOk = typeof id === "string" && isValidId(id);
110
+ if (!idOk) {
108
111
  return send(res, 403, { ok: false, message: "Invalid action id." });
109
112
  }
110
113
  // Try Prisma first; if the saved change is not a Prisma model (or Prisma is not in use), try pg.
@@ -138,11 +141,6 @@ function handle(req, res, isValidId) {
138
141
  (e.code === "unsupported" ||
139
142
  e.code === "no-client" ||
140
143
  e.code === "not-found");
141
- log.debug(
142
- "undo route: undoer said",
143
- (e && e.code) || (e && e.name) || "unknown",
144
- next ? "(trying next database)" : "(stopping)",
145
- );
146
144
  if (!next) throw e;
147
145
  if (e.code === "unsupported" && !unsupported) unsupported = e;
148
146
  if (e.code === "not-found" && !notFound) notFound = e;
@@ -161,15 +159,6 @@ function handle(req, res, isValidId) {
161
159
  NOTE,
162
160
  });
163
161
  } catch (e) {
164
- log.debug(
165
- "undo route FINAL error:",
166
- e instanceof undoPrisma.Refuse
167
- ? "refuse code=" + e.code
168
- : "unexpected " +
169
- (e && e.name) +
170
- " code=" +
171
- ((e && e.code) || "none"),
172
- );
173
162
  if (e instanceof undoPrisma.Refuse) {
174
163
  const status =
175
164
  e.code === "forbidden"