@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 +1 -0
- package/lib/db-declare-view.js +424 -0
- package/lib/db.js +5 -0
- package/lib/external-db-migrate.js +434 -0
- package/lib/external-db.js +18 -0
- package/package.json +1 -1
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
|
+
};
|
package/lib/external-db.js
CHANGED
|
@@ -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
|
};
|