@ultimat3/cli 27.6.1 → 27.8.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/CLAUDE.md +1 -1
- package/package.json +28 -28
- package/src/admin-registration.ts +15 -14
- package/src/api-registration.ts +48 -47
- package/src/app-manifest.ts +15 -0
- package/src/cmd-generate.ts +65 -49
- package/src/dev-boot.ts +10 -1
- package/src/generate-disk.ts +38 -0
- package/src/generate-files.ts +6 -1
- package/src/generate-follow-ups.ts +64 -0
- package/src/generate-grant-findings.ts +8 -6
- package/src/generate-grants.ts +79 -52
- package/src/generate-kinds.ts +7 -2
- package/src/generate-plural.ts +11 -0
- package/src/generate-sibling.ts +34 -0
- package/src/generated-imports.ts +9 -6
- package/src/handle-registration.ts +71 -29
- package/src/i18n-index.ts +30 -14
- package/src/isr-query-advice.ts +63 -0
- package/src/isr-query-guard.ts +49 -0
- package/src/mcp-host.ts +2 -1
- package/src/messages.ts +4 -0
- package/src/role-sync-bus-wait.ts +66 -0
- package/src/role-sync.ts +6 -2
- package/src/runtime-bus.ts +198 -0
- package/src/runtime-cache.ts +158 -10
- package/src/runtime-isr-outcome.ts +60 -13
- package/src/runtime-isr.ts +23 -0
- package/src/runtime-render.ts +4 -3
- package/src/runtime-services.ts +30 -37
- package/src/seo-routes.ts +114 -14
- package/src/serve-boot.ts +4 -1
- package/src/serve-types.ts +6 -0
- package/src/serve.ts +8 -1
- package/src/source-list-edit.ts +218 -0
- package/src/templates/entity-module.ts +29 -0
- package/src/templates/entity.ts +30 -14
- package/src/verify-checks.ts +5 -0
- package/src/workspace-dep-edit.ts +13 -10
package/CLAUDE.md
CHANGED
|
@@ -91,7 +91,7 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
|
|
|
91
91
|
| `module-imports.ts` | the files a module's source imports, resolved once for both graphs: `referenced` (reload) and `evaluated` (document) |
|
|
92
92
|
| `cmd-dev.ts` / `dev-boot.ts` / `dev-rebuild.ts` / `dev-route-table.ts` | `x dev`: the command (`cmd-dev.ts`, re-exports `startDev`); the boot — every role in one process, `/_x`, the watcher (`dev-boot.ts`); one save rebuilt as one generation (`dev-rebuild.ts`) |
|
|
93
93
|
| `runtime-bindings.ts` | which service each binding points at (embedded or external); events follow `realtime.transport` / `urlEnv` |
|
|
94
|
-
| `runtime-queue.ts` / `runtime-services.ts` |
|
|
94
|
+
| `runtime-queue.ts` / `runtime-services.ts` / `runtime-bus.ts` | db + queue; every other accessor; the bus |
|
|
95
95
|
| `framework-schema-apply.ts` / `framework-schema-stamp.ts` | the framework tables: `ROLE=migrate`, `x dev` and every CLI command APPLY them in one transaction behind `MIGRATION_LOCK_KEY` (`pg_advisory_xact_lock`) with a bounded `lock_timeout`, then stamp the build on `x_jobs`' table comment; a serving role on an external database runs no DDL and VERIFIES the stamp (`X_FRAMEWORK_SCHEMA_UNAPPLIED`, a major skew is a warning). A comment, never a table: a new framework table moves every app's schema dump |
|
|
96
96
|
| `runtime-isr.ts` | the ISR controller both boots serve through, ATTACHED to `invalidateTags` and released on stop (`attachedIsr`); `appRoutes` without one builds and attaches its own |
|
|
97
97
|
| `runtime-mfa-warning.ts` / `runtime-idempotency-scope.ts` | boot warnings an operator can act on: unsealed MFA secrets (web role, `x auth seal-mfa`), a per-process idempotency store after the app loaded |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.8.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -39,33 +39,33 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@babel/core": "^8.0.1",
|
|
42
|
-
"@ultimat3/action": "27.
|
|
43
|
-
"@ultimat3/admin": "27.
|
|
44
|
-
"@ultimat3/ai": "27.
|
|
45
|
-
"@ultimat3/auth": "27.
|
|
46
|
-
"@ultimat3/cache": "27.
|
|
47
|
-
"@ultimat3/core": "27.
|
|
48
|
-
"@ultimat3/db": "27.
|
|
49
|
-
"@ultimat3/entity": "27.
|
|
50
|
-
"@ultimat3/flags": "27.
|
|
51
|
-
"@ultimat3/http": "27.
|
|
52
|
-
"@ultimat3/i18n": "27.
|
|
53
|
-
"@ultimat3/jobs": "27.
|
|
54
|
-
"@ultimat3/mail": "27.
|
|
55
|
-
"@ultimat3/manifest": "27.
|
|
56
|
-
"@ultimat3/mcp": "27.
|
|
57
|
-
"@ultimat3/money": "27.
|
|
58
|
-
"@ultimat3/notify": "27.
|
|
59
|
-
"@ultimat3/policy": "27.
|
|
60
|
-
"@ultimat3/pwa": "27.
|
|
61
|
-
"@ultimat3/query": "27.
|
|
62
|
-
"@ultimat3/realtime": "27.
|
|
63
|
-
"@ultimat3/render": "27.
|
|
64
|
-
"@ultimat3/schema": "27.
|
|
65
|
-
"@ultimat3/seo": "27.
|
|
66
|
-
"@ultimat3/storage": "27.
|
|
67
|
-
"@ultimat3/testing": "27.
|
|
68
|
-
"@ultimat3/time": "27.
|
|
42
|
+
"@ultimat3/action": "27.8.0",
|
|
43
|
+
"@ultimat3/admin": "27.8.0",
|
|
44
|
+
"@ultimat3/ai": "27.8.0",
|
|
45
|
+
"@ultimat3/auth": "27.8.0",
|
|
46
|
+
"@ultimat3/cache": "27.8.0",
|
|
47
|
+
"@ultimat3/core": "27.8.0",
|
|
48
|
+
"@ultimat3/db": "27.8.0",
|
|
49
|
+
"@ultimat3/entity": "27.8.0",
|
|
50
|
+
"@ultimat3/flags": "27.8.0",
|
|
51
|
+
"@ultimat3/http": "27.8.0",
|
|
52
|
+
"@ultimat3/i18n": "27.8.0",
|
|
53
|
+
"@ultimat3/jobs": "27.8.0",
|
|
54
|
+
"@ultimat3/mail": "27.8.0",
|
|
55
|
+
"@ultimat3/manifest": "27.8.0",
|
|
56
|
+
"@ultimat3/mcp": "27.8.0",
|
|
57
|
+
"@ultimat3/money": "27.8.0",
|
|
58
|
+
"@ultimat3/notify": "27.8.0",
|
|
59
|
+
"@ultimat3/policy": "27.8.0",
|
|
60
|
+
"@ultimat3/pwa": "27.8.0",
|
|
61
|
+
"@ultimat3/query": "27.8.0",
|
|
62
|
+
"@ultimat3/realtime": "27.8.0",
|
|
63
|
+
"@ultimat3/render": "27.8.0",
|
|
64
|
+
"@ultimat3/schema": "27.8.0",
|
|
65
|
+
"@ultimat3/seo": "27.8.0",
|
|
66
|
+
"@ultimat3/storage": "27.8.0",
|
|
67
|
+
"@ultimat3/testing": "27.8.0",
|
|
68
|
+
"@ultimat3/time": "27.8.0",
|
|
69
69
|
"babel-preset-solid": "^1.9.15"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// Performed here; where the admin is not a `defineAdmin({ … })` this can add to, the finding names
|
|
5
5
|
// the two lines.
|
|
6
6
|
|
|
7
|
-
import { ERROR_DOCS_URL, stripComments } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
7
|
+
import { ERROR_DOCS_URL, maskLiterals, stripComments } from '@ultimat3/core';
|
|
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';
|
|
11
12
|
import type { GeneratedFile } from './templates/naming';
|
|
@@ -70,7 +71,8 @@ export function insertAdminResources(
|
|
|
70
71
|
let next = source;
|
|
71
72
|
const missing: AdminResourceEntry[] = [];
|
|
72
73
|
for (const entry of entries) {
|
|
73
|
-
|
|
74
|
+
// Literals masked too: a brace inside a string label is not one of the call's.
|
|
75
|
+
const masked = maskLiterals(next);
|
|
74
76
|
const call = /\bdefineAdmin\(\s*\{/.exec(masked);
|
|
75
77
|
const open = call === null ? -1 : call.index + call[0].length - 1;
|
|
76
78
|
const close = open === -1 ? -1 : closeOf(masked, open);
|
|
@@ -140,18 +142,17 @@ export function withAdminResources(
|
|
|
140
142
|
|
|
141
143
|
/** The overrides among the written paths, each named by what its file and its entity export. */
|
|
142
144
|
async function entriesFor(
|
|
143
|
-
|
|
145
|
+
disk: GenerateDisk,
|
|
144
146
|
written: readonly string[],
|
|
145
147
|
webModule: string,
|
|
146
148
|
): Promise<readonly AdminResourceEntry[]> {
|
|
147
149
|
const entries: AdminResourceEntry[] = [];
|
|
148
150
|
for (const path of written) {
|
|
149
151
|
if (!OVERRIDE_PATH.test(path)) continue;
|
|
150
|
-
const entity = Bun.file(containedPath(root, entityPathOf(path)));
|
|
151
152
|
const entry = entryOf(
|
|
152
153
|
path,
|
|
153
|
-
await
|
|
154
|
-
|
|
154
|
+
(await disk.read(path)) ?? '',
|
|
155
|
+
await disk.read(entityPathOf(path)),
|
|
155
156
|
webModule,
|
|
156
157
|
);
|
|
157
158
|
if (entry !== undefined) entries.push(entry);
|
|
@@ -169,24 +170,24 @@ export interface AdminRegistration {
|
|
|
169
170
|
export async function registerAdminResources(
|
|
170
171
|
root: string,
|
|
171
172
|
written: readonly string[],
|
|
173
|
+
disk: GenerateDisk = appDisk(root),
|
|
172
174
|
): Promise<AdminRegistration> {
|
|
173
|
-
const web = await readWorkspace(root, WEB_WORKSPACE);
|
|
174
|
-
const entries = web === undefined ? [] : await entriesFor(
|
|
175
|
+
const web = await readWorkspace(root, WEB_WORKSPACE, disk);
|
|
176
|
+
const entries = web === undefined ? [] : await entriesFor(disk, written, web.name);
|
|
175
177
|
if (entries.length === 0) return { edited: [], findings: [] };
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
178
|
+
const before = await disk.read(ADMIN_FILE);
|
|
179
|
+
if (before === undefined) {
|
|
178
180
|
return {
|
|
179
181
|
edited: [],
|
|
180
182
|
findings: entries.map((entry) => adminResourceUnwiredFinding(entry, 'does not exist')),
|
|
181
183
|
};
|
|
182
184
|
}
|
|
183
|
-
const before = await file.text();
|
|
184
185
|
const { source, missing } = insertAdminResources(before, entries);
|
|
185
186
|
const findings = missing.map((entry) =>
|
|
186
187
|
adminResourceUnwiredFinding(entry, 'holds no defineAdmin({ … }) call to add to'),
|
|
187
188
|
);
|
|
188
189
|
if (source === before) return { edited: [], findings };
|
|
189
|
-
await
|
|
190
|
-
const manifest = await declareWorkspaceDependency(root, ADMIN_WORKSPACE, WEB_WORKSPACE);
|
|
190
|
+
await disk.write(ADMIN_FILE, source);
|
|
191
|
+
const manifest = await declareWorkspaceDependency(root, ADMIN_WORKSPACE, WEB_WORKSPACE, disk);
|
|
191
192
|
return { edited: manifest === undefined ? [ADMIN_FILE] : [ADMIN_FILE, manifest], findings };
|
|
192
193
|
}
|
package/src/api-registration.ts
CHANGED
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
8
8
|
import { API_INDEX } from './app-root';
|
|
9
|
+
import type { GenerateDisk } from './generate-disk';
|
|
10
|
+
import { appDisk } from './generate-disk';
|
|
9
11
|
import { containedPath } from './generate-write';
|
|
10
12
|
import type { Finding } from './output';
|
|
13
|
+
import type { SourceList } from './source-list-edit';
|
|
14
|
+
import { appendToList, maskOf, readList, readProperties } from './source-list-edit';
|
|
11
15
|
import { camel } from './templates/naming';
|
|
12
|
-
import { wrapList } from './templates/wrap';
|
|
13
16
|
|
|
14
17
|
/** One module to import as a namespace and list under a `defineApi` key. */
|
|
15
18
|
export interface ApiEntry {
|
|
@@ -56,42 +59,30 @@ export function apiEntriesFor(written: readonly string[]): readonly ApiEntry[] {
|
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* call is never the one edited. `line` is found from the key, never from `start`: in a list already
|
|
64
|
-
* wrapped one entry per line the character AT `start` is the newline after `[`, and a backwards
|
|
65
|
-
* search from there answered the first ITEM's line — the rewrite then nested a second `jobs: [`
|
|
66
|
-
* inside the first and left the old `]` behind.
|
|
62
|
+
* The `key: [...]` lists of the `defineApi({ ... })` call, read off the MASKED text: a `jobs: [` in
|
|
63
|
+
* a comment or an object above the call is never the one edited, and a `]` or a comma inside a
|
|
64
|
+
* comment between two entries ends nothing. (The list was closed at the first `]` in the raw text
|
|
65
|
+
* and split at every comma — a commented list was rewritten with its comment as an entry.)
|
|
67
66
|
*/
|
|
68
|
-
const
|
|
69
|
-
source
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const end = source.indexOf(']', start);
|
|
80
|
-
if (end === -1) return undefined;
|
|
81
|
-
const items = source
|
|
82
|
-
.slice(start, end)
|
|
83
|
-
.split(',')
|
|
84
|
-
.map((item) => item.trim())
|
|
85
|
-
.filter((item) => item.length > 0);
|
|
86
|
-
return { line, start, end, items };
|
|
67
|
+
const listsOf = (source: string): ((key: string) => SourceList | undefined) | undefined => {
|
|
68
|
+
const masked = maskOf(source);
|
|
69
|
+
const call = /\bdefineApi\(\s*\{/.exec(masked);
|
|
70
|
+
if (call === null) return undefined;
|
|
71
|
+
const properties = readProperties(source, masked, call.index + call[0].length - 1);
|
|
72
|
+
return (key) => {
|
|
73
|
+
const property = properties.find((one) => one.key === key);
|
|
74
|
+
return property === undefined || masked[property.value] !== '['
|
|
75
|
+
? undefined
|
|
76
|
+
: readList(source, masked, property.value);
|
|
77
|
+
};
|
|
87
78
|
};
|
|
88
79
|
|
|
89
80
|
/**
|
|
90
81
|
* `source` with each entry imported and listed. The import joins the `import * as` block in sorted
|
|
91
82
|
* position; the binding joins its list, which is created after `actions: [...]` when the call has
|
|
92
|
-
* none. An index that is not the scaffold's shape — no `defineApi({`, no `actions:` list —
|
|
93
|
-
* returned untouched with the entries in `skipped`, and the
|
|
94
|
-
* names the edit.
|
|
83
|
+
* none. An index that is not the scaffold's shape — no `defineApi({`, no `actions:` list — or a
|
|
84
|
+
* list that cannot be edited safely is returned untouched with the entries in `skipped`, and the
|
|
85
|
+
* `manifest` step's X_JOB_UNREGISTERED names the edit.
|
|
95
86
|
*/
|
|
96
87
|
export function insertApiEntries(
|
|
97
88
|
source: string,
|
|
@@ -101,28 +92,38 @@ export function insertApiEntries(
|
|
|
101
92
|
const skipped: ApiEntry[] = [];
|
|
102
93
|
for (const entry of entries) {
|
|
103
94
|
const importLine = `import * as ${entry.binding} from '${entry.specifier}';`;
|
|
104
|
-
const
|
|
105
|
-
const actions =
|
|
106
|
-
if (
|
|
95
|
+
const listOf = listsOf(next);
|
|
96
|
+
const actions = listOf?.('actions');
|
|
97
|
+
if (listOf === undefined || actions === undefined) {
|
|
107
98
|
skipped.push(entry);
|
|
108
99
|
continue;
|
|
109
100
|
}
|
|
110
|
-
const list = listOf(
|
|
111
|
-
if (list?.
|
|
101
|
+
const list = listOf(entry.key);
|
|
102
|
+
if (list?.entries.some((one) => one.text === entry.binding) === true) continue;
|
|
112
103
|
if (list === undefined) {
|
|
113
104
|
// In the order `x new` writes — actions, queries, jobs, tasks: after the nearest list
|
|
114
105
|
// before it that the call already holds, which is `actions` at the least.
|
|
115
106
|
const earlier = LIST_ORDER.slice(0, LIST_ORDER.indexOf(entry.key))
|
|
116
|
-
.map((key) => listOf(
|
|
107
|
+
.map((key) => listOf(key))
|
|
117
108
|
.findLast((found) => found !== undefined);
|
|
118
|
-
const after = next.indexOf('\n', (earlier ?? actions).
|
|
109
|
+
const after = next.indexOf('\n', (earlier ?? actions).close);
|
|
119
110
|
const line = ` ${entry.key}: [${entry.binding}],`;
|
|
120
|
-
|
|
111
|
+
const candidate = `${next.slice(0, after + 1)}${line}\n${next.slice(after + 1)}`;
|
|
112
|
+
// Read back, as every other edit here is: when the earlier list shares its row with the
|
|
113
|
+
// call's own `});`, "the row after it" is OUTSIDE the call — a property of nothing.
|
|
114
|
+
const landed = listsOf(candidate)?.(entry.key);
|
|
115
|
+
if (landed?.entries.some((one) => one.text === entry.binding) !== true) {
|
|
116
|
+
skipped.push(entry);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
next = candidate;
|
|
121
120
|
} else {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
const edited = appendToList(next, list.open, [entry.binding]);
|
|
122
|
+
if (edited === undefined) {
|
|
123
|
+
skipped.push(entry);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
next = edited;
|
|
126
127
|
}
|
|
127
128
|
if (!next.includes(importLine)) next = withImport(next, importLine);
|
|
128
129
|
}
|
|
@@ -236,13 +237,13 @@ export async function indexBindingFindings(
|
|
|
236
237
|
export async function registerGeneratedPrimitives(
|
|
237
238
|
root: string,
|
|
238
239
|
written: readonly string[],
|
|
240
|
+
disk: GenerateDisk = appDisk(root),
|
|
239
241
|
): Promise<readonly string[]> {
|
|
240
242
|
const entries = apiEntriesFor(written);
|
|
241
|
-
const
|
|
242
|
-
if (
|
|
243
|
-
const before = await Bun.file(file).text();
|
|
243
|
+
const before = entries.length === 0 ? undefined : await disk.read(API_INDEX);
|
|
244
|
+
if (before === undefined) return [];
|
|
244
245
|
const { source } = insertApiEntries(before, entries);
|
|
245
246
|
if (source === before) return [];
|
|
246
|
-
await
|
|
247
|
+
await disk.write(API_INDEX, source);
|
|
247
248
|
return [API_INDEX];
|
|
248
249
|
}
|
package/src/app-manifest.ts
CHANGED
|
@@ -113,10 +113,25 @@ const routeFacts = (): readonly RouteFact[] =>
|
|
|
113
113
|
offline: route.offline,
|
|
114
114
|
hydrate: route.hydrate,
|
|
115
115
|
revalidateTags: route.revalidateTags,
|
|
116
|
+
...revalidateOf(route),
|
|
116
117
|
surface: route.surface,
|
|
117
118
|
...budgetOf(route),
|
|
118
119
|
}));
|
|
119
120
|
|
|
121
|
+
/** An `isr` route's invalidation and key facts — `onInvalidate` always, the other two as declared. */
|
|
122
|
+
function revalidateOf(
|
|
123
|
+
route: RouteDescriptor,
|
|
124
|
+
): Pick<RouteFact, 'onInvalidate' | 'maxStale' | 'query'> {
|
|
125
|
+
if (route.mode !== 'isr') return {};
|
|
126
|
+
const maxStale = route.revalidateMaxStale ?? null;
|
|
127
|
+
const query = route.revalidateQuery ?? null;
|
|
128
|
+
return {
|
|
129
|
+
onInvalidate: route.revalidateOnInvalidate ?? 'stale',
|
|
130
|
+
...(maxStale === null ? {} : { maxStale }),
|
|
131
|
+
...(query === null ? {} : { query }),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
120
135
|
function budgetOf(route: RouteDescriptor): { budget?: { js: string } } {
|
|
121
136
|
return route.budgetJs === null ? {} : { budget: { js: route.budgetJs } };
|
|
122
137
|
}
|
package/src/cmd-generate.ts
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
7
|
-
import {
|
|
8
|
-
import { indexBindingFindings, registerGeneratedPrimitives } from './api-registration';
|
|
7
|
+
import { indexBindingFindings } from './api-registration';
|
|
9
8
|
import { writeAppArtifacts } from './app-artifacts';
|
|
10
9
|
import { appManifest } from './app-manifest';
|
|
11
10
|
import { requireAppRoot } from './app-root';
|
|
@@ -13,19 +12,19 @@ import { generateSpec } from './cmd-generate-spec';
|
|
|
13
12
|
import type { CliCommand, CommandContext } from './command';
|
|
14
13
|
import { invocationOf } from './command';
|
|
15
14
|
import { localiseCatalogs } from './generate-catalog-locales';
|
|
15
|
+
import { appDisk, plannedDisk } from './generate-disk';
|
|
16
16
|
import { assertFeatureExists } from './generate-feature';
|
|
17
17
|
import { generate, sliceDir } from './generate-files';
|
|
18
|
-
import {
|
|
19
|
-
import { grantGeneratedPermissions } from './generate-grants';
|
|
18
|
+
import { followUpEdits } from './generate-follow-ups';
|
|
20
19
|
import type { Generator } from './generate-kinds';
|
|
21
20
|
import { readFeature, readKind, readName, readPermission, readSurface } from './generate-kinds';
|
|
22
21
|
import { refusePluralTable } from './generate-plural';
|
|
23
22
|
import { refuseShadowedTypes, refuseShadowedValues } from './generate-shadow';
|
|
23
|
+
import { declaresOnlyOtherEntities } from './generate-sibling';
|
|
24
24
|
import { containedPath, planWrites, writeFiles } from './generate-write';
|
|
25
|
-
import {
|
|
26
|
-
import { registerGeneratedEntities, resolveDbModule } from './handle-registration';
|
|
25
|
+
import { resolveDbModule } from './handle-registration';
|
|
27
26
|
import { resolveCatalogModule } from './i18n-audit';
|
|
28
|
-
import { catalogLocales
|
|
27
|
+
import { catalogLocales } from './i18n-index';
|
|
29
28
|
import { reproducedFlags } from './invocation-flags';
|
|
30
29
|
import { msg } from './messages';
|
|
31
30
|
import type { CommandResult, Finding } from './output';
|
|
@@ -33,6 +32,7 @@ import { flagBool, flagList, flagString } from './parse';
|
|
|
33
32
|
import { quoteArg } from './shell-quote';
|
|
34
33
|
import type { GeneratedFile } from './templates';
|
|
35
34
|
import { kebab, names, resolveLocales } from './templates';
|
|
35
|
+
import { isEntityModule } from './templates/entity-module';
|
|
36
36
|
|
|
37
37
|
// One import path for the generator, unchanged by the split: `index.ts`, `x new` and the scaffold
|
|
38
38
|
// fixture reach the kinds, the pure file list and the writer through this module, and a second path
|
|
@@ -94,6 +94,11 @@ export const generateCommand: CliCommand = {
|
|
|
94
94
|
// THIS feature's own `entity.ts`/`repo.ts`, not a default the template gets to assume.
|
|
95
95
|
const sliceEntity = await readSliceFile(root, kind, slice, 'entity.ts');
|
|
96
96
|
const sliceRepo = await readSliceFile(root, kind, slice, 'repo.ts');
|
|
97
|
+
// A feature's SECOND entity: `entity.ts` is another table's, so this one lands beside it.
|
|
98
|
+
const sibling =
|
|
99
|
+
kind === 'entity' &&
|
|
100
|
+
featureFlag !== undefined &&
|
|
101
|
+
(await holdsAnotherEntity(root, slice, name));
|
|
97
102
|
const planFor = (planned: string) =>
|
|
98
103
|
generate({
|
|
99
104
|
kind,
|
|
@@ -111,6 +116,7 @@ export const generateCommand: CliCommand = {
|
|
|
111
116
|
...(catalogModule === undefined ? {} : { catalogModule }),
|
|
112
117
|
...(dbModule === undefined ? {} : { dbModule }),
|
|
113
118
|
shell,
|
|
119
|
+
sibling,
|
|
114
120
|
});
|
|
115
121
|
// A non-default locale's strings arrive marked, and a locale with no catalog yet is refused
|
|
116
122
|
// with `x i18n add <locale>` — before a dry run answers, so it answers the same files.
|
|
@@ -149,14 +155,43 @@ export const generateCommand: CliCommand = {
|
|
|
149
155
|
if (flagBool(ctx.args, 'dry-run')) {
|
|
150
156
|
// The write plan, never the bare file list: what the real run would write, skip and refuse.
|
|
151
157
|
const plan = await planWrites(root, files, force, invocation);
|
|
152
|
-
const
|
|
153
|
-
|
|
158
|
+
const refused = [...plan.conflicts, ...bindings];
|
|
159
|
+
// And the edits OUTSIDE the slice — role map, handle, API index, manifests, catalog index —
|
|
160
|
+
// run for real against the plan held in memory: the same code, so the same list. A dry run
|
|
161
|
+
// that named five new files while the run rewrote four the caller owns was no plan.
|
|
162
|
+
const planned = new Map(
|
|
163
|
+
files.flatMap((file) =>
|
|
164
|
+
plan.written.includes(file.path) && typeof file.contents === 'string'
|
|
165
|
+
? [[file.path, file.contents] as const]
|
|
166
|
+
: [],
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
const follow =
|
|
170
|
+
refused.length > 0
|
|
171
|
+
? { edited: [], findings: [] }
|
|
172
|
+
: await followUpEdits(root, plan.written, dbModule, plannedDisk(root, planned));
|
|
173
|
+
const findings = [...refused, ...follow.findings];
|
|
174
|
+
const touched = refused.length === 0 ? [...plan.written, ...follow.edited] : [];
|
|
175
|
+
// Re-projected after a real write, from the loaded app: named, since their bytes are not
|
|
176
|
+
// knowable without the files on disk.
|
|
177
|
+
const refreshes =
|
|
178
|
+
plan.written.length === 0 || refused.length > 0
|
|
179
|
+
? []
|
|
180
|
+
: [MANIFEST_FILENAME, 'openapi.json'].filter((file) =>
|
|
181
|
+
existsSync(containedPath(root, file)),
|
|
182
|
+
);
|
|
154
183
|
return {
|
|
155
184
|
ok: findings.length === 0,
|
|
156
185
|
command: 'g',
|
|
157
|
-
summary: msg('cli.generate.planned', { count:
|
|
158
|
-
data: {
|
|
159
|
-
|
|
186
|
+
summary: msg('cli.generate.planned', { count: touched.length, kind, name }),
|
|
187
|
+
data: {
|
|
188
|
+
files: touched,
|
|
189
|
+
// The subset that already exists and would be EDITED, never created.
|
|
190
|
+
edits: follow.edited.filter((path) => existsSync(containedPath(root, path))),
|
|
191
|
+
...(refreshes.length === 0 ? {} : { refreshes }),
|
|
192
|
+
dryRun: true,
|
|
193
|
+
},
|
|
194
|
+
lines: touched.map((file) => msg('cli.file.added', { path: file })),
|
|
160
195
|
findings,
|
|
161
196
|
};
|
|
162
197
|
}
|
|
@@ -164,34 +199,11 @@ export const generateCommand: CliCommand = {
|
|
|
164
199
|
bindings.length > 0
|
|
165
200
|
? { written: [], conflicts: bindings }
|
|
166
201
|
: await writeFiles(root, files, force, invocation);
|
|
167
|
-
// The
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const handle = await registerGeneratedEntities(root, report.written, dbModule);
|
|
173
|
-
// And a fourth, for `--admin`: the override it wrote, listed where `defineAdmin()` reads it.
|
|
174
|
-
const adminWiring = await registerAdminResources(root, report.written);
|
|
175
|
-
// And a fifth, last because the others add imports too: every sibling workspace a written file
|
|
176
|
-
// imports, declared in the manifest it landed under — or `package-shape` refuses the output.
|
|
177
|
-
const edited = [
|
|
178
|
-
...new Set([
|
|
179
|
-
...(await grantGeneratedPermissions(root, report.written)),
|
|
180
|
-
...(await registerGeneratedPrimitives(root, report.written)),
|
|
181
|
-
...handle.edited,
|
|
182
|
-
...adminWiring.edited,
|
|
183
|
-
...(await declareGeneratedImports(root, report.written)),
|
|
184
|
-
]),
|
|
185
|
-
];
|
|
186
|
-
// A grant that edit could not place is said so, with the role each permission belongs to: an
|
|
187
|
-
// app whose role map is not the scaffold's got no grant and no word, and a 403 on every
|
|
188
|
-
// endpoint the run had just written.
|
|
189
|
-
const ungranted = await ungrantedByGenerator(root, report.written);
|
|
190
|
-
// A locale's catalog existing on disk and the app being able to select it are two different
|
|
191
|
-
// facts — see `syncI18nIndex`. Runs before the manifest load below so a route or resource
|
|
192
|
-
// this same invocation just wrote never gets projected against a stale catalog registration.
|
|
193
|
-
const indexSync =
|
|
194
|
-
report.written.length > 0 ? await syncI18nIndex(root) : { registered: true, findings: [] };
|
|
202
|
+
// The edits a generated primitive needs outside its own slice, performed rather than left as
|
|
203
|
+
// findings (`generate-follow-ups.ts`). Before the manifest load below, so the projection sees
|
|
204
|
+
// them all — a repo reading a table the handle lacks would not load at all.
|
|
205
|
+
const follow = await followUpEdits(root, report.written, dbModule, appDisk(root));
|
|
206
|
+
const edited = follow.edited;
|
|
195
207
|
// Facts, not prose: every `x g` run leaves the route/action/entity/job/policy table current,
|
|
196
208
|
// the same guarantee `x manifest` makes on its own — an agent reading it after `x g` never
|
|
197
209
|
// sees a resource that exists on disk but not in the manifest.
|
|
@@ -216,14 +228,7 @@ export const generateCommand: CliCommand = {
|
|
|
216
228
|
buildId = manifest.buildId;
|
|
217
229
|
} else loadFailures.push(...findings);
|
|
218
230
|
}
|
|
219
|
-
const findings = [
|
|
220
|
-
...report.conflicts,
|
|
221
|
-
...handle.findings,
|
|
222
|
-
...ungranted,
|
|
223
|
-
...adminWiring.findings,
|
|
224
|
-
...indexSync.findings,
|
|
225
|
-
...loadFailures,
|
|
226
|
-
];
|
|
231
|
+
const findings = [...report.conflicts, ...follow.findings, ...loadFailures];
|
|
227
232
|
// One list behind all three renderings. The manifest was printed as a `+` line while the count
|
|
228
233
|
// beside it came from `report.written` alone, so `x g island` said "wrote 2 file(s)" over three
|
|
229
234
|
// lines — and `--json` carried the shorter list, which is the drift `--json` exists to prevent.
|
|
@@ -289,7 +294,7 @@ export function nextSteps(
|
|
|
289
294
|
edited: readonly string[],
|
|
290
295
|
table: string,
|
|
291
296
|
): readonly string[] {
|
|
292
|
-
if (!written.some(
|
|
297
|
+
if (!written.some(isEntityModule)) return [];
|
|
293
298
|
return [
|
|
294
299
|
...(edited.some((path) => path.endsWith('package.json')) ? ['bun install'] : []),
|
|
295
300
|
`bunx x db gen "create ${table}"`,
|
|
@@ -322,6 +327,17 @@ async function readSliceErrors(
|
|
|
322
327
|
return existsSync(file) ? await Bun.file(file).text() : undefined;
|
|
323
328
|
}
|
|
324
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Whether the slice's `entity.ts` is there and readably declares only tables OTHER than `name`'s
|
|
332
|
+
* (`generate-sibling.ts`). The same entity again — or one this cannot read — is the conflict it
|
|
333
|
+
* always was (`--force` regenerates it); a different one is a second table for the feature, which
|
|
334
|
+
* `--force` would have written OVER the first.
|
|
335
|
+
*/
|
|
336
|
+
async function holdsAnotherEntity(root: string, slice: string, name: string): Promise<boolean> {
|
|
337
|
+
const file = containedPath(root, `${slice}/entity.ts`);
|
|
338
|
+
return existsSync(file) && declaresOnlyOtherEntities(await Bun.file(file).text(), name);
|
|
339
|
+
}
|
|
340
|
+
|
|
325
341
|
/** The generators whose output depends on what the slice's `entity.ts` / `repo.ts` declare. */
|
|
326
342
|
const READS_SLICE: ReadonlySet<Generator> = new Set([
|
|
327
343
|
'job',
|
package/src/dev-boot.ts
CHANGED
|
@@ -113,7 +113,16 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
113
113
|
// boot passed nothing here and built its own embedded disk, so an app's `/_storage` routes and its
|
|
114
114
|
// actions wrote to two different places in `x dev` and to one in production.
|
|
115
115
|
const appRuntime = await loadAppRuntime(options.root);
|
|
116
|
-
|
|
116
|
+
// The roles decide whether this boot waits for the bus, exactly as a container's one role does.
|
|
117
|
+
const runtime: RunningServices = await startServices(
|
|
118
|
+
resolved,
|
|
119
|
+
env,
|
|
120
|
+
appRuntime,
|
|
121
|
+
'apply',
|
|
122
|
+
options.roles ?? DEV_ROLES,
|
|
123
|
+
// Every role in one process: a bus that is away must not take the pages down with it.
|
|
124
|
+
true,
|
|
125
|
+
);
|
|
117
126
|
// The events binding the boot line reports is the bus the runtime chose, read off it.
|
|
118
127
|
const services = withRealtimeEvents(resolved, env, runtime.realtime);
|
|
119
128
|
// `serve.ts`'s `releaseBoot` shape: everything acquired from here on is released, newest first,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The files a generator's follow-up edits read and write, behind one seam — so `x g --dry-run` can
|
|
2
|
+
// run the SAME edits against the plan and answer every path a real run would touch. The dry run
|
|
3
|
+
// listed the files a generator creates and none it edits: the role map, the typed handle, the API
|
|
4
|
+
// index and two manifests changed under a plan that never named them.
|
|
5
|
+
|
|
6
|
+
import { containedPath } from './generate-write';
|
|
7
|
+
|
|
8
|
+
/** App-root-relative reads and writes. A read of a file that is not there answers `undefined`. */
|
|
9
|
+
export interface GenerateDisk {
|
|
10
|
+
read(path: string): Promise<string | undefined>;
|
|
11
|
+
write(path: string, contents: string): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The app's own disk. */
|
|
15
|
+
export const appDisk = (root: string): GenerateDisk => ({
|
|
16
|
+
async read(path) {
|
|
17
|
+
const file = Bun.file(containedPath(root, path));
|
|
18
|
+
return (await file.exists()) ? await file.text() : undefined;
|
|
19
|
+
},
|
|
20
|
+
async write(path, contents) {
|
|
21
|
+
await Bun.write(containedPath(root, path), contents);
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The disk as it WOULD stand after a run: `planned` is what the run creates, read before the real
|
|
27
|
+
* file, and a write lands in memory only. Nothing under `root` changes.
|
|
28
|
+
*/
|
|
29
|
+
export const plannedDisk = (root: string, planned: ReadonlyMap<string, string>): GenerateDisk => {
|
|
30
|
+
const held = new Map(planned);
|
|
31
|
+
const real = appDisk(root);
|
|
32
|
+
return {
|
|
33
|
+
read: async (path) => held.get(path) ?? (await real.read(path)),
|
|
34
|
+
async write(path, contents) {
|
|
35
|
+
held.set(path, contents);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
};
|
package/src/generate-files.ts
CHANGED
|
@@ -82,6 +82,11 @@ export interface GenerateOptions {
|
|
|
82
82
|
* resource's page renders inside it. Read off the disk by `run`, for `catalogModule`'s reason.
|
|
83
83
|
*/
|
|
84
84
|
readonly shell?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* `entity` only: the feature's `entity.ts` already declares ANOTHER table, so this entity is
|
|
87
|
+
* written beside it as `entity-<name>.ts` + `repo-<name>.ts`. Read off the disk by `run`.
|
|
88
|
+
*/
|
|
89
|
+
readonly sibling?: boolean;
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
|
|
@@ -174,7 +179,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
174
179
|
// With the labels the admin reads for it: the entity is an admin screen once it is in the
|
|
175
180
|
// handle, whether or not a resource was generated around it.
|
|
176
181
|
return dedupe([
|
|
177
|
-
...entityFiles(options.name, target),
|
|
182
|
+
...entityFiles(options.name, target, { sibling: options.sibling === true }),
|
|
178
183
|
...adminCatalogFiles(options.name, target, options.locales),
|
|
179
184
|
]);
|
|
180
185
|
case 'policy':
|