@c9up/atlas 0.3.11 → 0.3.12
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/db.darwin-arm64.node +0 -0
- package/db.darwin-x64.node +0 -0
- package/db.linux-arm64-gnu.node +0 -0
- package/db.linux-x64-gnu.node +0 -0
- package/db.win32-x64-msvc.node +0 -0
- package/dist/AtlasProvider.d.ts +1 -0
- package/dist/AtlasProvider.d.ts.map +1 -1
- package/dist/AtlasProvider.js +52 -13
- package/dist/AtlasProvider.js.map +1 -1
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +30 -12
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +34 -13
- package/dist/ModelQuery.js.map +1 -1
- package/dist/augmentations.d.ts +44 -0
- package/dist/augmentations.d.ts.map +1 -0
- package/dist/augmentations.js +19 -0
- package/dist/augmentations.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/query/DatabaseQueryBuilder.d.ts.map +1 -1
- package/dist/query/DatabaseQueryBuilder.js +24 -8
- package/dist/query/DatabaseQueryBuilder.js.map +1 -1
- package/dist/query/QueryBuilder.js +15 -1
- package/dist/query/QueryBuilder.js.map +1 -1
- package/dist/schema/MigrationRunner.d.ts.map +1 -1
- package/dist/schema/MigrationRunner.js +112 -7
- package/dist/schema/MigrationRunner.js.map +1 -1
- package/dist/schema/SchemaCheck.d.ts.map +1 -1
- package/dist/schema/SchemaCheck.js +19 -7
- package/dist/schema/SchemaCheck.js.map +1 -1
- package/dist/testing/DatabaseCleanup.js +15 -1
- package/dist/testing/DatabaseCleanup.js.map +1 -1
- package/dist/testing/Factory.js +2 -2
- package/dist/testing/Factory.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +10 -5
- package/scripts/build-napi-types.mjs +4 -3
- package/scripts/generate-napi-types.mjs +9 -6
- package/src/AtlasProvider.ts +65 -18
- package/src/BaseRepository.ts +30 -12
- package/src/ModelQuery.ts +37 -11
- package/src/augmentations.ts +49 -0
- package/src/index.ts +1 -0
- package/src/query/DatabaseQueryBuilder.ts +25 -8
- package/src/query/QueryBuilder.ts +16 -1
- package/src/schema/MigrationRunner.ts +124 -11
- package/src/schema/SchemaCheck.ts +27 -10
- package/src/testing/DatabaseCleanup.ts +16 -1
- package/src/testing/Factory.ts +2 -2
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
import * as fsp from "node:fs/promises";
|
|
9
|
+
import { hostname } from "node:os";
|
|
9
10
|
import * as path from "node:path";
|
|
10
11
|
import { pathToFileURL } from "node:url";
|
|
11
12
|
import { AtlasError } from "../errors.js";
|
|
@@ -30,6 +31,66 @@ const TABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
|
30
31
|
/** The single lock row's fixed primary key — the lock is always this one row. */
|
|
31
32
|
const LOCK_ROW_ID = 1;
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Who holds the migration lock, in a form a later run can act on.
|
|
36
|
+
*
|
|
37
|
+
* The token has to be unique — reading it back is what tells a writer it won
|
|
38
|
+
* the conditional UPDATE — but a bare UUID says nothing about the holder, so a
|
|
39
|
+
* lock abandoned by a killed process looked exactly like one a live migration
|
|
40
|
+
* is using. Carrying the pid and the host makes the difference checkable.
|
|
41
|
+
*/
|
|
42
|
+
function mintLockToken(): string {
|
|
43
|
+
return `${process.pid}@${hostname()}/${randomUUID()}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The pid and host a token names, for the tokens that carry them. */
|
|
47
|
+
function readLockToken(
|
|
48
|
+
token: string,
|
|
49
|
+
): { pid: number; host: string } | undefined {
|
|
50
|
+
const matched = /^(\d+)@([^/]*)\//.exec(token);
|
|
51
|
+
if (matched === null) return undefined;
|
|
52
|
+
const [, pid, host] = matched;
|
|
53
|
+
if (pid === undefined || host === undefined) return undefined;
|
|
54
|
+
return { pid: Number(pid), host };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The `code` of a Node system error, without asserting the error's shape. */
|
|
58
|
+
function errnoCode(error: unknown): string | undefined {
|
|
59
|
+
if (typeof error !== "object" || error === null) return undefined;
|
|
60
|
+
const code = Reflect.get(error, "code");
|
|
61
|
+
return typeof code === "string" ? code : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Is the process that took this lock gone?
|
|
66
|
+
*
|
|
67
|
+
* Only answerable for a lock taken on THIS host: a pid means nothing on another
|
|
68
|
+
* machine, and guessing there is how two migrations end up running at once.
|
|
69
|
+
* Signal 0 sends nothing — it only asks whether the process is still there —
|
|
70
|
+
* and `EPERM` means it is, owned by another user. Anything short of a definite
|
|
71
|
+
* "no such process" leaves the lock alone.
|
|
72
|
+
*
|
|
73
|
+
* A recycled pid can only make this answer "still running", which is the
|
|
74
|
+
* behaviour there was before.
|
|
75
|
+
*/
|
|
76
|
+
function holderIsGone(token: string): boolean {
|
|
77
|
+
const held = readLockToken(token);
|
|
78
|
+
if (held === undefined || held.host !== hostname()) return false;
|
|
79
|
+
try {
|
|
80
|
+
process.kill(held.pid, 0);
|
|
81
|
+
return false;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return errnoCode(error) === "ESRCH";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Name the holder in a failure, when the token says who it is. */
|
|
88
|
+
function describeHolder(token: string | undefined): string {
|
|
89
|
+
const held = token === undefined ? undefined : readLockToken(token);
|
|
90
|
+
if (held === undefined) return "";
|
|
91
|
+
return `Held by pid ${held.pid} on ${held.host}. `;
|
|
92
|
+
}
|
|
93
|
+
|
|
33
94
|
/**
|
|
34
95
|
* Split a schema-dump `.sql` file into executable statements. Statements are
|
|
35
96
|
* `;`-terminated (the dump format atlas writes); chunks that are empty or only
|
|
@@ -72,7 +133,7 @@ async function queryStmt<T>(
|
|
|
72
133
|
spec: object,
|
|
73
134
|
): Promise<T[]> {
|
|
74
135
|
const compiled = compileStatementNative(spec, dialect);
|
|
75
|
-
return db.query<T>(compiled
|
|
136
|
+
return db.query<T>(onlyStatement(compiled), compiled.params);
|
|
76
137
|
}
|
|
77
138
|
|
|
78
139
|
export interface BatchStmt {
|
|
@@ -348,7 +409,42 @@ export class MigrationRunner {
|
|
|
348
409
|
async #acquireLock(): Promise<void> {
|
|
349
410
|
if (this.#disableLocks) return;
|
|
350
411
|
await this.#ensureLockTable();
|
|
351
|
-
|
|
412
|
+
if (await this.#tryLock()) return;
|
|
413
|
+
|
|
414
|
+
// Nobody released it. A lock whose holder is a process on THIS machine
|
|
415
|
+
// that no longer exists outlived the run that took it — a dev server
|
|
416
|
+
// restarted mid-migration, a crash, a Ctrl-C between the UPDATE and the
|
|
417
|
+
// `finally`. Nothing would ever release it, and every later boot fails
|
|
418
|
+
// with the message above until someone intervenes by hand.
|
|
419
|
+
//
|
|
420
|
+
// Clearing it targets that exact token, so a lock taken by someone else
|
|
421
|
+
// in the meantime does not match and is left alone.
|
|
422
|
+
const holder = await this.#lockHolder();
|
|
423
|
+
if (holder !== undefined && holderIsGone(holder)) {
|
|
424
|
+
await this.#db.execute(
|
|
425
|
+
`UPDATE ${this.#lockTableName} SET is_locked = 0, locked_by = NULL WHERE id = ${LOCK_ROW_ID} AND locked_by = ${this.#ph}`,
|
|
426
|
+
[holder],
|
|
427
|
+
);
|
|
428
|
+
if (await this.#tryLock()) return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
throw new AtlasError(
|
|
432
|
+
"E_MIGRATION_LOCKED",
|
|
433
|
+
"Could not acquire the migration lock — another migration is already running.",
|
|
434
|
+
{
|
|
435
|
+
hint: `${describeHolder(holder)}Wait for it to finish, or run \`migration:unlock\` if it is stuck. \`disableLocks\` skips the lock entirely.`,
|
|
436
|
+
},
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* One attempt at the conditional UPDATE, reporting whether we won it.
|
|
442
|
+
*
|
|
443
|
+
* Split out because acquiring may be tried twice: once normally, and once
|
|
444
|
+
* more after clearing a lock its holder can no longer release.
|
|
445
|
+
*/
|
|
446
|
+
async #tryLock(): Promise<boolean> {
|
|
447
|
+
const token = mintLockToken();
|
|
352
448
|
await this.#db.execute(
|
|
353
449
|
`UPDATE ${this.#lockTableName} SET is_locked = 1, locked_by = ${this.#ph} WHERE id = ${LOCK_ROW_ID} AND is_locked = 0`,
|
|
354
450
|
[token],
|
|
@@ -356,16 +452,18 @@ export class MigrationRunner {
|
|
|
356
452
|
const rows = await this.#db.query<{ locked_by: unknown }>(
|
|
357
453
|
`SELECT locked_by FROM ${this.#lockTableName} WHERE id = ${LOCK_ROW_ID}`,
|
|
358
454
|
);
|
|
359
|
-
if (rows[0]?.locked_by !== token)
|
|
360
|
-
throw new AtlasError(
|
|
361
|
-
"E_MIGRATION_LOCKED",
|
|
362
|
-
"Could not acquire the migration lock — another migration is already running.",
|
|
363
|
-
{
|
|
364
|
-
hint: `Wait for it to finish, clear the ${this.#lockTableName} table if it is stuck, or pass disableLocks.`,
|
|
365
|
-
},
|
|
366
|
-
);
|
|
367
|
-
}
|
|
455
|
+
if (rows[0]?.locked_by !== token) return false;
|
|
368
456
|
this.#lockToken = token;
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** The token currently stamped on the lock row, when there is one. */
|
|
461
|
+
async #lockHolder(): Promise<string | undefined> {
|
|
462
|
+
const rows = await this.#db.query<{ locked_by: unknown }>(
|
|
463
|
+
`SELECT locked_by FROM ${this.#lockTableName} WHERE id = ${LOCK_ROW_ID}`,
|
|
464
|
+
);
|
|
465
|
+
const held = rows[0]?.locked_by;
|
|
466
|
+
return typeof held === "string" ? held : undefined;
|
|
369
467
|
}
|
|
370
468
|
|
|
371
469
|
/** Run `fn` while holding the migration lock; always release, even on throw. */
|
|
@@ -1156,3 +1254,18 @@ export class MigrationRunner {
|
|
|
1156
1254
|
return new MigrationClass(this.#dialect);
|
|
1157
1255
|
}
|
|
1158
1256
|
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* The single statement a compile produced.
|
|
1260
|
+
*
|
|
1261
|
+
* `compileStatementNative` answers a list because a few specs expand to more
|
|
1262
|
+
* than one; the callers here compile specs that do not, and this is where that
|
|
1263
|
+
* is stated instead of reading index zero as a value that might not be there.
|
|
1264
|
+
*/
|
|
1265
|
+
function onlyStatement(compiled: { statements: string[] }): string {
|
|
1266
|
+
const [statement] = compiled.statements;
|
|
1267
|
+
if (statement === undefined) {
|
|
1268
|
+
throw new Error("atlas: the query compiler produced no statement");
|
|
1269
|
+
}
|
|
1270
|
+
return statement;
|
|
1271
|
+
}
|
|
@@ -72,21 +72,38 @@ export function typesCompatible(modelType: string, dbType: string): boolean {
|
|
|
72
72
|
// ─── `did you mean` (atlas-local; no @c9up/ream import) ───────────────
|
|
73
73
|
|
|
74
74
|
function levenshtein(a: string, b: string): number {
|
|
75
|
-
|
|
75
|
+
// Two rows rather than one mutated in place: every read below then comes
|
|
76
|
+
// from a row this loop just filled, instead of an index that might not be.
|
|
77
|
+
let previous: number[] = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
76
78
|
for (let i = 1; i <= a.length; i++) {
|
|
77
|
-
|
|
78
|
-
dp[0] = i;
|
|
79
|
+
const current: number[] = [i];
|
|
79
80
|
for (let j = 1; j <= b.length; j++) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
current.push(
|
|
82
|
+
Math.min(
|
|
83
|
+
cell(previous, j) + 1,
|
|
84
|
+
cell(current, j - 1) + 1,
|
|
85
|
+
cell(previous, j - 1) + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
86
|
+
),
|
|
85
87
|
);
|
|
86
|
-
prev = tmp;
|
|
87
88
|
}
|
|
89
|
+
previous = current;
|
|
88
90
|
}
|
|
89
|
-
return
|
|
91
|
+
return cell(previous, b.length);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One cell of a row that has already been filled. Rows are built left to right
|
|
96
|
+
* and every column is written before it is read, so a miss cannot happen —
|
|
97
|
+
* this is where that is stated rather than asserted past.
|
|
98
|
+
*/
|
|
99
|
+
function cell(row: number[], index: number): number {
|
|
100
|
+
const value = row[index];
|
|
101
|
+
if (value === undefined) {
|
|
102
|
+
throw new RangeError(
|
|
103
|
+
`levenshtein: column ${index} was read before it was written`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
90
107
|
}
|
|
91
108
|
|
|
92
109
|
/** Closest candidate within edit distance 2 (typo suggestion), else undefined. */
|
|
@@ -95,7 +95,22 @@ export async function truncateAll(
|
|
|
95
95
|
{ kind: "delete", table: name, wheres: [] },
|
|
96
96
|
dialect,
|
|
97
97
|
);
|
|
98
|
-
return { sql: compiled
|
|
98
|
+
return { sql: onlyStatement(compiled), params: compiled.params };
|
|
99
99
|
});
|
|
100
100
|
await runWithoutForeignKeys(db, dialect, statements);
|
|
101
101
|
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The single statement a compile produced.
|
|
105
|
+
*
|
|
106
|
+
* `compileStatementNative` answers a list because a few specs expand to more
|
|
107
|
+
* than one; the callers here compile specs that do not, and this is where that
|
|
108
|
+
* is stated instead of reading index zero as a value that might not be there.
|
|
109
|
+
*/
|
|
110
|
+
function onlyStatement(compiled: { statements: string[] }): string {
|
|
111
|
+
const [statement] = compiled.statements;
|
|
112
|
+
if (statement === undefined) {
|
|
113
|
+
throw new Error("atlas: the query compiler produced no statement");
|
|
114
|
+
}
|
|
115
|
+
return statement;
|
|
116
|
+
}
|
package/src/testing/Factory.ts
CHANGED
|
@@ -762,11 +762,11 @@ export function factory<T extends BaseEntity>(
|
|
|
762
762
|
`Factory .with('${req.name}'): pivotAttributes array length (${nestedPivot.length}) must match the related-row count (${rows.length}).`,
|
|
763
763
|
);
|
|
764
764
|
}
|
|
765
|
-
for (
|
|
765
|
+
for (const [i, row] of rows.entries()) {
|
|
766
766
|
const pivot = Array.isArray(nestedPivot)
|
|
767
767
|
? nestedPivot[i]
|
|
768
768
|
: nestedPivot;
|
|
769
|
-
await recurse([await proxy.create(
|
|
769
|
+
await recurse([await proxy.create(row, pivot)]);
|
|
770
770
|
}
|
|
771
771
|
} else {
|
|
772
772
|
throw new Error(
|