@abgov/nx-adsp 13.23.1 → 13.24.0

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.
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "generators": {
4
+ "add-migrate-advisory-lock": {
5
+ "version": "13.24.0",
6
+ "description": "Wrap express-service's generated src/migrate.ts drizzle migrate() call in a Postgres advisory lock, so concurrent init containers serialize instead of racing.",
7
+ "implementation": "./src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock",
8
+ "prompt": "./src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.md"
9
+ }
10
+ }
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abgov/nx-adsp",
3
- "version": "13.23.1",
3
+ "version": "13.24.0",
4
4
  "license": "Apache-2.0",
5
5
  "main": "src/index.js",
6
6
  "description": "Government of Alberta - Nx plugin for ADSP apps.",
@@ -41,6 +41,9 @@
41
41
  "socket.io-client": "^4.8.3"
42
42
  },
43
43
  "generators": "./generators.json",
44
+ "nx-migrations": {
45
+ "migrations": "./migrations.json"
46
+ },
44
47
  "scripts": {},
45
48
  "types": "./src/index.d.ts",
46
49
  "type": "commonjs"
@@ -59,4 +59,78 @@ describe('build assets packaging', () => {
59
59
 
60
60
  expect(unmatched).toEqual([]);
61
61
  });
62
+
63
+ // The same boundary one level up. A migration is only reachable if
64
+ // package.json declares the registry and project.json ships it — and the
65
+ // migration's own unit tests resolve everything from the source tree, so they
66
+ // pass either way. Nothing else catches a migration that publishes inert.
67
+ it('ships the migrations registry declared in package.json', () => {
68
+ const pkg = JSON.parse(
69
+ fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'),
70
+ );
71
+ const registry: string | undefined = pkg['nx-migrations']?.migrations;
72
+ expect(registry).toBeDefined();
73
+
74
+ const registryPath = path.join(projectRoot, registry as string);
75
+ expect(fs.existsSync(registryPath)).toBe(true);
76
+
77
+ const project = JSON.parse(
78
+ fs.readFileSync(path.join(projectRoot, 'project.json'), 'utf-8'),
79
+ );
80
+ const assets: unknown[] = project.targets.build.options.assets ?? [];
81
+ const rootGlobs = assets
82
+ .filter(
83
+ (a): a is { input: string; glob: string } =>
84
+ typeof a === 'object' &&
85
+ a !== null &&
86
+ 'input' in a &&
87
+ path.resolve(repoRoot, (a as { input: string }).input) ===
88
+ path.resolve(projectRoot),
89
+ )
90
+ .map((a) => a.glob);
91
+
92
+ const rel = path.relative(projectRoot, registryPath);
93
+ expect(rootGlobs.some((glob) => minimatch(rel, glob, { dot: true }))).toBe(
94
+ true,
95
+ );
96
+ });
97
+
98
+ // Every file a migration names must resolve, or `nx migrate` fails at run
99
+ // time in the consumer's workspace rather than here. `prompt` is the markdown
100
+ // handed to the paired AI step; Nx resolves it relative to migrations.json
101
+ // (not through package exports), and requires at least one of implementation,
102
+ // factory, or prompt per entry.
103
+ it('points every migration at files that exist', () => {
104
+ const registry = JSON.parse(
105
+ fs.readFileSync(path.join(projectRoot, 'migrations.json'), 'utf-8'),
106
+ );
107
+ const entries: [
108
+ string,
109
+ { implementation?: string; factory?: string; prompt?: string },
110
+ ][] = Object.entries(registry.generators ?? {});
111
+ expect(entries.length).toBeGreaterThan(0);
112
+
113
+ const problems: string[] = [];
114
+ for (const [name, entry] of entries) {
115
+ if (!entry.implementation && !entry.factory && !entry.prompt) {
116
+ problems.push(`${name}: needs implementation, factory, or prompt`);
117
+ }
118
+ if (
119
+ entry.implementation &&
120
+ !fs.existsSync(path.join(projectRoot, `${entry.implementation}.ts`))
121
+ ) {
122
+ problems.push(`${name}: implementation not found`);
123
+ }
124
+ // Referenced verbatim, extension included — unlike implementation, which
125
+ // Nx resolves without one.
126
+ if (
127
+ entry.prompt &&
128
+ !fs.existsSync(path.join(projectRoot, entry.prompt))
129
+ ) {
130
+ problems.push(`${name}: prompt not found`);
131
+ }
132
+ }
133
+
134
+ expect(problems).toEqual([]);
135
+ });
62
136
  });
@@ -0,0 +1,7 @@
1
+ import { Tree } from '@nx/devkit';
2
+ interface MigrationReport {
3
+ nextSteps: string[];
4
+ agentContext: string[];
5
+ }
6
+ export default function addMigrateAdvisoryLock(tree: Tree): Promise<MigrationReport | undefined>;
7
+ export {};
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.default = addMigrateAdvisoryLock;
4
+ const tslib_1 = require("tslib");
5
+ const devkit_1 = require("@nx/devkit");
6
+ const fs_1 = require("fs");
7
+ const path_1 = require("path");
8
+ // express-service's generated migration runner, verbatim, either side of the
9
+ // advisory-lock fix — as the generator actually emits it, i.e. after
10
+ // formatFiles(). That differs from the files-postgres template itself, whose
11
+ // .ts__tmpl__ extension Prettier doesn't recognise and so never formats; the
12
+ // generated file is what this migration reads and writes, so it's the generated
13
+ // file that gets captured here.
14
+ //
15
+ // Held as fixtures rather than read from the live template because a migration
16
+ // has to keep applying the same change forever: sourcing from the template would
17
+ // mean a later edit to it silently changed what this already-released migration
18
+ // does.
19
+ const BEFORE_PATH = (0, path_1.join)(__dirname, 'migrate.before.txt');
20
+ const AFTER_PATH = (0, path_1.join)(__dirname, 'migrate.after.txt');
21
+ const MIGRATE_PATH = 'src/migrate.ts';
22
+ const DRIZZLE_MIGRATOR = 'drizzle-orm/node-postgres/migrator';
23
+ // What the rewrite must contain to be the rewrite at all. The asset glob in
24
+ // project.json is the only thing putting the fixtures in the published package,
25
+ // so a mis-scoped glob would otherwise overwrite every matching migrate.ts with
26
+ // nothing — checked once, loudly, instead of trusted.
27
+ const REQUIRED_MARKERS = [
28
+ 'MIGRATION_LOCK_KEY',
29
+ 'pg_advisory_lock',
30
+ 'pg_advisory_unlock',
31
+ 'lockClient.release()',
32
+ ];
33
+ // Compared after normalising only line endings and trailing whitespace — the
34
+ // noise git and editors introduce, which cannot hide a meaningful change.
35
+ //
36
+ // Deliberately NOT a general source normaliser. Tolerating arbitrary
37
+ // reformatting is either a half-measure that silently misses cases (collapsing
38
+ // whitespace handles a wrapped argument list but not the spaces Prettier puts
39
+ // inside the parens of a wrapped `if`, nor the trailing comma it adds) or
40
+ // aggressive enough to risk matching a file that isn't the generated one. A
41
+ // workspace whose own Prettier reformatted this file gets a warning naming it
42
+ // instead — the safe direction, since we only rewrite a file we can positively
43
+ // identify. In practice `create-nx-workspace` writes the same
44
+ // `{ "singleQuote": true }` config this repo uses, so the generated file is
45
+ // byte-identical for all but a deliberately customised setup.
46
+ function normalize(content) {
47
+ return content
48
+ .replace(/\r\n/g, '\n')
49
+ .replace(/[ \t]+$/gm, '')
50
+ .trimEnd();
51
+ }
52
+ function addMigrateAdvisoryLock(tree) {
53
+ return tslib_1.__awaiter(this, void 0, void 0, function* () {
54
+ var _a;
55
+ const before = (0, fs_1.readFileSync)(BEFORE_PATH, 'utf-8');
56
+ const after = (0, fs_1.readFileSync)(AFTER_PATH, 'utf-8');
57
+ const missing = REQUIRED_MARKERS.filter((marker) => !after.includes(marker));
58
+ if (missing.length > 0) {
59
+ throw new Error(`[nx-adsp] ${AFTER_PATH} is missing ${missing.join(', ')} — the packaged ` +
60
+ `migration fixture is incomplete, so nothing was rewritten. This is a ` +
61
+ `packaging bug in @abgov/nx-adsp, not a problem with your workspace.`);
62
+ }
63
+ // The other fixture fails safe (nothing matches, so nothing is rewritten),
64
+ // but silently and with a warning per project — check it too so a truncated
65
+ // asset reports itself rather than looking like every service was customised.
66
+ if (!before.includes(DRIZZLE_MIGRATOR) ||
67
+ before.includes('pg_advisory_lock')) {
68
+ throw new Error(`[nx-adsp] ${BEFORE_PATH} is not the pre-fix migration runner — the ` +
69
+ `packaged migration fixture is wrong, so nothing was rewritten. This ` +
70
+ `is a packaging bug in @abgov/nx-adsp, not a problem with your workspace.`);
71
+ }
72
+ let updated = 0;
73
+ const skipped = [];
74
+ for (const [name, project] of (0, devkit_1.getProjects)(tree)) {
75
+ const migratePath = (0, devkit_1.joinPathFragments)(project.root, MIGRATE_PATH);
76
+ if (!tree.exists(migratePath)) {
77
+ continue;
78
+ }
79
+ const content = (_a = tree.read(migratePath, 'utf-8')) !== null && _a !== void 0 ? _a : '';
80
+ // Already serialized — by a newer generator, a previous run of this
81
+ // migration, or by hand.
82
+ if (content.includes('pg_advisory_lock')) {
83
+ continue;
84
+ }
85
+ // Not the file this migration is about: a hand-written runner, or a
86
+ // Prisma-era service from before the Drizzle switch.
87
+ if (!content.includes(DRIZZLE_MIGRATOR)) {
88
+ continue;
89
+ }
90
+ if (normalize(content) !== normalize(before)) {
91
+ // Warned per file so it appears inline next to this migration; the
92
+ // actionable instruction is carried once, in nextSteps, rather than
93
+ // repeated in full for every file.
94
+ devkit_1.logger.warn(`[nx-adsp] ${migratePath} (project "${name}") runs drizzle's migrate() with no ` +
95
+ `advisory lock, but differs from the generated version — left untouched.`);
96
+ skipped.push(migratePath);
97
+ continue;
98
+ }
99
+ tree.write(migratePath, after);
100
+ updated++;
101
+ }
102
+ if (updated > 0) {
103
+ yield (0, devkit_1.formatFiles)(tree);
104
+ devkit_1.logger.info(`[nx-adsp] Serialized ${updated} generated migrate.ts file(s) with a Postgres advisory lock.`);
105
+ }
106
+ if (skipped.length === 0) {
107
+ return undefined;
108
+ }
109
+ return {
110
+ nextSteps: [
111
+ `${skipped.length} migration runner(s) still race concurrent init containers: ` +
112
+ `${skipped.join(', ')}. Each runs drizzle's migrate() with no advisory lock but ` +
113
+ `differs from the generated file, so it was left untouched rather than pattern-edited. ` +
114
+ `Wrap each migrate() call in pg_advisory_lock/pg_advisory_unlock on a dedicated ` +
115
+ `pool.connect() client, keeping whatever else the file customises — ` +
116
+ `express-service's files-postgres/src/migrate.ts template is the reference.`,
117
+ ],
118
+ agentContext: skipped.map((path) => `${path} needs the advisory lock applied by hand: it calls drizzle's migrate() with ` +
119
+ `no pg_advisory_lock, and differs from the file this migration knows how to rewrite. ` +
120
+ `Preserve its existing customisations.`),
121
+ };
122
+ });
123
+ }
124
+ //# sourceMappingURL=add-migrate-advisory-lock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"add-migrate-advisory-lock.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,99 @@
1
+ # Apply the Postgres advisory lock to a customised `migrate.ts`
2
+
3
+ The generator phase rewrote every `src/migrate.ts` it could identify as the
4
+ unmodified file `@abgov/nx-adsp`'s `express-service` generated. It deliberately
5
+ left alone any file that differs, rather than pattern-editing source it cannot
6
+ positively identify. Finishing those files is this step's only job.
7
+
8
+ ## First, check whether there is anything to do
9
+
10
+ Stop and make no changes if either of these holds:
11
+
12
+ 1. `<advisory_context>` is absent or lists no files. The generator phase either
13
+ rewrote everything or found nothing to rewrite. **This is the common case.**
14
+ 2. Every file it does list already contains `pg_advisory_lock`. Someone applied
15
+ the fix by hand; re-applying it would double-lock.
16
+
17
+ Only the paths named in `<advisory_context>` are in scope. Do not search the
18
+ workspace for other candidates, and do not touch a `migrate.ts` the generator
19
+ phase already rewrote — `<generator_output>` and whichever change list Nx
20
+ included above (`<inspect_changes>` or `<files_changed>`) show which those were.
21
+
22
+ ## Why this matters
23
+
24
+ `drizzle-orm`'s `migrate()` has no protection against concurrent execution
25
+ ([drizzle-team/drizzle-orm#874], open, acknowledged upstream). It reads the
26
+ last-applied migration with a plain `SELECT` before opening a transaction, so two
27
+ replicas starting at once can both see "nothing applied yet" and both run the
28
+ same migration. In a deployment this shows up as the second pod's init container
29
+ stuck in `Init:CrashLoopBackOff` on an "already exists" error, while the first
30
+ pod serves traffic normally — so it reads as a flaky deploy rather than a race.
31
+
32
+ [drizzle-team/drizzle-orm#874]: https://github.com/drizzle-team/drizzle-orm/issues/874
33
+
34
+ ## What to change
35
+
36
+ For each file in `<advisory_context>`, make these four edits and nothing else.
37
+
38
+ 1. A stable lock key alongside the existing module-level constants:
39
+
40
+ ```ts
41
+ const MIGRATION_LOCK_KEY = 8812345;
42
+ ```
43
+
44
+ Use exactly this value. Advisory locks are scoped per-database, not per
45
+ Postgres instance, so it only has to be unique within this app's own
46
+ database — and keeping it identical to the generated file means a service that
47
+ later regenerates does not end up with two different keys.
48
+
49
+ 2. A dedicated client for the lock, taken from the existing pool immediately
50
+ after it is created:
51
+
52
+ ```ts
53
+ const lockClient = await pool.connect();
54
+ ```
55
+
56
+ It must be its own connection. Taking the lock on a connection that
57
+ `migrate()` also uses can deadlock.
58
+
59
+ 3. Acquire the lock as the first statement inside the `try` that wraps
60
+ `migrate()`:
61
+
62
+ ```ts
63
+ await lockClient.query('SELECT pg_advisory_lock($1)', [MIGRATION_LOCK_KEY]);
64
+ ```
65
+
66
+ 4. Release it and return the client in the matching `finally`, before the pool
67
+ is closed:
68
+
69
+ ```ts
70
+ await lockClient.query('SELECT pg_advisory_unlock($1)', [MIGRATION_LOCK_KEY]);
71
+ lockClient.release();
72
+ ```
73
+
74
+ Ordering is the whole point: a lock taken after `migrate()` serializes nothing,
75
+ and one never released strands every later deploy. If the file's structure
76
+ differs enough that these four edits do not map cleanly onto it — no `try`/
77
+ `finally` around `migrate()`, no `Pool`, a different database client — do not
78
+ force them. Leave the file unchanged and say so in your handoff.
79
+
80
+ ## Preserve what the file customises
81
+
82
+ These files were skipped precisely because a team changed them. Keep every such
83
+ change: a different `MIGRATIONS_FOLDER`, extra logging, a seed step, custom error
84
+ handling, a different formatting style. Add the lock around what is already
85
+ there; do not reformat the file, reorder its imports, or "restore" it toward the
86
+ generated version.
87
+
88
+ ## Verify
89
+
90
+ - The file still compiles: `npx nx build <project>` for the project that owns it
91
+ (`migrate.js` is a second webpack bundle emitted by that same build).
92
+ - `pg_advisory_lock` appears before the `migrate()` call, and
93
+ `pg_advisory_unlock` plus `lockClient.release()` appear in the `finally`
94
+ before `pool.end()`.
95
+ - The diff for each file contains only the four additions above.
96
+
97
+ Do not attempt to run the migration against a real database; there is no
98
+ Postgres to connect to here, and the lock's behaviour is already covered by
99
+ `@abgov/nx-adsp`'s own tests.
@@ -0,0 +1,209 @@
1
+ import { addProjectConfiguration, logger, Tree } from '@nx/devkit';
2
+ import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
3
+ import { readFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ import migration from './add-migrate-advisory-lock';
6
+
7
+ // The real generated file, either side of the fix — not a synthetic
8
+ // approximation of it. Turning the first into the second is the migration's
9
+ // entire job, so these are the only fixtures that prove anything.
10
+ const BEFORE = readFileSync(join(__dirname, 'migrate.before.txt'), 'utf-8');
11
+ const AFTER = readFileSync(join(__dirname, 'migrate.after.txt'), 'utf-8');
12
+
13
+ // The fixtures are the post-formatFiles generated form, so under this
14
+ // workspace's own Prettier config the rewrite is byte-exact and the assertions
15
+ // below can compare directly.
16
+
17
+ function addService(host: Tree, name: string, migrateContent?: string): void {
18
+ addProjectConfiguration(host, name, { root: `apps/${name}` });
19
+ if (migrateContent !== undefined) {
20
+ host.write(`apps/${name}/src/migrate.ts`, migrateContent);
21
+ }
22
+ }
23
+
24
+ describe('nx-adsp add-migrate-advisory-lock migration', () => {
25
+ let host: Tree;
26
+ let warn: jest.SpyInstance;
27
+
28
+ beforeEach(() => {
29
+ host = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
30
+ warn = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
31
+ jest.spyOn(logger, 'info').mockImplementation(() => undefined);
32
+ });
33
+
34
+ afterEach(() => {
35
+ jest.restoreAllMocks();
36
+ });
37
+
38
+ it('rewrites the generated pre-fix runner into the locked version', async () => {
39
+ addService(host, 'api', BEFORE);
40
+
41
+ await migration(host);
42
+
43
+ expect(host.read('apps/api/src/migrate.ts', 'utf-8')).toEqual(AFTER);
44
+ expect(warn).not.toHaveBeenCalled();
45
+ });
46
+
47
+ it('acquires the lock before migrating and releases it in the finally block', async () => {
48
+ addService(host, 'api', BEFORE);
49
+
50
+ await migration(host);
51
+
52
+ const result = host.read('apps/api/src/migrate.ts', 'utf-8');
53
+ // Ordering is the whole point — a lock taken after migrate() serializes
54
+ // nothing, and one never released strands every later deploy.
55
+ const lockAt = result.indexOf('pg_advisory_lock');
56
+ const migrateAt = result.indexOf('await migrate(');
57
+ const unlockAt = result.indexOf('pg_advisory_unlock');
58
+ const endAt = result.indexOf('await pool.end()');
59
+ expect(lockAt).toBeGreaterThan(-1);
60
+ expect(lockAt).toBeLessThan(migrateAt);
61
+ expect(migrateAt).toBeLessThan(unlockAt);
62
+ expect(unlockAt).toBeLessThan(endAt);
63
+ expect(result).toContain('lockClient.release()');
64
+ });
65
+
66
+ it('matches through CRLF line endings and trailing whitespace', async () => {
67
+ // git autocrlf and editor settings, not a change to the code.
68
+ addService(
69
+ host,
70
+ 'api',
71
+ BEFORE.replace(/\n/g, '\r\n').replace(/\r\n/g, ' \r\n'),
72
+ );
73
+
74
+ await migration(host);
75
+
76
+ expect(host.read('apps/api/src/migrate.ts', 'utf-8')).toEqual(AFTER);
77
+ expect(warn).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('warns rather than rewriting when the file has been reformatted', async () => {
81
+ // A workspace whose Prettier uses a narrower print width wraps the `if`
82
+ // and the migrate() argument list. Semantically the generated file, but not
83
+ // one this migration can positively identify — so it says so instead of
84
+ // guessing. See normalize()'s own comment for why that is the safe
85
+ // direction.
86
+ const reformatted = BEFORE.replace(
87
+ ' await migrate(drizzle(pool), { migrationsFolder: MIGRATIONS_FOLDER });',
88
+ [
89
+ ' await migrate(drizzle(pool), {',
90
+ ' migrationsFolder: MIGRATIONS_FOLDER,',
91
+ ' });',
92
+ ].join('\n'),
93
+ );
94
+ addService(host, 'api', reformatted);
95
+
96
+ await migration(host);
97
+
98
+ expect(host.read('apps/api/src/migrate.ts', 'utf-8')).toEqual(reformatted);
99
+ expect(warn).toHaveBeenCalledWith(
100
+ expect.stringContaining('apps/api/src/migrate.ts'),
101
+ );
102
+ });
103
+
104
+ it('leaves an already-locked runner byte-identical and silent', async () => {
105
+ addService(host, 'api', AFTER);
106
+
107
+ await migration(host);
108
+
109
+ expect(host.read('apps/api/src/migrate.ts', 'utf-8')).toEqual(AFTER);
110
+ expect(warn).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('is a no-op on a second run', async () => {
114
+ addService(host, 'api', BEFORE);
115
+
116
+ await migration(host);
117
+ const afterFirst = host.read('apps/api/src/migrate.ts', 'utf-8');
118
+ await migration(host);
119
+
120
+ expect(host.read('apps/api/src/migrate.ts', 'utf-8')).toEqual(afterFirst);
121
+ expect(warn).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it('warns and leaves a customized runner alone rather than overwriting it', async () => {
125
+ const customized = BEFORE.replace(
126
+ "const MIGRATIONS_FOLDER = 'drizzle';",
127
+ "const MIGRATIONS_FOLDER = 'db/migrations';",
128
+ );
129
+ addService(host, 'api', customized);
130
+
131
+ await migration(host);
132
+
133
+ expect(host.read('apps/api/src/migrate.ts', 'utf-8')).toEqual(customized);
134
+ expect(warn).toHaveBeenCalledWith(
135
+ expect.stringContaining('apps/api/src/migrate.ts'),
136
+ );
137
+ });
138
+
139
+ // Nx surfaces nextSteps in the run summary and hands agentContext to the
140
+ // paired prompt phase, so a skipped file has to reach both — the warning
141
+ // alone is easy to scroll past, and the prompt has nothing to work from
142
+ // without agentContext.
143
+ it('returns the skipped file in both nextSteps and agentContext', async () => {
144
+ const customized = BEFORE.replace(
145
+ "const MIGRATIONS_FOLDER = 'drizzle';",
146
+ "const MIGRATIONS_FOLDER = 'db/migrations';",
147
+ );
148
+ addService(host, 'api', customized);
149
+
150
+ const result = await migration(host);
151
+
152
+ expect(result).toBeDefined();
153
+ expect(result?.nextSteps).toHaveLength(1);
154
+ expect(result?.nextSteps[0]).toContain('apps/api/src/migrate.ts');
155
+ expect(result?.nextSteps[0]).toContain('pg_advisory_lock');
156
+ expect(result?.agentContext).toEqual([
157
+ expect.stringContaining('apps/api/src/migrate.ts'),
158
+ ]);
159
+ });
160
+
161
+ it('returns nothing when every file was rewritten or already correct', async () => {
162
+ addService(host, 'needs-fix', BEFORE);
163
+ addService(host, 'already-ok', AFTER);
164
+
165
+ await expect(migration(host)).resolves.toBeUndefined();
166
+ });
167
+
168
+ it('names every skipped file, not just the first', async () => {
169
+ const customized = BEFORE.replace('drizzle', 'db/migrations');
170
+ addService(host, 'one', customized);
171
+ addService(host, 'two', customized);
172
+
173
+ const result = await migration(host);
174
+
175
+ expect(result?.agentContext).toHaveLength(2);
176
+ expect(result?.nextSteps[0]).toContain('apps/one/src/migrate.ts');
177
+ expect(result?.nextSteps[0]).toContain('apps/two/src/migrate.ts');
178
+ });
179
+
180
+ it('ignores a migrate.ts that is not drizzle-based, without warning', async () => {
181
+ const unrelated = 'export function migrate() {\n return null;\n}\n';
182
+ addService(host, 'legacy', unrelated);
183
+
184
+ await migration(host);
185
+
186
+ expect(host.read('apps/legacy/src/migrate.ts', 'utf-8')).toEqual(unrelated);
187
+ expect(warn).not.toHaveBeenCalled();
188
+ });
189
+
190
+ it('skips a project with no migrate.ts (mongo, or no database)', async () => {
191
+ addService(host, 'mongo-svc');
192
+
193
+ await expect(migration(host)).resolves.toBeUndefined();
194
+ expect(host.exists('apps/mongo-svc/src/migrate.ts')).toBe(false);
195
+ expect(warn).not.toHaveBeenCalled();
196
+ });
197
+
198
+ it('handles a mix of projects in one pass', async () => {
199
+ addService(host, 'needs-fix', BEFORE);
200
+ addService(host, 'already-ok', AFTER);
201
+ addService(host, 'no-db');
202
+
203
+ await migration(host);
204
+
205
+ expect(host.read('apps/needs-fix/src/migrate.ts', 'utf-8')).toEqual(AFTER);
206
+ expect(host.read('apps/already-ok/src/migrate.ts', 'utf-8')).toEqual(AFTER);
207
+ expect(warn).not.toHaveBeenCalled();
208
+ });
209
+ });
@@ -0,0 +1,63 @@
1
+ import { existsSync } from 'fs';
2
+ import { drizzle } from 'drizzle-orm/node-postgres';
3
+ import { migrate } from 'drizzle-orm/node-postgres/migrator';
4
+ import { Pool } from 'pg';
5
+
6
+ const MIGRATIONS_FOLDER = 'drizzle';
7
+ // Arbitrary, stable key — advisory locks are scoped per-database, not shared
8
+ // across a Postgres instance (confirmed via the pg_locks documentation: "the
9
+ // same advisory lock key can be held simultaneously in different databases
10
+ // ... they don't conflict across DB boundaries"), so this only needs to be
11
+ // unique within this app's own database, not across every app sharing a
12
+ // sandbox Postgres instance.
13
+ const MIGRATION_LOCK_KEY = 8812345;
14
+
15
+ // Standalone migration runner — the deployment runs this as an init container
16
+ // (`node migrate.js`) before the app starts. It uses only drizzle-orm + pg,
17
+ // both runtime dependencies, so it survives `npm prune --omit=dev` and needs no
18
+ // CLI or native engine in the image. The SQL files in ./drizzle are shipped as
19
+ // build assets alongside this bundle.
20
+ async function main() {
21
+ // A freshly generated service has no models yet, so there are no migrations
22
+ // to apply. Skip cleanly instead of failing the init container.
23
+ if (!existsSync(`${MIGRATIONS_FOLDER}/meta/_journal.json`)) {
24
+ // eslint-disable-next-line no-console
25
+ console.log('No migrations to apply.');
26
+ return;
27
+ }
28
+
29
+ const connectionString = process.env.DATABASE_URL;
30
+ if (!connectionString) {
31
+ throw new Error('DATABASE_URL is not set.');
32
+ }
33
+
34
+ const pool = new Pool({ connectionString });
35
+ // drizzle-orm's migrate() has no protection against concurrent execution
36
+ // (github.com/drizzle-team/drizzle-orm/issues/874, open, acknowledged by
37
+ // its maintainers) — it reads the last-applied migration with a plain
38
+ // SELECT before opening a transaction, so two replicas starting at once can
39
+ // both see "nothing applied yet" and both try to run the same migration.
40
+ // A session-scoped advisory lock on a dedicated connection serializes
41
+ // concurrent runs of this script: whichever loses blocks here until the
42
+ // winner releases the lock, then proceeds against an already-migrated
43
+ // database — a safe no-op, since __drizzle_migrations already records it.
44
+ const lockClient = await pool.connect();
45
+ try {
46
+ await lockClient.query('SELECT pg_advisory_lock($1)', [MIGRATION_LOCK_KEY]);
47
+ await migrate(drizzle(pool), { migrationsFolder: MIGRATIONS_FOLDER });
48
+ // eslint-disable-next-line no-console
49
+ console.log('Migrations applied.');
50
+ } finally {
51
+ await lockClient.query('SELECT pg_advisory_unlock($1)', [
52
+ MIGRATION_LOCK_KEY,
53
+ ]);
54
+ lockClient.release();
55
+ await pool.end();
56
+ }
57
+ }
58
+
59
+ main().catch((err) => {
60
+ // eslint-disable-next-line no-console
61
+ console.error('Migration failed:', err);
62
+ process.exit(1);
63
+ });
@@ -0,0 +1,41 @@
1
+ import { existsSync } from 'fs';
2
+ import { drizzle } from 'drizzle-orm/node-postgres';
3
+ import { migrate } from 'drizzle-orm/node-postgres/migrator';
4
+ import { Pool } from 'pg';
5
+
6
+ const MIGRATIONS_FOLDER = 'drizzle';
7
+
8
+ // Standalone migration runner — the deployment runs this as an init container
9
+ // (`node migrate.js`) before the app starts. It uses only drizzle-orm + pg,
10
+ // both runtime dependencies, so it survives `npm prune --omit=dev` and needs no
11
+ // CLI or native engine in the image. The SQL files in ./drizzle are shipped as
12
+ // build assets alongside this bundle.
13
+ async function main() {
14
+ // A freshly generated service has no models yet, so there are no migrations
15
+ // to apply. Skip cleanly instead of failing the init container.
16
+ if (!existsSync(`${MIGRATIONS_FOLDER}/meta/_journal.json`)) {
17
+ // eslint-disable-next-line no-console
18
+ console.log('No migrations to apply.');
19
+ return;
20
+ }
21
+
22
+ const connectionString = process.env.DATABASE_URL;
23
+ if (!connectionString) {
24
+ throw new Error('DATABASE_URL is not set.');
25
+ }
26
+
27
+ const pool = new Pool({ connectionString });
28
+ try {
29
+ await migrate(drizzle(pool), { migrationsFolder: MIGRATIONS_FOLDER });
30
+ // eslint-disable-next-line no-console
31
+ console.log('Migrations applied.');
32
+ } finally {
33
+ await pool.end();
34
+ }
35
+ }
36
+
37
+ main().catch((err) => {
38
+ // eslint-disable-next-line no-console
39
+ console.error('Migration failed:', err);
40
+ process.exit(1);
41
+ });