@blamejs/core 0.14.27 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/ai-content-detect.js +9 -10
  5. package/lib/api-key.js +107 -74
  6. package/lib/atomic-file.js +29 -1
  7. package/lib/audit-chain.js +47 -11
  8. package/lib/audit-sign.js +77 -2
  9. package/lib/audit-tools.js +79 -51
  10. package/lib/audit.js +218 -100
  11. package/lib/backup/index.js +13 -10
  12. package/lib/break-glass.js +202 -144
  13. package/lib/cache.js +174 -105
  14. package/lib/chain-writer.js +38 -16
  15. package/lib/cli.js +19 -14
  16. package/lib/cluster-provider-db.js +130 -104
  17. package/lib/cluster-storage.js +119 -22
  18. package/lib/cluster.js +119 -71
  19. package/lib/compliance.js +22 -0
  20. package/lib/consent.js +73 -24
  21. package/lib/constants.js +16 -11
  22. package/lib/crypto-field.js +387 -91
  23. package/lib/db-declare-row-policy.js +35 -22
  24. package/lib/db-file-lifecycle.js +3 -2
  25. package/lib/db-query.js +497 -255
  26. package/lib/db-schema.js +209 -44
  27. package/lib/db.js +176 -95
  28. package/lib/external-db-migrate.js +229 -139
  29. package/lib/external-db.js +25 -15
  30. package/lib/framework-error.js +11 -0
  31. package/lib/framework-files.js +73 -0
  32. package/lib/framework-schema.js +695 -394
  33. package/lib/gate-contract.js +596 -1
  34. package/lib/guard-agent-registry.js +26 -44
  35. package/lib/guard-all.js +1 -0
  36. package/lib/guard-auth.js +42 -112
  37. package/lib/guard-cidr.js +33 -154
  38. package/lib/guard-csv.js +46 -113
  39. package/lib/guard-domain.js +34 -157
  40. package/lib/guard-dsn.js +27 -43
  41. package/lib/guard-email.js +47 -69
  42. package/lib/guard-envelope.js +19 -32
  43. package/lib/guard-event-bus-payload.js +24 -42
  44. package/lib/guard-event-bus-topic.js +25 -43
  45. package/lib/guard-filename.js +42 -106
  46. package/lib/guard-graphql.js +42 -123
  47. package/lib/guard-html.js +53 -108
  48. package/lib/guard-idempotency-key.js +24 -42
  49. package/lib/guard-image.js +46 -103
  50. package/lib/guard-imap-command.js +18 -32
  51. package/lib/guard-jmap.js +16 -30
  52. package/lib/guard-json.js +38 -108
  53. package/lib/guard-jsonpath.js +38 -171
  54. package/lib/guard-jwt.js +49 -179
  55. package/lib/guard-list-id.js +25 -41
  56. package/lib/guard-list-unsubscribe.js +27 -43
  57. package/lib/guard-mail-compose.js +24 -42
  58. package/lib/guard-mail-move.js +26 -44
  59. package/lib/guard-mail-query.js +28 -46
  60. package/lib/guard-mail-reply.js +24 -42
  61. package/lib/guard-mail-sieve.js +24 -42
  62. package/lib/guard-managesieve-command.js +17 -31
  63. package/lib/guard-markdown.js +37 -104
  64. package/lib/guard-message-id.js +26 -45
  65. package/lib/guard-mime.js +39 -151
  66. package/lib/guard-oauth.js +54 -135
  67. package/lib/guard-pdf.js +45 -101
  68. package/lib/guard-pop3-command.js +21 -31
  69. package/lib/guard-posture-chain.js +24 -42
  70. package/lib/guard-regex.js +33 -107
  71. package/lib/guard-saga-config.js +24 -42
  72. package/lib/guard-shell.js +42 -172
  73. package/lib/guard-smtp-command.js +48 -54
  74. package/lib/guard-snapshot-envelope.js +24 -42
  75. package/lib/guard-sql.js +1491 -0
  76. package/lib/guard-stream-args.js +24 -43
  77. package/lib/guard-svg.js +47 -65
  78. package/lib/guard-template.js +35 -172
  79. package/lib/guard-tenant-id.js +26 -45
  80. package/lib/guard-time.js +32 -154
  81. package/lib/guard-trace-context.js +25 -44
  82. package/lib/guard-uuid.js +32 -153
  83. package/lib/guard-xml.js +38 -113
  84. package/lib/guard-yaml.js +51 -163
  85. package/lib/http-client.js +14 -0
  86. package/lib/inbox.js +120 -107
  87. package/lib/legal-hold.js +107 -50
  88. package/lib/log-stream-cloudwatch.js +47 -31
  89. package/lib/log-stream-otlp.js +32 -18
  90. package/lib/mail-crypto-smime.js +2 -6
  91. package/lib/mail-greylist.js +2 -6
  92. package/lib/mail-helo.js +2 -6
  93. package/lib/mail-journal.js +85 -64
  94. package/lib/mail-rbl.js +2 -6
  95. package/lib/mail-scan.js +2 -6
  96. package/lib/mail-spam-score.js +2 -6
  97. package/lib/mail-store.js +287 -154
  98. package/lib/middleware/fetch-metadata.js +17 -7
  99. package/lib/middleware/idempotency-key.js +54 -38
  100. package/lib/middleware/rate-limit.js +102 -32
  101. package/lib/middleware/security-headers.js +21 -5
  102. package/lib/migrations.js +108 -66
  103. package/lib/network-heartbeat.js +7 -0
  104. package/lib/nonce-store.js +31 -9
  105. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  106. package/lib/object-store/azure-blob.js +31 -3
  107. package/lib/object-store/sigv4.js +10 -0
  108. package/lib/outbox.js +136 -82
  109. package/lib/pqc-agent.js +44 -0
  110. package/lib/pubsub-cluster.js +42 -20
  111. package/lib/queue-local.js +202 -139
  112. package/lib/queue-redis.js +9 -1
  113. package/lib/queue-sqs.js +6 -0
  114. package/lib/retention.js +82 -39
  115. package/lib/safe-dns.js +29 -45
  116. package/lib/safe-ical.js +18 -33
  117. package/lib/safe-icap.js +27 -43
  118. package/lib/safe-sieve.js +21 -40
  119. package/lib/safe-sql.js +124 -3
  120. package/lib/safe-vcard.js +18 -33
  121. package/lib/scheduler.js +35 -12
  122. package/lib/seeders.js +122 -74
  123. package/lib/session-stores.js +42 -14
  124. package/lib/session.js +109 -72
  125. package/lib/sql.js +3885 -0
  126. package/lib/static.js +45 -7
  127. package/lib/subject.js +55 -17
  128. package/lib/vault/index.js +3 -2
  129. package/lib/vault/passphrase-ops.js +3 -2
  130. package/lib/vault/rotate.js +104 -64
  131. package/lib/vendor-data.js +2 -0
  132. package/lib/websocket.js +16 -0
  133. package/package.json +1 -1
  134. package/sbom.cdx.json +6 -6
package/lib/safe-sql.js CHANGED
@@ -175,7 +175,7 @@ function validateIdentifier(name, opts) {
175
175
 
176
176
  /**
177
177
  * @primitive b.safeSql.quoteIdentifier
178
- * @signature b.safeSql.quoteIdentifier(name, dialect?)
178
+ * @signature b.safeSql.quoteIdentifier(name, dialect?, opts?)
179
179
  * @since 0.1.0
180
180
  * @status stable
181
181
  * @related b.safeSql.validateIdentifier, b.safeSql.quoteQualified
@@ -185,6 +185,17 @@ function validateIdentifier(name, opts) {
185
185
  * MySQL. Default dialect is `"sqlite"`. Throws `SafeSqlError` if the
186
186
  * identifier fails `validateIdentifier`.
187
187
  *
188
+ * `opts` is forwarded to `validateIdentifier` — pass
189
+ * `{ allowReserved: true }` to quote a name that collides with a SQL
190
+ * keyword (a column literally named `from` / `select`). Quoting is
191
+ * exactly what makes a reserved word safe in identifier position, so the
192
+ * query builder (`b.sql`) routes every identifier through here with
193
+ * `allowReserved` on; the default still rejects reserved words so a bare
194
+ * caller catches the likely typo.
195
+ *
196
+ * @opts
197
+ * allowReserved: boolean, // default: false — permit SQL-keyword names (safe once quoted)
198
+ *
188
199
  * @example
189
200
  * var b = require("blamejs");
190
201
  * b.safeSql.quoteIdentifier("users");
@@ -193,11 +204,14 @@ function validateIdentifier(name, opts) {
193
204
  * b.safeSql.quoteIdentifier("Order", "postgres");
194
205
  * // → '"Order"'
195
206
  *
207
+ * b.safeSql.quoteIdentifier("from", "postgres", { allowReserved: true });
208
+ * // → '"from"'
209
+ *
196
210
  * b.safeSql.quoteIdentifier("users", "mysql");
197
211
  * // → "`users`"
198
212
  */
199
- function quoteIdentifier(name, dialect) {
200
- validateIdentifier(name);
213
+ function quoteIdentifier(name, dialect, opts) {
214
+ validateIdentifier(name, opts);
201
215
  dialect = (dialect || "sqlite").toLowerCase();
202
216
  if (dialect === "mysql") return "`" + name + "`";
203
217
  // sqlite + postgres both use double-quote per SQL standard
@@ -258,6 +272,53 @@ function quoteQualified(parts, dialect) {
258
272
  return quoted.join(".");
259
273
  }
260
274
 
275
+ /**
276
+ * @primitive b.safeSql.quoteList
277
+ * @signature b.safeSql.quoteList(names, dialect?, opts?)
278
+ * @since 0.15.0
279
+ * @status stable
280
+ * @related b.safeSql.quoteIdentifier, b.safeSql.quoteQualified, b.sql
281
+ *
282
+ * Quote a list of identifiers into a comma-joined fragment — each name
283
+ * validated + quoted via `quoteIdentifier`. The "many" companion to
284
+ * `quoteIdentifier` (one) and `quoteQualified` (a dotted name): use it for
285
+ * SELECT projections and INSERT column lists so the recurring
286
+ * `cols.map(quoteIdentifier).join(", ")` shape is composed, not hand-rolled.
287
+ *
288
+ * There is deliberately NO value/string-literal quoter in this module:
289
+ * values flow as bound placeholders (`?` / `$N`), never interpolated, which
290
+ * is what makes the injection class structurally impossible. Quoting a
291
+ * literal would reopen it — use the query builder's parameter binding.
292
+ *
293
+ * `opts` is forwarded to each `quoteIdentifier` (e.g.
294
+ * `{ allowReserved: true }` for column lists that may contain SQL-keyword
295
+ * names, as `b.sql` does).
296
+ *
297
+ * Throws `SafeSqlError` (`sql/empty`) on an empty array and (per
298
+ * `quoteIdentifier`) on any invalid identifier.
299
+ *
300
+ * @opts
301
+ * allowReserved: boolean, // default: false — forwarded to quoteIdentifier
302
+ *
303
+ * @example
304
+ * var b = require("blamejs");
305
+ * b.safeSql.quoteList(["id", "createdAt"], "postgres");
306
+ * // → '"id", "createdAt"'
307
+ *
308
+ * b.safeSql.quoteList(["queueName", "status"], "mysql");
309
+ * // → "`queueName`, `status`"
310
+ */
311
+ function quoteList(names, dialect, opts) {
312
+ if (!Array.isArray(names) || names.length === 0) {
313
+ throw new SafeSqlError("quoteList requires a non-empty array of identifiers", "sql/empty");
314
+ }
315
+ var out = [];
316
+ for (var i = 0; i < names.length; i++) {
317
+ out.push(quoteIdentifier(names[i], dialect, opts));
318
+ }
319
+ return out.join(", ");
320
+ }
321
+
261
322
  /**
262
323
  * @primitive b.safeSql.assertOneOf
263
324
  * @signature b.safeSql.assertOneOf(name, allowlist)
@@ -310,6 +371,64 @@ function assertOneOf(name, allowlist) {
310
371
  return name;
311
372
  }
312
373
 
374
+ /**
375
+ * @primitive b.safeSql.countPlaceholders
376
+ * @signature b.safeSql.countPlaceholders(sql)
377
+ * @since 0.14.29
378
+ * @status stable
379
+ * @related b.safeSql.quoteIdentifier, b.safeSql.validateIdentifier
380
+ *
381
+ * Count the bound `?` placeholders in a SQL string, skipping any `?`
382
+ * that appears inside a string literal (`'...'` / `"..."`, doubled-quote
383
+ * escape aware) or inside a line or block comment. The canonical quote-
384
+ * and comment-aware scanner the query builder uses to check placeholder /
385
+ * param parity and the residency write-gate uses to align bound values;
386
+ * both compose this so the skip rules live in one place.
387
+ *
388
+ * @example
389
+ * var b = require("blamejs");
390
+ * b.safeSql.countPlaceholders("a = ? AND b = ?");
391
+ * // → 2
392
+ *
393
+ * b.safeSql.countPlaceholders("note = 'is ? literal' AND id = ?");
394
+ * // → 1
395
+ */
396
+ function countPlaceholders(sql) {
397
+ var count = 0;
398
+ var i = 0;
399
+ var len = sql.length;
400
+ while (i < len) {
401
+ var ch = sql.charAt(i);
402
+ var next = i + 1 < len ? sql.charAt(i + 1) : "";
403
+ if (ch === "'" || ch === '"') {
404
+ var quote = ch;
405
+ i += 1;
406
+ while (i < len) {
407
+ if (sql.charAt(i) === quote) {
408
+ // SQL doubles the quote char to escape it within a literal.
409
+ if (sql.charAt(i + 1) === quote) { i += 2; continue; }
410
+ i += 1; break;
411
+ }
412
+ i += 1;
413
+ }
414
+ continue;
415
+ }
416
+ if (ch === "-" && next === "-") {
417
+ while (i < len && sql.charAt(i) !== "\n") i += 1;
418
+ continue;
419
+ }
420
+ if (ch === "/" && next === "*") {
421
+ i += 2;
422
+ while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
423
+ i += 2;
424
+ continue;
425
+ }
426
+ if (ch === "?") count += 1;
427
+ i += 1;
428
+ }
429
+ return count;
430
+ }
431
+
313
432
  /**
314
433
  * @primitive b.safeSql.DEFAULT_IDENTIFIER_RE
315
434
  * @signature b.safeSql.DEFAULT_IDENTIFIER_RE
@@ -355,7 +474,9 @@ module.exports = {
355
474
  validateIdentifier: validateIdentifier,
356
475
  quoteIdentifier: quoteIdentifier,
357
476
  quoteQualified: quoteQualified,
477
+ quoteList: quoteList,
358
478
  assertOneOf: assertOneOf,
479
+ countPlaceholders: countPlaceholders,
359
480
  SafeSqlError: SafeSqlError,
360
481
  // Exposed so consumers can compose their own validators
361
482
  DEFAULT_IDENTIFIER_RE: DEFAULT_IDENTIFIER_RE,
package/lib/safe-vcard.js CHANGED
@@ -63,6 +63,7 @@
63
63
 
64
64
  var C = require("./constants");
65
65
  var { defineClass } = require("./framework-error");
66
+ var gateContract = require("./gate-contract");
66
67
 
67
68
  var SafeVcardError = defineClass("SafeVcardError", { alwaysPermanent: true });
68
69
 
@@ -90,12 +91,7 @@ var PROFILES = Object.freeze({
90
91
  }),
91
92
  });
92
93
 
93
- var COMPLIANCE_POSTURES = Object.freeze({
94
- hipaa: "strict",
95
- "pci-dss": "strict",
96
- gdpr: "strict",
97
- soc2: "strict",
98
- });
94
+ var COMPLIANCE_POSTURES = gateContract.ALL_STRICT_POSTURES;
99
95
 
100
96
  // Property-name allowlist per RFC 6350 §6 (vCard 4.0 property
101
97
  // registry) + RFC 2426 §3 (legacy 3.0 properties retained for
@@ -217,24 +213,6 @@ function parse(text, opts) {
217
213
  return { vcards: vcards };
218
214
  }
219
215
 
220
- /**
221
- * @primitive b.safeVcard.compliancePosture
222
- * @signature b.safeVcard.compliancePosture(name)
223
- * @since 0.9.81
224
- * @status stable
225
- * @related b.safeVcard.parse
226
- *
227
- * Map a compliance-posture name to its profile. Returns the profile
228
- * string for a known posture, `null` for unknown names.
229
- *
230
- * @example
231
- * b.safeVcard.compliancePosture("hipaa"); // -> "strict"
232
- * b.safeVcard.compliancePosture("loose"); // -> null
233
- */
234
- function compliancePosture(name) {
235
- return COMPLIANCE_POSTURES[name] || null;
236
- }
237
-
238
216
  // ---- Internal ----
239
217
 
240
218
  function _resolveCaps(opts) {
@@ -462,12 +440,19 @@ function _preview(s) {
462
440
  return s.length > 64 ? s.slice(0, 64) + "..." : s; // log-preview length cap
463
441
  }
464
442
 
465
- module.exports = {
466
- parse: parse,
467
- compliancePosture: compliancePosture,
468
- PROFILES: PROFILES,
469
- COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
470
- KNOWN_PROPERTIES: KNOWN_PROPERTIES,
471
- EMBED_PROPERTIES: EMBED_PROPERTIES,
472
- SafeVcardError: SafeVcardError,
473
- };
443
+ // compliancePosture is assembled by gateContract.defineParser below; its
444
+ // wiki section renders from the single-sourced @abiTemplate (defineParser)
445
+ // block in gate-contract.js, instantiated for this guard by the page
446
+ // generator.
447
+ module.exports = gateContract.defineParser({
448
+ name: "vcard",
449
+ entry: parse,
450
+ entryName: "parse",
451
+ errorClass: SafeVcardError,
452
+ profiles: PROFILES,
453
+ postures: COMPLIANCE_POSTURES,
454
+ extra: {
455
+ KNOWN_PROPERTIES: KNOWN_PROPERTIES,
456
+ EMBED_PROPERTIES: EMBED_PROPERTIES,
457
+ },
458
+ });
package/lib/scheduler.js CHANGED
@@ -43,6 +43,7 @@ var lazyRequire = require("./lazy-require");
43
43
  var audit = lazyRequire(function () { return require("./audit"); });
44
44
  var log = lazyRequire(function () { return require("./log").boot("scheduler"); });
45
45
  var clusterStorage = require("./cluster-storage");
46
+ var sql = require("./sql");
46
47
  var validateOpts = require("./validate-opts");
47
48
  var C = require("./constants");
48
49
  var { SchedulerError } = require("./framework-error");
@@ -51,6 +52,18 @@ var DEFAULT_MAX_JOB_MS = C.TIME.minutes(10);
51
52
  var DEFAULT_TICK_RETENTION_MS = C.TIME.days(7);
52
53
  var DEFAULT_TICK_PRUNE_INTERVAL_MS = C.TIME.minutes(1);
53
54
 
55
+ // b.sql opts for every _blamejs_scheduler_ticks statement: thread the ACTIVE
56
+ // backend dialect (clusterStorage.dialect() — "sqlite" single-node,
57
+ // "postgres" | "mysql" in cluster mode) so the emitted identifier quoting +
58
+ // dialect idioms (ON CONFLICT DO NOTHING vs the MySQL no-op fold) match the
59
+ // backend the SQL dispatches to. Defaulting to "sqlite" works on Postgres
60
+ // only by accident (both double-quote identifiers) and emits the wrong
61
+ // quoting on MySQL. clusterStorage.execute still rewrites the bare table name
62
+ // + translates `?` placeholders at dispatch; this controls only the builder-
63
+ // side quoting + idiom selection. The table name stays BARE (no quoteName)
64
+ // so clusterStorage's prefix rewrite still fires.
65
+ function _ticksSqlOpts() { return { dialect: clusterStorage.dialect() }; }
66
+
54
67
  // ---- Cron parsing ----
55
68
 
56
69
  var CRON_SHORTHANDS = {
@@ -497,7 +510,7 @@ function create(opts) {
497
510
  task.nextRun = Date.now() + spec.every;
498
511
  }
499
512
  task.exprDesc = "every " + spec.every + "ms" +
500
- (spec.baseline ? " from " + spec.baseline : "") +
513
+ (spec.baseline ? " anchored " + spec.baseline : "") +
501
514
  (tz ? " " + tz : "");
502
515
  }
503
516
 
@@ -562,13 +575,23 @@ function create(opts) {
562
575
  var tickKey = task.name + ":" + nominalRun;
563
576
  var claimedBy = (typeof clusterInstance.currentNodeId === "function")
564
577
  ? clusterInstance.currentNodeId() : "unknown";
565
- clusterStorage.execute(
566
- "INSERT INTO _blamejs_scheduler_ticks " +
567
- "(tickKey, name, scheduledAtUnix, claimedAtUnix, claimedBy) " +
568
- "VALUES (?, ?, ?, ?, ?) " +
569
- "ON CONFLICT (tickKey) DO NOTHING",
570
- [tickKey, task.name, nominalRun, Date.now(), claimedBy]
571
- ).then(function (result) {
578
+ // BARE logical table name — clusterStorage rewrites _blamejs_scheduler_ticks
579
+ // to the configured prefix and placeholderizes the ? markers. The
580
+ // PRIMARY KEY race on tickKey deduplicates the split-brain window; the
581
+ // loser's ON CONFLICT DO NOTHING reports zero rowCount and skips.
582
+ var claimBuilt = sql.upsert("_blamejs_scheduler_ticks", _ticksSqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
583
+ .columns(["tickKey", "name", "scheduledAtUnix", "claimedAtUnix", "claimedBy"])
584
+ .values({
585
+ tickKey: tickKey,
586
+ name: task.name,
587
+ scheduledAtUnix: nominalRun,
588
+ claimedAtUnix: Date.now(),
589
+ claimedBy: claimedBy,
590
+ })
591
+ .onConflict(["tickKey"])
592
+ .doNothing()
593
+ .toSql();
594
+ clusterStorage.execute(claimBuilt.sql, claimBuilt.params).then(function (result) {
572
595
  var won = (result && result.rowCount > 0);
573
596
  if (won) {
574
597
  _runFire(task);
@@ -604,10 +627,10 @@ function create(opts) {
604
627
  var threshold = Date.now() - (
605
628
  typeof olderThanMs === "number" ? olderThanMs : tickRetentionMs
606
629
  );
607
- var result = await clusterStorage.execute(
608
- "DELETE FROM _blamejs_scheduler_ticks WHERE scheduledAtUnix < ?",
609
- [threshold]
610
- );
630
+ var pruneBuilt = sql.delete("_blamejs_scheduler_ticks", _ticksSqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
631
+ .where("scheduledAtUnix", "<", threshold)
632
+ .toSql();
633
+ var result = await clusterStorage.execute(pruneBuilt.sql, pruneBuilt.params);
611
634
  var removed = (result && result.rowCount) || 0;
612
635
  if (removed > 0) {
613
636
  _emit("system.scheduler.tick.pruned", {
package/lib/seeders.js CHANGED
@@ -58,10 +58,13 @@ var nodePath = require("node:path");
58
58
  var atomicFile = require("./atomic-file");
59
59
  var C = require("./constants");
60
60
  var dbSchema = require("./db-schema");
61
+ var frameworkSchema = require("./framework-schema");
61
62
  var lazyRequire = require("./lazy-require");
62
63
  var { boot } = require("./log");
63
64
  var migrationFiles = require("./migration-files");
64
65
  var requestHelpers = require("./request-helpers");
66
+ var safeSql = require("./safe-sql");
67
+ var sql = require("./sql");
65
68
  var validateOpts = require("./validate-opts");
66
69
  var { SeederError } = require("./framework-error");
67
70
 
@@ -72,13 +75,29 @@ var observability = lazyRequire(function () { return require("./observability");
72
75
 
73
76
  var _err = SeederError.factory;
74
77
 
75
- var SEEDERS_TABLE = "_blamejs_seeders";
76
- var LOCK_TABLE = "_blamejs_seeders_lock";
77
- // Pre-quoted forms used at every SQL interpolation site — defense in
78
- // depth so a future rename to a reserved-word or whitespace-bearing
79
- // table name doesn't silently break the query.
80
- var Q_SEEDERS_TABLE = '"' + SEEDERS_TABLE + '"';
81
- var Q_LOCK_TABLE = '"' + LOCK_TABLE + '"';
78
+ // Logical framework-table names, resolved to the configured prefix via
79
+ // frameworkSchema.tableName at every call site. These run against the
80
+ // local node:sqlite handle directly (no clusterStorage rewrite in the
81
+ // path), so b.sql is built with quoteName: true on the resolved name —
82
+ // the `"name"` identifier form the single-node path always prepares.
83
+ var SEEDERS_TABLE = "_blamejs_seeders"; // allow:hand-rolled-sql logical name declaration; physical name + prefix resolve via frameworkSchema.tableName below
84
+ var LOCK_TABLE = "_blamejs_seeders_lock"; // allow:hand-rolled-sql — logical name declaration; physical name + prefix resolve via frameworkSchema.tableName below
85
+
86
+ // b.sql opts for the local single-node handle: the resolved table name,
87
+ // quoted by construction. tableName() applies the configurable prefix
88
+ // (byte-identical to the literal under the default _blamejs_ prefix).
89
+ function _seedersTable() { return frameworkSchema.tableName(SEEDERS_TABLE); }
90
+ function _lockTable() { return frameworkSchema.tableName(LOCK_TABLE); }
91
+ // b.sql opts resolved from the handle's dialect (sqlite by default; an
92
+ // operator's own Postgres / MySQL handle declares `handle.dialect`).
93
+ // quoteName forces the resolved framework name to quote. The
94
+ // handle-dialect / opts / key-text-type resolution is shared with
95
+ // db-schema's reconciler + migrations.js, so it is composed from db-schema
96
+ // rather than re-derived here. The historical default (sqlite) is
97
+ // byte-identical for every local-handle caller.
98
+ var _handleDialect = dbSchema.handleDialect;
99
+ var _sqlOpts = dbSchema.sqlOpts;
100
+ var _keyTextType = dbSchema.keyTextType;
82
101
 
83
102
  // Filename grammar: leading numeric prefix (any width), '-', non-empty
84
103
  // body of [A-Za-z0-9_-], '.js'. Same shape as migrations to avoid
@@ -279,48 +298,63 @@ function _ensureTables(db) {
279
298
  // Both _blamejs_seeders + _blamejs_seeders_lock are part of
280
299
  // FRAMEWORK_SCHEMA so db.js creates them at boot. The CREATE IF NOT
281
300
  // EXISTS here is defensive for tests that hand-seed a fresh
282
- // node:sqlite Database without going through b.db.
283
- _runSql(db,
284
- "CREATE TABLE IF NOT EXISTS " + Q_SEEDERS_TABLE + " (" +
285
- " env TEXT NOT NULL," +
286
- " name TEXT NOT NULL," +
287
- " description TEXT," +
288
- " appliedAt TEXT NOT NULL," +
289
- " rerunnable INTEGER NOT NULL DEFAULT 0," +
290
- " PRIMARY KEY (env, name)" +
291
- ")"
292
- );
293
- _runSql(db,
294
- "CREATE TABLE IF NOT EXISTS " + Q_LOCK_TABLE + " (" +
295
- " scope TEXT PRIMARY KEY CHECK (scope = 'lock')," +
296
- " lockedAt INTEGER NOT NULL," +
297
- " lockedBy TEXT NOT NULL" +
298
- ")"
299
- );
301
+ // node:sqlite Database without going through b.db. Built through b.sql
302
+ // so the identifiers quote by construction (composite PK + the single-
303
+ // row CHECK fence on the lock table mirror db.js's FRAMEWORK_SCHEMA).
304
+ // env + name are the composite PRIMARY KEY, so both take the key-safe
305
+ // text type (VARCHAR on mysql, TEXT elsewhere). The lock's scope CHECK
306
+ // quotes the column under the handle dialect (backtick on mysql); lockedAt
307
+ // is ms-epoch (`int` → BIGINT on Postgres/MySQL, INTEGER on SQLite).
308
+ var dialect = _handleDialect(db);
309
+ var kt = _keyTextType(db);
310
+ var scopeCheck = "CHECK (" + safeSql.quoteIdentifier("scope", dialect, { allowReserved: true }) + " = 'lock')";
311
+ var seedersDdl = sql.createTable(_seedersTable(), [
312
+ { name: "env", type: kt, notNull: true },
313
+ { name: "name", type: kt, notNull: true },
314
+ { name: "description", type: "text" },
315
+ { name: "appliedAt", type: "text", notNull: true },
316
+ { name: "rerunnable", type: "int", notNull: true, default: 0 },
317
+ ], { quoteName: true, primaryKey: ["env", "name"], dialect: dialect });
318
+ _runSql(db, seedersDdl.sql);
319
+ var lockDdl = sql.createTable(_lockTable(), [
320
+ { name: "scope", type: kt, primaryKey: true, constraints: scopeCheck },
321
+ { name: "lockedAt", type: "int", notNull: true },
322
+ { name: "lockedBy", type: "text", notNull: true },
323
+ ], { quoteName: true, dialect: dialect });
324
+ _runSql(db, lockDdl.sql);
300
325
  }
301
326
 
302
327
  function _lockHolderId() {
303
328
  return String(process.pid) + "@" + (require("node:os").hostname() || "unknown");
304
329
  }
305
330
 
331
+ // b.sql-built statements for the single advisory-lock row. Each binds
332
+ // every value as a placeholder (the constant scope "lock" included) and
333
+ // quotes the resolved table name by construction.
334
+ function _lockInsertSql(db, nowMs, holder) {
335
+ return sql.insert(_lockTable(), _sqlOpts(db))
336
+ .values({ scope: "lock", lockedAt: nowMs, lockedBy: holder }).toSql();
337
+ }
338
+
306
339
  function _acquireLock(db, lockStaleAfterMs, clock) {
307
340
  var holder = _lockHolderId();
308
341
  var nowMs = clock();
309
342
  try {
310
- db.prepare(
311
- "INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
312
- ).run(nowMs, holder);
343
+ var ins = _lockInsertSql(db, nowMs, holder);
344
+ var insStmt = db.prepare(ins.sql);
345
+ insStmt.run.apply(insStmt, ins.params);
313
346
  return holder;
314
347
  } catch (_e) {
315
- var existing = db.prepare(
316
- "SELECT lockedAt, lockedBy FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock'"
317
- ).get();
348
+ var selBuilt = sql.select(_lockTable(), _sqlOpts(db))
349
+ .columns(["lockedAt", "lockedBy"]).where("scope", "lock").toSql();
350
+ var selStmt = db.prepare(selBuilt.sql);
351
+ var existing = selStmt.get.apply(selStmt, selBuilt.params);
318
352
  if (!existing) {
319
353
  // Race window between INSERT failure and SELECT — try once more.
320
354
  try {
321
- db.prepare(
322
- "INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
323
- ).run(nowMs, holder);
355
+ var ins2 = _lockInsertSql(db, nowMs, holder);
356
+ var ins2Stmt = db.prepare(ins2.sql);
357
+ ins2Stmt.run.apply(ins2Stmt, ins2.params);
324
358
  return holder;
325
359
  } catch (e2) {
326
360
  throw _err("LOCK_BUSY",
@@ -329,23 +363,32 @@ function _acquireLock(db, lockStaleAfterMs, clock) {
329
363
  }
330
364
  var ageMs = nowMs - Number(existing.lockedAt);
331
365
  if (lockStaleAfterMs > 0 && ageMs > lockStaleAfterMs) {
332
- _runSql(db, "BEGIN IMMEDIATE");
366
+ // Force-replace the stale lock atomically. The transaction boundary
367
+ // is dialect-aware: only SQLite has the `BEGIN IMMEDIATE`
368
+ // write-lock-up-front form — Postgres + MySQL reject the `IMMEDIATE`
369
+ // keyword, so the shared runInTransaction helper emits a plain
370
+ // portable `BEGIN`/`COMMIT`/`ROLLBACK` there.
371
+ var lockMode = _handleDialect(db) === "sqlite" ? "IMMEDIATE" : null;
333
372
  try {
334
- db.prepare("DELETE FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock' AND lockedAt = ?")
335
- .run(existing.lockedAt);
336
- db.prepare(
337
- "INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
338
- ).run(nowMs, holder);
339
- _runSql(db, "COMMIT");
340
- return holder;
373
+ return dbSchema.runInTransaction(db, function () {
374
+ var delBuilt = sql.delete(_lockTable(), _sqlOpts(db))
375
+ .where("scope", "lock").where("lockedAt", existing.lockedAt).toSql();
376
+ var delStmt = db.prepare(delBuilt.sql);
377
+ delStmt.run.apply(delStmt, delBuilt.params);
378
+ var insForce = _lockInsertSql(db, nowMs, holder);
379
+ var insForceStmt = db.prepare(insForce.sql);
380
+ insForceStmt.run.apply(insForceStmt, insForce.params);
381
+ return holder;
382
+ }, {
383
+ lockMode: lockMode,
384
+ onRollbackFail: function (rollbackErr) {
385
+ log.debug("rollback-failed", {
386
+ op: "lock-stale-replace",
387
+ error: rollbackErr && rollbackErr.message,
388
+ });
389
+ },
390
+ });
341
391
  } catch (forceErr) {
342
- try { _runSql(db, "ROLLBACK"); }
343
- catch (rollbackErr) {
344
- log.debug("rollback-failed", {
345
- op: "lock-stale-replace",
346
- error: rollbackErr && rollbackErr.message,
347
- });
348
- }
349
392
  throw _err("LOCK_STALE_REPLACE_FAILED",
350
393
  "seeders: could not replace stale lock: " +
351
394
  ((forceErr && forceErr.message) || String(forceErr)));
@@ -359,9 +402,10 @@ function _acquireLock(db, lockStaleAfterMs, clock) {
359
402
 
360
403
  function _releaseLock(db, holder) {
361
404
  try {
362
- db.prepare(
363
- "DELETE FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock' AND lockedBy = ?"
364
- ).run(holder);
405
+ var built = sql.delete(_lockTable(), _sqlOpts(db))
406
+ .where("scope", "lock").where("lockedBy", holder).toSql();
407
+ var stmt = db.prepare(built.sql);
408
+ stmt.run.apply(stmt, built.params);
365
409
  } catch (_e) { /* best-effort */ }
366
410
  }
367
411
 
@@ -406,10 +450,13 @@ function create(opts) {
406
450
  }
407
451
 
408
452
  function _appliedRows(db, env) {
409
- return db.prepare(
410
- "SELECT name, description, appliedAt, rerunnable FROM " + Q_SEEDERS_TABLE +
411
- " WHERE env = ? ORDER BY appliedAt ASC, name ASC"
412
- ).all(env);
453
+ var built = sql.select(_seedersTable(), _sqlOpts(db))
454
+ .columns(["name", "description", "appliedAt", "rerunnable"])
455
+ .where("env", env)
456
+ .orderBy("appliedAt", "asc").orderBy("name", "asc")
457
+ .toSql();
458
+ var stmt = db.prepare(built.sql);
459
+ return stmt.all.apply(stmt, built.params);
413
460
  }
414
461
 
415
462
  function status(callerOpts) {
@@ -469,8 +516,11 @@ function create(opts) {
469
516
 
470
517
  var holder = _acquireLock(db, lockStaleAfterMs, clock);
471
518
  try {
519
+ var appliedSelBuilt = sql.select(_seedersTable(), _sqlOpts(db))
520
+ .columns(["name"]).where("env", env).toSql();
521
+ var appliedSelStmt = db.prepare(appliedSelBuilt.sql);
472
522
  var appliedSet = new Set(
473
- db.prepare("SELECT name FROM " + Q_SEEDERS_TABLE + " WHERE env = ?").all(env)
523
+ appliedSelStmt.all.apply(appliedSelStmt, appliedSelBuilt.params)
474
524
  .map(function (r) { return r.name; })
475
525
  );
476
526
 
@@ -503,27 +553,25 @@ function create(opts) {
503
553
  _runSql(db, "BEGIN");
504
554
  try {
505
555
  await mod.run(db, ctx);
556
+ var nowIso = new Date(clock()).toISOString();
557
+ var writeBuilt;
506
558
  if (alreadyApplied && mod.rerunnable) {
507
- db.prepare(
508
- "UPDATE " + Q_SEEDERS_TABLE +
509
- " SET appliedAt = ?, description = ?, rerunnable = ?" +
510
- " WHERE env = ? AND name = ?"
511
- ).run(new Date(clock()).toISOString(), mod.description || "",
512
- mod.rerunnable ? 1 : 0, env, name);
559
+ writeBuilt = sql.update(_seedersTable(), _sqlOpts(db))
560
+ .set({ appliedAt: nowIso, description: mod.description || "",
561
+ rerunnable: mod.rerunnable ? 1 : 0 })
562
+ .where("env", env).where("name", name).toSql();
513
563
  } else if (alreadyApplied && force) {
514
- db.prepare(
515
- "UPDATE " + Q_SEEDERS_TABLE +
516
- " SET appliedAt = ?, description = ?" +
517
- " WHERE env = ? AND name = ?"
518
- ).run(new Date(clock()).toISOString(), mod.description || "",
519
- env, name);
564
+ writeBuilt = sql.update(_seedersTable(), _sqlOpts(db))
565
+ .set({ appliedAt: nowIso, description: mod.description || "" })
566
+ .where("env", env).where("name", name).toSql();
520
567
  } else {
521
- db.prepare(
522
- "INSERT INTO " + Q_SEEDERS_TABLE +
523
- " (env, name, description, appliedAt, rerunnable) VALUES (?, ?, ?, ?, ?)"
524
- ).run(env, name, mod.description || "",
525
- new Date(clock()).toISOString(), mod.rerunnable ? 1 : 0);
568
+ writeBuilt = sql.insert(_seedersTable(), _sqlOpts(db))
569
+ .values({ env: env, name: name, description: mod.description || "",
570
+ appliedAt: nowIso, rerunnable: mod.rerunnable ? 1 : 0 })
571
+ .toSql();
526
572
  }
573
+ var writeStmt = db.prepare(writeBuilt.sql);
574
+ writeStmt.run.apply(writeStmt, writeBuilt.params);
527
575
  _runSql(db, "COMMIT");
528
576
  } catch (e) {
529
577
  try { _runSql(db, "ROLLBACK"); }