turbine-orm 0.40.0 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,24 @@ import { postgresql } from '../adapters/index.js';
19
19
  import { postgresDialect } from '../dialect.js';
20
20
  import { MigrationError } from '../errors.js';
21
21
  import { DESTRUCTIVE_KIND_LABEL, scanDestructiveSql } from './destructive.js';
22
+ /** Extract the YYYYMMDDHHMMSS timestamp prefix from a migration name, or null. */
23
+ export function migrationTimestamp(name) {
24
+ const m = name.match(/^(\d{14})(?:_|$)/);
25
+ return m ? m[1] : null;
26
+ }
27
+ /** Scan a set of migration files' UP sections for data-destroying statements. */
28
+ export function collectUpDestructive(files) {
29
+ const offenders = [];
30
+ for (const file of files) {
31
+ const { up } = parseMigrationSQL(file.path);
32
+ if (!up)
33
+ continue;
34
+ const hits = scanDestructiveSql(up);
35
+ if (hits.length > 0)
36
+ offenders.push({ file: file.filename, hits });
37
+ }
38
+ return offenders;
39
+ }
22
40
  // ---------------------------------------------------------------------------
23
41
  // Tracking table management
24
42
  // ---------------------------------------------------------------------------
@@ -432,7 +450,7 @@ async function validateChecksums(client, migrationsDir, dialect = postgresDialec
432
450
  }
433
451
  return mismatches;
434
452
  }
435
- function formatChecksumMismatchError(mismatches) {
453
+ export function formatChecksumMismatchError(mismatches) {
436
454
  const modified = mismatches.filter((m) => m.type === 'modified');
437
455
  const missing = mismatches.filter((m) => m.type === 'missing');
438
456
  const lines = [
@@ -450,7 +468,14 @@ function formatChecksumMismatchError(mismatches) {
450
468
  lines.push('');
451
469
  lines.push('Fix one of these:');
452
470
  lines.push(' 1. Restore the file(s) to their original content, OR');
453
- lines.push(' 2. Roll back the affected migrations with `npx turbine migrate down`, OR');
471
+ if (modified.length > 0) {
472
+ // `migrate down` needs the file on disk to read its DOWN section, so it is
473
+ // only a remedy for MODIFIED files, never for deleted ones.
474
+ lines.push(' 2. Roll back the affected migrations with `npx turbine migrate down` (modified files only), OR');
475
+ }
476
+ if (missing.length > 0) {
477
+ lines.push(' (deleted files cannot be rolled back: restore the file, then run `migrate down` if needed), OR');
478
+ }
454
479
  lines.push(' 3. Pass `--allow-drift` to bypass this check (advanced — make sure you know what you are doing).');
455
480
  return lines.join('\n');
456
481
  }
@@ -559,42 +584,43 @@ export async function migrateUp(connectionString, migrationsDir, options) {
559
584
  }
560
585
  const applied = await getAppliedMigrations(client, dialect);
561
586
  const appliedNames = new Set(applied.map((m) => m.name));
587
+ // Newest already-applied timestamp. Anything applied below this line is
588
+ // going in out of order (an older migration created/applied after a newer
589
+ // one). Used to surface a warning; never blocks.
590
+ const newestPrior = applied
591
+ .map((m) => ({ ts: migrationTimestamp(m.name), name: m.name }))
592
+ .filter((m) => m.ts !== null)
593
+ .sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0))[0];
562
594
  const allFiles = listMigrationFiles(migrationsDir);
563
595
  let pending = allFiles.filter((f) => !appliedNames.has(f.name));
564
596
  if (options?.step != null && options.step > 0) {
565
597
  pending = pending.slice(0, options.step);
566
598
  }
599
+ // Destructive statements in the pending batch, computed once. Returned in
600
+ // the result regardless of the gate so `deploy` can print a notice even
601
+ // though it proceeds by design.
602
+ const destructive = collectUpDestructive(pending);
567
603
  // Data-loss gate: refuse to run pending migrations containing destructive
568
604
  // statements unless the caller has EXPLICITLY opted in. The CLI layers an
569
605
  // interactive typed confirmation on top of this; programmatic callers must
570
606
  // pass `allowDestructive: true`. Safe-by-default is the whole point — a
571
607
  // DROP TABLE should never run just because a file exists.
572
- if (!options?.allowDestructive) {
573
- const offenders = [];
574
- for (const file of pending) {
575
- const { up } = parseMigrationSQL(file.path);
576
- if (!up)
577
- continue;
578
- const hits = scanDestructiveSql(up);
579
- if (hits.length > 0)
580
- offenders.push({ file: file.filename, hits });
581
- }
582
- if (offenders.length > 0) {
583
- const lines = ['[turbine] Refusing to apply migrations containing DESTRUCTIVE statements:', ''];
584
- for (const o of offenders) {
585
- lines.push(` ${o.file}`);
586
- for (const h of o.hits) {
587
- lines.push(` - [${h.kind}] ${h.target} — ${DESTRUCTIVE_KIND_LABEL[h.kind]}`);
588
- }
608
+ if (!options?.allowDestructive && destructive.length > 0) {
609
+ const lines = ['[turbine] Refusing to apply migrations containing DESTRUCTIVE statements:', ''];
610
+ for (const o of destructive) {
611
+ lines.push(` ${o.file}`);
612
+ for (const h of o.hits) {
613
+ lines.push(` - [${h.kind}] ${h.target}: ${DESTRUCTIVE_KIND_LABEL[h.kind]}`);
589
614
  }
590
- lines.push('');
591
- lines.push('Review the statements above. To proceed: run `npx turbine migrate up` interactively');
592
- lines.push('and confirm, pass --allow-destructive, or set allowDestructive: true programmatically.');
593
- throw new MigrationError(lines.join('\n'));
594
615
  }
616
+ lines.push('');
617
+ lines.push('Review the statements above. To proceed: run `npx turbine migrate up` interactively');
618
+ lines.push('and confirm, pass --allow-destructive, or set allowDestructive: true programmatically.');
619
+ throw new MigrationError(lines.join('\n'));
595
620
  }
596
621
  const results = [];
597
622
  const errors = [];
623
+ const outOfOrder = [];
598
624
  for (const file of pending) {
599
625
  const { up } = parseMigrationSQL(file.path);
600
626
  if (!up) {
@@ -609,6 +635,11 @@ export async function migrateUp(connectionString, migrationsDir, options) {
609
635
  await client.query(dialect.buildMigrationInsertApplied(quotedTrackingTable(dialect)), [file.name, hash]);
610
636
  await client.query('COMMIT');
611
637
  results.push(file);
638
+ // Flag an out-of-order apply: this file's timestamp is older than a
639
+ // migration that was already applied before this run started.
640
+ if (newestPrior && file.timestamp && file.timestamp < newestPrior.ts) {
641
+ outOfOrder.push({ applied: file.filename, newestPrior: `${newestPrior.name}.sql` });
642
+ }
612
643
  }
613
644
  catch (err) {
614
645
  await client.query('ROLLBACK');
@@ -618,7 +649,7 @@ export async function migrateUp(connectionString, migrationsDir, options) {
618
649
  break;
619
650
  }
620
651
  }
621
- return { applied: results, errors };
652
+ return { applied: results, errors, destructive, outOfOrder };
622
653
  }
623
654
  finally {
624
655
  await releaseLock(client, lockId, adapter);
@@ -634,7 +665,9 @@ export async function migrateUp(connectionString, migrationsDir, options) {
634
665
  */
635
666
  export async function migrateDeploy(connectionString, migrationsDir, options) {
636
667
  return migrateUp(connectionString, migrationsDir, {
637
- allowDrift: false,
668
+ // Honor `--allow-drift` on deploy exactly as `up` does: deploy's own drift
669
+ // error recommends this flag, so it must actually bypass the checksum block.
670
+ allowDrift: options?.allowDrift === true,
638
671
  allowDestructive: true,
639
672
  adapter: options?.adapter,
640
673
  dialect: options?.dialect,
@@ -693,7 +726,7 @@ export async function migrateDown(connectionString, migrationsDir, options) {
693
726
  for (const o of offenders) {
694
727
  lines.push(` ${o.file}`);
695
728
  for (const h of o.hits) {
696
- lines.push(` - [${h.kind}] ${h.target} ${DESTRUCTIVE_KIND_LABEL[h.kind]}`);
729
+ lines.push(` - [${h.kind}] ${h.target}: ${DESTRUCTIVE_KIND_LABEL[h.kind]}`);
697
730
  }
698
731
  }
699
732
  lines.push('');
@@ -755,7 +788,8 @@ export async function migrateStatus(connectionString, migrationsDir, options) {
755
788
  const applied = await getAppliedMigrations(client, dialect);
756
789
  const appliedMap = new Map(applied.map((m) => [m.name, m]));
757
790
  const allFiles = listMigrationFiles(migrationsDir);
758
- return allFiles.map((file) => {
791
+ const fileNames = new Set(allFiles.map((f) => f.name));
792
+ const fromFiles = allFiles.map((file) => {
759
793
  const record = appliedMap.get(file.name);
760
794
  let checksumValid;
761
795
  if (record) {
@@ -770,6 +804,25 @@ export async function migrateStatus(connectionString, migrationsDir, options) {
770
804
  checksumValid,
771
805
  };
772
806
  });
807
+ // Applied migrations whose file was deleted from disk. up/deploy already
808
+ // catch this as drift; status must not silently drop them from history.
809
+ const missing = applied
810
+ .filter((m) => !fileNames.has(m.name))
811
+ .map((m) => ({
812
+ file: parseMigrationFilename(`${m.name}.sql`) ?? {
813
+ filename: `${m.name}.sql`,
814
+ path: '',
815
+ name: m.name,
816
+ timestamp: '',
817
+ },
818
+ applied: true,
819
+ appliedAt: m.applied_at,
820
+ checksumValid: false,
821
+ missingFile: true,
822
+ }));
823
+ // Keep the overall list in timestamp order so a deleted entry appears where
824
+ // it belongs in history, not tacked on at the end.
825
+ return [...fromFiles, ...missing].sort((a, b) => a.file.name < b.file.name ? -1 : a.file.name > b.file.name ? 1 : 0);
773
826
  }
774
827
  finally {
775
828
  await client.end();
@@ -821,14 +821,23 @@ export async function schemaDiff(schema, connectionString) {
821
821
  }
822
822
  }
823
823
  }
824
- // Check for columns in DB that are not in schema
824
+ // Check for columns in DB that are not in schema.
825
+ // The DROP COLUMN statement stays IN `result.statements` so the destructive
826
+ // gate (findDestructivePushStatements / scanDestructiveSql, and the migrate
827
+ // up gate) can see it and refuse by default. Safety is enforced by that
828
+ // gate, not by hiding the statement: withholding it here previously made
829
+ // `push` report "already in sync" for a destructive-only diff and print a
830
+ // false "Altered" for a mixed one, since the 0.36 guard never received it.
831
+ // No reverse is emitted (a dropped column and its data cannot be recreated
832
+ // automatically), so diff-generated migrations fall back to the documented
833
+ // irreversible-DOWN placeholder for this change.
825
834
  for (const dbColName of Object.keys(dbCols)) {
826
835
  const hasField = Object.entries(tableDef.columns).some(([fieldName]) => camelToSnake(fieldName) === dbColName);
827
836
  if (!hasField) {
828
837
  const sql = `ALTER TABLE ${dialect.quoteIdentifier(tableName)} DROP COLUMN ${dialect.quoteIdentifier(dbColName)};`;
829
- const reverseSql = `-- Cannot auto-reverse DROP COLUMN for "${dbColName}" add it back manually`;
838
+ const reverseSql = `-- Cannot auto-reverse DROP COLUMN for "${dbColName}"; add it back manually`;
830
839
  alterDef.columns.push({ column: dbColName, action: 'drop', sql, reverseSql });
831
- // Don't auto-add drops to statements for safety — user must opt in
840
+ result.statements.push(sql);
832
841
  }
833
842
  }
834
843
  if (alterDef.columns.length > 0) {
package/dist/seed.d.ts CHANGED
@@ -1,4 +1,15 @@
1
1
  import { TurbineClient } from './client.js';
2
2
  export type SeedFunction = (db: TurbineClient) => Promise<void> | void;
3
3
  export type DefinedSeed = () => Promise<void>;
4
+ /**
5
+ * Extract the filesystem path from a single V8 stack-trace line, regardless of
6
+ * whether the frame is a `file://` URL (ESM), a bare absolute path (CJS / tsx),
7
+ * or a wrapped `(… )` location. The trailing `:line:col` (and any surrounding
8
+ * parens) are peeled from the END so a Windows drive colon or a URL scheme colon
9
+ * inside the path never confuses the match. Non-file frames (`node:internal/…`,
10
+ * `<anonymous>`) return null.
11
+ *
12
+ * Exported for unit testing the frame parser in isolation.
13
+ */
14
+ export declare function parseStackFramePath(line: string): string | null;
4
15
  export declare function defineSeed(fn: SeedFunction): DefinedSeed;
package/dist/seed.js CHANGED
@@ -1,32 +1,104 @@
1
- import { realpathSync } from 'node:fs';
1
+ import { realpathSync, writeFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
- import { pathToFileURL } from 'node:url';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
  import { TurbineClient } from './client.js';
5
5
  import { ConnectionError } from './errors.js';
6
6
  const emptySchema = { tables: {}, enums: {} };
7
- function entryUrl() {
8
- const entry = process.argv[1];
9
- if (!entry)
7
+ /**
8
+ * Extract the filesystem path from a single V8 stack-trace line, regardless of
9
+ * whether the frame is a `file://` URL (ESM), a bare absolute path (CJS / tsx),
10
+ * or a wrapped `(… )` location. The trailing `:line:col` (and any surrounding
11
+ * parens) are peeled from the END so a Windows drive colon or a URL scheme colon
12
+ * inside the path never confuses the match. Non-file frames (`node:internal/…`,
13
+ * `<anonymous>`) return null.
14
+ *
15
+ * Exported for unit testing the frame parser in isolation.
16
+ */
17
+ export function parseStackFramePath(line) {
18
+ // Peel the trailing `:line:col` (with any closing paren) from the END so a
19
+ // Windows drive colon or a `file://` scheme colon earlier in the path is never
20
+ // mistaken for the location separator.
21
+ const loc = line.match(/:(\d+):(\d+)\)?\s*$/);
22
+ if (!loc || loc.index === undefined)
10
23
  return null;
24
+ let head = line.slice(0, loc.index);
25
+ const paren = head.lastIndexOf('(');
26
+ if (paren !== -1) {
27
+ // `at fn (PATH:line:col)`: the path is whatever the last "(" wraps.
28
+ head = head.slice(paren + 1);
29
+ }
30
+ else {
31
+ // `at PATH:line:col`: drop the leading " at " prefix.
32
+ head = head.replace(/^\s*at\s+/, '');
33
+ }
34
+ head = head.trim();
35
+ if (head.startsWith('file://')) {
36
+ try {
37
+ return fileURLToPath(head);
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ // Accept only absolute filesystem paths (POSIX `/…` or Windows `C:\…` / `C:/…`).
44
+ if (/^(\/|[A-Za-z]:[\\/])/.test(head))
45
+ return head;
46
+ return null;
47
+ }
48
+ /** Best-effort canonicalization so two spellings of the same file compare equal. */
49
+ function canonicalPath(p) {
11
50
  try {
12
- return pathToFileURL(realpathSync(entry)).href;
51
+ return realpathSync(p);
13
52
  }
14
53
  catch {
15
- return pathToFileURL(resolve(entry)).href;
54
+ return resolve(p);
55
+ }
56
+ }
57
+ /**
58
+ * The canonical path of THIS module's own file, captured once at load time from
59
+ * a fresh stack. It is used to skip the library's own frames when locating the
60
+ * caller. This is robust to the src (`seed.ts`) vs published dist (`seed.js`)
61
+ * basename difference AND to the fact that the user's own file is ALSO named
62
+ * `seed.ts`, which a basename skip-list would wrongly exclude. This fixes the
63
+ * silent no-op: previously the plain-path skip-list only knew `src/seed.ts`, so
64
+ * the library's own `dist/seed.js` frame (or a tsx plain-path frame) was
65
+ * mistaken for the caller and the entry===caller self-run check never passed.
66
+ */
67
+ const SELF_PATH = (() => {
68
+ const stack = new Error().stack;
69
+ if (!stack)
70
+ return null;
71
+ // Frame [1] (after the "Error" header) is this IIFE, i.e. the current module.
72
+ for (const line of stack.split('\n').slice(1)) {
73
+ const p = parseStackFramePath(line);
74
+ if (p)
75
+ return canonicalPath(p);
16
76
  }
77
+ return null;
78
+ })();
79
+ function entryUrl() {
80
+ const entry = process.argv[1];
81
+ if (!entry)
82
+ return null;
83
+ return pathToFileURL(canonicalPath(entry)).href;
17
84
  }
85
+ /**
86
+ * The first stack frame that is NOT part of this module, expressed as a
87
+ * canonical `file://` URL. That frame is whoever invoked `defineSeed`: the
88
+ * user's seed module when the file is run directly.
89
+ */
18
90
  function callerUrl() {
19
91
  const stack = new Error().stack;
20
92
  if (!stack)
21
93
  return null;
22
94
  for (const line of stack.split('\n').slice(2)) {
23
- const fileUrl = line.match(/(file:\/\/\/[^):]+):\d+:\d+/)?.[1];
24
- if (fileUrl && !fileUrl.endsWith('/seed.ts') && !fileUrl.endsWith('/seed.js'))
25
- return fileUrl;
26
- const filePath = line.match(/\(?((?:\/|[A-Za-z]:\\)[^):]+):\d+:\d+\)?/)?.[1];
27
- if (filePath && !filePath.endsWith('/src/seed.ts') && !filePath.endsWith('\\src\\seed.ts')) {
28
- return pathToFileURL(resolve(filePath)).href;
29
- }
95
+ const p = parseStackFramePath(line);
96
+ if (!p)
97
+ continue;
98
+ const canonical = canonicalPath(p);
99
+ if (SELF_PATH && canonical === SELF_PATH)
100
+ continue; // skip the library's own frames
101
+ return pathToFileURL(canonical).href;
30
102
  }
31
103
  return null;
32
104
  }
@@ -35,6 +107,24 @@ function isDirectSeedModule() {
35
107
  const caller = callerUrl();
36
108
  return process.env.NODE_TEST_CONTEXT === undefined && !!entry && !!caller && entry === caller;
37
109
  }
110
+ /**
111
+ * Signal, to a parent `turbine seed` process, that a defineSeed callback
112
+ * actually executed to completion. The CLI sets `TURBINE_SEED_SENTINEL` to a
113
+ * temp path before spawning the seed; if the file never appears the CLI knows
114
+ * the seed module loaded but no callback ran, and reports that as a failure
115
+ * instead of a false "Seed completed".
116
+ */
117
+ function markSeedRan() {
118
+ const sentinel = process.env.TURBINE_SEED_SENTINEL;
119
+ if (!sentinel)
120
+ return;
121
+ try {
122
+ writeFileSync(sentinel, 'ran');
123
+ }
124
+ catch {
125
+ // Best-effort only: an unwritable sentinel must never fail a good seed run.
126
+ }
127
+ }
38
128
  async function runSeed(fn) {
39
129
  const connectionString = process.env.DATABASE_URL;
40
130
  if (!connectionString) {
@@ -43,6 +133,7 @@ async function runSeed(fn) {
43
133
  const db = new TurbineClient({ connectionString }, emptySchema);
44
134
  try {
45
135
  await fn(db);
136
+ markSeedRan();
46
137
  }
47
138
  finally {
48
139
  await db.disconnect();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.40.0",
3
+ "version": "0.40.1",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "exports": {