@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.
- package/migrations.json +11 -0
- package/package.json +4 -1
- package/src/build-assets.spec.ts +74 -0
- package/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.d.ts +7 -0
- package/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.js +124 -0
- package/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.js.map +1 -0
- package/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.md +99 -0
- package/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.spec.ts +209 -0
- package/src/migrations/add-migrate-advisory-lock/migrate.after.txt +63 -0
- package/src/migrations/add-migrate-advisory-lock/migrate.before.txt +41 -0
package/migrations.json
ADDED
|
@@ -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.
|
|
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"
|
package/src/build-assets.spec.ts
CHANGED
|
@@ -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,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
|
+
});
|