@c9up/atlas 0.3.10 → 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.
Files changed (57) hide show
  1. package/db.darwin-arm64.node +0 -0
  2. package/db.darwin-x64.node +0 -0
  3. package/db.linux-arm64-gnu.node +0 -0
  4. package/db.linux-x64-gnu.node +0 -0
  5. package/db.win32-x64-msvc.node +0 -0
  6. package/dist/AtlasProvider.d.ts +1 -0
  7. package/dist/AtlasProvider.d.ts.map +1 -1
  8. package/dist/AtlasProvider.js +52 -13
  9. package/dist/AtlasProvider.js.map +1 -1
  10. package/dist/BaseRepository.d.ts.map +1 -1
  11. package/dist/BaseRepository.js +30 -12
  12. package/dist/BaseRepository.js.map +1 -1
  13. package/dist/ModelQuery.d.ts.map +1 -1
  14. package/dist/ModelQuery.js +34 -13
  15. package/dist/ModelQuery.js.map +1 -1
  16. package/dist/augmentations.d.ts +44 -0
  17. package/dist/augmentations.d.ts.map +1 -0
  18. package/dist/augmentations.js +19 -0
  19. package/dist/augmentations.js.map +1 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/query/DatabaseQueryBuilder.d.ts.map +1 -1
  25. package/dist/query/DatabaseQueryBuilder.js +24 -8
  26. package/dist/query/DatabaseQueryBuilder.js.map +1 -1
  27. package/dist/query/QueryBuilder.js +15 -1
  28. package/dist/query/QueryBuilder.js.map +1 -1
  29. package/dist/schema/MigrationRunner.d.ts.map +1 -1
  30. package/dist/schema/MigrationRunner.js +112 -7
  31. package/dist/schema/MigrationRunner.js.map +1 -1
  32. package/dist/schema/SchemaCheck.d.ts.map +1 -1
  33. package/dist/schema/SchemaCheck.js +19 -7
  34. package/dist/schema/SchemaCheck.js.map +1 -1
  35. package/dist/testing/DatabaseCleanup.js +15 -1
  36. package/dist/testing/DatabaseCleanup.js.map +1 -1
  37. package/dist/testing/Factory.js +2 -2
  38. package/dist/testing/Factory.js.map +1 -1
  39. package/index.darwin-arm64.node +0 -0
  40. package/index.darwin-x64.node +0 -0
  41. package/index.linux-arm64-gnu.node +0 -0
  42. package/index.linux-x64-gnu.node +0 -0
  43. package/index.win32-x64-msvc.node +0 -0
  44. package/package.json +10 -5
  45. package/scripts/build-napi-types.mjs +4 -3
  46. package/scripts/generate-napi-types.mjs +9 -6
  47. package/src/AtlasProvider.ts +65 -18
  48. package/src/BaseRepository.ts +30 -12
  49. package/src/ModelQuery.ts +37 -11
  50. package/src/augmentations.ts +49 -0
  51. package/src/index.ts +1 -0
  52. package/src/query/DatabaseQueryBuilder.ts +25 -8
  53. package/src/query/QueryBuilder.ts +16 -1
  54. package/src/schema/MigrationRunner.ts +124 -11
  55. package/src/schema/SchemaCheck.ts +27 -10
  56. package/src/testing/DatabaseCleanup.ts +16 -1
  57. 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.statements[0], compiled.params);
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
- const token = randomUUID();
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
- const dp = Array.from({ length: b.length + 1 }, (_, i) => i);
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
- let prev = dp[0];
78
- dp[0] = i;
79
+ const current: number[] = [i];
79
80
  for (let j = 1; j <= b.length; j++) {
80
- const tmp = dp[j];
81
- dp[j] = Math.min(
82
- dp[j] + 1,
83
- dp[j - 1] + 1,
84
- prev + (a[i - 1] === b[j - 1] ? 0 : 1),
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 dp[b.length];
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.statements[0], params: compiled.params };
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
+ }
@@ -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 (let i = 0; i < rows.length; i++) {
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(rows[i], pivot)]);
769
+ await recurse([await proxy.create(row, pivot)]);
770
770
  }
771
771
  } else {
772
772
  throw new Error(