@ultimat3/cli 27.6.0 → 27.7.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.
@@ -3,6 +3,8 @@
3
3
  // every generated endpoint answered 403. Apart from that module because this one only REPORTS:
4
4
  // it re-asks the same pure edits of the file as it now stands and names what they could not place.
5
5
 
6
+ import type { GenerateDisk } from './generate-disk';
7
+ import { appDisk } from './generate-disk';
6
8
  import type { RoleGrant } from './generate-grants';
7
9
  import {
8
10
  adminGrantsFor,
@@ -11,7 +13,6 @@ import {
11
13
  insertPermissions,
12
14
  writtenTables,
13
15
  } from './generate-grants';
14
- import { containedPath } from './generate-write';
15
16
  import type { Finding } from './output';
16
17
  import { ROLES_FILE } from './permission-grants';
17
18
 
@@ -45,15 +46,16 @@ function stillMissing(source: string, grants: readonly RoleGrant[], admin: reado
45
46
  export async function ungrantedByGenerator(
46
47
  root: string,
47
48
  written: readonly string[],
49
+ disk: GenerateDisk = appDisk(root),
48
50
  ): Promise<readonly Finding[]> {
49
- const admin = (await writtenTables(root, written)).flatMap(adminGrantsFor);
51
+ const admin = (await writtenTables(root, written, disk)).flatMap(adminGrantsFor);
50
52
  const grants = [...grantsForWritten(written), ...admin];
51
53
  if (grants.length === 0) return [];
52
- const file = Bun.file(containedPath(root, ROLES_FILE));
54
+ const source = await disk.read(ROLES_FILE);
53
55
  // The file whose declarations nobody granted: where a reader starts when the map is not ours.
54
56
  const declaredIn =
55
57
  written.find((path) => path.endsWith('/policy.ts')) ?? written[0] ?? ROLES_FILE;
56
- if (!(await file.exists())) {
58
+ if (source === undefined) {
57
59
  return [
58
60
  {
59
61
  code: 'X_PERMISSION_UNGRANTED',
@@ -63,12 +65,12 @@ export async function ungrantedByGenerator(
63
65
  },
64
66
  ];
65
67
  }
66
- const missing = stillMissing(await file.text(), grants, admin);
68
+ const missing = stillMissing(source, grants, admin);
67
69
  if (missing.length === 0) return [];
68
70
  return [
69
71
  {
70
72
  code: 'X_PERMISSION_UNGRANTED',
71
- cause: `${ROLES_FILE} has no role, no "grants: [...]" list or no definePermissions([...]) these could be added to, so nothing granted: ${linesFor(missing)}`,
73
+ cause: `${ROLES_FILE} has no role, no "grants: [...]" list or no definePermissions([...]) these could be safely added to, so nothing granted: ${linesFor(missing)}`,
72
74
  fix: `edit ${ROLES_FILE} — add to each role's grants (and declare any the map does not know in definePermissions([...])) — ${linesFor(missing)} — then x verify --only policy`,
73
75
  at: ROLES_FILE,
74
76
  },
@@ -7,9 +7,11 @@
7
7
  // three permissions `defineAdmin()` derives from the table's own name. They are declared in the
8
8
  // role map and granted to `admin`, or the role that runs the admin is refused its newest screen.
9
9
 
10
- import { containedPath } from './generate-write';
10
+ import type { GenerateDisk } from './generate-disk';
11
+ import { appDisk } from './generate-disk';
11
12
  import { ROLES_FILE } from './permission-grants';
12
- import { wrapList } from './templates/wrap';
13
+ import { absentFrom, appendToList, maskOf, readList, readProperties } from './source-list-edit';
14
+ import { isEntityModule } from './templates/entity-module';
13
15
 
14
16
  /** One permission to add to one role's `grants`. */
15
17
  export interface RoleGrant {
@@ -37,8 +39,6 @@ export function grantsForWritten(written: readonly string[]): readonly RoleGrant
37
39
  });
38
40
  }
39
41
 
40
- /** A generated entity file: `<surface>/<feature>/entity.ts`. */
41
- const ENTITY_PATH = /^apps\/[^/]+\/[^/]+\/[a-z0-9-]+\/entity\.ts$/;
42
42
  /** `entity('widgets', {` — the name the admin's permissions are spelled with. */
43
43
  const ENTITY_NAME = /\bentity\(\s*'([^']+)'/;
44
44
 
@@ -56,11 +56,31 @@ export function adminGrantsFor(table: string): readonly RoleGrant[] {
56
56
 
57
57
  const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
58
58
 
59
+ const quoted = (permission: string): string => `'${permission}'`;
60
+
61
+ /**
62
+ * Where the object of role `name` opens: a property of the `defineRoles({ … })` call, or — in a
63
+ * map handed to it by name — the scaffold's own ` <role>: {` row. Read off the masked text, so a
64
+ * role named in a comment or a string is prose and never the one edited.
65
+ */
66
+ function roleObject(source: string, masked: string, name: string): number | undefined {
67
+ const call = /\bdefineRoles\(\s*\{/.exec(masked);
68
+ if (call !== null) {
69
+ const role = readProperties(source, masked, call.index + call[0].length - 1).find(
70
+ (property) => property.key === name,
71
+ );
72
+ return role !== undefined && masked[role.value] === '{' ? role.value : undefined;
73
+ }
74
+ const row = new RegExp(`\\n ${escapeRegExp(name)}: \\{`).exec(masked);
75
+ return row === null ? undefined : row.index + row[0].length - 1;
76
+ }
77
+
59
78
  /**
60
- * `source` with each grant added to its role's `grants: [...]`, re-wrapped the way Biome prints
61
- * it. A role or a `grants` array the scaffold's shape does not have is left alone and returned in
62
- * `skipped` — the `policy` step's X_PERMISSION_UNGRANTED names the edit, so a guess here would be
63
- * a second, worse answer.
79
+ * `source` with each grant added to its role's `grants: [...]`. A list on one line is re-wrapped
80
+ * the way Biome prints it; a list over several lines gains a row and keeps every comment it holds.
81
+ * A role or a `grants` array the map does not have — or one that cannot be edited safely — is left
82
+ * alone and returned in `skipped`: the finding names the edit, so a guess here would be a second,
83
+ * worse answer.
64
84
  */
65
85
  export function insertGrants(
66
86
  source: string,
@@ -69,62 +89,63 @@ export function insertGrants(
69
89
  let next = source;
70
90
  const skipped: RoleGrant[] = [];
71
91
  for (const grant of grants) {
72
- const role = new RegExp(`\\n ${escapeRegExp(grant.role)}: \\{`).exec(next);
73
- const open = role === null ? -1 : next.indexOf('grants: [', role.index);
74
- const close = open === -1 ? -1 : next.indexOf(']', open);
75
- const blockEnd = role === null ? -1 : next.indexOf('\n },', role.index);
76
- if (role === null || open === -1 || close === -1 || (blockEnd !== -1 && open > blockEnd)) {
77
- skipped.push(grant);
78
- continue;
79
- }
80
- const current = [...next.slice(open, close).matchAll(/'([^']+)'/g)].map((m) => m[1] ?? '');
81
- if (current.includes(grant.permission)) continue;
82
- const entries = [...current, grant.permission].map((permission) => `'${permission}'`);
83
- const lineStart = next.lastIndexOf('\n', open) + 1;
84
- const indent = next.slice(lineStart, open);
85
- const rewritten = wrapList(indent, 'grants: [', entries, ']').slice(indent.length);
86
- next = `${next.slice(0, open)}${rewritten}${next.slice(close + 1)}`;
92
+ const masked = maskOf(next);
93
+ const role = roleObject(next, masked, grant.role);
94
+ const list =
95
+ role === undefined
96
+ ? undefined
97
+ : readProperties(next, masked, role).find((property) => property.key === 'grants');
98
+ const edited =
99
+ list === undefined || masked[list.value] !== '['
100
+ ? undefined
101
+ : appendToList(next, list.value, [quoted(grant.permission)]);
102
+ if (edited === undefined) skipped.push(grant);
103
+ else next = edited;
87
104
  }
88
105
  return { source: next, skipped };
89
106
  }
90
107
 
91
- const DECLARATION = 'definePermissions([';
108
+ /** Every `definePermissions([` in the masked text: the index of each call's opening `[`. */
109
+ const declarationLists = (masked: string): readonly number[] =>
110
+ [...masked.matchAll(/\bdefinePermissions\(\s*\[/g)].map(
111
+ (found) => found.index + found[0].length - 1,
112
+ );
92
113
 
93
114
  /**
94
- * `source` with each permission added to its first `definePermissions([…])`, re-wrapped the way
95
- * Biome prints it. Declared BEFORE it is granted: `can()` refuses a name no call registered, and
96
- * `defineRoles()` does not — a grant nothing declares is a 500 on the first request that asks.
97
- * A role map with no such call is left alone and every permission returned in `skipped`.
115
+ * `source` with each permission added to its first `definePermissions([…])`. Declared BEFORE it is
116
+ * granted: `can()` refuses a name no call registered, and `defineRoles()` does not — a grant
117
+ * nothing declares is a 500 on the first request that asks. One ANY call already declares is left
118
+ * alone. A role map with no such call, or one that cannot be edited safely, is left alone and
119
+ * every permission returned in `skipped`.
98
120
  */
99
121
  export function insertPermissions(
100
122
  source: string,
101
123
  permissions: readonly string[],
102
124
  ): { readonly source: string; readonly skipped: readonly string[] } {
103
- const open = source.indexOf(DECLARATION);
104
- const close = open === -1 ? -1 : source.indexOf('])', open);
105
- if (open === -1 || close === -1) return { source, skipped: [...permissions] };
106
- const current = [...source.slice(open, close).matchAll(/'([^']+)'/g)].map((m) => m[1] ?? '');
107
- const added = permissions.filter((permission) => !current.includes(permission));
125
+ const masked = maskOf(source);
126
+ const lists = declarationLists(masked).flatMap((open) => readList(source, masked, open) ?? []);
127
+ const first = lists[0];
128
+ if (first === undefined) return { source, skipped: [...permissions] };
129
+ const added = lists.reduce<readonly string[]>(
130
+ (absent, list) => absentFrom(list, absent),
131
+ permissions.map(quoted),
132
+ );
108
133
  if (added.length === 0) return { source, skipped: [] };
109
- const lineStart = source.lastIndexOf('\n', open) + 1;
110
- const head = source.slice(lineStart, open);
111
- const tail = source.slice(close + 2, source.indexOf('\n', close));
112
- const entries = [...current, ...added].map((permission) => `'${permission}'`);
113
- const rewritten = wrapList('', `${head}${DECLARATION}`, entries, `])${tail}`);
114
- return {
115
- source: `${source.slice(0, lineStart)}${rewritten}${source.slice(source.indexOf('\n', close))}`,
116
- skipped: [],
117
- };
134
+ const edited = appendToList(source, first.open, added);
135
+ return edited === undefined
136
+ ? { source, skipped: [...permissions] }
137
+ : { source: edited, skipped: [] };
118
138
  }
119
139
 
120
- /** The entity names the written `entity.ts` files declare — read off each file, never guessed. */
140
+ /** The entity names the written entity files declare — read off each file, never guessed. */
121
141
  export async function writtenTables(
122
142
  root: string,
123
143
  written: readonly string[],
144
+ disk: GenerateDisk = appDisk(root),
124
145
  ): Promise<readonly string[]> {
125
146
  const tables: string[] = [];
126
- for (const path of written.filter((candidate) => ENTITY_PATH.test(candidate))) {
127
- const table = ENTITY_NAME.exec(await Bun.file(containedPath(root, path)).text())?.[1];
147
+ for (const path of written.filter(isEntityModule)) {
148
+ const table = ENTITY_NAME.exec((await disk.read(path)) ?? '')?.[1];
128
149
  if (table !== undefined) tables.push(table);
129
150
  }
130
151
  return tables;
@@ -134,24 +155,30 @@ export async function writtenTables(
134
155
  * Performs both edits on the app's role map: a written policy's grants, and — for each written
135
156
  * entity — the admin's three permissions for its table, declared and granted. Answers the paths
136
157
  * it rewrote.
158
+ *
159
+ * A permission is declared only where its grant can be placed. An app with no `admin` role got
160
+ * three permissions declared that no role held — `X_PERMISSION_UNGRANTED` on the next gate, for
161
+ * names the author never asked for — beside the finding that said nothing was granted.
137
162
  */
138
163
  export async function grantGeneratedPermissions(
139
164
  root: string,
140
165
  written: readonly string[],
166
+ disk: GenerateDisk = appDisk(root),
141
167
  ): Promise<readonly string[]> {
142
- const admin = (await writtenTables(root, written)).flatMap(adminGrantsFor);
168
+ const admin = (await writtenTables(root, written, disk)).flatMap(adminGrantsFor);
143
169
  const grants = [...grantsForWritten(written), ...admin];
144
- const file = containedPath(root, ROLES_FILE);
145
- if (grants.length === 0 || !(await Bun.file(file).exists())) return [];
146
- const before = await Bun.file(file).text();
170
+ const before = grants.length === 0 ? undefined : await disk.read(ROLES_FILE);
171
+ if (before === undefined) return [];
172
+ const unplaced = insertGrants(before, grants).skipped;
173
+ const placeable = grants.filter((grant) => !unplaced.includes(grant));
147
174
  const declared = insertPermissions(
148
175
  before,
149
- admin.map((grant) => grant.permission),
176
+ admin.filter((grant) => placeable.includes(grant)).map((grant) => grant.permission),
150
177
  );
151
178
  // Granted only where it could be declared: a grant with no declaration is the 500 above.
152
- const grantable = grants.filter((grant) => !declared.skipped.includes(grant.permission));
179
+ const grantable = placeable.filter((grant) => !declared.skipped.includes(grant.permission));
153
180
  const { source } = insertGrants(declared.source, grantable);
154
181
  if (source === before) return [];
155
- await Bun.write(file, source);
182
+ await disk.write(ROLES_FILE, source);
156
183
  return [ROLES_FILE];
157
184
  }
@@ -244,18 +244,23 @@ const exampleFor = (kind: Generator): string =>
244
244
  * space is refused for the same reason one step earlier: the value is spliced into a string
245
245
  * literal in emitted source, and neither is a permission any app declares.
246
246
  *
247
+ * Two segments or MORE: `admin:blog:write` is a `${string}:${string}` like any other, an app's
248
+ * staff console declares its permissions that way, and one colon exactly refused every one of them
249
+ * — the page was then generated under `blog:write` and renamed by hand in three places. An empty
250
+ * segment (`ops::read`, `ops:`) is still no permission.
251
+ *
247
252
  * It cannot ask whether the app DECLARES the permission — `x g` writes files against a root, it
248
253
  * never loads the app, and an app that will not import is exactly when a generator is reached for.
249
254
  * The generated page declaring it is what closes that half (`templates/admin-page.ts`).
250
255
  */
251
- const PERMISSION_SHAPE = /^[a-z0-9][a-z0-9_.-]*:[a-z0-9*][a-z0-9_.*-]*$/i;
256
+ const PERMISSION_SHAPE = /^[a-z0-9][a-z0-9_.-]*(?::[a-z0-9*][a-z0-9_.*-]*)+$/i;
252
257
 
253
258
  export function readPermission(raw: string | undefined, kind: Generator): string | undefined {
254
259
  if (raw === undefined || PERMISSION_SHAPE.test(raw)) return raw;
255
260
  throw new BadFlagError({
256
261
  flag: 'permission',
257
262
  command: 'g',
258
- reason: `expects a permission of the form <resource>:<verb>, got "${raw}"`,
263
+ reason: `expects a permission of the form <resource>:<verb> — or namespaced, <area>:<resource>:<verb> — got "${raw}"`,
259
264
  fix: `${exampleFor(kind)} --permission ops:read`,
260
265
  });
261
266
  }
@@ -33,6 +33,17 @@ export function refusePluralTable(
33
33
  feature: string | undefined,
34
34
  exists: (path: string) => boolean,
35
35
  ): void {
36
+ // A feature's second entity (`entity-<name>.ts`) is named by the NAME, never by the slice.
37
+ const sibling = files.find((file) => /\/entity-[a-z0-9-]+\.ts$/.test(file.path));
38
+ const single = sibling === undefined ? undefined : singularOf(kebab(name));
39
+ if (single !== undefined && feature !== undefined) {
40
+ throw new BadFlagError({
41
+ flag: 'name',
42
+ command: `g ${kind}`,
43
+ reason: `"${name}" is already plural, and the entity this run writes pluralises it again for its table — "${names(name).table}"`,
44
+ fix: ['x g', kind, single, '--feature', feature].join(' '),
45
+ });
46
+ }
36
47
  for (const slice of writtenEntitySlices(files, exists)) {
37
48
  const singular = singularOf(slice);
38
49
  if (singular === undefined) continue;
@@ -0,0 +1,34 @@
1
+ // Whether a feature's `entity.ts` makes the next `x g entity --feature` a SIBLING
2
+ // (`entity-<name>.ts`). Decided on what the file READABLY declares, and conservatively: the
3
+ // sibling path skips the conflict on `entity.ts`, so taking the same table for "another one" —
4
+ // because it is spelled as a template literal, or held in a constant — wrote a second module for
5
+ // one table where the run should have refused.
6
+
7
+ import { endOfLiteral, maskLiterals, QUOTES } from '@ultimat3/core';
8
+ import { names } from './templates/naming';
9
+
10
+ /** `entity(` as a call of the imported function — never a member (`schema.entity(`). */
11
+ const ENTITY_CALL = /(?<![.\w$])entity\(\s*/g;
12
+
13
+ /**
14
+ * True only when `source` declares at least one entity, EVERY `entity(…)` call in it names its
15
+ * table as a plain string literal, none of those tables is `name`'s, and no binding in the file
16
+ * is `name`'s own. Anything this cannot read — a constant, an interpolation — answers false: the
17
+ * run then takes the ordinary path, and an existing `entity.ts` is the conflict it always was.
18
+ */
19
+ export function declaresOnlyOtherEntities(source: string, name: string): boolean {
20
+ const masked = maskLiterals(source);
21
+ const calls = [...masked.matchAll(ENTITY_CALL)];
22
+ if (calls.length === 0) return false;
23
+ const { table, camel } = names(name);
24
+ // A camel spelling is `[A-Za-z0-9]` only (`naming.ts`), so it splices into a pattern as is.
25
+ if (new RegExp(`\\b(?:const|let|var)\\s+${camel}\\b`).test(masked)) return false;
26
+ return calls.every((call) => {
27
+ const at = call.index + call[0].length;
28
+ if (!QUOTES.has(masked[at] ?? '')) return false;
29
+ const end = endOfLiteral(source, at);
30
+ if (end === at + 1) return false;
31
+ const value = source.slice(at + 1, end - 1);
32
+ return !value.includes('${') && !value.includes('\\') && value !== table;
33
+ });
34
+ }
@@ -3,7 +3,8 @@
3
3
  // a generator that emits one hands the author a red gate for the generator's own output — the edit
4
4
  // is mechanical, so the generator makes it. Shipped source only, the checker's own scope.
5
5
 
6
- import { containedPath } from './generate-write';
6
+ import type { GenerateDisk } from './generate-disk';
7
+ import { appDisk } from './generate-disk';
7
8
  import { isGenerated, isTest } from './source-files';
8
9
  import { withDependency } from './workspace-dep-edit';
9
10
  import { importedPackages, scanWorkspaces, type WorkspaceNode } from './workspace-graph';
@@ -14,6 +15,7 @@ const SOURCE = /\.tsx?$/;
14
15
  async function missingEdges(
15
16
  root: string,
16
17
  written: readonly string[],
18
+ disk: GenerateDisk,
17
19
  ): Promise<Map<WorkspaceNode, Map<string, string>>> {
18
20
  const missing = new Map<WorkspaceNode, Map<string, string>>();
19
21
  const sources = written.filter(
@@ -27,7 +29,7 @@ async function missingEdges(
27
29
  for (const path of sources) {
28
30
  const owner = owners.find((node) => path.startsWith(`${node.dir}/`));
29
31
  if (owner === undefined) continue;
30
- const text = await Bun.file(containedPath(root, path)).text();
32
+ const text = (await disk.read(path)) ?? '';
31
33
  for (const name of importedPackages(text, path)) {
32
34
  const target = byName.get(name);
33
35
  if (target === undefined || target === owner || owner.dependencies.includes(name)) continue;
@@ -43,16 +45,17 @@ async function missingEdges(
43
45
  export async function declareGeneratedImports(
44
46
  root: string,
45
47
  written: readonly string[],
48
+ disk: GenerateDisk = appDisk(root),
46
49
  ): Promise<readonly string[]> {
47
50
  const edited: string[] = [];
48
- for (const [owner, edges] of await missingEdges(root, written)) {
51
+ for (const [owner, edges] of await missingEdges(root, written, disk)) {
49
52
  const path = `${owner.dir}/package.json`;
50
- const file = Bun.file(containedPath(root, path));
51
- const before = await file.text();
53
+ // Through the seam, not `owner.dependencies`: an earlier edit of this run may already hold it.
54
+ const before = (await disk.read(path)) ?? '';
52
55
  let after = before;
53
56
  for (const [name, version] of edges) after = withDependency(after, name, version) ?? after;
54
57
  if (after === before) continue;
55
- await Bun.write(containedPath(root, path), after);
58
+ await disk.write(path, after);
56
59
  edited.push(path);
57
60
  }
58
61
  return edited;
@@ -5,9 +5,12 @@
5
5
  // the two lines.
6
6
 
7
7
  import { ERROR_DOCS_URL, stripComments } from '@ultimat3/core';
8
- import { containedPath } from './generate-write';
8
+ import type { GenerateDisk } from './generate-disk';
9
+ import { appDisk } from './generate-disk';
9
10
  import type { Finding } from './output';
10
11
  import { holdsKey, withKeyedLine, withSortedImport } from './source-edit';
12
+ import { closingBracket, maskOf, readList } from './source-list-edit';
13
+ import { entityModuleIn } from './templates/entity-module';
11
14
  import type { GeneratedFile } from './templates/naming';
12
15
  import { plural } from './templates/naming';
13
16
  import type { HandleEntry } from './templates/scaffold-db-client';
@@ -27,7 +30,6 @@ export const DB_WORKSPACE = 'packages/db';
27
30
  const WEB_WORKSPACE = 'apps/web';
28
31
  const INDEX_FILE = `${DB_WORKSPACE}/src/index.ts`;
29
32
 
30
- const ENTITY_PATH = /^apps\/web\/(?<rest>[^/]+\/[^/]+)\/entity\.ts$/;
31
33
  const ENTITY_EXPORT = /\bexport const ([A-Za-z_$][\w$]*) = entity\(/;
32
34
  const IDENTIFIER = '[A-Za-z_$][\\w$]*';
33
35
 
@@ -44,19 +46,34 @@ export const handleUnregisteredFinding = (entry: HandleEntry, reason: string): F
44
46
  at: HANDLE_FILE,
45
47
  });
46
48
 
47
- /** The object literal `database(<name>, …)` is called with: where it opens and where it closes. */
49
+ /**
50
+ * The object literal `database(<name>, …)` is called with: where it opens and where it closes.
51
+ * Found on the MASKED text and closed by bracket depth — the first `}` after the `{` was the end
52
+ * of the set, so one in a comment or in an entry's own value cut the set short.
53
+ */
48
54
  function entitySetOf(
49
55
  source: string,
50
56
  ): { readonly open: number; readonly close: number } | undefined {
51
- const call = new RegExp(`\\bdatabase\\(\\s*(${IDENTIFIER})\\s*[,)]`).exec(source);
57
+ const masked = maskOf(source);
58
+ const call = new RegExp(`\\bdatabase\\(\\s*(${IDENTIFIER})\\s*[,)]`).exec(masked);
52
59
  if (call === null) return undefined;
53
- const declared = new RegExp(`\\bconst ${call[1]}\\s*=\\s*\\{`).exec(source);
60
+ const declared = new RegExp(`\\bconst ${call[1]}\\s*=\\s*\\{`).exec(masked);
54
61
  if (declared === null) return undefined;
55
62
  const open = declared.index + declared[0].length;
56
- const close = source.indexOf('}', open);
63
+ const close = closingBracket(masked, open - 1);
57
64
  return close === -1 ? undefined : { open, close };
58
65
  }
59
66
 
67
+ /** Whether the set opening before `open` reads back as what it held plus `line`'s entry. */
68
+ function setGained(before: string, after: string, open: number, line: string): boolean {
69
+ const was = readList(before, maskOf(before), open - 1);
70
+ const now = readList(after, maskOf(after), open - 1);
71
+ if (was === undefined || now === undefined) return false;
72
+ const expected = [...was.entries.map((entry) => entry.text), line.trim().replace(/,$/, '')];
73
+ const held = now.entries.map((entry) => entry.text);
74
+ return held.length === expected.length && expected.every((text) => held.includes(text));
75
+ }
76
+
60
77
  /**
61
78
  * `source` with each entry imported and listed. An entry whose key the set already holds is left
62
79
  * alone; a file with no `const <set> = { … }` handed to `database()` is returned untouched with
@@ -77,21 +94,24 @@ export function insertHandleEntries(
77
94
  const body = next.slice(set.open, set.close);
78
95
  if (holdsKey(stripComments(body), entry.key)) continue;
79
96
  const listed = withKeyedLine(body, entry.key, handleEntryLine(entry), ' ');
80
- next = withSortedImport(
81
- `${next.slice(0, set.open)}${listed}${next.slice(set.close)}`,
82
- handleImportLine(entry),
83
- entry.specifier,
84
- );
97
+ const edited = `${next.slice(0, set.open)}${listed}${next.slice(set.close)}`;
98
+ // Read back: a set this line-edit misread (a comment shaped like an entry, a value spanning
99
+ // rows) is refused with the two lines named, never written wrong.
100
+ if (!setGained(next, edited, set.open, handleEntryLine(entry))) {
101
+ missing.push(entry);
102
+ continue;
103
+ }
104
+ next = withSortedImport(edited, handleImportLine(entry), entry.specifier);
85
105
  }
86
106
  return { source: next, missing };
87
107
  }
88
108
 
89
109
  /** The entry an `entity.ts` implies, named by what the file exports; `undefined` for any other path. */
90
110
  function entryOf(path: string, source: string, webModule: string): HandleEntry | undefined {
91
- const rest = ENTITY_PATH.exec(path)?.groups?.['rest'];
111
+ const module = entityModuleIn(path, WEB_WORKSPACE);
92
112
  const binding = ENTITY_EXPORT.exec(source)?.[1];
93
- if (rest === undefined || binding === undefined) return undefined;
94
- return { key: plural(binding), binding, specifier: `${webModule}/${rest}/entity` };
113
+ if (module === undefined || binding === undefined) return undefined;
114
+ return { key: plural(binding), binding, specifier: `${webModule}/${module}` };
95
115
  }
96
116
 
97
117
  /**
@@ -117,14 +137,14 @@ export function withHandleEntries(
117
137
 
118
138
  /** The entries the written files imply: one per `entity.ts` this run put on disk. */
119
139
  async function entriesFor(
120
- root: string,
140
+ disk: GenerateDisk,
121
141
  written: readonly string[],
122
142
  webModule: string,
123
143
  ): Promise<readonly HandleEntry[]> {
124
144
  const entries: HandleEntry[] = [];
125
145
  for (const path of written) {
126
- if (!ENTITY_PATH.test(path)) continue;
127
- const entry = entryOf(path, await Bun.file(containedPath(root, path)).text(), webModule);
146
+ if (entityModuleIn(path, WEB_WORKSPACE) === undefined) continue;
147
+ const entry = entryOf(path, (await disk.read(path)) ?? '', webModule);
128
148
  if (entry !== undefined) entries.push(entry);
129
149
  }
130
150
  return entries;
@@ -136,11 +156,26 @@ export interface HandleRegistration {
136
156
  readonly findings: readonly Finding[];
137
157
  }
138
158
 
159
+ const exportsHandle = (index: string): boolean => /from\s+'\.\/client'/.test(stripComments(index));
160
+
161
+ /**
162
+ * The db package is the app's OWN: it has an index, no handle, and exports none. A handle written
163
+ * into it is a file nothing imports — `packages/db/src/client.ts` appeared in an app whose db
164
+ * package is a schema and a seed, beside a finding that said the index did not export it. Said
165
+ * instead, with what the generated repo expects, and nothing is created.
166
+ */
167
+ const ownDbPackageFinding = (entry: HandleEntry, dbModule: string): Finding => ({
168
+ code: 'X_DB_HANDLE_UNREGISTERED',
169
+ cause: `${DB_WORKSPACE} has its own ${INDEX_FILE} and no typed handle (${HANDLE_FILE}), so "import { db } from '${dbModule}'" in the generated repo is not database()'s and db.${entry.key} does not exist — no handle was created in a package that does not export one`,
170
+ // Command first, the edit behind a `#`: the line runs as printed and names what it then proves.
171
+ fix: `x verify --only typecheck # after rewriting the generated repo over this app's own client, or adopting the typed handle: create ${HANDLE_FILE} exporting "db = database({ ${entry.key}: ${entry.binding} }, { driver })" with ${handleImportLine(entry)} and add export { db, driver, selectDriver } from './client'; to ${INDEX_FILE}`,
172
+ docs: ERROR_DOCS_URL,
173
+ at: INDEX_FILE,
174
+ });
175
+
139
176
  /** The handle exists but the package does not export it: the repo's import reaches something else. */
140
- async function indexFindings(root: string, dbModule: string): Promise<readonly Finding[]> {
141
- const file = Bun.file(containedPath(root, INDEX_FILE));
142
- const source = (await file.exists()) ? await file.text() : '';
143
- if (/from\s+'\.\/client'/.test(stripComments(source))) return [];
177
+ async function indexFindings(disk: GenerateDisk, dbModule: string): Promise<readonly Finding[]> {
178
+ if (exportsHandle((await disk.read(INDEX_FILE)) ?? '')) return [];
144
179
  return [
145
180
  {
146
181
  code: 'X_DB_HANDLE_UNREGISTERED',
@@ -154,7 +189,10 @@ async function indexFindings(root: string, dbModule: string): Promise<readonly F
154
189
 
155
190
  /**
156
191
  * Performs the registration for every entity among `written`: the handle's set and import, then
157
- * the manifest lines the new edges need. Creates the handle in an app that has none.
192
+ * the manifest lines the new edges need. Creates the handle in an app that has none AND whose db
193
+ * package would export it — an index that names `./client`, or no index at all. A db package with
194
+ * its own index and no handle is the app's own layout: nothing is created there, and the finding
195
+ * says what the generated repo expects.
158
196
  *
159
197
  * PLANNED, then written — all of it or none. A handle with no `const <set> = { … }` refuses the
160
198
  * entry, and a run that then went on to edit `packages/db/package.json` left a manifest line for
@@ -170,15 +208,19 @@ export async function registerGeneratedEntities(
170
208
  root: string,
171
209
  written: readonly string[],
172
210
  dbModule: string | undefined,
211
+ disk: GenerateDisk = appDisk(root),
173
212
  ): Promise<HandleRegistration> {
174
- const web = await readWorkspace(root, WEB_WORKSPACE);
213
+ const web = await readWorkspace(root, WEB_WORKSPACE, disk);
175
214
  const module = dbModule ?? PLACEHOLDER_DB_MODULE;
176
215
  const webModule = web?.name ?? webModuleOf(module);
177
- const entries = await entriesFor(root, written, webModule);
216
+ const entries = await entriesFor(disk, written, webModule);
178
217
  if (entries.length === 0) return { edited: [], findings: [] };
179
218
  const planned: PlannedEdit[] = [];
180
- const file = Bun.file(containedPath(root, HANDLE_FILE));
181
- const before = (await file.exists()) ? await file.text() : undefined;
219
+ const before = await disk.read(HANDLE_FILE);
220
+ const index = before === undefined ? await disk.read(INDEX_FILE) : undefined;
221
+ if (index !== undefined && !exportsHandle(index)) {
222
+ return { edited: [], findings: entries.map((entry) => ownDbPackageFinding(entry, module)) };
223
+ }
182
224
  const { source, missing } =
183
225
  before === undefined
184
226
  ? { source: dbClientSource(entries), missing: [] }
@@ -202,16 +244,16 @@ export async function registerGeneratedEntities(
202
244
  docs: ERROR_DOCS_URL,
203
245
  at: `${DB_WORKSPACE}/package.json`,
204
246
  });
205
- } else findings.push(...(await indexFindings(root, dbModule)));
247
+ } else findings.push(...(await indexFindings(disk, dbModule)));
206
248
  const importsWeb = stripComments(source).includes(`from '${webModule}/`);
207
249
  const edges = [
208
250
  ...(importsWeb ? [[DB_WORKSPACE, WEB_WORKSPACE] as const] : []),
209
251
  [WEB_WORKSPACE, DB_WORKSPACE] as const,
210
252
  ];
211
253
  for (const [from, to] of edges) {
212
- const manifest = await planWorkspaceDependency(root, from, to);
254
+ const manifest = await planWorkspaceDependency(root, from, to, disk);
213
255
  if (manifest !== undefined) planned.push(manifest);
214
256
  }
215
- for (const edit of planned) await Bun.write(containedPath(root, edit.path), edit.contents);
257
+ for (const edit of planned) await disk.write(edit.path, edit.contents);
216
258
  return { edited: planned.map((edit) => edit.path), findings };
217
259
  }