@blamejs/core 0.14.27 → 0.15.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.
Files changed (134) hide show
  1. package/CHANGELOG.md +6 -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 +158 -77
  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 +228 -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 +82 -29
  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 +517 -256
  26. package/lib/db-schema.js +209 -44
  27. package/lib/db.js +202 -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 +293 -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 +116 -72
  125. package/lib/sql.js +3885 -0
  126. package/lib/static.js +45 -7
  127. package/lib/subject.js +89 -49
  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
@@ -34,17 +34,24 @@
34
34
  * Append-only WORM enforcement: `ensureSchema` installs BEFORE
35
35
  * DELETE / BEFORE UPDATE triggers on `audit_log`, `consent_log`,
36
36
  * and `audit_checkpoints` — Postgres via plpgsql RAISE EXCEPTION
37
- * functions, SQLite via `RAISE(ABORT, ...)`. Idempotent across
38
- * reboots; any operator-applied DROP TRIGGER is restored on the
39
- * next ensureSchema pass. MySQL is not currently supported
40
- * operators on MySQL must run on Postgres or SQLite until a MySQL
41
- * adapter ships.
37
+ * functions, MySQL via `SIGNAL SQLSTATE '45000'`, SQLite via
38
+ * `RAISE(ABORT, ...)`. Idempotent across reboots; any operator-applied
39
+ * DROP TRIGGER is restored on the next ensureSchema pass.
40
+ *
41
+ * Dialect portability: `postgres`, `mysql`, and `sqlite` are all
42
+ * supported targets. The integer token is BIGINT on Postgres + MySQL
43
+ * (a 32-bit INTEGER overflows a Date.now() ms-epoch value) and INTEGER
44
+ * on SQLite; the binary token is BYTEA / LONGBLOB / BLOB. TEXT columns
45
+ * that participate in a PRIMARY KEY or index become VARCHAR(191) on
46
+ * MySQL (which refuses an unbounded TEXT/BLOB in a key) and stay plain
47
+ * TEXT on Postgres + SQLite.
42
48
  *
43
49
  * @card
44
- * Framework-defined SQL schema (audit / sessions / api_keys / cache / break-glass / scheduler-ticks / pubsub / rate-limit / seeders / etc.) — declarative, migration-aware, and dialect-portable across Postgres and SQLite.
50
+ * Framework-defined SQL schema (audit / sessions / api_keys / cache / break-glass / scheduler-ticks / pubsub / rate-limit / seeders / etc.) — declarative, migration-aware, and dialect-portable across Postgres, MySQL, and SQLite.
45
51
  */
46
52
 
47
53
  var externalDb = require("./external-db");
54
+ var safeSql = require("./safe-sql");
48
55
  var { FrameworkError } = require("./framework-error");
49
56
 
50
57
  class FrameworkSchemaError extends FrameworkError {
@@ -138,16 +145,105 @@ var LOCAL_TO_EXTERNAL = Object.freeze({
138
145
  _blamejs_break_glass_grants: "_blamejs_break_glass_grants",
139
146
  });
140
147
 
148
+ // ---- Configurable framework-table prefix ----
149
+ //
150
+ // Every external-db (and prefixed local) framework table name carries a
151
+ // leading prefix so the framework's tables never collide with the
152
+ // operator's application tables. The default is `_blamejs_`; an operator
153
+ // running the framework alongside an app schema that itself uses
154
+ // `_blamejs_`-shaped names (or who simply wants a house prefix) can swap
155
+ // it at config-time via `setTablePrefix`. The default-prefix output is
156
+ // byte-identical to the historical hardcoded names, so this is a no-op
157
+ // for every existing deployment.
158
+ var DEFAULT_TABLE_PREFIX = "_blamejs_";
159
+ var currentPrefix = DEFAULT_TABLE_PREFIX;
160
+
161
+ /**
162
+ * @primitive b.frameworkSchema.setTablePrefix
163
+ * @signature b.frameworkSchema.setTablePrefix(prefix)
164
+ * @since 0.14.30
165
+ * @status stable
166
+ * @related b.frameworkSchema.getTablePrefix, b.frameworkSchema.tableName, b.db.init
167
+ *
168
+ * Set the leading prefix applied to every framework-owned table name
169
+ * (audit / consent / sessions / jobs / cache / break-glass / …). The
170
+ * default is `_blamejs_`; pass a different value to namespace the
171
+ * framework's tables away from an operator schema that would otherwise
172
+ * collide. Config-time only — call it once, before schema creation
173
+ * (`b.db.init` calls it for you when you pass `tablePrefix`). Throws a
174
+ * `FrameworkSchemaError` ("framework-schema/invalid-prefix") when the
175
+ * prefix is not a non-empty SQL identifier, so a typo surfaces at boot
176
+ * rather than as a silently-misnamed table.
177
+ *
178
+ * The default-prefix output is byte-identical to the historical names,
179
+ * so leaving the prefix unchanged is a no-op.
180
+ *
181
+ * @example
182
+ * b.frameworkSchema.setTablePrefix("acme_");
183
+ * b.frameworkSchema.tableName("audit_log");
184
+ * // → "acme_audit_log"
185
+ *
186
+ * try { b.frameworkSchema.setTablePrefix(""); }
187
+ * catch (e) { e.code; } // → "framework-schema/invalid-prefix"
188
+ */
189
+ function setTablePrefix(prefix) {
190
+ try {
191
+ safeSql.validateIdentifier(prefix, { allowReserved: true });
192
+ } catch (e) {
193
+ throw new FrameworkSchemaError(
194
+ "setTablePrefix: prefix must be a non-empty SQL identifier — " +
195
+ ((e && e.message) || String(e)),
196
+ "framework-schema/invalid-prefix"
197
+ );
198
+ }
199
+ currentPrefix = prefix;
200
+ return currentPrefix;
201
+ }
202
+
203
+ /**
204
+ * @primitive b.frameworkSchema.getTablePrefix
205
+ * @signature b.frameworkSchema.getTablePrefix()
206
+ * @since 0.14.30
207
+ * @status stable
208
+ * @related b.frameworkSchema.setTablePrefix, b.frameworkSchema.tableName
209
+ *
210
+ * Return the prefix currently applied to framework-owned table names —
211
+ * `_blamejs_` unless `setTablePrefix` changed it.
212
+ *
213
+ * @example
214
+ * b.frameworkSchema.getTablePrefix();
215
+ * // → "_blamejs_"
216
+ */
217
+ function getTablePrefix() {
218
+ return currentPrefix;
219
+ }
220
+
221
+ // Swap the leading default prefix on a resolved external name for the
222
+ // configured prefix. With the default prefix this returns the name
223
+ // unchanged (byte-identical to the historical literal); any framework
224
+ // name not carrying the default prefix (there are none today) passes
225
+ // through untouched.
226
+ function _applyPrefix(externalName) {
227
+ if (currentPrefix === DEFAULT_TABLE_PREFIX) return externalName;
228
+ if (externalName.indexOf(DEFAULT_TABLE_PREFIX) === 0) {
229
+ return currentPrefix + externalName.slice(DEFAULT_TABLE_PREFIX.length);
230
+ }
231
+ return externalName;
232
+ }
233
+
141
234
  /**
142
235
  * @primitive b.frameworkSchema.tableName
143
236
  * @signature b.frameworkSchema.tableName(localName)
144
237
  * @since 0.5.0
145
238
  * @status stable
146
- * @related b.frameworkSchema.ensureSchema
239
+ * @related b.frameworkSchema.ensureSchema, b.frameworkSchema.setTablePrefix
147
240
  *
148
241
  * Translate a local-SQLite table name into the external-db name. The
149
242
  * mapping is the frozen `LOCAL_TO_EXTERNAL` object — tables that already
150
- * carry the `_blamejs_` prefix locally pass through unchanged. Cluster
243
+ * carry the framework prefix locally pass through the mapping unchanged.
244
+ * The resolved name's leading prefix is then swapped to the configured
245
+ * prefix (`setTablePrefix`); with the default `_blamejs_` prefix the
246
+ * output is byte-identical to the historical names. Cluster
151
247
  * write-dispatch code uses this lookup so the same SQL works against
152
248
  * both backends without per-call branching.
153
249
  *
@@ -163,121 +259,224 @@ var LOCAL_TO_EXTERNAL = Object.freeze({
163
259
  */
164
260
  function tableName(localName) {
165
261
  if (Object.prototype.hasOwnProperty.call(LOCAL_TO_EXTERNAL, localName)) {
166
- return LOCAL_TO_EXTERNAL[localName];
262
+ return _applyPrefix(LOCAL_TO_EXTERNAL[localName]);
167
263
  }
168
- // For framework-internal tables that are already prefixed locally
169
- // (any name starting with _blamejs_), keep the same name.
170
- return localName;
264
+ // Framework-internal tables already carrying the default prefix locally
265
+ // but not in the LOCAL_TO_EXTERNAL map (e.g. `_blamejs_migrations`,
266
+ // `_blamejs_migrations_lock`, `_blamejs_counters`) still honor the
267
+ // configured prefix: swap the leading default prefix for the configured
268
+ // one. Under the default prefix this returns the name byte-identical, so
269
+ // it is a no-op for every existing deployment; under a custom prefix it
270
+ // namespaces these tables the same way the mapped names are namespaced.
271
+ return _applyPrefix(localName);
171
272
  }
172
273
 
173
274
  // ---- Dialect-specific column types ----
174
- // TEXT and BOOLEAN are identical across both. INTEGER and BLOB diverge.
275
+ // BOOLEAN is identical across all three. INTEGER, BLOB, and the
276
+ // "TEXT-used-in-a-key" token diverge.
277
+ //
278
+ // INT — ms-epoch counters / timestamps. Postgres BIGINT, SQLite
279
+ // INTEGER, MySQL BIGINT (a 32-bit INTEGER overflows a
280
+ // Date.now() ms value, so BIGINT is required on MySQL too).
281
+ // BLOB — Postgres BYTEA, SQLite BLOB, MySQL LONGBLOB.
282
+ // KT — "key text": a TEXT column that appears in a PRIMARY KEY or an
283
+ // index. MySQL refuses BLOB/TEXT in a key without a prefix
284
+ // length, so on MySQL such columns must be VARCHAR(n). 191 is
285
+ // the utf8mb4 index-safe length (191 * 4 bytes = 764 < the
286
+ // historical 767-byte InnoDB index-prefix limit), so a KT
287
+ // column is index-safe under every default MySQL/InnoDB
288
+ // configuration. On Postgres + SQLite a KT column is plain
289
+ // TEXT (both index TEXT without a length), so the on-disk shape
290
+ // is byte-identical to the historical schema there.
291
+ //
292
+ // Plain TEXT columns that are NEVER in a key (free-form payloads,
293
+ // metadata, reason strings) stay TEXT on every dialect — only key
294
+ // participants take the VARCHAR(n) treatment, so column values are not
295
+ // length-capped beyond what the schema needs.
296
+ var MYSQL_KEY_TEXT_LEN = 191;
175
297
 
298
+ // DT — "defaulted text": a short TEXT column that carries a string
299
+ // DEFAULT (e.g. an enum-like 'throw' / 'cleartext'). MySQL refuses
300
+ // a DEFAULT on a TEXT/BLOB column (error 1101), so such a column is
301
+ // VARCHAR(n) on MySQL and plain TEXT on Postgres + SQLite (both
302
+ // allow a TEXT default). Same VARCHAR(191) width as KT.
176
303
  function _types(dialect) {
177
304
  if (dialect === "postgres") {
178
- return { INT: "BIGINT", BLOB: "BYTEA" };
305
+ return { INT: "BIGINT", BLOB: "BYTEA", KT: "TEXT", DT: "TEXT" };
179
306
  }
180
307
  if (dialect === "sqlite") {
181
- return { INT: "INTEGER", BLOB: "BLOB" };
308
+ return { INT: "INTEGER", BLOB: "BLOB", KT: "TEXT", DT: "TEXT" };
309
+ }
310
+ if (dialect === "mysql") {
311
+ return {
312
+ INT: "BIGINT",
313
+ BLOB: "LONGBLOB",
314
+ KT: "VARCHAR(" + MYSQL_KEY_TEXT_LEN + ")",
315
+ DT: "VARCHAR(" + MYSQL_KEY_TEXT_LEN + ")",
316
+ };
182
317
  }
183
318
  throw new FrameworkSchemaError(
184
- "unsupported dialect '" + dialect + "' (postgres or sqlite)",
319
+ "unsupported dialect '" + dialect + "' (postgres, sqlite, or mysql)",
185
320
  "framework-schema/unsupported-dialect"
186
321
  );
187
322
  }
188
323
 
189
- // ---- Table DDL builders ----
324
+ // ---- Declarative, quote-by-construction DDL builder ----
190
325
  //
191
- // Each builder returns { create: <CREATE TABLE SQL>, indexes: [<CREATE INDEX SQL>, ...] }.
326
+ // Every column identifier is emitted through safeSql.quoteIdentifier so
327
+ // the on-disk name preserves its camelCase EXACTLY on every dialect.
328
+ // Postgres folds UNQUOTED identifiers to lowercase; the framework's DML
329
+ // reads camelCase (`row.rowHash` / `row.monotonicCounter`) and the
330
+ // chain-writer INSERTs safeSql.quoteIdentifier-quoted camelCase columns,
331
+ // so the DDL MUST quote to match — an unquoted DDL silently breaks the
332
+ // audit chain, consent chain, and cluster leadership on Postgres
333
+ // (the INSERT targets a column that doesn't exist; SELECT * returns
334
+ // lowercase keys). Quoting also makes reserved-word columns (`key`,
335
+ // `count`, `name`) safe by construction.
336
+ //
337
+ // A column entry is one of:
338
+ // { col: "<name>", def: "<TYPE> [constraints]" } → "<name>" <TYPE> ...
339
+ // { pk: ["<col>", ...] } → PRIMARY KEY ("a", "b")
340
+ // { raw: "<verbatim clause>" } → table-level CHECK etc.
341
+ // An index entry is { suffix, cols: [...], unique? }.
342
+ //
343
+ // Each builder returns { create: <CREATE TABLE SQL>, indexes: [...] }.
192
344
  // All DDL uses IF NOT EXISTS so re-running is idempotent.
193
345
 
194
- function _auditLogDDL(dialect) {
195
- var t = _types(dialect);
196
- var name = LOCAL_TO_EXTERNAL.audit_log;
346
+ // safeSql.quoteIdentifier dialect token: mysql → backtick, everything
347
+ // else double-quote (postgres + sqlite share the SQL-standard form).
348
+ function _qd(dialect) {
349
+ return dialect === "mysql" ? "mysql" : (dialect === "sqlite" ? "sqlite" : "postgres");
350
+ }
351
+
352
+ function _buildCreate(name, dialect, columns) {
353
+ var qd = _qd(dialect);
354
+ var parts = columns.map(function (c) {
355
+ if (c.raw) return " " + c.raw;
356
+ if (c.pk) {
357
+ return " PRIMARY KEY (" +
358
+ c.pk.map(function (k) { return safeSql.quoteIdentifier(k, qd); }).join(", ") + ")";
359
+ }
360
+ return " " + safeSql.quoteIdentifier(c.col, qd) + " " + c.def;
361
+ });
362
+ return "CREATE TABLE IF NOT EXISTS " + name + " (" + parts.join(",") + ")";
363
+ }
364
+
365
+ // Cap a generated index name to the strictest dialect identifier limit
366
+ // (Postgres NAMEDATALEN 63). A longer name is truncated with a short stable
367
+ // checksum suffix so two long names cannot collide after truncation. The
368
+ // name is a fresh label (never quoted / re-referenced), so sanitizing to a
369
+ // bare identifier is safe.
370
+ function _capIndexName(raw) {
371
+ // Framework index names are built from controlled identifiers (the
372
+ // _blamejs_* table name + an identifier suffix), so only the length needs
373
+ // bounding - a name over the limit is truncated with a short stable
374
+ // checksum suffix so two long names can't collide after truncation.
375
+ if (raw.length <= safeSql.MAX_IDENTIFIER_LENGTH) return raw;
376
+ var h = 0;
377
+ for (var i = 0; i < raw.length; i += 1) h = (h * 31 + raw.charCodeAt(i)) >>> 0;
378
+ return raw.slice(0, safeSql.MAX_IDENTIFIER_LENGTH - 9) + "_" + h.toString(36);
379
+ }
380
+
381
+ function _buildIndexes(name, dialect, indexes) {
382
+ var qd = _qd(dialect);
383
+ // MySQL has no CREATE INDEX IF NOT EXISTS — the clause is a syntax error
384
+ // there. Postgres + SQLite support it (idempotent re-creation). On MySQL
385
+ // the bare CREATE INDEX is emitted and ensureSchema swallows the
386
+ // duplicate-key-name error on re-run so the idempotence contract holds.
387
+ // The keyword phrase is a per-dialect string LITERAL (not a keyword + a
388
+ // variable) so the identifier-quoting detector reads it as the static
389
+ // clause it is.
390
+ var createIndex = dialect === "mysql" ? "CREATE INDEX " : "CREATE INDEX IF NOT EXISTS ";
391
+ var createUnique = dialect === "mysql" ? "CREATE UNIQUE INDEX " : "CREATE UNIQUE INDEX IF NOT EXISTS ";
392
+ return (indexes || []).map(function (ix) {
393
+ var idxName = _capIndexName("idx_" + name + "_" + ix.suffix);
394
+ return (ix.unique ? createUnique : createIndex) + idxName + " ON " + name +
395
+ " (" + ix.cols.map(function (col) { return safeSql.quoteIdentifier(col, qd); }).join(", ") + ")";
396
+ });
397
+ }
398
+
399
+ function _table(name, dialect, columns, indexes) {
197
400
  return {
198
- create:
199
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
200
- " _id TEXT PRIMARY KEY," +
201
- " recordedAt " + t.INT + " NOT NULL," +
202
- " monotonicCounter " + t.INT + " NOT NULL," +
203
- " actorUserId TEXT," +
204
- " actorUserIdHash TEXT," +
205
- " actorIp TEXT," +
206
- " actorUserAgent TEXT," +
207
- " actorSessionId TEXT," +
208
- " action TEXT NOT NULL," +
209
- " resourceKind TEXT," +
210
- " resourceId TEXT," +
211
- " resourceIdHash TEXT," +
212
- " outcome TEXT NOT NULL," +
213
- " reason TEXT," +
214
- " metadata TEXT," +
215
- " requestId TEXT," +
216
- " prevHash TEXT NOT NULL," +
217
- " rowHash TEXT NOT NULL," +
218
- " nonce " + t.BLOB + " NOT NULL," +
219
- " fencingToken " + t.INT + " NOT NULL DEFAULT 0" +
220
- ")",
221
- indexes: [
222
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_actorUserIdHash ON " + name + " (actorUserIdHash)",
223
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_resourceIdHash ON " + name + " (resourceIdHash)",
224
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_recordedAt ON " + name + " (recordedAt)",
225
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_action ON " + name + " (action)",
226
- "CREATE UNIQUE INDEX IF NOT EXISTS idx_" + name + "_monotonic ON " + name + " (monotonicCounter)",
227
- ],
401
+ create: _buildCreate(name, dialect, columns),
402
+ indexes: _buildIndexes(name, dialect, indexes),
228
403
  };
229
404
  }
230
405
 
406
+ function _auditLogDDL(dialect) {
407
+ var t = _types(dialect);
408
+ return _table(tableName("audit_log"), dialect, [
409
+ { col: "_id", def: t.KT + " PRIMARY KEY" },
410
+ { col: "recordedAt", def: t.INT + " NOT NULL" },
411
+ { col: "monotonicCounter", def: t.INT + " NOT NULL" },
412
+ { col: "actorUserId", def: "TEXT" },
413
+ { col: "actorUserIdHash", def: t.KT },
414
+ { col: "actorIp", def: "TEXT" },
415
+ { col: "actorUserAgent", def: "TEXT" },
416
+ { col: "actorSessionId", def: "TEXT" },
417
+ { col: "action", def: t.KT + " NOT NULL" },
418
+ { col: "resourceKind", def: t.KT },
419
+ { col: "resourceId", def: "TEXT" },
420
+ { col: "resourceIdHash", def: t.KT },
421
+ { col: "outcome", def: t.KT + " NOT NULL" },
422
+ { col: "reason", def: "TEXT" },
423
+ { col: "metadata", def: "TEXT" },
424
+ { col: "requestId", def: "TEXT" },
425
+ { col: "prevHash", def: "TEXT NOT NULL" },
426
+ { col: "rowHash", def: "TEXT NOT NULL" },
427
+ { col: "nonce", def: t.BLOB + " NOT NULL" },
428
+ { col: "fencingToken", def: t.INT + " NOT NULL DEFAULT 0" },
429
+ ], [
430
+ { suffix: "actorUserIdHash", cols: ["actorUserIdHash"] },
431
+ { suffix: "resourceIdHash", cols: ["resourceIdHash"] },
432
+ { suffix: "recordedAt", cols: ["recordedAt"] },
433
+ { suffix: "action", cols: ["action"] },
434
+ { suffix: "resourceKind", cols: ["resourceKind"] },
435
+ { suffix: "outcome", cols: ["outcome"] },
436
+ { suffix: "monotonic", cols: ["monotonicCounter"], unique: true },
437
+ ]);
438
+ }
439
+
231
440
  function _consentLogDDL(dialect) {
232
441
  var t = _types(dialect);
233
- var name = LOCAL_TO_EXTERNAL.consent_log;
234
- return {
235
- create:
236
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
237
- " _id TEXT PRIMARY KEY," +
238
- " recordedAt " + t.INT + " NOT NULL," +
239
- " monotonicCounter " + t.INT + " NOT NULL," +
240
- " subjectId TEXT NOT NULL," +
241
- " subjectIdHash TEXT NOT NULL," +
242
- " purpose TEXT NOT NULL," +
243
- " lawfulBasis TEXT NOT NULL," +
244
- " action TEXT NOT NULL," +
245
- " scope TEXT," +
246
- " channel TEXT NOT NULL," +
247
- " evidenceRef TEXT," +
248
- " prevHash TEXT NOT NULL," +
249
- " rowHash TEXT NOT NULL," +
250
- " nonce " + t.BLOB + " NOT NULL," +
251
- " fencingToken " + t.INT + " NOT NULL DEFAULT 0" +
252
- ")",
253
- indexes: [
254
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_subjectIdHash ON " + name + " (subjectIdHash)",
255
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_recordedAt ON " + name + " (recordedAt)",
256
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_purpose ON " + name + " (purpose)",
257
- "CREATE UNIQUE INDEX IF NOT EXISTS idx_" + name + "_monotonic ON " + name + " (monotonicCounter)",
258
- ],
259
- };
442
+ return _table(tableName("consent_log"), dialect, [
443
+ { col: "_id", def: t.KT + " PRIMARY KEY" },
444
+ { col: "recordedAt", def: t.INT + " NOT NULL" },
445
+ { col: "monotonicCounter", def: t.INT + " NOT NULL" },
446
+ { col: "subjectId", def: "TEXT NOT NULL" },
447
+ { col: "subjectIdHash", def: t.KT + " NOT NULL" },
448
+ { col: "purpose", def: t.KT + " NOT NULL" },
449
+ { col: "lawfulBasis", def: "TEXT NOT NULL" },
450
+ { col: "action", def: "TEXT NOT NULL" },
451
+ { col: "scope", def: "TEXT" },
452
+ { col: "channel", def: "TEXT NOT NULL" },
453
+ { col: "evidenceRef", def: "TEXT" },
454
+ { col: "prevHash", def: "TEXT NOT NULL" },
455
+ { col: "rowHash", def: "TEXT NOT NULL" },
456
+ { col: "nonce", def: t.BLOB + " NOT NULL" },
457
+ { col: "fencingToken", def: t.INT + " NOT NULL DEFAULT 0" },
458
+ ], [
459
+ { suffix: "subjectIdHash", cols: ["subjectIdHash"] },
460
+ { suffix: "recordedAt", cols: ["recordedAt"] },
461
+ { suffix: "purpose", cols: ["purpose"] },
462
+ { suffix: "monotonic", cols: ["monotonicCounter"], unique: true },
463
+ ]);
260
464
  }
261
465
 
262
466
  function _auditCheckpointsDDL(dialect) {
263
467
  var t = _types(dialect);
264
- var name = LOCAL_TO_EXTERNAL.audit_checkpoints;
265
- return {
266
- create:
267
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
268
- " _id TEXT PRIMARY KEY," +
269
- " createdAt " + t.INT + " NOT NULL," +
270
- " atMonotonicCounter " + t.INT + " NOT NULL," +
271
- " atRowHash TEXT NOT NULL," +
272
- " signature " + t.BLOB + " NOT NULL," +
273
- " publicKeyFingerprint TEXT NOT NULL," +
274
- " fencingToken " + t.INT + " NOT NULL DEFAULT 0" +
275
- ")",
276
- indexes: [
277
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_createdAt ON " + name + " (createdAt)",
278
- "CREATE UNIQUE INDEX IF NOT EXISTS idx_" + name + "_chkpt_counter ON " + name + " (atMonotonicCounter)",
279
- ],
280
- };
468
+ return _table(tableName("audit_checkpoints"), dialect, [
469
+ { col: "_id", def: t.KT + " PRIMARY KEY" },
470
+ { col: "createdAt", def: t.INT + " NOT NULL" },
471
+ { col: "atMonotonicCounter", def: t.INT + " NOT NULL" },
472
+ { col: "atRowHash", def: "TEXT NOT NULL" },
473
+ { col: "signature", def: t.BLOB + " NOT NULL" },
474
+ { col: "publicKeyFingerprint", def: "TEXT NOT NULL" },
475
+ { col: "fencingToken", def: t.INT + " NOT NULL DEFAULT 0" },
476
+ ], [
477
+ { suffix: "createdAt", cols: ["createdAt"] },
478
+ { suffix: "chkpt_counter", cols: ["atMonotonicCounter"], unique: true },
479
+ ]);
281
480
  }
282
481
 
283
482
  // audit_tip is single-row coordination state for cluster-mode rollback
@@ -291,19 +490,14 @@ function _auditCheckpointsDDL(dialect) {
291
490
  // `scope` column.
292
491
  function _auditTipDDL(dialect) {
293
492
  var t = _types(dialect);
294
- var name = LOCAL_TO_EXTERNAL._blamejs_audit_tip;
295
- return {
296
- create:
297
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
298
- " scope TEXT PRIMARY KEY," +
299
- " atMonotonicCounter " + t.INT + " NOT NULL," +
300
- " rowHash TEXT," +
301
- " signedAt TEXT," +
302
- " fencingToken " + t.INT + " NOT NULL DEFAULT 0," +
303
- " CHECK (scope = 'audit')" +
304
- ")",
305
- indexes: [],
306
- };
493
+ return _table(tableName("_blamejs_audit_tip"), dialect, [
494
+ { col: "scope", def: t.KT + " PRIMARY KEY" },
495
+ { col: "atMonotonicCounter", def: t.INT + " NOT NULL" },
496
+ { col: "rowHash", def: "TEXT" },
497
+ { col: "signedAt", def: "TEXT" },
498
+ { col: "fencingToken", def: t.INT + " NOT NULL DEFAULT 0" },
499
+ { raw: "CHECK (" + safeSql.quoteIdentifier("scope", _qd(dialect)) + " = 'audit')" },
500
+ ], []);
307
501
  }
308
502
 
309
503
  // Same shape + invariants as audit_tip but for the consent chain.
@@ -312,19 +506,14 @@ function _auditTipDDL(dialect) {
312
506
  // consent chain (previously only the audit chain had this protection).
313
507
  function _consentTipDDL(dialect) {
314
508
  var t = _types(dialect);
315
- var name = LOCAL_TO_EXTERNAL._blamejs_consent_tip;
316
- return {
317
- create:
318
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
319
- " scope TEXT PRIMARY KEY," +
320
- " atMonotonicCounter " + t.INT + " NOT NULL," +
321
- " rowHash TEXT," +
322
- " signedAt TEXT," +
323
- " fencingToken " + t.INT + " NOT NULL DEFAULT 0," +
324
- " CHECK (scope = 'consent')" +
325
- ")",
326
- indexes: [],
327
- };
509
+ return _table(tableName("_blamejs_consent_tip"), dialect, [
510
+ { col: "scope", def: t.KT + " PRIMARY KEY" },
511
+ { col: "atMonotonicCounter", def: t.INT + " NOT NULL" },
512
+ { col: "rowHash", def: "TEXT" },
513
+ { col: "signedAt", def: "TEXT" },
514
+ { col: "fencingToken", def: t.INT + " NOT NULL DEFAULT 0" },
515
+ { raw: "CHECK (" + safeSql.quoteIdentifier("scope", _qd(dialect)) + " = 'consent')" },
516
+ ], []);
328
517
  }
329
518
 
330
519
  // _blamejs_audit_purge_anchor — single-row chain-origin anchor written
@@ -334,19 +523,14 @@ function _consentTipDDL(dialect) {
334
523
  // column (matches _blamejs_audit_tip pattern).
335
524
  function _auditPurgeAnchorDDL(dialect) {
336
525
  var t = _types(dialect);
337
- var name = LOCAL_TO_EXTERNAL._blamejs_audit_purge_anchor;
338
- return {
339
- create:
340
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
341
- " scope TEXT PRIMARY KEY," +
342
- " lastPurgedCounter " + t.INT + " NOT NULL," +
343
- " lastPurgedRowHash TEXT NOT NULL," +
344
- " archiveBundleId TEXT NOT NULL," +
345
- " purgedAt " + t.INT + " NOT NULL," +
346
- " CHECK (scope = 'audit')" +
347
- ")",
348
- indexes: [],
349
- };
526
+ return _table(tableName("_blamejs_audit_purge_anchor"), dialect, [
527
+ { col: "scope", def: t.KT + " PRIMARY KEY" },
528
+ { col: "lastPurgedCounter", def: t.INT + " NOT NULL" },
529
+ { col: "lastPurgedRowHash", def: "TEXT NOT NULL" },
530
+ { col: "archiveBundleId", def: "TEXT NOT NULL" },
531
+ { col: "purgedAt", def: t.INT + " NOT NULL" },
532
+ { raw: "CHECK (" + safeSql.quoteIdentifier("scope", _qd(dialect)) + " = 'audit')" },
533
+ ], []);
350
534
  }
351
535
 
352
536
  // _blamejs_scheduler_ticks — exactly-once tick-claim table. PRIMARY KEY
@@ -354,20 +538,15 @@ function _auditPurgeAnchorDDL(dialect) {
354
538
  // the tick. claimedBy carries the node id for diagnostic.
355
539
  function _schedulerTicksDDL(dialect) {
356
540
  var t = _types(dialect);
357
- var name = LOCAL_TO_EXTERNAL._blamejs_scheduler_ticks;
358
- return {
359
- create:
360
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
361
- " tickKey TEXT PRIMARY KEY," +
362
- " name TEXT NOT NULL," +
363
- " scheduledAtUnix " + t.INT + " NOT NULL," +
364
- " claimedAtUnix " + t.INT + " NOT NULL," +
365
- " claimedBy TEXT" +
366
- ")",
367
- indexes: [
368
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_scheduledAt ON " + name + " (scheduledAtUnix)",
369
- ],
370
- };
541
+ return _table(tableName("_blamejs_scheduler_ticks"), dialect, [
542
+ { col: "tickKey", def: t.KT + " PRIMARY KEY" },
543
+ { col: "name", def: "TEXT NOT NULL" },
544
+ { col: "scheduledAtUnix", def: t.INT + " NOT NULL" },
545
+ { col: "claimedAtUnix", def: t.INT + " NOT NULL" },
546
+ { col: "claimedBy", def: "TEXT" },
547
+ ], [
548
+ { suffix: "scheduledAt", cols: ["scheduledAtUnix"] },
549
+ ]);
371
550
  }
372
551
 
373
552
  // _blamejs_rate_limit_counters — fixed-window counter table for the
@@ -377,60 +556,48 @@ function _schedulerTicksDDL(dialect) {
377
556
  // retention sweeps of expired windows.
378
557
  function _rateLimitCountersDDL(dialect) {
379
558
  var t = _types(dialect);
380
- var name = LOCAL_TO_EXTERNAL._blamejs_rate_limit_counters;
381
- return {
382
- create:
383
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
384
- " key TEXT PRIMARY KEY," +
385
- " windowStart " + t.INT + " NOT NULL," +
386
- " count " + t.INT + " NOT NULL DEFAULT 0" +
387
- ")",
388
- indexes: [
389
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_windowStart ON " + name + " (windowStart)",
390
- ],
391
- };
559
+ return _table(tableName("_blamejs_rate_limit_counters"), dialect, [
560
+ { col: "key", def: t.KT + " PRIMARY KEY" },
561
+ { col: "windowStart", def: t.INT + " NOT NULL" },
562
+ { col: "count", def: t.INT + " NOT NULL DEFAULT 0" },
563
+ ], [
564
+ { suffix: "windowStart", cols: ["windowStart"] },
565
+ ]);
392
566
  }
393
567
 
394
568
  // _blamejs_pubsub_messages — cluster fan-out for `b.pubsub` (the
395
569
  // generalization of the previous WebSocket-specific table). publish()
396
570
  // on any node writes a row; other nodes poll for new ids past their
397
571
  // last seen and dispatch to local subscribers. Auto-incrementing id
398
- // is essential — postgres needs BIGSERIAL, sqlite gets INTEGER
399
- // PRIMARY KEY (which auto-increments implicitly).
572
+ // is essential — postgres needs BIGSERIAL, sqlite gets INTEGER PRIMARY
573
+ // KEY (which auto-increments implicitly), mysql gets BIGINT
574
+ // AUTO_INCREMENT (which requires an explicit PRIMARY KEY clause).
400
575
  function _pubsubMessagesDDL(dialect) {
401
576
  var t = _types(dialect);
402
- var name = LOCAL_TO_EXTERNAL._blamejs_pubsub_messages;
403
- var idCol = dialect === "postgres"
404
- ? "id BIGSERIAL PRIMARY KEY"
405
- : "id INTEGER PRIMARY KEY AUTOINCREMENT";
406
- return {
407
- create:
408
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
409
- " " + idCol + "," +
410
- " topic TEXT NOT NULL," +
411
- " payload TEXT NOT NULL," +
412
- " publishedAt " + t.INT + " NOT NULL," +
413
- " publishedBy TEXT NOT NULL" +
414
- ")",
415
- indexes: [
416
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_publishedAt ON " + name + " (publishedAt)",
417
- ],
418
- };
577
+ var idType = dialect === "postgres"
578
+ ? "BIGSERIAL PRIMARY KEY"
579
+ : (dialect === "mysql"
580
+ ? "BIGINT AUTO_INCREMENT PRIMARY KEY"
581
+ : "INTEGER PRIMARY KEY AUTOINCREMENT");
582
+ return _table(tableName("_blamejs_pubsub_messages"), dialect, [
583
+ { col: "id", def: idType },
584
+ { col: "topic", def: "TEXT NOT NULL" },
585
+ { col: "payload", def: "TEXT NOT NULL" },
586
+ { col: "publishedAt", def: t.INT + " NOT NULL" },
587
+ { col: "publishedBy", def: "TEXT NOT NULL" },
588
+ ], [
589
+ { suffix: "publishedAt", cols: ["publishedAt"] },
590
+ ]);
419
591
  }
420
592
 
421
593
  function _apiEncryptNoncesDDL(dialect) {
422
594
  var t = _types(dialect);
423
- var name = LOCAL_TO_EXTERNAL._blamejs_api_encrypt_nonces;
424
- return {
425
- create:
426
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
427
- " nonceHash TEXT PRIMARY KEY," +
428
- " expireAt " + t.INT + " NOT NULL" +
429
- ")",
430
- indexes: [
431
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_expireAt ON " + name + " (expireAt)",
432
- ],
433
- };
595
+ return _table(tableName("_blamejs_api_encrypt_nonces"), dialect, [
596
+ { col: "nonceHash", def: t.KT + " PRIMARY KEY" },
597
+ { col: "expireAt", def: t.INT + " NOT NULL" },
598
+ ], [
599
+ { suffix: "expireAt", cols: ["expireAt"] },
600
+ ]);
434
601
  }
435
602
 
436
603
  // _blamejs_api_keys — operator-facing API-key registry. PRIMARY KEY is
@@ -439,31 +606,26 @@ function _apiEncryptNoncesDDL(dialect) {
439
606
  // lookups; expiresAt index supports purgeExpired sweeps.
440
607
  function _apiKeysDDL(dialect) {
441
608
  var t = _types(dialect);
442
- var name = LOCAL_TO_EXTERNAL._blamejs_api_keys;
443
- return {
444
- create:
445
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
446
- " id TEXT PRIMARY KEY," +
447
- " namespace TEXT NOT NULL," +
448
- " ownerId TEXT NOT NULL," +
449
- " ownerIdHash TEXT NOT NULL," +
450
- " secretHash TEXT NOT NULL," +
451
- " secondarySecretHash TEXT," +
452
- " secondaryExpiresAt " + t.INT + "," +
453
- " scopes TEXT," +
454
- " metadata TEXT," +
455
- " createdAt " + t.INT + " NOT NULL," +
456
- " expiresAt " + t.INT + "," +
457
- " revokedAt " + t.INT + "," +
458
- " lastUsedAt " + t.INT + "," +
459
- " prefix TEXT NOT NULL" +
460
- ")",
461
- indexes: [
462
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_ownerIdHash ON " + name + " (ownerIdHash)",
463
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_namespace_owner ON " + name + " (namespace, ownerIdHash)",
464
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_expiresAt ON " + name + " (expiresAt)",
465
- ],
466
- };
609
+ return _table(tableName("_blamejs_api_keys"), dialect, [
610
+ { col: "id", def: t.KT + " PRIMARY KEY" },
611
+ { col: "namespace", def: t.KT + " NOT NULL" },
612
+ { col: "ownerId", def: "TEXT NOT NULL" },
613
+ { col: "ownerIdHash", def: t.KT + " NOT NULL" },
614
+ { col: "secretHash", def: "TEXT NOT NULL" },
615
+ { col: "secondarySecretHash", def: "TEXT" },
616
+ { col: "secondaryExpiresAt", def: t.INT },
617
+ { col: "scopes", def: "TEXT" },
618
+ { col: "metadata", def: "TEXT" },
619
+ { col: "createdAt", def: t.INT + " NOT NULL" },
620
+ { col: "expiresAt", def: t.INT },
621
+ { col: "revokedAt", def: t.INT },
622
+ { col: "lastUsedAt", def: t.INT },
623
+ { col: "prefix", def: "TEXT NOT NULL" },
624
+ ], [
625
+ { suffix: "ownerIdHash", cols: ["ownerIdHash"] },
626
+ { suffix: "namespace_owner", cols: ["namespace", "ownerIdHash"] },
627
+ { suffix: "expiresAt", cols: ["expiresAt"] },
628
+ ]);
467
629
  }
468
630
 
469
631
  // _blamejs_sessions — DB-backed session store. Mirrors the local-SQLite
@@ -473,23 +635,18 @@ function _apiKeysDDL(dialect) {
473
635
  // session id never lands here).
474
636
  function _sessionsDDL(dialect) {
475
637
  var t = _types(dialect);
476
- var name = LOCAL_TO_EXTERNAL._blamejs_sessions;
477
- return {
478
- create:
479
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
480
- " sidHash TEXT PRIMARY KEY," +
481
- " userId TEXT NOT NULL," +
482
- " userIdHash TEXT NOT NULL," +
483
- " data TEXT," +
484
- " createdAt " + t.INT + " NOT NULL," +
485
- " expiresAt " + t.INT + " NOT NULL," +
486
- " lastActivity " + t.INT + " NOT NULL" +
487
- ")",
488
- indexes: [
489
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_userIdHash ON " + name + " (userIdHash)",
490
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_expiresAt ON " + name + " (expiresAt)",
491
- ],
492
- };
638
+ return _table(tableName("_blamejs_sessions"), dialect, [
639
+ { col: "sidHash", def: t.KT + " PRIMARY KEY" },
640
+ { col: "userId", def: "TEXT NOT NULL" },
641
+ { col: "userIdHash", def: t.KT + " NOT NULL" },
642
+ { col: "data", def: "TEXT" },
643
+ { col: "createdAt", def: t.INT + " NOT NULL" },
644
+ { col: "expiresAt", def: t.INT + " NOT NULL" },
645
+ { col: "lastActivity", def: t.INT + " NOT NULL" },
646
+ ], [
647
+ { suffix: "userIdHash", cols: ["userIdHash"] },
648
+ { suffix: "expiresAt", cols: ["expiresAt"] },
649
+ ]);
493
650
  }
494
651
 
495
652
  // _blamejs_jobs — local-protocol queue jobs. Mirrors db.js's
@@ -499,39 +656,34 @@ function _sessionsDDL(dialect) {
499
656
  // sweep (leaseExpiresAt).
500
657
  function _jobsDDL(dialect) {
501
658
  var t = _types(dialect);
502
- var name = LOCAL_TO_EXTERNAL._blamejs_jobs;
503
- return {
504
- create:
505
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
506
- " _id TEXT PRIMARY KEY," +
507
- " queueName TEXT NOT NULL," +
508
- " payload TEXT," +
509
- " status TEXT NOT NULL," +
510
- " enqueuedAt " + t.INT + " NOT NULL," +
511
- " availableAt " + t.INT + " NOT NULL," +
512
- " leasedAt " + t.INT + "," +
513
- " leaseExpiresAt " + t.INT + "," +
514
- " attempts " + t.INT + " NOT NULL DEFAULT 0," +
515
- " maxAttempts " + t.INT + " NOT NULL DEFAULT 5," +
516
- " lastError TEXT," +
517
- " finishedAt " + t.INT + "," +
518
- " traceId TEXT," +
519
- " classification TEXT," +
520
- " priority " + t.INT + " NOT NULL DEFAULT 0," +
521
- " repeatCron TEXT," +
522
- " repeatTimezone TEXT," +
523
- " flowId TEXT," +
524
- " flowChildName TEXT," +
525
- " dependsOn TEXT" +
526
- ")",
527
- indexes: [
528
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_lease ON " + name + " (queueName, status, availableAt)",
529
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_priority ON " + name + " (queueName, status, priority, availableAt)",
530
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_flow ON " + name + " (flowId)",
531
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_leaseExpiresAt ON " + name + " (leaseExpiresAt)",
532
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_finishedAt ON " + name + " (finishedAt)",
533
- ],
534
- };
659
+ return _table(tableName("_blamejs_jobs"), dialect, [
660
+ { col: "_id", def: t.KT + " PRIMARY KEY" },
661
+ { col: "queueName", def: t.KT + " NOT NULL" },
662
+ { col: "payload", def: "TEXT" },
663
+ { col: "status", def: t.KT + " NOT NULL" },
664
+ { col: "enqueuedAt", def: t.INT + " NOT NULL" },
665
+ { col: "availableAt", def: t.INT + " NOT NULL" },
666
+ { col: "leasedAt", def: t.INT },
667
+ { col: "leaseExpiresAt", def: t.INT },
668
+ { col: "attempts", def: t.INT + " NOT NULL DEFAULT 0" },
669
+ { col: "maxAttempts", def: t.INT + " NOT NULL DEFAULT 5" },
670
+ { col: "lastError", def: "TEXT" },
671
+ { col: "finishedAt", def: t.INT },
672
+ { col: "traceId", def: "TEXT" },
673
+ { col: "classification", def: "TEXT" },
674
+ { col: "priority", def: t.INT + " NOT NULL DEFAULT 0" },
675
+ { col: "repeatCron", def: "TEXT" },
676
+ { col: "repeatTimezone", def: "TEXT" },
677
+ { col: "flowId", def: t.KT },
678
+ { col: "flowChildName", def: "TEXT" },
679
+ { col: "dependsOn", def: "TEXT" },
680
+ ], [
681
+ { suffix: "lease", cols: ["queueName", "status", "availableAt"] },
682
+ { suffix: "priority", cols: ["queueName", "status", "priority", "availableAt"] },
683
+ { suffix: "flow", cols: ["flowId"] },
684
+ { suffix: "leaseExpiresAt", cols: ["leaseExpiresAt"] },
685
+ { suffix: "finishedAt", cols: ["finishedAt"] },
686
+ ]);
535
687
  }
536
688
 
537
689
  // _blamejs_seeders — registry of applied seed files for the b.seeders
@@ -541,35 +693,26 @@ function _jobsDDL(dialect) {
541
693
  // entries are insert-once.
542
694
  function _seedersDDL(dialect) {
543
695
  var t = _types(dialect);
544
- var name = LOCAL_TO_EXTERNAL._blamejs_seeders;
545
- return {
546
- create:
547
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
548
- " env TEXT NOT NULL," +
549
- " name TEXT NOT NULL," +
550
- " description TEXT," +
551
- " appliedAt TEXT NOT NULL," +
552
- " rerunnable " + t.INT + " NOT NULL DEFAULT 0," +
553
- " PRIMARY KEY (env, name)" +
554
- ")",
555
- indexes: [],
556
- };
696
+ return _table(tableName("_blamejs_seeders"), dialect, [
697
+ { col: "env", def: t.KT + " NOT NULL" },
698
+ { col: "name", def: t.KT + " NOT NULL" },
699
+ { col: "description", def: "TEXT" },
700
+ { col: "appliedAt", def: "TEXT NOT NULL" },
701
+ { col: "rerunnable", def: t.INT + " NOT NULL DEFAULT 0" },
702
+ { pk: ["env", "name"] },
703
+ ], []);
557
704
  }
558
705
 
559
706
  // _blamejs_seeders_lock — single-row advisory lock matching the
560
707
  // _blamejs_migrations_lock pattern. CHECK enforces single row.
561
708
  function _seedersLockDDL(dialect) {
562
709
  var t = _types(dialect);
563
- var name = LOCAL_TO_EXTERNAL._blamejs_seeders_lock;
564
- return {
565
- create:
566
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
567
- " scope TEXT PRIMARY KEY CHECK (scope = 'lock')," +
568
- " lockedAt " + t.INT + " NOT NULL," +
569
- " lockedBy TEXT NOT NULL" +
570
- ")",
571
- indexes: [],
572
- };
710
+ return _table(tableName("_blamejs_seeders_lock"), dialect, [
711
+ { col: "scope", def: t.KT + " PRIMARY KEY CHECK (" +
712
+ safeSql.quoteIdentifier("scope", _qd(dialect)) + " = 'lock')" },
713
+ { col: "lockedAt", def: t.INT + " NOT NULL" },
714
+ { col: "lockedBy", def: "TEXT NOT NULL" },
715
+ ], []);
573
716
  }
574
717
 
575
718
  // _blamejs_cache — operator-facing cache primitive's cluster backend
@@ -581,39 +724,33 @@ function _seedersLockDDL(dialect) {
581
724
  // expiresAt for the periodic prune query.
582
725
  function _cacheDDL(dialect) {
583
726
  var t = _types(dialect);
584
- var name = LOCAL_TO_EXTERNAL._blamejs_cache;
585
- return {
586
- create:
587
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
588
- " cacheKey TEXT PRIMARY KEY," +
589
- " valueJson TEXT NOT NULL," +
590
- " expiresAt " + t.INT + " NOT NULL," +
591
- " updatedAt " + t.INT + " NOT NULL" +
592
- ")",
593
- indexes: [
594
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_expiresAt ON " + name + " (expiresAt)",
595
- ],
596
- };
727
+ return _table(tableName("_blamejs_cache"), dialect, [
728
+ { col: "cacheKey", def: t.KT + " PRIMARY KEY" },
729
+ { col: "valueJson", def: "TEXT NOT NULL" },
730
+ { col: "expiresAt", def: t.INT + " NOT NULL" },
731
+ { col: "updatedAt", def: t.INT + " NOT NULL" },
732
+ ], [
733
+ { suffix: "expiresAt", cols: ["expiresAt"] },
734
+ ]);
597
735
  }
598
736
 
599
737
  // _blamejs_cache_tags — tag→cacheKey junction for cluster-backend
600
738
  // tag invalidation. b.cache.invalidateTag(t) finds matching cacheKeys
601
739
  // via the indexed `tag` column, deletes them from _blamejs_cache, and
602
740
  // drops the junction rows. Cleared on cache.clear() and del() too.
603
- function _cacheTagsDDL(_dialect) {
604
- // Junction table is TEXT-only no dialect-specific INT / BLOB needed.
605
- var name = LOCAL_TO_EXTERNAL._blamejs_cache_tags;
606
- return {
607
- create:
608
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
609
- " cacheKey TEXT NOT NULL," +
610
- " tag TEXT NOT NULL," +
611
- " PRIMARY KEY (cacheKey, tag)" +
612
- ")",
613
- indexes: [
614
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_tag ON " + name + " (tag)",
615
- ],
616
- };
741
+ function _cacheTagsDDL(dialect) {
742
+ // Junction table is TEXT-only, but every column participates in a key
743
+ // (composite PK + the tag index), so all take the key-text token —
744
+ // VARCHAR(n) on MySQL (TEXT in a key is refused there), plain TEXT on
745
+ // Postgres + SQLite.
746
+ var t = _types(dialect);
747
+ return _table(tableName("_blamejs_cache_tags"), dialect, [
748
+ { col: "cacheKey", def: t.KT + " NOT NULL" },
749
+ { col: "tag", def: t.KT + " NOT NULL" },
750
+ { pk: ["cacheKey", "tag"] },
751
+ ], [
752
+ { suffix: "tag", cols: ["tag"] },
753
+ ]);
617
754
  }
618
755
 
619
756
  // _blamejs_break_glass_policies — column-level break-glass policy
@@ -622,29 +759,24 @@ function _cacheTagsDDL(_dialect) {
622
759
  // column-list / factor-list / bypass config from cleartext browsing.
623
760
  function _breakGlassPoliciesDDL(dialect) {
624
761
  var t = _types(dialect);
625
- var name = LOCAL_TO_EXTERNAL._blamejs_break_glass_policies;
626
- return {
627
- create:
628
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
629
- " tableName TEXT PRIMARY KEY," +
630
- " columnsJson TEXT NOT NULL," +
631
- " factorsJson TEXT NOT NULL," +
632
- " cryptographic " + t.INT + " NOT NULL DEFAULT 0," +
633
- " grantTtlMs " + t.INT + " NOT NULL," +
634
- " maxRowsPerGrant " + t.INT + " NOT NULL DEFAULT 1," +
635
- " reasonRequired " + t.INT + " NOT NULL DEFAULT 1," +
636
- " reasonMinLength " + t.INT + " NOT NULL DEFAULT 12," +
637
- " pinIp " + t.INT + " NOT NULL DEFAULT 1," +
638
- " sessionPin " + t.INT + " NOT NULL DEFAULT 1," +
639
- " onLockedAccess TEXT NOT NULL DEFAULT 'throw'," +
640
- " requireScope TEXT," +
641
- " serviceAccountBypassJson TEXT," +
642
- " dekSealed TEXT," +
643
- " auditReasonStorage TEXT NOT NULL DEFAULT 'cleartext'," +
644
- " updatedAt " + t.INT + " NOT NULL" +
645
- ")",
646
- indexes: [],
647
- };
762
+ return _table(tableName("_blamejs_break_glass_policies"), dialect, [
763
+ { col: "tableName", def: t.KT + " PRIMARY KEY" },
764
+ { col: "columnsJson", def: "TEXT NOT NULL" },
765
+ { col: "factorsJson", def: "TEXT NOT NULL" },
766
+ { col: "cryptographic", def: t.INT + " NOT NULL DEFAULT 0" },
767
+ { col: "grantTtlMs", def: t.INT + " NOT NULL" },
768
+ { col: "maxRowsPerGrant", def: t.INT + " NOT NULL DEFAULT 1" },
769
+ { col: "reasonRequired", def: t.INT + " NOT NULL DEFAULT 1" },
770
+ { col: "reasonMinLength", def: t.INT + " NOT NULL DEFAULT 12" },
771
+ { col: "pinIp", def: t.INT + " NOT NULL DEFAULT 1" },
772
+ { col: "sessionPin", def: t.INT + " NOT NULL DEFAULT 1" },
773
+ { col: "onLockedAccess", def: t.DT + " NOT NULL DEFAULT 'throw'" },
774
+ { col: "requireScope", def: "TEXT" },
775
+ { col: "serviceAccountBypassJson", def: "TEXT" },
776
+ { col: "dekSealed", def: "TEXT" },
777
+ { col: "auditReasonStorage", def: t.DT + " NOT NULL DEFAULT 'cleartext'" },
778
+ { col: "updatedAt", def: t.INT + " NOT NULL" },
779
+ ], []);
648
780
  }
649
781
 
650
782
  // _blamejs_break_glass_grants — issued grants. One row per successful
@@ -652,33 +784,174 @@ function _breakGlassPoliciesDDL(dialect) {
652
784
  // ("each row access = its own grant").
653
785
  function _breakGlassGrantsDDL(dialect) {
654
786
  var t = _types(dialect);
655
- var name = LOCAL_TO_EXTERNAL._blamejs_break_glass_grants;
656
- return {
657
- create:
658
- "CREATE TABLE IF NOT EXISTS " + name + " (" +
659
- " _id TEXT PRIMARY KEY," +
660
- " issuedToActorId TEXT NOT NULL," +
661
- " issuedToActorHash TEXT NOT NULL," +
662
- " factorType TEXT NOT NULL," +
663
- " reasonSealed TEXT," +
664
- " scopeTable TEXT NOT NULL," +
665
- " scopeColumnsJson TEXT NOT NULL," +
666
- " issuedAt " + t.INT + " NOT NULL," +
667
- " expiresAt " + t.INT + " NOT NULL," +
668
- " maxRowsPerGrant " + t.INT + " NOT NULL," +
669
- " rowsConsumed " + t.INT + " NOT NULL DEFAULT 0," +
670
- " revokedAt " + t.INT + "," +
671
- " sessionId TEXT," +
672
- " ip TEXT," +
673
- " kwGrantHalf TEXT" +
674
- ")",
675
- indexes: [
676
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_actor ON " + name + " (issuedToActorHash)",
677
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_table ON " + name + " (scopeTable)",
678
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_expires ON " + name + " (expiresAt)",
679
- "CREATE INDEX IF NOT EXISTS idx_" + name + "_revoked ON " + name + " (revokedAt)",
680
- ],
681
- };
787
+ return _table(tableName("_blamejs_break_glass_grants"), dialect, [
788
+ { col: "_id", def: t.KT + " PRIMARY KEY" },
789
+ { col: "issuedToActorId", def: "TEXT NOT NULL" },
790
+ { col: "issuedToActorHash", def: t.KT + " NOT NULL" },
791
+ { col: "factorType", def: "TEXT NOT NULL" },
792
+ { col: "reasonSealed", def: "TEXT" },
793
+ { col: "scopeTable", def: t.KT + " NOT NULL" },
794
+ { col: "scopeColumnsJson", def: "TEXT NOT NULL" },
795
+ { col: "issuedAt", def: t.INT + " NOT NULL" },
796
+ { col: "expiresAt", def: t.INT + " NOT NULL" },
797
+ { col: "maxRowsPerGrant", def: t.INT + " NOT NULL" },
798
+ { col: "rowsConsumed", def: t.INT + " NOT NULL DEFAULT 0" },
799
+ { col: "revokedAt", def: t.INT },
800
+ { col: "sessionId", def: "TEXT" },
801
+ { col: "ip", def: "TEXT" },
802
+ { col: "kwGrantHalf", def: "TEXT" },
803
+ ], [
804
+ { suffix: "actor", cols: ["issuedToActorHash"] },
805
+ { suffix: "table", cols: ["scopeTable"] },
806
+ { suffix: "expires", cols: ["expiresAt"] },
807
+ { suffix: "revoked", cols: ["revokedAt"] },
808
+ ]);
809
+ }
810
+
811
+ // Every framework-owned table builder, in creation order. Single source
812
+ // for ensureSchema's DDL pass AND the canonical-column registry below.
813
+ function _allDDLs(dialect) {
814
+ return [
815
+ _auditLogDDL(dialect),
816
+ _consentLogDDL(dialect),
817
+ _auditCheckpointsDDL(dialect),
818
+ _auditTipDDL(dialect),
819
+ _consentTipDDL(dialect),
820
+ _auditPurgeAnchorDDL(dialect),
821
+ _schedulerTicksDDL(dialect),
822
+ _rateLimitCountersDDL(dialect),
823
+ _pubsubMessagesDDL(dialect),
824
+ _apiEncryptNoncesDDL(dialect),
825
+ _apiKeysDDL(dialect),
826
+ _sessionsDDL(dialect),
827
+ _jobsDDL(dialect),
828
+ _cacheDDL(dialect),
829
+ _cacheTagsDDL(dialect),
830
+ _seedersDDL(dialect),
831
+ _seedersLockDDL(dialect),
832
+ _breakGlassPoliciesDDL(dialect),
833
+ _breakGlassGrantsDDL(dialect),
834
+ ];
835
+ }
836
+
837
+ // Canonical, case-preserving column names across every framework table —
838
+ // derived from the GENERATED DDL (the only quoted identifiers in a CREATE
839
+ // statement are the column names; the table name is unquoted), so the set
840
+ // can never drift from the actual schema. The codebase-patterns
841
+ // `framework-column-must-be-quoted` detector consumes this set to flag any
842
+ // camelCase framework-column reference left unquoted in SQL, which would
843
+ // fold to lowercase on Postgres and miss the column. Computed once over
844
+ // both supported dialects at module load.
845
+ var CANONICAL_COLUMNS = (function () {
846
+ var set = new Set();
847
+ var all = _allDDLs("postgres").concat(_allDDLs("sqlite"));
848
+ for (var i = 0; i < all.length; i++) {
849
+ var quoted = all[i].create.match(/"([A-Za-z_][A-Za-z0-9_]*)"/g) || [];
850
+ for (var j = 0; j < quoted.length; j++) set.add(quoted[j].slice(1, -1));
851
+ }
852
+ return set;
853
+ })();
854
+
855
+ // Per-column type CATEGORY ("int" | "blob" | "text"), derived from the
856
+ // generated DDL so it can never drift from the real schema. Drivers
857
+ // disagree on the JS shape of non-text columns: node-postgres returns
858
+ // BIGINT as a STRING and BYTEA as a Buffer; better-sqlite3 returns
859
+ // INTEGER as a number and BLOB as a Buffer. coerceRow() uses this map to
860
+ // normalize every framework column to one stable JS shape regardless of
861
+ // backend — without it, BIGINT-as-string breaks numeric comparisons and
862
+ // hash-chain recomputation on Postgres (the chain only verified on
863
+ // SQLite). Computed once over both supported dialects at module load.
864
+ var COLUMN_TYPES = (function () {
865
+ var map = {};
866
+ var all = _allDDLs("postgres").concat(_allDDLs("sqlite"));
867
+ // Match a quoted column name followed by its TYPE word (the PK-clause
868
+ // `("cacheKey", "tag")` form has a comma/paren after the name, never a
869
+ // type word, so it is correctly skipped).
870
+ var re = /"([A-Za-z_][A-Za-z0-9_]*)"\s+([A-Za-z]+)/g;
871
+ for (var i = 0; i < all.length; i++) {
872
+ var m; re.lastIndex = 0;
873
+ while ((m = re.exec(all[i].create)) !== null) {
874
+ var col = m[1];
875
+ if (Object.prototype.hasOwnProperty.call(map, col)) continue; // first def wins
876
+ var typeWord = m[2].toUpperCase();
877
+ map[col] = (typeWord === "BIGINT" || typeWord === "INTEGER" ||
878
+ typeWord === "INT" || typeWord === "BIGSERIAL")
879
+ ? "int"
880
+ : (typeWord === "BYTEA" || typeWord === "BLOB") ? "blob" : "text";
881
+ }
882
+ }
883
+ return Object.freeze(map);
884
+ })();
885
+
886
+ /**
887
+ * @primitive b.frameworkSchema.coerceRow
888
+ * @signature b.frameworkSchema.coerceRow(row)
889
+ * @since 0.14.29
890
+ * @status stable
891
+ * @related b.frameworkSchema.coerceRows, b.externalDb.query
892
+ *
893
+ * Normalize one driver-returned framework row to a type-stable JS shape
894
+ * using `COLUMN_TYPES`, so a framework column reads identically on every
895
+ * backend: `int` columns become JS numbers (node-postgres hands BIGINT
896
+ * back as a string), `blob` columns become Buffers. `text` columns and
897
+ * any column NOT in the framework schema (operator tables, computed
898
+ * aliases) pass through untouched; `null` stays `null`. Idempotent — safe
899
+ * to call on an already-coerced or SQLite-shaped row. Mutates and returns
900
+ * the row.
901
+ *
902
+ * A BIGINT beyond `Number.MAX_SAFE_INTEGER` is left as a string rather
903
+ * than silently losing precision (framework counters/timestamps stay well
904
+ * within 2^53, so this never bites in practice).
905
+ *
906
+ * @example
907
+ * var row = frameworkSchema.coerceRow(driverRow);
908
+ * typeof row.monotonicCounter; // → "number" (was "1" on Postgres)
909
+ * Buffer.isBuffer(row.nonce); // → true
910
+ */
911
+ function coerceRow(row) {
912
+ if (!row || typeof row !== "object") return row;
913
+ var keys = Object.keys(row);
914
+ for (var i = 0; i < keys.length; i++) {
915
+ var k = keys[i];
916
+ var cat = COLUMN_TYPES[k];
917
+ if (!cat) continue;
918
+ var v = row[k];
919
+ if (v === null || v === undefined) continue;
920
+ if (cat === "int") {
921
+ // node-postgres returns BIGINT / int8 as a decimal string. Coerce
922
+ // back to a JS number only when it round-trips exactly as a safe
923
+ // integer (canonical decimal, no leading zeros or sign padding);
924
+ // leave anything outside safe-integer range as the string so no
925
+ // precision is silently lost.
926
+ if (typeof v === "string") {
927
+ var n = Number(v);
928
+ if (Number.isSafeInteger(n) && String(n) === v) row[k] = n;
929
+ }
930
+ } else if (cat === "blob") {
931
+ if (!Buffer.isBuffer(v) && v instanceof Uint8Array) row[k] = Buffer.from(v);
932
+ }
933
+ }
934
+ return row;
935
+ }
936
+
937
+ /**
938
+ * @primitive b.frameworkSchema.coerceRows
939
+ * @signature b.frameworkSchema.coerceRows(rows)
940
+ * @since 0.14.29
941
+ * @status stable
942
+ * @related b.frameworkSchema.coerceRow
943
+ *
944
+ * Apply `coerceRow` to every row in an array (in place); returns the
945
+ * array. A non-array argument is returned unchanged.
946
+ *
947
+ * @example
948
+ * var rows = frameworkSchema.coerceRows(await queryAll(sql));
949
+ */
950
+ function coerceRows(rows) {
951
+ if (Array.isArray(rows)) {
952
+ for (var i = 0; i < rows.length; i++) coerceRow(rows[i]);
953
+ }
954
+ return rows;
682
955
  }
683
956
 
684
957
  // ---- ensureSchema ----
@@ -702,11 +975,12 @@ function _breakGlassGrantsDDL(dialect) {
702
975
  * Throws `FrameworkSchemaError("framework-schema/invalid-config")`
703
976
  * when `externalDbBackend` is missing and
704
977
  * `FrameworkSchemaError("framework-schema/unsupported-dialect")`
705
- * when `dialect` is anything other than `postgres` or `sqlite`.
978
+ * when `dialect` is anything other than `postgres`, `mysql`, or
979
+ * `sqlite`.
706
980
  *
707
981
  * @opts
708
982
  * externalDbBackend: string, // backend name registered with b.externalDb (required)
709
- * dialect: "postgres"|"sqlite", // default: "postgres"
983
+ * dialect: "postgres"|"mysql"|"sqlite", // default: "postgres"
710
984
  *
711
985
  * @example
712
986
  * try {
@@ -727,41 +1001,35 @@ async function ensureSchema(opts) {
727
1001
  );
728
1002
  }
729
1003
  var dialect = (opts.dialect || "postgres").toLowerCase();
730
- if (dialect !== "postgres" && dialect !== "sqlite") {
1004
+ if (dialect !== "postgres" && dialect !== "sqlite" && dialect !== "mysql") {
731
1005
  throw new FrameworkSchemaError(
732
- "unsupported dialect '" + dialect + "' (postgres or sqlite)",
1006
+ "unsupported dialect '" + dialect + "' (postgres, sqlite, or mysql)",
733
1007
  "framework-schema/unsupported-dialect"
734
1008
  );
735
1009
  }
736
1010
 
737
- var ddls = [
738
- _auditLogDDL(dialect),
739
- _consentLogDDL(dialect),
740
- _auditCheckpointsDDL(dialect),
741
- _auditTipDDL(dialect),
742
- _consentTipDDL(dialect),
743
- _auditPurgeAnchorDDL(dialect),
744
- _schedulerTicksDDL(dialect),
745
- _rateLimitCountersDDL(dialect),
746
- _pubsubMessagesDDL(dialect),
747
- _apiEncryptNoncesDDL(dialect),
748
- _apiKeysDDL(dialect),
749
- _sessionsDDL(dialect),
750
- _jobsDDL(dialect),
751
- _cacheDDL(dialect),
752
- _cacheTagsDDL(dialect),
753
- _seedersDDL(dialect),
754
- _seedersLockDDL(dialect),
755
- _breakGlassPoliciesDDL(dialect),
756
- _breakGlassGrantsDDL(dialect),
757
- ];
1011
+ var ddls = _allDDLs(dialect);
758
1012
 
759
1013
  var created = [];
760
1014
  for (var i = 0; i < ddls.length; i++) {
761
1015
  var d = ddls[i];
762
1016
  await externalDb.query(d.create, [], { backend: opts.externalDbBackend });
763
1017
  for (var j = 0; j < d.indexes.length; j++) {
764
- await externalDb.query(d.indexes[j], [], { backend: opts.externalDbBackend });
1018
+ // MySQL has no CREATE INDEX IF NOT EXISTS, so a second ensureSchema
1019
+ // pass re-issues a plain CREATE INDEX and the engine rejects the
1020
+ // duplicate index name (error 1061 "Duplicate key name"). That is the
1021
+ // intended idempotent end state — the index already exists — so the
1022
+ // duplicate error is swallowed on MySQL only; every other dialect uses
1023
+ // the native IF NOT EXISTS and never reaches here.
1024
+ if (dialect === "mysql") {
1025
+ try {
1026
+ await externalDb.query(d.indexes[j], [], { backend: opts.externalDbBackend });
1027
+ } catch (e) {
1028
+ if (!/duplicate|exist|1061/i.test((e && e.message) || "")) throw e;
1029
+ }
1030
+ } else {
1031
+ await externalDb.query(d.indexes[j], [], { backend: opts.externalDbBackend });
1032
+ }
765
1033
  }
766
1034
  created.push(d.create.match(/CREATE TABLE IF NOT EXISTS\s+(\S+)/)[1]);
767
1035
  }
@@ -784,9 +1052,9 @@ async function ensureSchema(opts) {
784
1052
  // at the next ensureSchema pass.
785
1053
  async function _installWormTriggers(backend, dialect) {
786
1054
  var wormTables = [
787
- LOCAL_TO_EXTERNAL.audit_log,
788
- LOCAL_TO_EXTERNAL.consent_log,
789
- LOCAL_TO_EXTERNAL.audit_checkpoints,
1055
+ tableName("audit_log"),
1056
+ tableName("consent_log"),
1057
+ tableName("audit_checkpoints"),
790
1058
  ];
791
1059
  for (var i = 0; i < wormTables.length; i++) {
792
1060
  var t = wormTables[i];
@@ -818,6 +1086,32 @@ async function _installWormTriggers(backend, dialect) {
818
1086
  " FOR EACH ROW EXECUTE FUNCTION " + fnName + "()",
819
1087
  [], { backend: backend }
820
1088
  );
1089
+ } else if (dialect === "mysql") {
1090
+ // MySQL has no CREATE TRIGGER IF NOT EXISTS, so DROP-then-CREATE
1091
+ // is the idempotent shape (matches the Postgres path). The body
1092
+ // SIGNALs SQLSTATE '45000' (unhandled user-defined exception) with
1093
+ // an append-only MESSAGE_TEXT — MySQL aborts the DELETE/UPDATE and
1094
+ // the driver surfaces it as a query-failure audit row, exactly like
1095
+ // the Postgres RAISE EXCEPTION and the SQLite RAISE(ABORT).
1096
+ var qt = "`" + t + "`";
1097
+ await externalDb.query(
1098
+ "DROP TRIGGER IF EXISTS no_delete_" + t, [], { backend: backend }
1099
+ );
1100
+ await externalDb.query(
1101
+ "CREATE TRIGGER no_delete_" + t + " BEFORE DELETE ON " + qt +
1102
+ " FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '" +
1103
+ t + " is append-only — DELETE prohibited'",
1104
+ [], { backend: backend }
1105
+ );
1106
+ await externalDb.query(
1107
+ "DROP TRIGGER IF EXISTS no_update_" + t, [], { backend: backend }
1108
+ );
1109
+ await externalDb.query(
1110
+ "CREATE TRIGGER no_update_" + t + " BEFORE UPDATE ON " + qt +
1111
+ " FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '" +
1112
+ t + " is append-only — UPDATE prohibited'",
1113
+ [], { backend: backend }
1114
+ );
821
1115
  } else {
822
1116
  // SQLite cluster path. CREATE TRIGGER IF NOT EXISTS matches the
823
1117
  // local-SQLite shape installed by lib/db.js.
@@ -840,6 +1134,13 @@ async function _installWormTriggers(backend, dialect) {
840
1134
  module.exports = {
841
1135
  ensureSchema: ensureSchema,
842
1136
  tableName: tableName,
1137
+ setTablePrefix: setTablePrefix,
1138
+ getTablePrefix: getTablePrefix,
1139
+ DEFAULT_TABLE_PREFIX: DEFAULT_TABLE_PREFIX,
843
1140
  LOCAL_TO_EXTERNAL: LOCAL_TO_EXTERNAL,
1141
+ CANONICAL_COLUMNS: CANONICAL_COLUMNS,
1142
+ COLUMN_TYPES: COLUMN_TYPES,
1143
+ coerceRow: coerceRow,
1144
+ coerceRows: coerceRows,
844
1145
  FrameworkSchemaError: FrameworkSchemaError,
845
1146
  };