@flytedesk/app-kit 0.2.0 → 0.3.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.
- package/dist/cli/flags-sync.d.ts +2 -0
- package/dist/cli/flags-sync.js +23 -0
- package/dist/cli/flags-sync.js.map +1 -0
- package/dist/cli/profile-sync.js +2 -5
- package/dist/cli/profile-sync.js.map +1 -1
- package/dist/cli/sync-engine.d.ts +32 -0
- package/dist/cli/sync-engine.js +362 -44
- package/dist/cli/sync-engine.js.map +1 -1
- package/dist/cli/trace-sync.js +2 -5
- package/dist/cli/trace-sync.js.map +1 -1
- package/dist/flags/index.d.ts +49 -0
- package/dist/flags/index.js +49 -0
- package/dist/flags/index.js.map +1 -0
- package/dist/flags/plugin.d.ts +3 -0
- package/dist/flags/plugin.js +296 -0
- package/dist/flags/plugin.js.map +1 -0
- package/dist/flags/types.d.ts +159 -0
- package/dist/flags/types.js +11 -0
- package/dist/flags/types.js.map +1 -0
- package/package.json +7 -2
- package/prisma/fragments/flags.meta.json +4 -0
- package/prisma/fragments/flags.prisma +52 -0
- package/prisma/migrations/0003_app_kit_flags_init/migration.sql +16 -0
- package/prisma/migrations/manifest.json +7 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* flags-sync — the CLI half of @flytedesk/app-kit/flags (DEC-39).
|
|
4
|
+
*
|
|
5
|
+
* A thin wrapper around the generalized sync engine in ./sync-engine.js — see that
|
|
6
|
+
* file's doc comment for the full subcommand reference (init/diff/apply/status) and
|
|
7
|
+
* how fragments/migrations/lock files work. This file only supplies the "flags"
|
|
8
|
+
* fragment name and the "flags-sync" bin name; trace-sync.ts/profile-sync.ts are the
|
|
9
|
+
* same wrapper for the trace/profile fragments.
|
|
10
|
+
*/
|
|
11
|
+
import { isRunningAsMain, runSyncCli, SyncCliError } from "./sync-engine.js";
|
|
12
|
+
export async function main(argv) {
|
|
13
|
+
return runSyncCli(argv, { name: "flags", binName: "flags-sync" });
|
|
14
|
+
}
|
|
15
|
+
if (isRunningAsMain(import.meta.url)) {
|
|
16
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
17
|
+
if (!(err instanceof SyncCliError)) {
|
|
18
|
+
console.error(err);
|
|
19
|
+
}
|
|
20
|
+
process.exitCode = process.exitCode ?? 1;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=flags-sync.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flags-sync.js","sourceRoot":"","sources":["../../src/cli/flags-sync.ts"],"names":[],"mappings":";AACA;;;;;;;;GAQG;AACH,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAE7E,MAAM,CAAC,KAAK,UAAU,IAAI,CAAC,IAAc;IACvC,OAAO,UAAU,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,CAAC,CAAC;AACpE,CAAC;AAED,IAAI,eAAe,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;IACrC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;QACjD,IAAI,CAAC,CAAC,GAAG,YAAY,YAAY,CAAC,EAAE,CAAC;YACnC,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACrB,CAAC;QACD,OAAO,CAAC,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,CAAC,CAAC;IAC3C,CAAC,CAAC,CAAC;AACL,CAAC"}
|
package/dist/cli/profile-sync.js
CHANGED
|
@@ -8,14 +8,11 @@
|
|
|
8
8
|
* fragment name and the "profile-sync" bin name; trace-sync.ts is the same wrapper
|
|
9
9
|
* for the trace fragment.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
12
|
-
import { runSyncCli, SyncCliError } from "./sync-engine.js";
|
|
11
|
+
import { isRunningAsMain, runSyncCli, SyncCliError } from "./sync-engine.js";
|
|
13
12
|
export async function main(argv) {
|
|
14
13
|
return runSyncCli(argv, { name: "profile", binName: "profile-sync" });
|
|
15
14
|
}
|
|
16
|
-
|
|
17
|
-
import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
18
|
-
if (isMainModule) {
|
|
15
|
+
if (isRunningAsMain(import.meta.url)) {
|
|
19
16
|
main(process.argv.slice(2)).catch((err) => {
|
|
20
17
|
if (!(err instanceof SyncCliError)) {
|
|
21
18
|
console.error(err);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"profile-sync.js","sourceRoot":"","sources":["../../src/cli/profile-sync.ts"],"names":[],"mappings":";AACA;;;;;;;;GAQG;AACH,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"profile-sync.js","sourceRoot":"","sources":["../../src/cli/profile-sync.ts"],"names":[],"mappings":";AACA;;;;;;;;GAQG;AACH,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAE7E,MAAM,CAAC,KAAK,UAAU,IAAI,CAAC,IAAc;IACvC,OAAO,UAAU,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,cAAc,EAAE,CAAC,CAAC;AACxE,CAAC;AAED,IAAI,eAAe,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;IACrC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;QACjD,IAAI,CAAC,CAAC,GAAG,YAAY,YAAY,CAAC,EAAE,CAAC;YACnC,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACrB,CAAC;QACD,OAAO,CAAC,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,CAAC,CAAC;IAC3C,CAAC,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the module at `moduleUrl` (pass `import.meta.url`) is the one Node was
|
|
3
|
+
* invoked to run directly — the ESM successor to `require.main === module`, shared
|
|
4
|
+
* by every bin wrapper (trace-sync.ts/profile-sync.ts/flags-sync.ts) so there's one
|
|
5
|
+
* fix point instead of three copies of the same check.
|
|
6
|
+
*
|
|
7
|
+
* A raw `import.meta.url === pathToFileURL(process.argv[1]).href` string comparison
|
|
8
|
+
* (the previous check) is NOT reliable: a package manager's bin shim can leave
|
|
9
|
+
* `argv[1]` pointing at a *symlink* while Node resolves the ESM module id through
|
|
10
|
+
* that symlink to the real target path for `import.meta.url` — so the two name the
|
|
11
|
+
* same file but compare unequal. This is not hypothetical: pnpm's
|
|
12
|
+
* `node_modules/<pkg>` is exactly such a symlink into its content-addressable
|
|
13
|
+
* store, and it reproduces on Windows for every real invocation path (`pnpm exec
|
|
14
|
+
* flags-sync init`, an npm-script `"flags-sync init"` run via `pnpm run`, etc.) —
|
|
15
|
+
* confirmed by instrumenting a real 0.3.0 install: `argv[1]` resolved to
|
|
16
|
+
* `...\node_modules\@flytedesk\app-kit\dist\cli\flags-sync.js` (the symlinked
|
|
17
|
+
* path) while `import.meta.url` resolved to
|
|
18
|
+
* `...\node_modules\.pnpm\@flytedesk+app-kit@0.3.0_.../node_modules\@flytedesk\app-kit\dist\cli\flags-sync.js`
|
|
19
|
+
* (the real store path). The mismatch meant `main()` was never called at all —
|
|
20
|
+
* every CLI entrypoint silently no-op'd: exit code 0, no output, nothing written.
|
|
21
|
+
*
|
|
22
|
+
* `realpathSync.native` (the real OS `realpath` syscall, not Node's pure-JS
|
|
23
|
+
* fallback — verified directly: the JS fallback leaves an on-disk casing mismatch
|
|
24
|
+
* unresolved, e.g. `RealFile.js` vs `realfile.js` compare unequal even though NTFS
|
|
25
|
+
* treats them as the same file, while `.native` correctly normalizes both to the
|
|
26
|
+
* same canonical path) resolves both sides to the same canonical filesystem path
|
|
27
|
+
* first — following symlinks AND normalizing casing — so the comparison is correct
|
|
28
|
+
* regardless of symlinks, path-separator style, or casing differences between how
|
|
29
|
+
* a shell/bin-shim sets `argv[1]` and how Node constructs `import.meta.url` for
|
|
30
|
+
* the same file.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isRunningAsMain(moduleUrl: string): boolean;
|
|
1
33
|
/**
|
|
2
34
|
* Everything that differs between `trace-sync` and `profile-sync`. `name` picks out
|
|
3
35
|
* prisma/fragments/<name>.prisma, prisma/fragments/<name>.meta.json, and every
|
package/dist/cli/sync-engine.js
CHANGED
|
@@ -3,12 +3,26 @@
|
|
|
3
3
|
* (DEC-39, generalized to a second fragment for the profile module).
|
|
4
4
|
*
|
|
5
5
|
* This package never generates its own Prisma Client for any of its fragments.
|
|
6
|
-
* Instead, this engine
|
|
7
|
-
* into the *consumer's* own
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* Instead, this engine installs a package fragment (prisma/fragments/<fragment>.prisma)
|
|
7
|
+
* into the *consumer's* own Prisma schema, applies that fragment's raw SQL migrations
|
|
8
|
+
* directly against the consumer's configured datasource, and tracks what's been
|
|
9
|
+
* applied in a lock file (`.app-kit-<fragment>.lock.json`) written at the consumer
|
|
10
|
+
* project root. The consumer's own `prisma generate` then produces one client that
|
|
11
|
+
* includes these models alongside their own.
|
|
12
|
+
*
|
|
13
|
+
* A consumer's `prisma.config.ts` `schema` setting points at one of two real layouts
|
|
14
|
+
* (see classifySchemaLayout below), and this engine handles both:
|
|
15
|
+
* - A multi-file schema FOLDER (`schema: "prisma/schema"`, no `.prisma` suffix):
|
|
16
|
+
* the fragment is copied as a sibling file, `prisma/schema/<fragment>.prisma`.
|
|
17
|
+
* - A flat single-file schema.prisma (`schema: "prisma/schema.prisma"`), which is
|
|
18
|
+
* what both real consumers (flytedesk-id, media-planner) actually use: the
|
|
19
|
+
* fragment's model(s) are merged directly into that one file, inside a
|
|
20
|
+
* delimited, app-kit-owned marker region (see mergeIntoFlatSchema) rather than
|
|
21
|
+
* copied as a sibling file — a sibling file next to a flat schema.prisma is never
|
|
22
|
+
* read by Prisma, since `schema` names that one file, not a folder. An earlier
|
|
23
|
+
* version of this engine always did the sibling-file copy regardless of layout,
|
|
24
|
+
* which is why the CLI had never successfully run against a real consumer
|
|
25
|
+
* (flytedesk-id's /profile integration had to be pasted in by hand).
|
|
12
26
|
*
|
|
13
27
|
* Both bin commands are thin wrappers around `runSyncCli` (see src/cli/trace-sync.ts
|
|
14
28
|
* and src/cli/profile-sync.ts) — everything fragment-specific is passed in as a
|
|
@@ -39,9 +53,53 @@
|
|
|
39
53
|
*/
|
|
40
54
|
import { createHash } from "node:crypto";
|
|
41
55
|
import spawn from "cross-spawn";
|
|
42
|
-
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
56
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
|
|
43
57
|
import path from "node:path";
|
|
44
58
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
59
|
+
/**
|
|
60
|
+
* Whether the module at `moduleUrl` (pass `import.meta.url`) is the one Node was
|
|
61
|
+
* invoked to run directly — the ESM successor to `require.main === module`, shared
|
|
62
|
+
* by every bin wrapper (trace-sync.ts/profile-sync.ts/flags-sync.ts) so there's one
|
|
63
|
+
* fix point instead of three copies of the same check.
|
|
64
|
+
*
|
|
65
|
+
* A raw `import.meta.url === pathToFileURL(process.argv[1]).href` string comparison
|
|
66
|
+
* (the previous check) is NOT reliable: a package manager's bin shim can leave
|
|
67
|
+
* `argv[1]` pointing at a *symlink* while Node resolves the ESM module id through
|
|
68
|
+
* that symlink to the real target path for `import.meta.url` — so the two name the
|
|
69
|
+
* same file but compare unequal. This is not hypothetical: pnpm's
|
|
70
|
+
* `node_modules/<pkg>` is exactly such a symlink into its content-addressable
|
|
71
|
+
* store, and it reproduces on Windows for every real invocation path (`pnpm exec
|
|
72
|
+
* flags-sync init`, an npm-script `"flags-sync init"` run via `pnpm run`, etc.) —
|
|
73
|
+
* confirmed by instrumenting a real 0.3.0 install: `argv[1]` resolved to
|
|
74
|
+
* `...\node_modules\@flytedesk\app-kit\dist\cli\flags-sync.js` (the symlinked
|
|
75
|
+
* path) while `import.meta.url` resolved to
|
|
76
|
+
* `...\node_modules\.pnpm\@flytedesk+app-kit@0.3.0_.../node_modules\@flytedesk\app-kit\dist\cli\flags-sync.js`
|
|
77
|
+
* (the real store path). The mismatch meant `main()` was never called at all —
|
|
78
|
+
* every CLI entrypoint silently no-op'd: exit code 0, no output, nothing written.
|
|
79
|
+
*
|
|
80
|
+
* `realpathSync.native` (the real OS `realpath` syscall, not Node's pure-JS
|
|
81
|
+
* fallback — verified directly: the JS fallback leaves an on-disk casing mismatch
|
|
82
|
+
* unresolved, e.g. `RealFile.js` vs `realfile.js` compare unequal even though NTFS
|
|
83
|
+
* treats them as the same file, while `.native` correctly normalizes both to the
|
|
84
|
+
* same canonical path) resolves both sides to the same canonical filesystem path
|
|
85
|
+
* first — following symlinks AND normalizing casing — so the comparison is correct
|
|
86
|
+
* regardless of symlinks, path-separator style, or casing differences between how
|
|
87
|
+
* a shell/bin-shim sets `argv[1]` and how Node constructs `import.meta.url` for
|
|
88
|
+
* the same file.
|
|
89
|
+
*/
|
|
90
|
+
export function isRunningAsMain(moduleUrl) {
|
|
91
|
+
if (process.argv[1] === undefined)
|
|
92
|
+
return false;
|
|
93
|
+
try {
|
|
94
|
+
return (realpathSync.native(fileURLToPath(moduleUrl)) ===
|
|
95
|
+
realpathSync.native(process.argv[1]));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Either path doesn't exist / isn't resolvable (e.g. a REPL or a bundler that
|
|
99
|
+
// gives import.meta.url a non-file URL) — definitely not "run directly" then.
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
45
103
|
export class SyncCliError extends Error {
|
|
46
104
|
}
|
|
47
105
|
function readJson(filePath) {
|
|
@@ -53,6 +111,9 @@ function sha256(content) {
|
|
|
53
111
|
function sha256File(filePath) {
|
|
54
112
|
return sha256(readFileSync(filePath));
|
|
55
113
|
}
|
|
114
|
+
function escapeRegExp(value) {
|
|
115
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
116
|
+
}
|
|
56
117
|
// ---------------------------------------------------------------------------
|
|
57
118
|
// Engine: bound to one SyncFragment + the consumer root it's operating on
|
|
58
119
|
// ---------------------------------------------------------------------------
|
|
@@ -155,19 +216,31 @@ class SyncEngine {
|
|
|
155
216
|
}
|
|
156
217
|
return path.resolve(path.dirname(configFile), schemaPath);
|
|
157
218
|
}
|
|
158
|
-
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Classifies the consumer's schema layout from their prisma.config.ts `schema`
|
|
221
|
+
* path: a path ending in `.prisma` names one specific file — Prisma reads only
|
|
222
|
+
* that file, so this is the flat single-file layout, and the fragment must be
|
|
223
|
+
* merged into it directly (mergeIntoFlatSchema). Anything else names a folder —
|
|
224
|
+
* the multi-file layout — and the fragment is copied in as a sibling file
|
|
225
|
+
* (copyFragment), same as before.
|
|
226
|
+
*/
|
|
227
|
+
classifySchemaLayout(schemaPath) {
|
|
162
228
|
if (schemaPath.endsWith(".prisma")) {
|
|
163
|
-
return
|
|
229
|
+
return {
|
|
230
|
+
kind: "flat-file",
|
|
231
|
+
file: schemaPath,
|
|
232
|
+
prismaRoot: path.dirname(schemaPath),
|
|
233
|
+
};
|
|
164
234
|
}
|
|
165
|
-
return
|
|
235
|
+
return {
|
|
236
|
+
kind: "folder",
|
|
237
|
+
folder: schemaPath,
|
|
238
|
+
prismaRoot: this.resolvePrismaRoot(schemaPath),
|
|
239
|
+
};
|
|
166
240
|
}
|
|
167
241
|
/** The conventional "prisma root" a schema folder sits under, used to locate (or
|
|
168
242
|
* create) the sibling `migrations/` directory. For the common multi-file layout
|
|
169
|
-
* `prisma/schema/*.prisma`, that's `prisma
|
|
170
|
-
* layout, `resolveSchemaFolder` already returned `prisma/`, so this is a no-op. */
|
|
243
|
+
* `prisma/schema/*.prisma`, that's `prisma/`. */
|
|
171
244
|
resolvePrismaRoot(schemaFolder) {
|
|
172
245
|
return path.basename(schemaFolder) === "schema"
|
|
173
246
|
? path.dirname(schemaFolder)
|
|
@@ -182,7 +255,10 @@ class SyncEngine {
|
|
|
182
255
|
// with an argument array is an unescaped-concatenation footgun (path arguments
|
|
183
256
|
// here can contain spaces). cross-spawn resolves and correctly quotes .cmd/.bat
|
|
184
257
|
// executables on Windows without going through a shell at all.
|
|
185
|
-
const result = spawn.sync("npx", ["prisma", ...args], {
|
|
258
|
+
const result = spawn.sync("npx", ["prisma", ...args], {
|
|
259
|
+
cwd,
|
|
260
|
+
stdio: "inherit",
|
|
261
|
+
});
|
|
186
262
|
if (result.error) {
|
|
187
263
|
this.fail(`failed to run "npx prisma ${args.join(" ")}": ${result.error.message}`);
|
|
188
264
|
}
|
|
@@ -204,16 +280,221 @@ class SyncEngine {
|
|
|
204
280
|
cpSync(srcDir, destDir, { recursive: true });
|
|
205
281
|
}
|
|
206
282
|
// -------------------------------------------------------------------------
|
|
283
|
+
// Flat single-file schema.prisma support
|
|
284
|
+
// -------------------------------------------------------------------------
|
|
285
|
+
/**
|
|
286
|
+
* The portion of a fragment's .prisma source that gets merged into a consumer's
|
|
287
|
+
* flat schema.prisma — everything from the first `model` declaration onward,
|
|
288
|
+
* trimmed. Deliberately drops the fragment file's own leading design-rationale
|
|
289
|
+
* doc comment (see prisma/fragments/profile.prisma / trace.prisma's file
|
|
290
|
+
* headers): that comment describes the *fragment file itself* ("this file is
|
|
291
|
+
* copied by <bin> init...", "matching the raw SQL in prisma/migrations/...")
|
|
292
|
+
* which reads oddly once inlined into someone else's schema.prisma, whereas the
|
|
293
|
+
* marker region's own header (markerStart) already identifies the block as
|
|
294
|
+
* app-kit-owned and names which command manages it.
|
|
295
|
+
*/
|
|
296
|
+
extractFragmentBody(fragmentSource) {
|
|
297
|
+
const lines = fragmentSource.split(/\r?\n/);
|
|
298
|
+
const firstModelLine = lines.findIndex((line) => /^model\s+\w+\s*\{/.test(line));
|
|
299
|
+
const body = firstModelLine === -1
|
|
300
|
+
? fragmentSource
|
|
301
|
+
: lines.slice(firstModelLine).join("\n");
|
|
302
|
+
return body.trim();
|
|
303
|
+
}
|
|
304
|
+
static DATASOURCE_SCHEMAS_RE = /datasource\s+\w+\s*\{[^}]*\bschemas\s*=\s*\[([^\]]*)\]/;
|
|
305
|
+
/**
|
|
306
|
+
* The Postgres schema name(s) declared on the consumer's own `datasource` block
|
|
307
|
+
* (`schemas = [...]`) in a flat schema.prisma's full text. Prisma's multiSchema
|
|
308
|
+
* feature requires `@@schema(...)` on every single model once this array is
|
|
309
|
+
* non-empty (P1012 otherwise) — see resolveFragmentBody, which uses this to
|
|
310
|
+
* decide whether merged-in fragment models need one. Returns `[]` when the
|
|
311
|
+
* datasource has no `schemas` setting at all (a non-multiSchema consumer).
|
|
312
|
+
*/
|
|
313
|
+
readConsumerSchemaNames(schemaContent) {
|
|
314
|
+
const match = schemaContent.match(SyncEngine.DATASOURCE_SCHEMAS_RE);
|
|
315
|
+
if (!match)
|
|
316
|
+
return [];
|
|
317
|
+
return match[1]
|
|
318
|
+
.split(",")
|
|
319
|
+
.map((entry) => entry.trim())
|
|
320
|
+
.filter((entry) => entry.length > 0)
|
|
321
|
+
.map((entry) => entry.replace(/^["']|["']$/g, ""));
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Inserts `@@schema("<name>")` as the last block attribute of every top-level
|
|
325
|
+
* `model { ... }` in `body`, immediately before its closing brace — the same
|
|
326
|
+
* position a hand-written model in a multiSchema consumer's own schema.prisma
|
|
327
|
+
* uses (see FLAT_SCHEMA_FIXTURE in sync-engine.test.ts).
|
|
328
|
+
*
|
|
329
|
+
* Relies on this package's own fragment files' formatting convention (verified
|
|
330
|
+
* against every prisma/fragments/*.prisma file): `model Foo {` always starts at
|
|
331
|
+
* column 0, and its matching `}` is always alone on its own line at column 0 —
|
|
332
|
+
* the same convention extractFragmentBody's firstModelLine scan already depends
|
|
333
|
+
* on. This is deliberately not a general-purpose Prisma parser (it would, for
|
|
334
|
+
* instance, mis-handle a `{`/`}` inside a string default like flags.prisma's
|
|
335
|
+
* `@default("{}")` if it tried to brace-count) — it only ever needs to run
|
|
336
|
+
* against this package's own fragment files, never a consumer's.
|
|
337
|
+
*/
|
|
338
|
+
injectSchemaAttribute(body, schemaName) {
|
|
339
|
+
const lines = body.split(/\r?\n/);
|
|
340
|
+
const out = [];
|
|
341
|
+
let inModel = false;
|
|
342
|
+
for (const line of lines) {
|
|
343
|
+
if (!inModel && /^model\s+\w+\s*\{$/.test(line)) {
|
|
344
|
+
inModel = true;
|
|
345
|
+
out.push(line);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (inModel && line === "}") {
|
|
349
|
+
out.push(` @@schema(${JSON.stringify(schemaName)})`);
|
|
350
|
+
out.push(line);
|
|
351
|
+
inModel = false;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
out.push(line);
|
|
355
|
+
}
|
|
356
|
+
return out.join("\n");
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Resolves this fragment's exact body text as it should be merged into a
|
|
360
|
+
* consumer's flat schema.prisma: extractFragmentBody's output, with
|
|
361
|
+
* `@@schema(...)` injected into every model when the consumer's datasource
|
|
362
|
+
* declares `schemas = [...]` (multiSchema) — fixing the bug where a merged
|
|
363
|
+
* fragment had no `@@schema` at all and broke `prisma validate` (P1012:
|
|
364
|
+
* "This model is missing an @@schema attribute") for every multiSchema consumer,
|
|
365
|
+
* which is both real consumers (flytedesk-id, media-planner).
|
|
366
|
+
*
|
|
367
|
+
* `schemaNameOverride` is the CLI's `--schema-name` flag. When the consumer
|
|
368
|
+
* declares exactly one schema name, that name is used automatically and the
|
|
369
|
+
* override (if given) is only validated, not required. When more than one is
|
|
370
|
+
* declared, this engine has no way to guess which one a given fragment's models
|
|
371
|
+
* belong to — guessing silently is exactly the class of bug this fixes — so
|
|
372
|
+
* `--schema-name` becomes required and this fails loudly (via `fail()`,
|
|
373
|
+
* surfaced to the consumer with the exact flag to pass) rather than picking one.
|
|
374
|
+
*/
|
|
375
|
+
resolveFragmentBody(schemaFileContent, schemaNameOverride) {
|
|
376
|
+
const rawBody = this.extractFragmentBody(readFileSync(this.fragmentSrcPath, "utf8"));
|
|
377
|
+
const declaredSchemas = this.readConsumerSchemaNames(schemaFileContent);
|
|
378
|
+
if (declaredSchemas.length === 0) {
|
|
379
|
+
return rawBody;
|
|
380
|
+
}
|
|
381
|
+
let schemaName;
|
|
382
|
+
if (declaredSchemas.length === 1) {
|
|
383
|
+
schemaName = schemaNameOverride ?? declaredSchemas[0];
|
|
384
|
+
}
|
|
385
|
+
else if (schemaNameOverride) {
|
|
386
|
+
schemaName = schemaNameOverride;
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
this.fail(`the consumer's datasource declares multiple schemas (${declaredSchemas.join(", ")}) via ` +
|
|
390
|
+
`\`schemas = [...]\`. ${this.fragment.binName} can't guess which one this fragment's model(s) ` +
|
|
391
|
+
`belong to — pass --schema-name=<name> to disambiguate, e.g. ` +
|
|
392
|
+
`"${this.fragment.binName} init --schema-name=${declaredSchemas[0]}".`);
|
|
393
|
+
}
|
|
394
|
+
if (!declaredSchemas.includes(schemaName)) {
|
|
395
|
+
this.fail(`--schema-name=${schemaName} is not one of the consumer's declared schemas (${declaredSchemas.join(", ")}).`);
|
|
396
|
+
}
|
|
397
|
+
return this.injectSchemaAttribute(rawBody, schemaName);
|
|
398
|
+
}
|
|
399
|
+
markerStart() {
|
|
400
|
+
return `// --- @flytedesk/app-kit:${this.fragment.name} start (managed by ${this.fragment.binName} — do not hand-edit this region) ---`;
|
|
401
|
+
}
|
|
402
|
+
markerEnd() {
|
|
403
|
+
return `// --- @flytedesk/app-kit:${this.fragment.name} end ---`;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Extracts the current content of THIS fragment's own marker region from a flat
|
|
407
|
+
* schema.prisma's full text, or null if the region isn't present yet. Matching on
|
|
408
|
+
* this fragment's own name means two different fragments merged into the same
|
|
409
|
+
* flat file never see or touch each other's regions.
|
|
410
|
+
*
|
|
411
|
+
* Exact inverse of the `${start}\n${body}\n${end}` block mergeIntoFlatSchema
|
|
412
|
+
* writes, so a round trip (write, then extract) returns the same body
|
|
413
|
+
* byte-for-byte — this is what makes both mergeIntoFlatSchema's own
|
|
414
|
+
* no-op-if-unchanged check and cmdStatus's checksum comparison correct.
|
|
415
|
+
*/
|
|
416
|
+
extractInstalledRegion(schemaContent) {
|
|
417
|
+
const re = new RegExp(`${escapeRegExp(this.markerStart())}\\n([\\s\\S]*?)\\n${escapeRegExp(this.markerEnd())}`);
|
|
418
|
+
const match = schemaContent.match(re);
|
|
419
|
+
return match ? match[1] : null;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Merges this fragment's model(s) into a consumer's flat single-file
|
|
423
|
+
* prisma/schema.prisma, inside a delimited, app-kit-owned marker region — the
|
|
424
|
+
* flat-file counterpart to copyFragment's sibling-file copy for the multi-file
|
|
425
|
+
* folder layout.
|
|
426
|
+
*
|
|
427
|
+
* Idempotent: running this twice with the same fragment content is a no-op (the
|
|
428
|
+
* installed region already matches, so the file isn't rewritten at all). Multiple
|
|
429
|
+
* fragments merged into the same file coexist as separate regions — this only
|
|
430
|
+
* ever reads or replaces text between ITS OWN start/end marker pair; everything
|
|
431
|
+
* else in the file (the consumer's own models, datasource/generator blocks,
|
|
432
|
+
* `schemas = [...]`, other fragments' regions) is left byte-for-byte untouched.
|
|
433
|
+
*/
|
|
434
|
+
mergeIntoFlatSchema(schemaFilePath, schemaNameOverride) {
|
|
435
|
+
if (!existsSync(schemaFilePath)) {
|
|
436
|
+
this.fail(`schema file not found at ${schemaFilePath} (expected a flat single-file prisma/schema.prisma)`);
|
|
437
|
+
}
|
|
438
|
+
const original = readFileSync(schemaFilePath, "utf8");
|
|
439
|
+
const body = this.resolveFragmentBody(original, schemaNameOverride);
|
|
440
|
+
const existingRegion = this.extractInstalledRegion(original);
|
|
441
|
+
if (existingRegion === body) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
let updated;
|
|
445
|
+
if (existingRegion !== null) {
|
|
446
|
+
const re = new RegExp(`${escapeRegExp(this.markerStart())}\\n[\\s\\S]*?\\n${escapeRegExp(this.markerEnd())}`);
|
|
447
|
+
updated = original.replace(re, `${this.markerStart()}\n${body}\n${this.markerEnd()}`);
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
updated = `${original.replace(/\s*$/, "")}\n\n${this.markerStart()}\n${body}\n${this.markerEnd()}\n`;
|
|
451
|
+
}
|
|
452
|
+
writeFileSync(schemaFilePath, updated, "utf8");
|
|
453
|
+
}
|
|
454
|
+
/** Installs the fragment for either layout — the one call site cmdInit/cmdApply
|
|
455
|
+
* both use, so they can never disagree about which path a given layout takes. */
|
|
456
|
+
installFragment(layout, schemaNameOverride) {
|
|
457
|
+
const bin = this.fragment.binName;
|
|
458
|
+
if (layout.kind === "folder") {
|
|
459
|
+
console.log(`${bin}: copying ${this.fragment.name}.prisma into ${layout.folder}`);
|
|
460
|
+
this.copyFragment(layout.folder);
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
console.log(`${bin}: merging ${this.fragment.name} model(s) into ${layout.file} (managed region)`);
|
|
464
|
+
this.mergeIntoFlatSchema(layout.file, schemaNameOverride);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* What gets recorded as `fragmentChecksum` in the lock file, and what cmdStatus
|
|
469
|
+
* recomputes from the consumer's tree to detect hand-edits. Must be called AFTER
|
|
470
|
+
* installFragment has already written to the consumer's tree (both call sites,
|
|
471
|
+
* cmdInit/cmdApply, satisfy this).
|
|
472
|
+
*
|
|
473
|
+
* For the flat-file/merge case this reads the marker region back out of the file
|
|
474
|
+
* that was just written, rather than re-deriving it from the fragment source —
|
|
475
|
+
* that keeps this in lockstep with whatever mergeIntoFlatSchema actually wrote
|
|
476
|
+
* (including any `@@schema(...)` injected by resolveFragmentBody) without
|
|
477
|
+
* duplicating that resolution logic a second time here.
|
|
478
|
+
*/
|
|
479
|
+
computeFragmentChecksum(layout) {
|
|
480
|
+
if (layout.kind === "folder") {
|
|
481
|
+
return sha256File(this.fragmentSrcPath);
|
|
482
|
+
}
|
|
483
|
+
const installed = this.extractInstalledRegion(readFileSync(layout.file, "utf8"));
|
|
484
|
+
if (installed === null) {
|
|
485
|
+
this.fail(`internal error: no managed region found in ${layout.file} immediately after installing it`);
|
|
486
|
+
}
|
|
487
|
+
return sha256(installed);
|
|
488
|
+
}
|
|
489
|
+
// -------------------------------------------------------------------------
|
|
207
490
|
// Subcommands
|
|
208
491
|
// -------------------------------------------------------------------------
|
|
209
|
-
async cmdInit(consumerRoot) {
|
|
492
|
+
async cmdInit(consumerRoot, schemaNameOverride) {
|
|
210
493
|
const bin = this.fragment.binName;
|
|
211
494
|
const schemaPath = await this.loadConsumerSchemaPath(consumerRoot);
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
console.log(`${bin}: copying ${this.fragment.name}.prisma into ${schemaFolder}`);
|
|
216
|
-
this.copyFragment(schemaFolder);
|
|
495
|
+
const layout = this.classifySchemaLayout(schemaPath);
|
|
496
|
+
const migrationsDestDir = path.join(layout.prismaRoot, "migrations");
|
|
497
|
+
this.installFragment(layout, schemaNameOverride);
|
|
217
498
|
console.log(`${bin}: running "npx prisma validate" against ${consumerRoot}`);
|
|
218
499
|
this.runPrisma(["validate"], consumerRoot);
|
|
219
500
|
const manifest = this.readManifest();
|
|
@@ -236,7 +517,7 @@ class SyncEngine {
|
|
|
236
517
|
const meta = this.readFragmentMeta();
|
|
237
518
|
this.writeLockFile(consumerRoot, {
|
|
238
519
|
fragmentVersion: meta.fragmentVersion,
|
|
239
|
-
fragmentChecksum:
|
|
520
|
+
fragmentChecksum: this.computeFragmentChecksum(layout),
|
|
240
521
|
appliedMigrations,
|
|
241
522
|
lastSyncedAt: new Date().toISOString(),
|
|
242
523
|
});
|
|
@@ -277,15 +558,13 @@ class SyncEngine {
|
|
|
277
558
|
}
|
|
278
559
|
console.log(`${this.fragment.binName} diff is read-only. Run "${this.fragment.binName} apply" to apply these.`);
|
|
279
560
|
}
|
|
280
|
-
async cmdApply(consumerRoot) {
|
|
561
|
+
async cmdApply(consumerRoot, schemaNameOverride) {
|
|
281
562
|
const bin = this.fragment.binName;
|
|
282
563
|
const report = this.computePending(consumerRoot);
|
|
283
564
|
const schemaPath = await this.loadConsumerSchemaPath(consumerRoot);
|
|
284
|
-
const
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
console.log(`${bin}: refreshing ${this.fragment.name}.prisma in ${schemaFolder}`);
|
|
288
|
-
this.copyFragment(schemaFolder);
|
|
565
|
+
const layout = this.classifySchemaLayout(schemaPath);
|
|
566
|
+
const migrationsDestDir = path.join(layout.prismaRoot, "migrations");
|
|
567
|
+
this.installFragment(layout, schemaNameOverride);
|
|
289
568
|
const lock = this.readLockFile(consumerRoot);
|
|
290
569
|
if (!lock) {
|
|
291
570
|
this.fail(`no ${this.lockFileName} found at ${consumerRoot}. Run "${bin} init" first.`);
|
|
@@ -308,7 +587,7 @@ class SyncEngine {
|
|
|
308
587
|
const meta = this.readFragmentMeta();
|
|
309
588
|
this.writeLockFile(consumerRoot, {
|
|
310
589
|
fragmentVersion: meta.fragmentVersion,
|
|
311
|
-
fragmentChecksum:
|
|
590
|
+
fragmentChecksum: this.computeFragmentChecksum(layout),
|
|
312
591
|
appliedMigrations,
|
|
313
592
|
lastSyncedAt: new Date().toISOString(),
|
|
314
593
|
});
|
|
@@ -330,18 +609,36 @@ class SyncEngine {
|
|
|
330
609
|
}
|
|
331
610
|
try {
|
|
332
611
|
const schemaPath = await this.loadConsumerSchemaPath(consumerRoot);
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
612
|
+
const layout = this.classifySchemaLayout(schemaPath);
|
|
613
|
+
let installedChecksum;
|
|
614
|
+
let location;
|
|
615
|
+
if (layout.kind === "folder") {
|
|
616
|
+
const copiedFragmentPath = path.join(layout.folder, `${this.fragment.name}.prisma`);
|
|
617
|
+
if (!existsSync(copiedFragmentPath)) {
|
|
618
|
+
console.log(`fragment copy: MISSING at ${copiedFragmentPath}`);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
installedChecksum = sha256File(copiedFragmentPath);
|
|
622
|
+
location = copiedFragmentPath;
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
if (!existsSync(layout.file)) {
|
|
626
|
+
console.log(`fragment copy: MISSING — ${layout.file} does not exist`);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const region = this.extractInstalledRegion(readFileSync(layout.file, "utf8"));
|
|
630
|
+
if (region === null) {
|
|
631
|
+
console.log(`fragment copy: MISSING — no managed region found in ${layout.file}`);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
installedChecksum = sha256(region);
|
|
635
|
+
location = `${layout.file} (managed region)`;
|
|
338
636
|
}
|
|
339
|
-
const installedChecksum = sha256File(copiedFragmentPath);
|
|
340
637
|
if (installedChecksum === lock.fragmentChecksum) {
|
|
341
638
|
console.log("fragment copy: unmodified");
|
|
342
639
|
}
|
|
343
640
|
else {
|
|
344
|
-
console.log(`fragment copy: CHECKSUM MISMATCH — ${
|
|
641
|
+
console.log(`fragment copy: CHECKSUM MISMATCH — ${location} has been hand-edited ` +
|
|
345
642
|
`(or is out of sync with the lock file)`);
|
|
346
643
|
}
|
|
347
644
|
}
|
|
@@ -357,6 +654,30 @@ const SUBCOMMANDS = ["init", "diff", "apply", "status"];
|
|
|
357
654
|
function isSubcommand(value) {
|
|
358
655
|
return SUBCOMMANDS.includes(value);
|
|
359
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* Parses `--schema-name=<name>` / `--schema-name <name>` out of a subcommand's
|
|
659
|
+
* trailing args — the flag `resolveFragmentBody` requires when a consumer's flat
|
|
660
|
+
* schema.prisma declares more than one entry in `schemas = [...]` and this engine
|
|
661
|
+
* can't guess which one a fragment's models belong to. Only `init`/`apply` use the
|
|
662
|
+
* result (they're the only subcommands that install the fragment); `diff`/`status`
|
|
663
|
+
* ignore it since they never write anything.
|
|
664
|
+
*/
|
|
665
|
+
function parseSchemaNameFlag(rest) {
|
|
666
|
+
for (let i = 0; i < rest.length; i++) {
|
|
667
|
+
const arg = rest[i];
|
|
668
|
+
if (arg === "--schema-name") {
|
|
669
|
+
const value = rest[i + 1];
|
|
670
|
+
if (value === undefined) {
|
|
671
|
+
throw new SyncCliError("--schema-name requires a value, e.g. --schema-name=my_schema");
|
|
672
|
+
}
|
|
673
|
+
return value;
|
|
674
|
+
}
|
|
675
|
+
if (arg.startsWith("--schema-name=")) {
|
|
676
|
+
return arg.slice("--schema-name=".length);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return undefined;
|
|
680
|
+
}
|
|
360
681
|
/**
|
|
361
682
|
* Runs one CLI invocation for the given fragment. Called by each bin wrapper
|
|
362
683
|
* (src/cli/trace-sync.ts, src/cli/profile-sync.ts) with `process.argv.slice(2)`.
|
|
@@ -364,25 +685,22 @@ function isSubcommand(value) {
|
|
|
364
685
|
export async function runSyncCli(argv, fragment) {
|
|
365
686
|
const [subcommand, ...rest] = argv;
|
|
366
687
|
if (!isSubcommand(subcommand)) {
|
|
367
|
-
console.error(`usage: ${fragment.binName} <${SUBCOMMANDS.join("|")}
|
|
688
|
+
console.error(`usage: ${fragment.binName} <${SUBCOMMANDS.join("|")}> [--schema-name=<name>]`);
|
|
368
689
|
process.exitCode = 1;
|
|
369
690
|
return;
|
|
370
691
|
}
|
|
371
|
-
|
|
372
|
-
// the directory it's invoked from, which is how `npx <bin> ...` / an npm script in
|
|
373
|
-
// the consumer's own package.json will run it.
|
|
374
|
-
void rest;
|
|
692
|
+
const schemaNameOverride = parseSchemaNameFlag(rest);
|
|
375
693
|
const consumerRoot = process.cwd();
|
|
376
694
|
const engine = new SyncEngine(fragment);
|
|
377
695
|
switch (subcommand) {
|
|
378
696
|
case "init":
|
|
379
|
-
await engine.cmdInit(consumerRoot);
|
|
697
|
+
await engine.cmdInit(consumerRoot, schemaNameOverride);
|
|
380
698
|
break;
|
|
381
699
|
case "diff":
|
|
382
700
|
engine.cmdDiff(consumerRoot);
|
|
383
701
|
break;
|
|
384
702
|
case "apply":
|
|
385
|
-
await engine.cmdApply(consumerRoot);
|
|
703
|
+
await engine.cmdApply(consumerRoot, schemaNameOverride);
|
|
386
704
|
break;
|
|
387
705
|
case "status":
|
|
388
706
|
await engine.cmdStatus(consumerRoot);
|