@blamejs/core 0.6.4 → 0.6.5

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 CHANGED
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.6.x
10
10
 
11
+ - **0.6.4** (2026-05-01) — wiki schema docs realigned with the actual lib API
11
12
  - **0.6.3** (2026-05-01) — externalDb pool tuning + role-aware connect + read-replica routing
12
13
  - **0.6.2** (2026-05-01) — input validation + identifier-quoting consistency
13
14
  - **0.6.1** (2026-05-01) — security tightenings + operator-facing jargon sweep
@@ -0,0 +1,424 @@
1
+ "use strict";
2
+ /**
3
+ * b.db.declareView — declarative view + GRANT migration spec.
4
+ *
5
+ * Returns a migration-shape object that b.externalDb.migrate(...) applies
6
+ * against a Postgres backend. The view exposes a deliberately-narrowed
7
+ * column projection of the source table — sensitive columns are dropped
8
+ * (redactColumns), sealed columns are auto-omitted (operator declares
9
+ * via sealedColumns; a future externalDb sealed-fields registry will
10
+ * make this automatic), and existing derived hash columns can be
11
+ * exposed in place of their plaintext source via hashColumns.
12
+ *
13
+ * Postgres-only: SQLite has no GRANT semantics; MySQL's CREATE VIEW
14
+ * grammar differs and isn't covered. Apply throws NOT_SUPPORTED at
15
+ * migration-apply time when the targeted backend's dialect isn't
16
+ * "postgres" so operators see the failure cause clearly.
17
+ *
18
+ * Public API (b.db.declareView):
19
+ *
20
+ * var mig = b.db.declareView({
21
+ * schema: "analytics", // target schema
22
+ * name: "sessions", // view name
23
+ * source: "public.sessions", // schema.table OR ["public","sessions"]
24
+ * redactColumns: ["ssn", "diagnosis"], // dropped from view
25
+ * sealedColumns: ["secrets_jsonb"], // operator-declared sealed cols (auto-omit)
26
+ * hashColumns: { emailHash: "email" },// hide source plaintext, keep hash
27
+ * whereClause: "deleted_at IS NULL", // optional filter
28
+ * grantTo: ["analytics_user"], // roles that get SELECT
29
+ * backend: "main", // optional — defaults to default backend
30
+ * });
31
+ *
32
+ * // mig is a migration-shape object: { description, target, backend, up, down }
33
+ * // Place it in a migration file:
34
+ * // module.exports = mig;
35
+ * // and run b.externalDb.migrate.create({ dir }).up();
36
+ *
37
+ * Validation runs at migration apply (not declare) time so source-column
38
+ * existence and role existence checks query the live database. Operator
39
+ * typos surface as clear errors at the migrate command, not as silent
40
+ * empty views or grant-to-nonexistent-role footguns.
41
+ *
42
+ * Tier-A validation at declareView() call time:
43
+ * - schema, name, source segments → safeSql.validateIdentifier
44
+ * - column names in redactColumns / sealedColumns / hashColumns →
45
+ * safeSql.validateIdentifier
46
+ * - role names in grantTo → safeSql.validateIdentifier
47
+ * - whereClause stays operator-supplied SQL (the framework cannot
48
+ * validate arbitrary expressions); it interpolates as-is into the
49
+ * migration text. Operators wrap any literal values inside
50
+ * whereClause via standard SQL quoting.
51
+ *
52
+ * Audit metadata emitted on apply:
53
+ * {
54
+ * view: "schema.name",
55
+ * source: "schema.table",
56
+ * selectedColumns: [string],
57
+ * redactedColumns: [string], // intersection of redactColumns & source
58
+ * autoExcludedSealed: [string], // sealedColumns members found in source
59
+ * hashedColumns: { aliasOrHashCol: srcCol },
60
+ * grantedTo: [string],
61
+ * }
62
+ */
63
+ var safeSql = require("./safe-sql");
64
+ var { defineClass } = require("./framework-error");
65
+
66
+ var DeclareViewError = defineClass("DeclareViewError", { alwaysPermanent: true });
67
+
68
+ var ALLOWED_OPTS = [
69
+ "schema", "name", "source",
70
+ "redactColumns", "sealedColumns", "hashColumns",
71
+ "whereClause", "grantTo", "backend",
72
+ ];
73
+
74
+ function _err(code, message) {
75
+ return new DeclareViewError(code, message);
76
+ }
77
+
78
+ function _validateIdent(where, value) {
79
+ try {
80
+ safeSql.validateIdentifier(value, { allowReserved: true });
81
+ } catch (e) {
82
+ throw _err("declare-view/bad-identifier",
83
+ where + ": invalid identifier '" + value + "': " + ((e && e.message) || String(e)));
84
+ }
85
+ }
86
+
87
+ function _validateStringArray(where, arr, optional) {
88
+ if (arr === undefined || arr === null) {
89
+ if (optional) return [];
90
+ throw _err("declare-view/missing-opt", where + " is required");
91
+ }
92
+ if (!Array.isArray(arr)) {
93
+ throw _err("declare-view/bad-type", where + " must be an array of strings");
94
+ }
95
+ for (var i = 0; i < arr.length; i++) {
96
+ if (typeof arr[i] !== "string" || arr[i].length === 0) {
97
+ throw _err("declare-view/bad-entry",
98
+ where + "[" + i + "] must be a non-empty string");
99
+ }
100
+ }
101
+ return arr.slice();
102
+ }
103
+
104
+ function _parseSource(source) {
105
+ // Accepts "schema.table" or ["schema", "table"] or "table" (defaults to public).
106
+ var parts;
107
+ if (typeof source === "string") {
108
+ if (source.length === 0) {
109
+ throw _err("declare-view/bad-source", "source must be a non-empty string or array");
110
+ }
111
+ parts = source.split(".");
112
+ } else if (Array.isArray(source)) {
113
+ parts = source.slice();
114
+ } else {
115
+ throw _err("declare-view/bad-source",
116
+ "source must be a string ('schema.table') or array (['schema','table']), got " + typeof source);
117
+ }
118
+ if (parts.length === 1) parts = ["public", parts[0]];
119
+ if (parts.length !== 2) {
120
+ throw _err("declare-view/bad-source",
121
+ "source must resolve to two segments [schema, table]; got " + parts.length + " segment(s)");
122
+ }
123
+ for (var i = 0; i < parts.length; i++) _validateIdent("source[" + i + "]", parts[i]);
124
+ return { schema: parts[0], name: parts[1] };
125
+ }
126
+
127
+ function _validateOpts(opts) {
128
+ if (!opts || typeof opts !== "object") {
129
+ throw _err("declare-view/bad-opts", "declareView requires an opts object");
130
+ }
131
+ for (var k in opts) {
132
+ if (Object.prototype.hasOwnProperty.call(opts, k) && ALLOWED_OPTS.indexOf(k) === -1) {
133
+ throw _err("declare-view/unknown-opt",
134
+ "unknown opt '" + k + "'. Allowed: " + ALLOWED_OPTS.join(", "));
135
+ }
136
+ }
137
+
138
+ if (typeof opts.schema !== "string" || opts.schema.length === 0) {
139
+ throw _err("declare-view/missing-opt", "schema is required");
140
+ }
141
+ _validateIdent("schema", opts.schema);
142
+
143
+ if (typeof opts.name !== "string" || opts.name.length === 0) {
144
+ throw _err("declare-view/missing-opt", "name is required");
145
+ }
146
+ _validateIdent("name", opts.name);
147
+
148
+ if (opts.source === undefined) {
149
+ throw _err("declare-view/missing-opt", "source is required");
150
+ }
151
+ var src = _parseSource(opts.source);
152
+
153
+ var redactColumns = _validateStringArray("redactColumns", opts.redactColumns, true);
154
+ for (var i = 0; i < redactColumns.length; i++) {
155
+ _validateIdent("redactColumns[" + i + "]", redactColumns[i]);
156
+ }
157
+
158
+ var sealedColumns = _validateStringArray("sealedColumns", opts.sealedColumns, true);
159
+ for (var j = 0; j < sealedColumns.length; j++) {
160
+ _validateIdent("sealedColumns[" + j + "]", sealedColumns[j]);
161
+ }
162
+
163
+ var hashColumns = {};
164
+ if (opts.hashColumns !== undefined && opts.hashColumns !== null) {
165
+ if (typeof opts.hashColumns !== "object" || Array.isArray(opts.hashColumns)) {
166
+ throw _err("declare-view/bad-type",
167
+ "hashColumns must be an object { aliasOrHashCol: srcCol }");
168
+ }
169
+ for (var hc in opts.hashColumns) {
170
+ if (!Object.prototype.hasOwnProperty.call(opts.hashColumns, hc)) continue;
171
+ _validateIdent("hashColumns key '" + hc + "'", hc);
172
+ var v = opts.hashColumns[hc];
173
+ if (typeof v !== "string" || v.length === 0) {
174
+ throw _err("declare-view/bad-entry",
175
+ "hashColumns['" + hc + "'] must be a non-empty string (the source plaintext column)");
176
+ }
177
+ _validateIdent("hashColumns['" + hc + "']", v);
178
+ hashColumns[hc] = v;
179
+ }
180
+ }
181
+
182
+ var whereClause = null;
183
+ if (opts.whereClause !== undefined && opts.whereClause !== null) {
184
+ if (typeof opts.whereClause !== "string") {
185
+ throw _err("declare-view/bad-type", "whereClause must be a string");
186
+ }
187
+ if (opts.whereClause.indexOf(";") !== -1) {
188
+ throw _err("declare-view/bad-where",
189
+ "whereClause must not contain ';' — use a single boolean expression");
190
+ }
191
+ whereClause = opts.whereClause;
192
+ }
193
+
194
+ var grantTo = _validateStringArray("grantTo", opts.grantTo, true);
195
+ for (var g = 0; g < grantTo.length; g++) {
196
+ _validateIdent("grantTo[" + g + "]", grantTo[g]);
197
+ }
198
+
199
+ if (opts.backend !== undefined && opts.backend !== null) {
200
+ if (typeof opts.backend !== "string" || opts.backend.length === 0) {
201
+ throw _err("declare-view/bad-type", "backend must be a non-empty string");
202
+ }
203
+ }
204
+
205
+ return {
206
+ schema: opts.schema,
207
+ name: opts.name,
208
+ source: src,
209
+ redactColumns: redactColumns,
210
+ sealedColumns: sealedColumns,
211
+ hashColumns: hashColumns,
212
+ whereClause: whereClause,
213
+ grantTo: grantTo,
214
+ backend: opts.backend || null,
215
+ };
216
+ }
217
+
218
+ // ---- Apply-time helpers (run inside up()) ----
219
+
220
+ async function _fetchSourceColumns(xdb, schema, table) {
221
+ var res = await xdb.query(
222
+ "SELECT column_name FROM information_schema.columns " +
223
+ "WHERE table_schema = $1 AND table_name = $2 " +
224
+ "ORDER BY ordinal_position ASC",
225
+ [schema, table]
226
+ );
227
+ var rows = (res && res.rows) || [];
228
+ return rows.map(function (r) { return r.column_name; });
229
+ }
230
+
231
+ async function _fetchExistingRoles(xdb, names) {
232
+ if (names.length === 0) return new Set();
233
+ var placeholders = names.map(function (_, i) { return "$" + (i + 1); }).join(", ");
234
+ var res = await xdb.query(
235
+ "SELECT rolname FROM pg_roles WHERE rolname IN (" + placeholders + ")",
236
+ names
237
+ );
238
+ var rows = (res && res.rows) || [];
239
+ return new Set(rows.map(function (r) { return r.rolname; }));
240
+ }
241
+
242
+ function _missing(required, available) {
243
+ var miss = [];
244
+ for (var i = 0; i < required.length; i++) {
245
+ if (available.indexOf(required[i]) === -1) miss.push(required[i]);
246
+ }
247
+ return miss;
248
+ }
249
+
250
+ function _buildSelectColumnList(sourceCols, spec) {
251
+ // Drop set: redactColumns ∩ source, sealedColumns ∩ source, hashColumns.values ∩ source.
252
+ var dropSet = Object.create(null);
253
+ for (var i = 0; i < spec.redactColumns.length; i++) dropSet[spec.redactColumns[i]] = true;
254
+ for (var j = 0; j < spec.sealedColumns.length; j++) dropSet[spec.sealedColumns[j]] = true;
255
+ for (var hc in spec.hashColumns) dropSet[spec.hashColumns[hc]] = true;
256
+
257
+ var kept = [];
258
+ for (var k = 0; k < sourceCols.length; k++) {
259
+ if (!dropSet[sourceCols[k]]) kept.push(sourceCols[k]);
260
+ }
261
+ return kept;
262
+ }
263
+
264
+ function _intersectInSource(list, sourceCols) {
265
+ var srcSet = Object.create(null);
266
+ for (var i = 0; i < sourceCols.length; i++) srcSet[sourceCols[i]] = true;
267
+ var out = [];
268
+ for (var j = 0; j < list.length; j++) {
269
+ if (srcSet[list[j]]) out.push(list[j]);
270
+ }
271
+ return out;
272
+ }
273
+
274
+ function _ensureBackendIsPostgres(externalDb, backendName) {
275
+ // externalDb.listBackends() returns { name, dialect, ... } per backend.
276
+ var list = externalDb.listBackends();
277
+ var found = null;
278
+ for (var i = 0; i < list.length; i++) {
279
+ if (list[i].name === backendName) { found = list[i]; break; }
280
+ }
281
+ if (!found) {
282
+ throw _err("declare-view/unknown-backend",
283
+ "no externalDb backend named '" + backendName + "' — declared backends: " +
284
+ list.map(function (b) { return b.name; }).join(", "));
285
+ }
286
+ if (found.dialect !== "postgres") {
287
+ throw _err("declare-view/not-supported",
288
+ "declareView is Postgres-only; backend '" + backendName + "' has dialect='" +
289
+ found.dialect + "'. Write the view as a hand-rolled migration for this dialect.");
290
+ }
291
+ }
292
+
293
+ // ---- The factory ----
294
+
295
+ function declareView(opts) {
296
+ var spec = _validateOpts(opts);
297
+
298
+ // The migration shape consumed by b.externalDb.migrate. The runner
299
+ // resolves the backend at apply time (operator may set spec.backend
300
+ // explicitly OR rely on the migrate runner's default backend).
301
+ var description = "declareView " + spec.schema + "." + spec.name;
302
+ var qView = safeSql.quoteQualified([spec.schema, spec.name], "postgres");
303
+ var qSource = safeSql.quoteQualified([spec.source.schema, spec.source.name], "postgres");
304
+
305
+ async function up(xdb, ctx) {
306
+ // Tier-A boundary: confirm we're on Postgres before any DDL leaves the process.
307
+ if (ctx && ctx.externalDb && ctx.backendName) {
308
+ _ensureBackendIsPostgres(ctx.externalDb, ctx.backendName);
309
+ }
310
+
311
+ // Live validation — source columns + roles must exist.
312
+ var sourceCols = await _fetchSourceColumns(xdb, spec.source.schema, spec.source.name);
313
+ if (sourceCols.length === 0) {
314
+ throw _err("declare-view/source-not-found",
315
+ "source table '" + spec.source.schema + "." + spec.source.name +
316
+ "' has no columns visible (does it exist? does the migration role have SELECT on information_schema.columns?)");
317
+ }
318
+
319
+ var missingRedact = _missing(spec.redactColumns, sourceCols);
320
+ if (missingRedact.length > 0) {
321
+ throw _err("declare-view/redact-not-in-source",
322
+ "redactColumns [" + missingRedact.join(", ") + "] not present on source '" +
323
+ spec.source.schema + "." + spec.source.name + "'");
324
+ }
325
+
326
+ var missingHashKeys = _missing(Object.keys(spec.hashColumns), sourceCols);
327
+ if (missingHashKeys.length > 0) {
328
+ throw _err("declare-view/hash-key-not-in-source",
329
+ "hashColumns keys [" + missingHashKeys.join(", ") + "] not present on source '" +
330
+ spec.source.schema + "." + spec.source.name +
331
+ "' — declare them as derivedHashes on the source schema first");
332
+ }
333
+
334
+ var missingHashSrc = _missing(_objValues(spec.hashColumns), sourceCols);
335
+ if (missingHashSrc.length > 0) {
336
+ throw _err("declare-view/hash-source-not-in-source",
337
+ "hashColumns values [" + missingHashSrc.join(", ") + "] not present on source '" +
338
+ spec.source.schema + "." + spec.source.name + "'");
339
+ }
340
+
341
+ if (spec.grantTo.length > 0) {
342
+ var existing = await _fetchExistingRoles(xdb, spec.grantTo);
343
+ var missingRoles = [];
344
+ for (var i = 0; i < spec.grantTo.length; i++) {
345
+ if (!existing.has(spec.grantTo[i])) missingRoles.push(spec.grantTo[i]);
346
+ }
347
+ if (missingRoles.length > 0) {
348
+ throw _err("declare-view/role-not-found",
349
+ "grantTo roles [" + missingRoles.join(", ") +
350
+ "] not present in pg_roles — CREATE ROLE them in an earlier migration");
351
+ }
352
+ }
353
+
354
+ var selectedColumns = _buildSelectColumnList(sourceCols, spec);
355
+ if (selectedColumns.length === 0) {
356
+ throw _err("declare-view/empty-select",
357
+ "view '" + spec.schema + "." + spec.name +
358
+ "' would have zero columns after redact/sealed/hash exclusions — adjust the spec");
359
+ }
360
+
361
+ // Build CREATE VIEW. Each column is independently quoted so a
362
+ // reserved-word column name (e.g. "user", "order") resolves correctly.
363
+ var quotedCols = selectedColumns.map(function (c) {
364
+ return safeSql.quoteIdentifier(c, "postgres");
365
+ }).join(", ");
366
+ var createSql = "CREATE VIEW " + qView + " AS SELECT " + quotedCols +
367
+ " FROM " + qSource;
368
+ if (spec.whereClause) createSql += " WHERE " + spec.whereClause;
369
+
370
+ await xdb.query(createSql, []);
371
+
372
+ // GRANT SELECT — one statement covers all roles.
373
+ if (spec.grantTo.length > 0) {
374
+ var quotedRoles = spec.grantTo.map(function (r) {
375
+ return safeSql.quoteIdentifier(r, "postgres");
376
+ }).join(", ");
377
+ await xdb.query(
378
+ "GRANT SELECT ON " + qView + " TO " + quotedRoles,
379
+ []
380
+ );
381
+ }
382
+
383
+ return {
384
+ view: spec.schema + "." + spec.name,
385
+ source: spec.source.schema + "." + spec.source.name,
386
+ selectedColumns: selectedColumns,
387
+ redactedColumns: _intersectInSource(spec.redactColumns, sourceCols),
388
+ autoExcludedSealed: _intersectInSource(spec.sealedColumns, sourceCols),
389
+ hashedColumns: Object.assign({}, spec.hashColumns),
390
+ grantedTo: spec.grantTo.slice(),
391
+ };
392
+ }
393
+
394
+ async function down(xdb, ctx) {
395
+ if (ctx && ctx.externalDb && ctx.backendName) {
396
+ _ensureBackendIsPostgres(ctx.externalDb, ctx.backendName);
397
+ }
398
+ await xdb.query("DROP VIEW IF EXISTS " + qView, []);
399
+ }
400
+
401
+ return {
402
+ description: description,
403
+ target: "externalDb",
404
+ backend: spec.backend,
405
+ up: up,
406
+ down: down,
407
+ // Expose the validated spec for testability — declareView callers
408
+ // can introspect what the migration will emit without running it.
409
+ _spec: spec,
410
+ };
411
+ }
412
+
413
+ function _objValues(obj) {
414
+ var out = [];
415
+ for (var k in obj) {
416
+ if (Object.prototype.hasOwnProperty.call(obj, k)) out.push(obj[k]);
417
+ }
418
+ return out;
419
+ }
420
+
421
+ module.exports = {
422
+ declareView: declareView,
423
+ DeclareViewError: DeclareViewError,
424
+ };
package/lib/db.js CHANGED
@@ -1168,6 +1168,11 @@ module.exports = {
1168
1168
  var m = tableMetadata[name];
1169
1169
  return m ? JSON.parse(JSON.stringify(m)) : null;
1170
1170
  },
1171
+ // declareView — declarative CREATE VIEW + GRANT migration spec for an
1172
+ // externalDb backend. Returns a migration-shape object for use with
1173
+ // b.externalDb.migrate. Postgres-only; fail-fast at apply time on other
1174
+ // dialects. See lib/db-declare-view.js.
1175
+ declareView: require("./db-declare-view").declareView,
1171
1176
  // Internal accessors used by audit / subject / consent modules.
1172
1177
  // Not part of the public contract — apps should not depend on them.
1173
1178
  _getSubjectTables: function () { return subjectTables.slice(); },
@@ -0,0 +1,434 @@
1
+ "use strict";
2
+ /**
3
+ * b.externalDb.migrate — versioned migrations for an externalDb backend.
4
+ *
5
+ * Mirrors b.migrations (which targets the framework's local SQLite) but
6
+ * runs against an externalDb backend. Tracking + lock tables live on
7
+ * the externalDb side under `_blamejs_externaldb_migrations` and
8
+ * `_blamejs_externaldb_migrations_lock`. Each migration runs inside
9
+ * `externalDb.transaction(fn)` so a failing migration rolls back
10
+ * cleanly and stops the wave.
11
+ *
12
+ * Migration file format (filename pattern: NNNN-<slug>.js):
13
+ *
14
+ * module.exports = {
15
+ * description: "Create users table",
16
+ * up: async function (xdb, ctx) { await xdb.query("CREATE TABLE ..."); },
17
+ * down: async function (xdb, ctx) { await xdb.query("DROP TABLE ..."); },
18
+ * };
19
+ *
20
+ * `xdb` exposes `.query(sql, params) → { rows, rowCount }`. `ctx` carries
21
+ * `{ externalDb, backendName }` so migrations that need backend-introspection
22
+ * (e.g. `b.db.declareView()`-shaped specs) can call back into the framework.
23
+ *
24
+ * `up` is required; `down` is optional. Calling `down()` on a migration
25
+ * that didn't export `down()` surfaces a clear error.
26
+ *
27
+ * var migrate = b.externalDb.migrate.create({
28
+ * dir: "./migrations-pg",
29
+ * backend: "main", // optional; defaults to default backend
30
+ * audit: b.audit, // optional
31
+ * });
32
+ *
33
+ * await migrate.up(); // → { applied: [name], skipped: [name] }
34
+ * await migrate.down({ steps: 1 });
35
+ * migrate.status(); // → { applied: [{name, description, appliedAt}], pending: [name], total }
36
+ *
37
+ * Concurrent-apply protection: a single-row advisory lock in
38
+ * `_blamejs_externaldb_migrations_lock` ensures two processes can't apply
39
+ * the same wave concurrently. The losing process gets a clear "lock held
40
+ * by other process" error. Stale locks can be force-replaced via
41
+ * `staleAfterMs`.
42
+ *
43
+ * Audit emissions when wired with `audit: b.audit`:
44
+ * - externaldb.migrate.up.success { migration, durationMs }
45
+ * - externaldb.migrate.up.failure { migration, durationMs, reason }
46
+ * - externaldb.migrate.down.success { migration, durationMs }
47
+ * - externaldb.migrate.down.failure { migration, durationMs, reason }
48
+ * - externaldb.migrate.lock.acquired { holder }
49
+ * - externaldb.migrate.lock.released { holder }
50
+ */
51
+ var path = require("path");
52
+ var atomicFile = require("./atomic-file");
53
+ var lazyRequire = require("./lazy-require");
54
+ var validateOpts = require("./validate-opts");
55
+ var { defineClass } = require("./framework-error");
56
+
57
+ var ExternalDbMigrateError = defineClass("ExternalDbMigrateError", { alwaysPermanent: true });
58
+
59
+ // Lazy require — external-db imports back into this module via its
60
+ // public `migrate` namespace; load-order would cycle without lazy.
61
+ var externalDbModule = lazyRequire(function () { return require("./external-db"); });
62
+
63
+ var TRACKING_TABLE = "_blamejs_externaldb_migrations";
64
+ var LOCK_TABLE = "_blamejs_externaldb_migrations_lock";
65
+ // Identifiers wrapped in `"..."` per project convention so a reserved-word
66
+ // or whitespace-bearing name resolves correctly.
67
+ var Q_TRACKING = '"' + TRACKING_TABLE + '"';
68
+ var Q_LOCK = '"' + LOCK_TABLE + '"';
69
+
70
+ // Filename grammar — mirrors lib/migrations.js so operators with a
71
+ // mixed local + externalDb workflow use the same numeric-prefix pattern.
72
+ var FILE_RE = /^(\d+)-([A-Za-z0-9_-]+)\.js$/;
73
+
74
+ function _err(code, message) {
75
+ return new ExternalDbMigrateError(code, message);
76
+ }
77
+
78
+ function _lockHolderId() {
79
+ return String(process.pid) + "@" + (require("node:os").hostname() || "unknown");
80
+ }
81
+
82
+ async function _ensureTrackingTable(xdb) {
83
+ // Tracking table holds the migration history. ISO-8601 timestamp
84
+ // strings (TEXT) keep the framework's tracking table portable across
85
+ // Postgres/SQLite without dialect-specific type juggling — operators
86
+ // who want strict TIMESTAMPTZ for their own ad-hoc queries against
87
+ // the table ALTER it post-creation.
88
+ await xdb.query(
89
+ "CREATE TABLE IF NOT EXISTS " + Q_TRACKING + " (" +
90
+ " name TEXT PRIMARY KEY," +
91
+ " description TEXT," +
92
+ " appliedAt TEXT NOT NULL" +
93
+ ")",
94
+ []
95
+ );
96
+ }
97
+
98
+ async function _ensureLockTable(xdb) {
99
+ await xdb.query(
100
+ "CREATE TABLE IF NOT EXISTS " + Q_LOCK + " (" +
101
+ " scope TEXT PRIMARY KEY," +
102
+ " lockedAt INTEGER NOT NULL," +
103
+ " lockedBy TEXT NOT NULL," +
104
+ " CHECK (scope = 'lock')" +
105
+ ")",
106
+ []
107
+ );
108
+ }
109
+
110
+ // ---- Lock acquire / release ----
111
+
112
+ async function _acquireLock(xdb, opts) {
113
+ await _ensureLockTable(xdb);
114
+ var holder = _lockHolderId();
115
+ var nowMs = Date.now();
116
+ var staleAfterMs = (opts && typeof opts.staleAfterMs === "number" && opts.staleAfterMs > 0)
117
+ ? opts.staleAfterMs : 0;
118
+ try {
119
+ await xdb.query(
120
+ "INSERT INTO " + Q_LOCK + " (scope, lockedAt, lockedBy) VALUES ('lock', $1, $2)",
121
+ [nowMs, holder]
122
+ );
123
+ return holder;
124
+ } catch (_e) {
125
+ // PRIMARY KEY conflict → existing lock. Inspect it.
126
+ var existingRes = await xdb.query(
127
+ "SELECT lockedAt, lockedBy FROM " + Q_LOCK + " WHERE scope = 'lock'",
128
+ []
129
+ );
130
+ var existing = existingRes && existingRes.rows && existingRes.rows[0];
131
+ if (!existing) {
132
+ try {
133
+ await xdb.query(
134
+ "INSERT INTO " + Q_LOCK + " (scope, lockedAt, lockedBy) VALUES ('lock', $1, $2)",
135
+ [nowMs, holder]
136
+ );
137
+ return holder;
138
+ } catch (e2) {
139
+ throw _err("externaldb-migrate/lock-busy",
140
+ "could not acquire migration lock: " + ((e2 && e2.message) || String(e2)));
141
+ }
142
+ }
143
+ var ageMs = nowMs - Number(existing.lockedat || existing.lockedAt);
144
+ if (staleAfterMs > 0 && ageMs > staleAfterMs) {
145
+ // Force-replace the stale lock atomically.
146
+ await xdb.query(
147
+ "DELETE FROM " + Q_LOCK + " WHERE scope = 'lock' AND lockedAt = $1",
148
+ [Number(existing.lockedat || existing.lockedAt)]
149
+ );
150
+ await xdb.query(
151
+ "INSERT INTO " + Q_LOCK + " (scope, lockedAt, lockedBy) VALUES ('lock', $1, $2)",
152
+ [nowMs, holder]
153
+ );
154
+ return holder;
155
+ }
156
+ throw _err("externaldb-migrate/lock-held",
157
+ "migration lock is held by " + (existing.lockedby || existing.lockedBy) +
158
+ " (acquired " + ageMs + "ms ago). Another process is running migrations" +
159
+ " — wait for it to finish, or pass staleAfterMs to force-replace stale locks.");
160
+ }
161
+ }
162
+
163
+ async function _releaseLock(xdb, holder) {
164
+ try {
165
+ await xdb.query(
166
+ "DELETE FROM " + Q_LOCK + " WHERE scope = 'lock' AND lockedBy = $1",
167
+ [holder]
168
+ );
169
+ } catch (_e) {
170
+ // best-effort release; operator can DELETE manually.
171
+ }
172
+ }
173
+
174
+ // ---- File loading ----
175
+
176
+ function _list(dir) {
177
+ return atomicFile.listDir(dir, {
178
+ filter: function (f) { return FILE_RE.test(f); },
179
+ }).map(function (e) { return e.name; }).sort();
180
+ }
181
+
182
+ function _loadMigration(file, dir) {
183
+ var fullPath = path.join(dir, file);
184
+ // Drop the require cache so a test/dev that edits a file picks up the
185
+ // new content. Matches lib/migrations.js semantics.
186
+ try { delete require.cache[require.resolve(fullPath)]; } catch (_e) { /* not yet cached */ }
187
+ var mod;
188
+ try { mod = require(fullPath); }
189
+ catch (e) {
190
+ throw _err("externaldb-migrate/load-failed",
191
+ "migration '" + file + "' failed to load: " + ((e && e.message) || String(e)));
192
+ }
193
+ if (!mod || typeof mod.up !== "function") {
194
+ throw _err("externaldb-migrate/missing-up",
195
+ "migration '" + file + "' must export an `up(xdb, ctx)` function");
196
+ }
197
+ return mod;
198
+ }
199
+
200
+ // ---- Audit emit (drop-silent) ----
201
+
202
+ function _emit(audit, action, outcome, info, reason) {
203
+ if (!audit) return;
204
+ try {
205
+ audit.safeEmit({
206
+ action: action,
207
+ outcome: outcome,
208
+ metadata: info || {},
209
+ reason: reason || null,
210
+ });
211
+ } catch (_e) { /* Tier B: drop-silent */ }
212
+ }
213
+
214
+ // ---- Backend resolution ----
215
+
216
+ function _resolveBackendName(opts) {
217
+ if (opts && typeof opts.backend === "string" && opts.backend.length > 0) {
218
+ return opts.backend;
219
+ }
220
+ // Default to the externalDb's defaultBackend; throw clear if not initialized.
221
+ var listed;
222
+ try { listed = externalDbModule().listBackends(); }
223
+ catch (_e) {
224
+ throw _err("externaldb-migrate/not-initialized",
225
+ "externalDb is not initialized — call b.externalDb.init({ backends }) first");
226
+ }
227
+ if (!listed || listed.length === 0) {
228
+ throw _err("externaldb-migrate/no-backends",
229
+ "externalDb has no backends configured");
230
+ }
231
+ return listed[0].name;
232
+ }
233
+
234
+ // ---- Public factory ----
235
+
236
+ function create(opts) {
237
+ opts = opts || {};
238
+ validateOpts(opts, ["dir", "backend", "audit", "staleAfterMs"], "b.externalDb.migrate");
239
+ if (typeof opts.dir !== "string" || opts.dir.length === 0) {
240
+ throw _err("externaldb-migrate/no-dir",
241
+ "externalDb.migrate.create requires opts.dir (path to migrations directory)");
242
+ }
243
+ if (opts.staleAfterMs !== undefined &&
244
+ (typeof opts.staleAfterMs !== "number" || !isFinite(opts.staleAfterMs) || opts.staleAfterMs < 0)) {
245
+ throw _err("externaldb-migrate/bad-stale",
246
+ "staleAfterMs must be a non-negative finite number");
247
+ }
248
+ if (opts.audit !== undefined && opts.audit !== null &&
249
+ (typeof opts.audit !== "object" || typeof opts.audit.safeEmit !== "function")) {
250
+ throw _err("externaldb-migrate/bad-audit",
251
+ "audit must be a b.audit-shaped object (safeEmit fn)");
252
+ }
253
+ var dir = opts.dir;
254
+ var audit = opts.audit || null;
255
+
256
+ function _ctx(backendName) {
257
+ return {
258
+ externalDb: externalDbModule(),
259
+ backendName: backendName,
260
+ };
261
+ }
262
+
263
+ async function status() {
264
+ var backendName = _resolveBackendName(opts);
265
+ return await externalDbModule().transaction(async function (xdb) {
266
+ await _ensureTrackingTable(xdb);
267
+ var res = await xdb.query(
268
+ "SELECT name, description, appliedAt FROM " + Q_TRACKING +
269
+ " ORDER BY appliedAt ASC, name ASC",
270
+ []
271
+ );
272
+ var applied = (res && res.rows) || [];
273
+ var appliedNames = new Set(applied.map(function (r) { return r.name; }));
274
+ var files = _list(dir);
275
+ var pending = files.filter(function (f) { return !appliedNames.has(f); });
276
+ return {
277
+ applied: applied,
278
+ pending: pending,
279
+ total: files.length,
280
+ backend: backendName,
281
+ };
282
+ }, { backend: backendName });
283
+ }
284
+
285
+ async function up() {
286
+ var backendName = _resolveBackendName(opts);
287
+ var ctx = _ctx(backendName);
288
+
289
+ return await externalDbModule().transaction(async function (xdb) {
290
+ await _ensureTrackingTable(xdb);
291
+ await _ensureLockTable(xdb);
292
+ }, { backend: backendName }).then(async function () {
293
+ // Acquire the lock OUTSIDE the per-migration transaction so the
294
+ // lock survives across migration boundaries. We use a separate
295
+ // pool acquisition for the lock connection — the migrate runner
296
+ // serializes apply order, so this single-connection lock is
297
+ // sufficient.
298
+ var lockHolder = await externalDbModule().transaction(async function (xdb) {
299
+ return await _acquireLock(xdb, opts);
300
+ }, { backend: backendName });
301
+
302
+ _emit(audit, "externaldb.migrate.lock.acquired", "success",
303
+ { holder: lockHolder, backend: backendName }, null);
304
+
305
+ try {
306
+ var appliedRes = await externalDbModule().query(
307
+ "SELECT name FROM " + Q_TRACKING, [], { backend: backendName }
308
+ );
309
+ var appliedSet = new Set(((appliedRes && appliedRes.rows) || []).map(function (r) { return r.name; }));
310
+ var files = _list(dir);
311
+ var applied = [];
312
+ var skipped = [];
313
+
314
+ for (var i = 0; i < files.length; i++) {
315
+ var file = files[i];
316
+ if (appliedSet.has(file)) { skipped.push(file); continue; }
317
+ var mod = _loadMigration(file, dir);
318
+ var t0 = Date.now();
319
+ try {
320
+ await externalDbModule().transaction(async function (xdb) {
321
+ await mod.up(xdb, ctx);
322
+ await xdb.query(
323
+ "INSERT INTO " + Q_TRACKING +
324
+ " (name, description, appliedAt) VALUES ($1, $2, $3)",
325
+ [file, mod.description || "", new Date().toISOString()]
326
+ );
327
+ }, { backend: backendName });
328
+ _emit(audit, "externaldb.migrate.up", "success",
329
+ { migration: file, durationMs: Date.now() - t0, backend: backendName }, null);
330
+ applied.push(file);
331
+ } catch (e) {
332
+ _emit(audit, "externaldb.migrate.up", "failure",
333
+ { migration: file, durationMs: Date.now() - t0, backend: backendName },
334
+ (e && e.message) || String(e));
335
+ throw _err("externaldb-migrate/up-failed",
336
+ "migration '" + file + "' failed to apply: " + ((e && e.message) || String(e)));
337
+ }
338
+ }
339
+ return { applied: applied, skipped: skipped, backend: backendName };
340
+ } finally {
341
+ try {
342
+ await externalDbModule().transaction(async function (xdb) {
343
+ await _releaseLock(xdb, lockHolder);
344
+ }, { backend: backendName });
345
+ _emit(audit, "externaldb.migrate.lock.released", "success",
346
+ { holder: lockHolder, backend: backendName }, null);
347
+ } catch (_e) { /* best-effort release; emit a failure audit */
348
+ _emit(audit, "externaldb.migrate.lock.released", "failure",
349
+ { holder: lockHolder, backend: backendName }, "release failed");
350
+ }
351
+ }
352
+ });
353
+ }
354
+
355
+ async function down(downOpts) {
356
+ downOpts = downOpts || {};
357
+ var steps = (typeof downOpts.steps === "number" && downOpts.steps > 0)
358
+ ? Math.floor(downOpts.steps) : 1;
359
+ var backendName = _resolveBackendName(opts);
360
+ var ctx = _ctx(backendName);
361
+
362
+ await externalDbModule().transaction(async function (xdb) {
363
+ await _ensureTrackingTable(xdb);
364
+ await _ensureLockTable(xdb);
365
+ }, { backend: backendName });
366
+
367
+ var lockHolder = await externalDbModule().transaction(async function (xdb) {
368
+ return await _acquireLock(xdb, opts);
369
+ }, { backend: backendName });
370
+
371
+ _emit(audit, "externaldb.migrate.lock.acquired", "success",
372
+ { holder: lockHolder, backend: backendName }, null);
373
+
374
+ try {
375
+ var appliedRes = await externalDbModule().query(
376
+ "SELECT name FROM " + Q_TRACKING + " ORDER BY appliedAt DESC, name DESC LIMIT $1",
377
+ [steps], { backend: backendName }
378
+ );
379
+ var rows = (appliedRes && appliedRes.rows) || [];
380
+ var reverted = [];
381
+ for (var i = 0; i < rows.length; i++) {
382
+ var file = rows[i].name;
383
+ var mod = _loadMigration(file, dir);
384
+ if (typeof mod.down !== "function") {
385
+ throw _err("externaldb-migrate/no-down",
386
+ "migration '" + file + "' has no down() — write one or restore from backup");
387
+ }
388
+ var t0 = Date.now();
389
+ try {
390
+ await externalDbModule().transaction(async function (xdb) {
391
+ await mod.down(xdb, ctx);
392
+ await xdb.query(
393
+ "DELETE FROM " + Q_TRACKING + " WHERE name = $1",
394
+ [file]
395
+ );
396
+ }, { backend: backendName });
397
+ _emit(audit, "externaldb.migrate.down", "success",
398
+ { migration: file, durationMs: Date.now() - t0, backend: backendName }, null);
399
+ reverted.push(file);
400
+ } catch (e) {
401
+ _emit(audit, "externaldb.migrate.down", "failure",
402
+ { migration: file, durationMs: Date.now() - t0, backend: backendName },
403
+ (e && e.message) || String(e));
404
+ throw _err("externaldb-migrate/down-failed",
405
+ "migration '" + file + "' failed to roll back: " + ((e && e.message) || String(e)));
406
+ }
407
+ }
408
+ return { reverted: reverted, backend: backendName };
409
+ } finally {
410
+ try {
411
+ await externalDbModule().transaction(async function (xdb) {
412
+ await _releaseLock(xdb, lockHolder);
413
+ }, { backend: backendName });
414
+ _emit(audit, "externaldb.migrate.lock.released", "success",
415
+ { holder: lockHolder, backend: backendName }, null);
416
+ } catch (_e) {
417
+ _emit(audit, "externaldb.migrate.lock.released", "failure",
418
+ { holder: lockHolder, backend: backendName }, "release failed");
419
+ }
420
+ }
421
+ }
422
+
423
+ return {
424
+ up: up,
425
+ down: down,
426
+ status: status,
427
+ };
428
+ }
429
+
430
+ module.exports = {
431
+ create: create,
432
+ ExternalDbMigrateError: ExternalDbMigrateError,
433
+ TRACKING_TABLE: TRACKING_TABLE,
434
+ };
@@ -174,8 +174,21 @@ function init(opts) {
174
174
  if (typeof cfg.query !== "function") {
175
175
  throw _err("INVALID_CONFIG", "backend '" + name + "' missing query() function", true);
176
176
  }
177
+ // dialect — informational marker so dialect-specific consumers
178
+ // (e.g. b.db.declareView) can fail-fast at apply time. Defaults to
179
+ // "postgres" because that's the dominant blamejs externalDb target;
180
+ // operators on SQLite/MySQL/etc. set this explicitly so downstream
181
+ // primitives surface NOT_SUPPORTED with a clear message instead of
182
+ // emitting Postgres-flavored DDL into the wrong dialect.
183
+ var dialect = (cfg.dialect || "postgres").toLowerCase();
184
+ if (["postgres", "mysql", "sqlite", "mongodb", "other"].indexOf(dialect) === -1) {
185
+ throw _err("INVALID_CONFIG",
186
+ "backend '" + name + "': dialect must be one of " +
187
+ "'postgres' | 'mysql' | 'sqlite' | 'mongodb' | 'other', got '" + dialect + "'", true);
188
+ }
177
189
  backends[name] = {
178
190
  name: name,
191
+ dialect: dialect,
179
192
  pool: new Pool(name, cfg),
180
193
  query: cfg.query,
181
194
  ping: cfg.ping || null,
@@ -372,6 +385,7 @@ function listBackends() {
372
385
  var b = backends[name];
373
386
  return {
374
387
  name: name,
388
+ dialect: b.dialect,
375
389
  classifications: b.classifications.slice(),
376
390
  residencyTag: b.residencyTag,
377
391
  breakerState: b.breaker.getState(),
@@ -790,6 +804,10 @@ module.exports = {
790
804
  adapters: {
791
805
  connectAs: _adaptersConnectAs,
792
806
  },
807
+ // Migration runner targeting an externalDb backend. Mirrors b.migrations
808
+ // (which targets local SQLite) but runs against externalDb. Tracking +
809
+ // lock tables live on the externalDb side. See lib/external-db-migrate.js.
810
+ migrate: require("./external-db-migrate"),
793
811
  Pool: Pool,
794
812
  _resetForTest: _resetForTest,
795
813
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",