@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.
- package/CHANGELOG.md +6 -0
- package/README.md +2 -2
- package/index.js +4 -0
- package/lib/ai-content-detect.js +9 -10
- package/lib/api-key.js +158 -77
- package/lib/atomic-file.js +29 -1
- package/lib/audit-chain.js +47 -11
- package/lib/audit-sign.js +77 -2
- package/lib/audit-tools.js +79 -51
- package/lib/audit.js +228 -100
- package/lib/backup/index.js +13 -10
- package/lib/break-glass.js +202 -144
- package/lib/cache.js +174 -105
- package/lib/chain-writer.js +38 -16
- package/lib/cli.js +19 -14
- package/lib/cluster-provider-db.js +130 -104
- package/lib/cluster-storage.js +119 -22
- package/lib/cluster.js +119 -71
- package/lib/compliance.js +22 -0
- package/lib/consent.js +82 -29
- package/lib/constants.js +16 -11
- package/lib/crypto-field.js +387 -91
- package/lib/db-declare-row-policy.js +35 -22
- package/lib/db-file-lifecycle.js +3 -2
- package/lib/db-query.js +517 -256
- package/lib/db-schema.js +209 -44
- package/lib/db.js +202 -95
- package/lib/external-db-migrate.js +229 -139
- package/lib/external-db.js +25 -15
- package/lib/framework-error.js +11 -0
- package/lib/framework-files.js +73 -0
- package/lib/framework-schema.js +695 -394
- package/lib/gate-contract.js +596 -1
- package/lib/guard-agent-registry.js +26 -44
- package/lib/guard-all.js +1 -0
- package/lib/guard-auth.js +42 -112
- package/lib/guard-cidr.js +33 -154
- package/lib/guard-csv.js +46 -113
- package/lib/guard-domain.js +34 -157
- package/lib/guard-dsn.js +27 -43
- package/lib/guard-email.js +47 -69
- package/lib/guard-envelope.js +19 -32
- package/lib/guard-event-bus-payload.js +24 -42
- package/lib/guard-event-bus-topic.js +25 -43
- package/lib/guard-filename.js +42 -106
- package/lib/guard-graphql.js +42 -123
- package/lib/guard-html.js +53 -108
- package/lib/guard-idempotency-key.js +24 -42
- package/lib/guard-image.js +46 -103
- package/lib/guard-imap-command.js +18 -32
- package/lib/guard-jmap.js +16 -30
- package/lib/guard-json.js +38 -108
- package/lib/guard-jsonpath.js +38 -171
- package/lib/guard-jwt.js +49 -179
- package/lib/guard-list-id.js +25 -41
- package/lib/guard-list-unsubscribe.js +27 -43
- package/lib/guard-mail-compose.js +24 -42
- package/lib/guard-mail-move.js +26 -44
- package/lib/guard-mail-query.js +28 -46
- package/lib/guard-mail-reply.js +24 -42
- package/lib/guard-mail-sieve.js +24 -42
- package/lib/guard-managesieve-command.js +17 -31
- package/lib/guard-markdown.js +37 -104
- package/lib/guard-message-id.js +26 -45
- package/lib/guard-mime.js +39 -151
- package/lib/guard-oauth.js +54 -135
- package/lib/guard-pdf.js +45 -101
- package/lib/guard-pop3-command.js +21 -31
- package/lib/guard-posture-chain.js +24 -42
- package/lib/guard-regex.js +33 -107
- package/lib/guard-saga-config.js +24 -42
- package/lib/guard-shell.js +42 -172
- package/lib/guard-smtp-command.js +48 -54
- package/lib/guard-snapshot-envelope.js +24 -42
- package/lib/guard-sql.js +1491 -0
- package/lib/guard-stream-args.js +24 -43
- package/lib/guard-svg.js +47 -65
- package/lib/guard-template.js +35 -172
- package/lib/guard-tenant-id.js +26 -45
- package/lib/guard-time.js +32 -154
- package/lib/guard-trace-context.js +25 -44
- package/lib/guard-uuid.js +32 -153
- package/lib/guard-xml.js +38 -113
- package/lib/guard-yaml.js +51 -163
- package/lib/http-client.js +14 -0
- package/lib/inbox.js +120 -107
- package/lib/legal-hold.js +107 -50
- package/lib/log-stream-cloudwatch.js +47 -31
- package/lib/log-stream-otlp.js +32 -18
- package/lib/mail-crypto-smime.js +2 -6
- package/lib/mail-greylist.js +2 -6
- package/lib/mail-helo.js +2 -6
- package/lib/mail-journal.js +85 -64
- package/lib/mail-rbl.js +2 -6
- package/lib/mail-scan.js +2 -6
- package/lib/mail-spam-score.js +2 -6
- package/lib/mail-store.js +293 -154
- package/lib/middleware/fetch-metadata.js +17 -7
- package/lib/middleware/idempotency-key.js +54 -38
- package/lib/middleware/rate-limit.js +102 -32
- package/lib/middleware/security-headers.js +21 -5
- package/lib/migrations.js +108 -66
- package/lib/network-heartbeat.js +7 -0
- package/lib/nonce-store.js +31 -9
- package/lib/object-store/azure-blob-bucket-ops.js +9 -4
- package/lib/object-store/azure-blob.js +31 -3
- package/lib/object-store/sigv4.js +10 -0
- package/lib/outbox.js +136 -82
- package/lib/pqc-agent.js +44 -0
- package/lib/pubsub-cluster.js +42 -20
- package/lib/queue-local.js +202 -139
- package/lib/queue-redis.js +9 -1
- package/lib/queue-sqs.js +6 -0
- package/lib/retention.js +82 -39
- package/lib/safe-dns.js +29 -45
- package/lib/safe-ical.js +18 -33
- package/lib/safe-icap.js +27 -43
- package/lib/safe-sieve.js +21 -40
- package/lib/safe-sql.js +124 -3
- package/lib/safe-vcard.js +18 -33
- package/lib/scheduler.js +35 -12
- package/lib/seeders.js +122 -74
- package/lib/session-stores.js +42 -14
- package/lib/session.js +116 -72
- package/lib/sql.js +3885 -0
- package/lib/static.js +45 -7
- package/lib/subject.js +89 -49
- package/lib/vault/index.js +3 -2
- package/lib/vault/passphrase-ops.js +3 -2
- package/lib/vault/rotate.js +104 -64
- package/lib/vendor-data.js +2 -0
- package/lib/websocket.js +16 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/framework-schema.js
CHANGED
|
@@ -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,
|
|
38
|
-
* reboots; any operator-applied
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
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
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
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
|
-
//
|
|
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
|
|
319
|
+
"unsupported dialect '" + dialect + "' (postgres, sqlite, or mysql)",
|
|
185
320
|
"framework-schema/unsupported-dialect"
|
|
186
321
|
);
|
|
187
322
|
}
|
|
188
323
|
|
|
189
|
-
// ----
|
|
324
|
+
// ---- Declarative, quote-by-construction DDL builder ----
|
|
190
325
|
//
|
|
191
|
-
//
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
"
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
"
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
//
|
|
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
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
"
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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(
|
|
604
|
-
// Junction table is TEXT-only
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
};
|