@starci/hfs 5.3.0 → 5.4.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/CHANGELOG.md +9 -0
- package/README.md +1 -0
- package/codemod/account-doors.mjs +7 -1
- package/codemod/app-prettier.d.mts +5 -0
- package/codemod/app-prettier.mjs +27 -0
- package/codemod/explicit-surface.mjs +1 -25
- package/codemod/main.mjs +8 -4
- package/codemod/module-definition.d.mts +10 -3
- package/codemod/module-definition.mjs +32 -17
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/sync/compile-guard.d.mts +36 -0
- package/sync/compile-guard.mjs +189 -0
- package/sync/index.d.mts +9 -1
- package/sync/index.mjs +28 -8
- package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +3 -1
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +4 -0
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +6 -3
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.4.0 - 2026-11-03
|
|
4
|
+
|
|
5
|
+
- Fixed: `starci app sync --execute` never leaves an app uncompilable. Before it writes a managed TypeScript file or deletes a retired one, it compiles each front-end and back-end project with the change applied (the app's own TypeScript; the changed files and every file importing them, against what is on disk). A managed group whose render adds a type error, in its own files or in an app file that imports one, is held whole: `HFS_SYNC_GROUP_HELD`, exit 1, naming the group, its files and the first errors (what the app must add or still imports); the rest is written, and a side without TypeScript installed is named in a notice (`sync/compile-guard.mts`).
|
|
6
|
+
- Fixed: `starci app codemod module-definition` drops every imported name the rewrite made unused (`ConfigurableModuleCls`, `ConfigurableModuleHost`, `ConfigurableModuleBuilder`), keeps the names the file still uses and `import type`, keeps the builder's JSDoc, and writes each file as the app's Prettier prints it, so no TS6133 and no prettier-hook refusal follow it (`codemod/app-prettier.mts`).
|
|
7
|
+
- Changed: `starci app codemod account-doors --contract` says why it writes nothing: the contract migration is already in the chain, or no migration grants the app role on schema `auth`.
|
|
8
|
+
- Changed: the lite sign-in renders `@starci/auth` 0.2.1: `identity_refresh_tokens.raced_count` in the baseline (the package's `identity-sessions.sql`), the refresh-token entity's `racedCount` and the session store's `markTokenRaced`. An app whose baseline holds the 0.2.0 tables adds `@starci/auth/migrations/identity-refresh-raced-successors.sql` as a migration.
|
|
9
|
+
- Changed: the canon pins name `@starci/auth` 0.2.1, `@starci/auth-oidc` 0.1.1 and the Grammar family 0.21.16.
|
|
10
|
+
- Upgrade, for an app on the CLI 1.1.0-alpha.49 / hfs 5.0.8 render moving to this version: (1) `npx starci app codemod module-definition --execute`, then `npm install`; (2) `npx starci app sync --execute`, which writes everything that compiles and holds the auth-lite sign-in glue while the app's own identity code differs, naming each missing or still-imported symbol; (3) adopt `@starci/mail` and make the identity domain provide what the held errors name; (4) `npx starci app sync --execute` again, which writes the glue once it compiles; (5) `npx starci app codemod account-doors --execute`, and `--contract` when the app still grants on schema `auth`; (6) add the `raced_count` migration above.
|
|
11
|
+
|
|
3
12
|
## 5.3.0 - 2026-11-03
|
|
4
13
|
|
|
5
14
|
- Fixed: the secrets family of the committed check cache stores codes, paths, positions and digests only (`cache/secret-records.mts`, `runtime/scripts/lib/secret-records.mts`), and the one secret judgement of R06 judges a file of `.starcistacks/cache/check/secrets/` by that contract, so `starci app check` can no longer write a cache the staged guard refuses (`HFS_PLAINTEXT_SECRET` for the `secrets/` folder name). A cache written before reads back as digests. Codemod: `starci app codemod secrets-cache`.
|
package/README.md
CHANGED
|
@@ -207,6 +207,7 @@ Its own checks (`scripts/hfs/check.mjs`, `scripts/hfs/rules/`; the rendered-file
|
|
|
207
207
|
|
|
208
208
|
The managed files are judged by `sync/managed.mjs` and `sync/ts-strict.mjs` (the package renders the templates; the runtime copy does not). The list of managed
|
|
209
209
|
files is the `managedBy` slots of `slots.yaml`; `starci app sync --execute` renders them and `starci app check` compares them, so a hand edit and a forgotten `sync` are the same finding.
|
|
210
|
+
`starci app sync --execute` never leaves the app uncompilable (`sync/compile-guard.mjs`): before it writes a managed TypeScript source or deletes a retired one, the side's own TypeScript compiles the app with those writes over the disk, and a managed group (`managedBy`) whose writes add a type error, in its own files or in an app file that imports one, is held whole: `HFS_SYNC_GROUP_HELD` (exit 1) names the group, its files and the errors (the names the render reads that the app does not provide yet, or the names it drops that the app still imports), and the rest of the render is written. The held files stay `HFS_SYNC_DRIFT` until the app provides what the render reads and sync runs again; a side whose TypeScript is not installed is written and named in a notice.
|
|
210
211
|
Each finding is reported once: the managed ESLint and stylelint factory configs under R17, `tsconfig.json` under R22 when it names a flag, a workflow or hook that lost a canon step under R13 (or R19 for the format step), everything else under R05.
|
|
211
212
|
The root and both sides are rendered by the one mechanism. The root: the `scripts` block of `app.package-manifest` (`dev:be`, `dev:fe`, `build:be`, `build:fe`, `start:<app>`, `lint`, `lint:fix`, `test`, `test:integration`, `test:e2e`, `test:contract`, `test:world`, `codegen`, `contract:emit`, `migrate`, `typecheck`, ...; a be script runs from `be/`, where its tsconfig and jest configuration are), `app.format-config`, `app.hooks`, the CI slots (`app.ci`, `app.ci-e2e`, `app.ci-images`), `app.quality-config` and the managed block of `app.git-meta`. A side: its `be.tool-config` or `fe.tool-config` slot — the side's tsconfig (resolving `@starci/tsconfig` from the root `node_modules`) and its managed ESLint factory config, the back end's build tsconfig, tests tsconfig and jest configuration, the front end's stylelint configuration. `lint` is `starci app check --gate lint`: ESLint over each side with its canon and stylelint over fe; `lint:fix` is the same with `--fix`. The front end has no test script, no test configuration and no e2e or coverage file: it has no tests, and `FE_NO_TESTS` (R97) refuses any spec, e2e file, test tool or test script under `fe/`. `turbo.json` stays the app's own
|
|
212
213
|
(slot `app.task-graph`): it carries the task graph, which no preset can render.
|
|
@@ -97,8 +97,14 @@ export function migrateAccountDoors({ root, write = false, contract = false, now
|
|
|
97
97
|
}
|
|
98
98
|
if (report.findings.length)
|
|
99
99
|
return report;
|
|
100
|
-
if (names.some((name) => name.includes(`_${CONTRACT_NAME}`))
|
|
100
|
+
if (names.some((name) => name.includes(`_${CONTRACT_NAME}`))) {
|
|
101
|
+
report.next.push('the contract migration is already in the chain: nothing is left to revoke');
|
|
101
102
|
return report;
|
|
103
|
+
}
|
|
104
|
+
if (!names.some((name) => AUTH_GRANT.test(statementsOf(root, name)))) {
|
|
105
|
+
report.next.push('no migration grants the application role on schema auth (the app reached accounts through the doors from the start): nothing to revoke');
|
|
106
|
+
return report;
|
|
107
|
+
}
|
|
102
108
|
const file = `${MIGRATIONS_DIR}/${stamp}_${CONTRACT_NAME}.sql`;
|
|
103
109
|
report.changes.push({ file, phase, why: 'revokes the direct grants on schema auth, auth.users and auth.identities from the application role' });
|
|
104
110
|
report.next.push('the deploy applies it right after the accept; the release before the doors cannot sign in after a rollback unless the grants are given back');
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Prettier } from '../sync/format.mjs';
|
|
2
|
+
/** The app's own Prettier, from the first side or the root that installs one, or undefined. */
|
|
3
|
+
export declare function prettierOf(root: string, folders: readonly string[]): Prettier | undefined;
|
|
4
|
+
/** `text` formatted by the app's Prettier the way `app check --format` judges it, or unchanged when the file is ignored or cannot be formatted. */
|
|
5
|
+
export declare function formatted(prettier: Prettier, root: string, file: string, text: string): Promise<string>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadPrettier } from '../sync/format.mjs';
|
|
4
|
+
/** The app's own Prettier, from the first side or the root that installs one, or undefined. */
|
|
5
|
+
export function prettierOf(root, folders) {
|
|
6
|
+
for (const folder of [...folders, root]) {
|
|
7
|
+
try {
|
|
8
|
+
return loadPrettier(folder);
|
|
9
|
+
}
|
|
10
|
+
catch { /* the next folder */ }
|
|
11
|
+
}
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
/** `text` formatted by the app's Prettier the way `app check --format` judges it, or unchanged when the file is ignored or cannot be formatted. */
|
|
15
|
+
export async function formatted(prettier, root, file, text) {
|
|
16
|
+
const ignorePath = path.join(root, '.prettierignore');
|
|
17
|
+
try {
|
|
18
|
+
const info = await prettier.getFileInfo(file, { ignorePath: fs.existsSync(ignorePath) ? ignorePath : undefined, resolveConfig: true });
|
|
19
|
+
if (info.ignored || !info.inferredParser)
|
|
20
|
+
return text;
|
|
21
|
+
const options = (await prettier.resolveConfig(file, { editorconfig: true, useCache: true })) ?? {};
|
|
22
|
+
return await prettier.format(text, { ...options, filepath: file });
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { declarationSurface, isEmittableSource } from '../runtime/scripts/lib/declaration-surface.mjs';
|
|
5
5
|
import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import { formatted, prettierOf } from './app-prettier.mjs';
|
|
7
7
|
import { applyEdits, importEdits } from './explicit-surface-edit.mjs';
|
|
8
8
|
import { conventionType, MAX_TYPE_LENGTH, renderType } from './explicit-surface-render.mjs';
|
|
9
9
|
import { GENERATED, relativePosix, sideFolders } from './source-tree.mjs';
|
|
@@ -212,30 +212,6 @@ async function sideSources(root, side, folder) {
|
|
|
212
212
|
visit(folder);
|
|
213
213
|
return { files: files.sort(), scope: selects === undefined ? 'walk' : 'canon' };
|
|
214
214
|
}
|
|
215
|
-
/** The app's own Prettier, from the first side or the root that installs one, or undefined. */
|
|
216
|
-
function prettierOf(root, folders) {
|
|
217
|
-
for (const folder of [...folders, root]) {
|
|
218
|
-
try {
|
|
219
|
-
return loadPrettier(folder);
|
|
220
|
-
}
|
|
221
|
-
catch { /* the next folder */ }
|
|
222
|
-
}
|
|
223
|
-
return undefined;
|
|
224
|
-
}
|
|
225
|
-
/** `text` formatted by the app's Prettier the way `app check --format` judges it, or unchanged when the file is ignored or cannot be formatted. */
|
|
226
|
-
async function formatted(prettier, root, file, text) {
|
|
227
|
-
const ignorePath = path.join(root, '.prettierignore');
|
|
228
|
-
try {
|
|
229
|
-
const info = await prettier.getFileInfo(file, { ignorePath: fs.existsSync(ignorePath) ? ignorePath : undefined, resolveConfig: true });
|
|
230
|
-
if (info.ignored || !info.inferredParser)
|
|
231
|
-
return text;
|
|
232
|
-
const options = (await prettier.resolveConfig(file, { editorconfig: true, useCache: true })) ?? {};
|
|
233
|
-
return await prettier.format(text, { ...options, filepath: file });
|
|
234
|
-
}
|
|
235
|
-
catch {
|
|
236
|
-
return text;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
215
|
/** The side-relative name a report gives `absolute`. */
|
|
240
216
|
const nameOf = (root, absolute) => relativePosix(root, absolute);
|
|
241
217
|
/** Plans every open file of one program, checks that the rewritten files read no worse than the originals, and returns what each file becomes. */
|
package/codemod/main.mjs
CHANGED
|
@@ -59,10 +59,6 @@ function runCodemod(name, root, write) {
|
|
|
59
59
|
const report = migrateSopsRules({ root, write });
|
|
60
60
|
return { report, markdown: sopsRulesMarkdown(report) };
|
|
61
61
|
}
|
|
62
|
-
if (name === 'module-definition') {
|
|
63
|
-
const report = migrateModuleDefinition({ root, write });
|
|
64
|
-
return { report, markdown: moduleDefinitionMarkdown(report) };
|
|
65
|
-
}
|
|
66
62
|
if (name === 'secrets-cache') {
|
|
67
63
|
const report = migrateSecretsCache({ root, write });
|
|
68
64
|
return { report, markdown: secretsCacheMarkdown(report) };
|
|
@@ -126,6 +122,12 @@ async function explicitSurfaceMain(argv, io) {
|
|
|
126
122
|
const report = await migrateExplicitSurface({ root: path.resolve(io.cwd), write: options.write });
|
|
127
123
|
return emit(options, { report, markdown: explicitSurfaceMarkdown(report) }, io);
|
|
128
124
|
}
|
|
125
|
+
/** `starci app codemod module-definition`: it formats each rewritten definition with the app's Prettier, which is asynchronous. */
|
|
126
|
+
async function moduleDefinitionMain(argv, io) {
|
|
127
|
+
const options = reportArguments(argv);
|
|
128
|
+
const report = await migrateModuleDefinition({ root: path.resolve(io.cwd), write: options.write });
|
|
129
|
+
return emit(options, { report, markdown: moduleDefinitionMarkdown(report) }, io);
|
|
130
|
+
}
|
|
129
131
|
/** `starci app codemod account-doors [--contract]`: the expand migration of the account doors, or (a release later) the contract migration that revokes the old grants. */
|
|
130
132
|
function accountDoorsMain(argv, io) {
|
|
131
133
|
const contract = argv.includes('--contract');
|
|
@@ -145,6 +147,8 @@ export function codemodMain(argv, io) {
|
|
|
145
147
|
return explicitSurfaceMain(rest, io);
|
|
146
148
|
if (name === 'account-doors')
|
|
147
149
|
return accountDoorsMain(rest, io);
|
|
150
|
+
if (name === 'module-definition')
|
|
151
|
+
return moduleDefinitionMain(rest, io);
|
|
148
152
|
if (name === undefined || !CODEMODS.includes(name))
|
|
149
153
|
throw new Error(`starci app codemod takes ${CODEMODS.join(' or ')}`);
|
|
150
154
|
const options = reportArguments(rest);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Prettier } from '../sync/format.mjs';
|
|
1
2
|
/** The TypeScript compiler API of the app's own install (loadTypescript), typed by the version this package develops against. */
|
|
2
3
|
type Ts = typeof import('typescript');
|
|
3
4
|
/** One definition the migration rewrites: which form it was written in, the helper it became and the names it exports. */
|
|
@@ -41,12 +42,18 @@ export declare function transformModuleDefinition({ ts, text, file }: {
|
|
|
41
42
|
changes: ModuleDefinitionChange[];
|
|
42
43
|
findings: ModuleDefinitionFinding[];
|
|
43
44
|
};
|
|
44
|
-
/**
|
|
45
|
-
export
|
|
45
|
+
/** What `migrateModuleDefinition` takes: the app root, whether to write, and the compiler and formatter of the app (found in it when left out; `prettier: null` writes the codemod's own layout). */
|
|
46
|
+
export interface ModuleDefinitionOptions {
|
|
46
47
|
root: string;
|
|
47
48
|
write?: boolean;
|
|
48
49
|
ts?: Ts | null;
|
|
49
|
-
|
|
50
|
+
prettier?: Prettier | null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Preview or apply the rewrite in one app: every `*.module-definition.ts` under its sides, each rewritten file formatted by the app's Prettier
|
|
54
|
+
* (so `app check --format` and the app's hook accept it as written), then the dependency in the root package.json.
|
|
55
|
+
*/
|
|
56
|
+
export declare function migrateModuleDefinition({ root, write, ts, prettier }: ModuleDefinitionOptions): Promise<ModuleDefinitionReport>;
|
|
50
57
|
/** Render the rewrite and what is left for a person as Markdown; JSON consumers read the same report object. */
|
|
51
58
|
export declare function moduleDefinitionMarkdown(report: ModuleDefinitionReport): string;
|
|
52
59
|
export {};
|
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { bundledPins } from '../runtime/scripts/hfs/canon-pins.mjs';
|
|
5
5
|
import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
|
|
6
|
+
import { formatted, prettierOf } from './app-prettier.mjs';
|
|
6
7
|
import { filesUnder, relativePosix, sideFolders } from './source-tree.mjs';
|
|
7
8
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
9
|
const KIT = '@starci/nest-kit';
|
|
@@ -118,7 +119,7 @@ function readDefinition(ts, source) {
|
|
|
118
119
|
drop.add(statement);
|
|
119
120
|
if (at === null) {
|
|
120
121
|
at = statement;
|
|
121
|
-
doc = docOf(ts, source, statement);
|
|
122
|
+
doc = docOf(ts, source, statement) || docOf(ts, source, builtStatement);
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
if (at === null || names.length === 0)
|
|
@@ -130,25 +131,34 @@ function readDefinition(ts, source) {
|
|
|
130
131
|
return unfit(source, builtStatement, 'MODULE_DEFINITION_NAMES', 'another statement of the file reads the builder result or `Host`');
|
|
131
132
|
return { ...read, names: [...names].sort((a, b) => [...EXPORTED].indexOf(a) - [...EXPORTED].indexOf(b)), doc, drop, at, from: 'typed builder' };
|
|
132
133
|
}
|
|
133
|
-
/** The one export of the new definition, laid out the way Prettier prints it at the width of the canon (
|
|
134
|
+
/** The one export of the new definition, laid out the way Prettier prints it at the width of the canon (one line; else the options object opens on the line of the `=` when that fits; else the call breaks after the `=`). */
|
|
134
135
|
function exportLine(reading, helper) {
|
|
135
136
|
const head = `export const { ${reading.names.join(', ')} } =`;
|
|
136
137
|
const call = `${helper}<${reading.options}>(${reading.token === null ? '' : `{ optionsInjectionToken: ${reading.token} }`})`;
|
|
137
138
|
if (`${head} ${call}`.length <= PRINT_WIDTH)
|
|
138
139
|
return `${head} ${call}`;
|
|
139
|
-
|
|
140
|
+
const hug = `${head} ${helper}<${reading.options}>({`;
|
|
141
|
+
return reading.token !== null && hug.length <= PRINT_WIDTH ? `${hug}\n optionsInjectionToken: ${reading.token},\n})` : `${head}\n ${call}`;
|
|
140
142
|
}
|
|
141
|
-
/** The
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
/** The words of `text` (identifier characters), so a binding is known to be read by its name. */
|
|
144
|
+
const wordsOf = (text) => new Set(text.match(/[\w$]+/g));
|
|
145
|
+
/**
|
|
146
|
+
* An import declaration without the named bindings the rewrite made unused (read by the dropped statements, by none of the kept ones), or null
|
|
147
|
+
* when nothing is left of it; `import type` and inline `type` stay as written.
|
|
148
|
+
*/
|
|
149
|
+
function prunedImport(ts, source, statement, before, after) {
|
|
150
|
+
const clause = statement.importClause;
|
|
151
|
+
const bindings = clause?.namedBindings;
|
|
152
|
+
if (clause === undefined || bindings === undefined || !ts.isNamedImports(bindings))
|
|
145
153
|
return statement.getText(source);
|
|
146
|
-
const kept = bindings.elements.filter((element) =>
|
|
154
|
+
const kept = bindings.elements.filter((element) => after.has(element.name.text) || !before.has(element.name.text));
|
|
147
155
|
if (kept.length === bindings.elements.length)
|
|
148
156
|
return statement.getText(source);
|
|
149
|
-
if (kept.length === 0 &&
|
|
157
|
+
if (kept.length === 0 && clause.name === undefined)
|
|
150
158
|
return null;
|
|
151
|
-
|
|
159
|
+
const named = kept.length === 0 ? '' : `{ ${kept.map((element) => element.getText(source)).join(', ')} }`;
|
|
160
|
+
const head = [clause.name?.text, named].filter((part) => part !== undefined && part !== '').join(', ');
|
|
161
|
+
return `import ${clause.isTypeOnly ? 'type ' : ''}${head} from ${statement.moduleSpecifier.getText(source)}`;
|
|
152
162
|
}
|
|
153
163
|
/** Rewrite one definition file's text to the helper form; `text` unchanged when the file holds no definition the codemod can rewrite. */
|
|
154
164
|
export function transformModuleDefinition({ ts, text, file = 'x.module-definition.ts' }) {
|
|
@@ -162,13 +172,11 @@ export function transformModuleDefinition({ ts, text, file = 'x.module-definitio
|
|
|
162
172
|
if ('reason' in read)
|
|
163
173
|
return { ...none, findings: [{ file, line: read.line, code: read.code, reason: read.reason }] };
|
|
164
174
|
const helper = read.representative ? 'defineCapabilityModule' : 'defineConfigurableModule';
|
|
165
|
-
const imports = [`import { ${helper} } from "${KIT}"`];
|
|
166
175
|
const body = [];
|
|
176
|
+
const declarations = [];
|
|
167
177
|
for (const statement of source.statements) {
|
|
168
178
|
if (ts.isImportDeclaration(statement)) {
|
|
169
|
-
|
|
170
|
-
if (nest !== null)
|
|
171
|
-
imports.push(nest);
|
|
179
|
+
declarations.push(statement);
|
|
172
180
|
continue;
|
|
173
181
|
}
|
|
174
182
|
if (read.drop.has(statement))
|
|
@@ -179,6 +187,9 @@ export function transformModuleDefinition({ ts, text, file = 'x.module-definitio
|
|
|
179
187
|
}
|
|
180
188
|
body.push(text.slice(statement.pos, statement.end).replace(/^\s*\n/, '').trimEnd());
|
|
181
189
|
}
|
|
190
|
+
const before = wordsOf(source.statements.filter((statement) => !ts.isImportDeclaration(statement)).map((statement) => statement.getText(source)).join('\n'));
|
|
191
|
+
const after = wordsOf(body.join('\n'));
|
|
192
|
+
const imports = [`import { ${helper} } from "${KIT}"`, ...declarations.flatMap((statement) => prunedImport(ts, source, statement, before, after) ?? [])];
|
|
182
193
|
const output = `${imports.join('\n')}\n\n${body.join('\n\n')}\n`;
|
|
183
194
|
return { text: output, changes: [{ file, from: read.from, helper, names: read.names }], findings: [] };
|
|
184
195
|
}
|
|
@@ -186,13 +197,17 @@ export function transformModuleDefinition({ ts, text, file = 'x.module-definitio
|
|
|
186
197
|
function kitVersion() {
|
|
187
198
|
return bundledPins()[KIT]?.version ?? '0.1.0';
|
|
188
199
|
}
|
|
189
|
-
/**
|
|
190
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Preview or apply the rewrite in one app: every `*.module-definition.ts` under its sides, each rewritten file formatted by the app's Prettier
|
|
202
|
+
* (so `app check --format` and the app's hook accept it as written), then the dependency in the root package.json.
|
|
203
|
+
*/
|
|
204
|
+
export async function migrateModuleDefinition({ root, write = false, ts = loadTypescript(root, PACKAGE_ROOT), prettier }) {
|
|
191
205
|
if (!ts)
|
|
192
206
|
throw new Error('module-definition needs TypeScript installed in the app or @starci/hfs development checkout');
|
|
193
207
|
const folders = sideFolders(root);
|
|
194
208
|
if (!folders.length)
|
|
195
209
|
throw new Error('module-definition runs at an app root with a be/ directory');
|
|
210
|
+
const formatter = prettier === undefined ? prettierOf(root, folders) : prettier ?? undefined;
|
|
196
211
|
const packageFile = path.join(root, 'package.json');
|
|
197
212
|
const manifest = fs.existsSync(packageFile) ? JSON.parse(fs.readFileSync(packageFile, 'utf8')) : null; // reason: package.json parsed from disk, only dependencies is read
|
|
198
213
|
const declared = manifest?.dependencies?.[KIT] !== undefined;
|
|
@@ -207,7 +222,7 @@ export function migrateModuleDefinition({ root, write = false, ts = loadTypescri
|
|
|
207
222
|
if (result.text !== original) {
|
|
208
223
|
report.filesChanged.push(file);
|
|
209
224
|
if (write)
|
|
210
|
-
fs.writeFileSync(target, result.text);
|
|
225
|
+
fs.writeFileSync(target, formatter === undefined ? result.text : await formatted(formatter, root, target, result.text));
|
|
211
226
|
}
|
|
212
227
|
}
|
|
213
228
|
if (report.filesChanged.length > 0 && !declared && manifest !== null) {
|
package/package.json
CHANGED
|
@@ -38,7 +38,7 @@ pins:
|
|
|
38
38
|
source: packages/next-data/package.json
|
|
39
39
|
why: 'the SWR data helpers of every front end (fe-stack data, owner:next-data-package): pollingRestart, the viewer and tenant key helpers, viewerSwrConfig and the viewer cache; each operation hook stays in the app and the package holds no component.'
|
|
40
40
|
'@starci/grammar':
|
|
41
|
-
version: 0.21.
|
|
41
|
+
version: 0.21.16
|
|
42
42
|
group: starci
|
|
43
43
|
install: registry
|
|
44
44
|
side: fe
|
|
@@ -52,28 +52,28 @@ pins:
|
|
|
52
52
|
source: packages/next-i18n/package.json
|
|
53
53
|
why: 'the next-intl stack of every front end (fe-stack i18n, owner:next-i18n-package): defineI18n over the locales of the app, the locale-negotiating proxy, the locale-aware navigation, the request config that loads the catalogs of the app and the locale read of the [locale] layout; the app keeps its message catalogs and writes none of the stack.'
|
|
54
54
|
'@starci/eslint-canon-be':
|
|
55
|
-
version: 3.14.
|
|
55
|
+
version: 3.14.1
|
|
56
56
|
group: starci
|
|
57
57
|
install: registry
|
|
58
58
|
side: be
|
|
59
59
|
source: packages/eslint-canon-be/package.json
|
|
60
60
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
61
61
|
'@starci/eslint-canon-fe':
|
|
62
|
-
version: 9.12.
|
|
62
|
+
version: 9.12.1
|
|
63
63
|
group: starci
|
|
64
64
|
install: registry
|
|
65
65
|
side: fe
|
|
66
66
|
source: packages/eslint-canon-fe/package.json
|
|
67
67
|
why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
|
|
68
68
|
'@starci/eslint-grammar-core':
|
|
69
|
-
version: 0.21.
|
|
69
|
+
version: 0.21.16
|
|
70
70
|
group: starci
|
|
71
71
|
install: registry
|
|
72
72
|
side: fe
|
|
73
73
|
source: packages/eslint-grammar-core/package.json
|
|
74
74
|
why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
|
|
75
75
|
'@starci/eslint-grammar-offset-pop':
|
|
76
|
-
version: 0.21.
|
|
76
|
+
version: 0.21.16
|
|
77
77
|
group: starci
|
|
78
78
|
install: registry
|
|
79
79
|
side: fe
|
|
@@ -127,14 +127,14 @@ pins:
|
|
|
127
127
|
source: packages/nest-kit/package.json
|
|
128
128
|
why: 'the module definition of every back-end module: defineConfigurableModule and defineCapabilityModule return the module base class, the options token and the options types with their types stated, so a <x>.module-definition.ts is one call that the lint on stated export types (R368) and the duplicate-code check (R21) both accept; @starci/jest-preset compiles it as an ESM-only dependency.'
|
|
129
129
|
'@starci/auth':
|
|
130
|
-
version: 0.2.
|
|
130
|
+
version: 0.2.1
|
|
131
131
|
group: starci
|
|
132
132
|
install: registry
|
|
133
133
|
side: both
|
|
134
134
|
source: packages/auth/package.json
|
|
135
135
|
why: 'the lite sign-in: the NestJS module (EdDSA access tokens with a multi-key JWKS, passwords, Google OAuth with state and PKCE, rotating refresh sessions, the sign-in budget, the refresh cookie), the front-end client and its React bindings, the door contracts and test doubles; the one importer of the identity libraries, reached by a back end only through integrations/passport; @starci/jest-preset compiles it as an ESM-only dependency.'
|
|
136
136
|
'@starci/auth-oidc':
|
|
137
|
-
version: 0.1.
|
|
137
|
+
version: 0.1.1
|
|
138
138
|
group: starci
|
|
139
139
|
install: registry
|
|
140
140
|
side: both
|
|
@@ -155,7 +155,7 @@ pins:
|
|
|
155
155
|
source: packages/mail/package.json
|
|
156
156
|
why: 'outgoing mail of a back end: a NestJS module and the Mailer port over SMTP (connect, greeting, socket and total timeouts, STARTTLS required by default, recipient, message and attachment checks, a shared SMTP target policy, coded errors, typed escaping render helpers returning { subject, text, html }), with @starci/mail/testing for its specs; the only importer of nodemailer (R90).'
|
|
157
157
|
'@starci/hfs':
|
|
158
|
-
version: 5.
|
|
158
|
+
version: 5.4.0
|
|
159
159
|
group: starci
|
|
160
160
|
install: registry
|
|
161
161
|
side: both
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** The TypeScript compiler API of the app's own install, typed by the version this package develops against. */
|
|
2
|
+
type Ts = typeof import('typescript');
|
|
3
|
+
/** The refusal code of a held group. */
|
|
4
|
+
export declare const GROUP_HELD = "HFS_SYNC_GROUP_HELD";
|
|
5
|
+
/** The pseudo-group of the retired files a sync deletes. */
|
|
6
|
+
export declare const RETIRED_GROUP = "retired";
|
|
7
|
+
/** One write the guard judges: an app-relative path, its managed group, and its new text (null: the file is deleted). */
|
|
8
|
+
export interface GuardedWrite {
|
|
9
|
+
path: string;
|
|
10
|
+
group: string;
|
|
11
|
+
text: string | null;
|
|
12
|
+
}
|
|
13
|
+
/** A group sync does not write, with its files and the errors its writes would add. */
|
|
14
|
+
export interface HeldGroup {
|
|
15
|
+
group: string;
|
|
16
|
+
files: string[];
|
|
17
|
+
errors: string[];
|
|
18
|
+
}
|
|
19
|
+
/** What the guard answers: the held groups, and the sides it could not compile. */
|
|
20
|
+
export interface GuardVerdict {
|
|
21
|
+
held: HeldGroup[];
|
|
22
|
+
unchecked: string[];
|
|
23
|
+
}
|
|
24
|
+
/** Whether `file` (app-relative) is TypeScript source a side compiles. */
|
|
25
|
+
export declare const isGuardedSource: (file: string) => boolean;
|
|
26
|
+
/** The tsconfig that compiles an app-relative source: `be/tsconfig.json`, or the front-end app's or package's own; null when there is none. */
|
|
27
|
+
export declare function projectOf(root: string, file: string): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* The groups `starci app sync --execute` holds back at `root`: every write of a TypeScript source is compiled, per side, with the side's own
|
|
30
|
+
* TypeScript (`loadTs`, the app's install by default); a side with no tsconfig compiles nothing, a side whose TypeScript is not installed is
|
|
31
|
+
* named in `unchecked`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function guardWrites(root: string, writes: readonly GuardedWrite[], loadTs?: (dir: string) => Ts | null): GuardVerdict;
|
|
34
|
+
/** The lines sync prints for a held group: what is held and why, the first errors, and what to do. */
|
|
35
|
+
export declare function heldLines(group: HeldGroup): string[];
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// compile-guard.mjs - HFS_SYNC_GROUP_HELD: `starci app sync --execute` never writes a render that leaves the app uncompilable. Before it writes
|
|
2
|
+
// a managed TypeScript source (or deletes a retired one), the side's own TypeScript compiles the app with the writes laid over the disk and
|
|
3
|
+
// compares the diagnostics of every file they touch, and of every file that imports one, with the same files on disk. A managed group whose
|
|
4
|
+
// writes add an error (a template that reads a name the app does not provide yet, or drops one the app still imports) is held whole: none
|
|
5
|
+
// of its files is written, the rest of the render is, and the sync names the group, its files and the errors, so the app provides what the
|
|
6
|
+
// render reads and runs sync again. A side whose TypeScript is not installed cannot be compiled; its writes go through and say so.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
|
|
10
|
+
/** The refusal code of a held group. */
|
|
11
|
+
export const GROUP_HELD = 'HFS_SYNC_GROUP_HELD';
|
|
12
|
+
/** The pseudo-group of the retired files a sync deletes. */
|
|
13
|
+
export const RETIRED_GROUP = 'retired';
|
|
14
|
+
/** How many errors a held group prints. */
|
|
15
|
+
const SHOWN_ERRORS = 20;
|
|
16
|
+
const SOURCE = /\.tsx?$/;
|
|
17
|
+
/** Whether `file` (app-relative) is TypeScript source a side compiles. */
|
|
18
|
+
export const isGuardedSource = (file) => SOURCE.test(file) && !file.endsWith('.d.ts') && (file.startsWith('be/') || file.startsWith('fe/'));
|
|
19
|
+
/** The tsconfig that compiles an app-relative source: `be/tsconfig.json`, or the front-end app's or package's own; null when there is none. */
|
|
20
|
+
export function projectOf(root, file) {
|
|
21
|
+
const parts = file.split('/');
|
|
22
|
+
const project = parts[0] === 'be' ? 'be/tsconfig.json' : parts.length > 3 ? `fe/${parts[1]}/${parts[2]}/tsconfig.json` : null;
|
|
23
|
+
return project !== null && fs.existsSync(path.join(root, project)) ? project : null;
|
|
24
|
+
}
|
|
25
|
+
/** One side's compilation: its compiler, options and root files, the overlay of writes keyed the way the host compares file names. */
|
|
26
|
+
class Side {
|
|
27
|
+
#ts;
|
|
28
|
+
#root;
|
|
29
|
+
#options;
|
|
30
|
+
#rootNames;
|
|
31
|
+
#baseline = new Map();
|
|
32
|
+
#program;
|
|
33
|
+
constructor(ts, root, project) {
|
|
34
|
+
this.#ts = ts;
|
|
35
|
+
this.#root = root;
|
|
36
|
+
const file = path.join(root, project);
|
|
37
|
+
const read = ts.readConfigFile(file, ts.sys.readFile);
|
|
38
|
+
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(file), { noEmit: true }, file);
|
|
39
|
+
this.#options = { ...parsed.options, noEmit: true };
|
|
40
|
+
this.#rootNames = parsed.fileNames;
|
|
41
|
+
}
|
|
42
|
+
/** The name the host compares: absolute, forward slashes, lower case where the file system ignores case. */
|
|
43
|
+
key(file) {
|
|
44
|
+
const absolute = path.resolve(this.#root, file).replaceAll('\\', '/');
|
|
45
|
+
return this.#ts.sys.useCaseSensitiveFileNames ? absolute : absolute.toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
#host(overlay) {
|
|
48
|
+
const host = this.#ts.createCompilerHost(this.#options, true);
|
|
49
|
+
const { readFile, fileExists, getSourceFile } = host;
|
|
50
|
+
host.readFile = (name) => (overlay.has(this.key(name)) ? overlay.get(this.key(name)) ?? undefined : readFile.call(host, name));
|
|
51
|
+
host.fileExists = (name) => (overlay.has(this.key(name)) ? overlay.get(this.key(name)) !== null : fileExists.call(host, name));
|
|
52
|
+
host.getSourceFile = (name, language, onError, create) => {
|
|
53
|
+
if (!overlay.has(this.key(name)))
|
|
54
|
+
return getSourceFile.call(host, name, language, onError, create);
|
|
55
|
+
const text = overlay.get(this.key(name));
|
|
56
|
+
return text === null || text === undefined ? undefined : this.#ts.createSourceFile(name, text, language, true);
|
|
57
|
+
};
|
|
58
|
+
return host;
|
|
59
|
+
}
|
|
60
|
+
#programOf(overlay, added) {
|
|
61
|
+
const rootNames = [...this.#rootNames.filter((name) => overlay.get(this.key(name)) !== null), ...added];
|
|
62
|
+
return this.#ts.createProgram({ rootNames, options: this.#options, host: this.#host(overlay), ...(this.#program === undefined ? {} : { oldProgram: this.#program }) });
|
|
63
|
+
}
|
|
64
|
+
/** The files of the program that import one of `targets` (keys), each with the targets it imports. */
|
|
65
|
+
importers(targets, overlay) {
|
|
66
|
+
const host = this.#host(overlay);
|
|
67
|
+
const cache = this.#ts.createModuleResolutionCache(this.#root, (name) => this.key(name), this.#options);
|
|
68
|
+
const found = new Map();
|
|
69
|
+
for (const name of this.#rootNames) {
|
|
70
|
+
const text = host.readFile(name);
|
|
71
|
+
if (text === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
for (const imported of this.#ts.preProcessFile(text, true, true).importedFiles) {
|
|
74
|
+
const resolved = this.#ts.resolveModuleName(imported.fileName, name, this.#options, host, cache).resolvedModule?.resolvedFileName;
|
|
75
|
+
if (resolved === undefined || !targets.has(this.key(resolved)))
|
|
76
|
+
continue;
|
|
77
|
+
const into = found.get(this.key(name)) ?? new Set();
|
|
78
|
+
into.add(this.key(resolved));
|
|
79
|
+
found.set(this.key(name), into);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return found;
|
|
83
|
+
}
|
|
84
|
+
/** The errors of `files` (keys) as `path TScode message`, each file once, in the program with `overlay` laid over the disk. */
|
|
85
|
+
errors(files, overlay, added) {
|
|
86
|
+
const program = this.#programOf(overlay, added);
|
|
87
|
+
if (overlay.size === 0)
|
|
88
|
+
this.#program = program;
|
|
89
|
+
const byKey = new Map(program.getSourceFiles().map((source) => [this.key(source.fileName), source]));
|
|
90
|
+
const answer = new Map();
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
const source = byKey.get(file);
|
|
93
|
+
const relative = path.relative(this.#root, source?.fileName ?? file).replaceAll('\\', '/');
|
|
94
|
+
const found = source === undefined ? [] : [...program.getSyntacticDiagnostics(source), ...program.getSemanticDiagnostics(source)];
|
|
95
|
+
answer.set(file, found.map((diagnostic) => `${relative}${source === undefined || diagnostic.start === undefined ? '' : `:${String(source.getLineAndCharacterOfPosition(diagnostic.start).line + 1)}`} TS${String(diagnostic.code)} ${this.#ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}`));
|
|
96
|
+
}
|
|
97
|
+
return answer;
|
|
98
|
+
}
|
|
99
|
+
/** The errors of `files` on disk, computed once per file. */
|
|
100
|
+
baseline(files) {
|
|
101
|
+
const missing = files.filter((file) => !this.#baseline.has(file));
|
|
102
|
+
if (missing.length > 0)
|
|
103
|
+
for (const [file, found] of this.errors(missing, new Map(), []))
|
|
104
|
+
this.#baseline.set(file, found);
|
|
105
|
+
return new Map(files.map((file) => [file, this.#baseline.get(file) ?? []]));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** An error without its line, so a line moved by the write is the same error. */
|
|
109
|
+
const unplaced = (error) => error.replace(/^([^ ]*?):\d+ /, '$1 ');
|
|
110
|
+
/** The errors of `after` that `before` does not hold, counted as a multiset of unplaced errors. */
|
|
111
|
+
function added(before, after) {
|
|
112
|
+
const left = new Map();
|
|
113
|
+
for (const error of before)
|
|
114
|
+
left.set(unplaced(error), (left.get(unplaced(error)) ?? 0) + 1);
|
|
115
|
+
return after.filter((error) => {
|
|
116
|
+
const count = left.get(unplaced(error)) ?? 0;
|
|
117
|
+
left.set(unplaced(error), count - 1);
|
|
118
|
+
return count <= 0;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** The held groups of one side: compile with every write of the groups still in play, hold the groups that add an error, again until none does. */
|
|
122
|
+
function judgeSide(side, writes) {
|
|
123
|
+
const held = [];
|
|
124
|
+
let open = [...writes];
|
|
125
|
+
while (open.length > 0) {
|
|
126
|
+
const overlay = new Map(open.map((write) => [side.key(write.path), write.text]));
|
|
127
|
+
const groupOf = new Map(open.map((write) => [side.key(write.path), write.group]));
|
|
128
|
+
// An importer is found on the disk with the new files added, so the importer of a file the writes delete is found too.
|
|
129
|
+
const importers = side.importers(new Set(overlay.keys()), new Map([...overlay].filter(([, text]) => text !== null)));
|
|
130
|
+
const files = [...new Set([...[...overlay.keys()].filter((file) => overlay.get(file) !== null), ...importers.keys()])];
|
|
131
|
+
const before = side.baseline(files);
|
|
132
|
+
const after = side.errors(files, overlay, open.filter((write) => write.text !== null).map((write) => path.resolve(write.path)));
|
|
133
|
+
const errors = new Map();
|
|
134
|
+
for (const file of files) {
|
|
135
|
+
const fresh = added(before.get(file) ?? [], after.get(file) ?? []);
|
|
136
|
+
if (fresh.length === 0)
|
|
137
|
+
continue;
|
|
138
|
+
const groups = groupOf.has(file) ? [groupOf.get(file)] : [...(importers.get(file) ?? [])].map((target) => groupOf.get(target));
|
|
139
|
+
for (const group of groups)
|
|
140
|
+
errors.set(group, [...(errors.get(group) ?? []), ...fresh]);
|
|
141
|
+
}
|
|
142
|
+
if (errors.size === 0)
|
|
143
|
+
break;
|
|
144
|
+
for (const [group, found] of errors)
|
|
145
|
+
held.push({ group, files: open.filter((write) => write.group === group).map((write) => write.path), errors: [...new Set(found)] });
|
|
146
|
+
open = open.filter((write) => !errors.has(write.group));
|
|
147
|
+
}
|
|
148
|
+
return held;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The groups `starci app sync --execute` holds back at `root`: every write of a TypeScript source is compiled, per side, with the side's own
|
|
152
|
+
* TypeScript (`loadTs`, the app's install by default); a side with no tsconfig compiles nothing, a side whose TypeScript is not installed is
|
|
153
|
+
* named in `unchecked`.
|
|
154
|
+
*/
|
|
155
|
+
export function guardWrites(root, writes, loadTs = (dir) => loadTypescript(dir, root)) {
|
|
156
|
+
const verdict = { held: [], unchecked: [] };
|
|
157
|
+
const bySide = new Map();
|
|
158
|
+
for (const write of writes.filter((candidate) => isGuardedSource(candidate.path))) {
|
|
159
|
+
const project = projectOf(root, write.path);
|
|
160
|
+
if (project !== null)
|
|
161
|
+
bySide.set(project, [...(bySide.get(project) ?? []), { ...write, path: path.join(root, write.path) }]);
|
|
162
|
+
}
|
|
163
|
+
for (const [project, sideWrites] of bySide) {
|
|
164
|
+
const ts = loadTs(path.join(root, path.dirname(project)));
|
|
165
|
+
if (ts === null) {
|
|
166
|
+
verdict.unchecked.push(project);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
for (const group of judgeSide(new Side(ts, root, project), sideWrites)) {
|
|
170
|
+
const relative = group.files.map((file) => path.relative(root, file).replaceAll('\\', '/'));
|
|
171
|
+
const known = verdict.held.find((entry) => entry.group === group.group);
|
|
172
|
+
if (known === undefined)
|
|
173
|
+
verdict.held.push({ ...group, files: relative });
|
|
174
|
+
else
|
|
175
|
+
verdict.held.splice(verdict.held.indexOf(known), 1, { group: group.group, files: [...known.files, ...relative], errors: [...known.errors, ...group.errors] });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return verdict;
|
|
179
|
+
}
|
|
180
|
+
/** The lines sync prints for a held group: what is held and why, the first errors, and what to do. */
|
|
181
|
+
export function heldLines(group) {
|
|
182
|
+
const more = group.errors.length > SHOWN_ERRORS ? [` ... ${String(group.errors.length - SHOWN_ERRORS)} more`] : [];
|
|
183
|
+
return [
|
|
184
|
+
`${GROUP_HELD} ${group.group}: ${String(group.files.length)} files not written, the render adds ${String(group.errors.length)} type errors to the app (${group.files.join(', ')})`,
|
|
185
|
+
...group.errors.slice(0, SHOWN_ERRORS).map((error) => ` ${error}`),
|
|
186
|
+
...more,
|
|
187
|
+
` make the app provide what the render reads (each error names it), then run "npx --no-install starci app sync --execute" again`,
|
|
188
|
+
];
|
|
189
|
+
}
|
package/sync/index.d.mts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type RepoView } from '../runtime/scripts/hfs/slots.mjs';
|
|
2
2
|
import type { AppRepo } from '../runtime/scripts/hfs/slot-app-view.mjs';
|
|
3
3
|
import type { Slot, SlotManifest } from '../runtime/scripts/hfs/manifest-shape.mjs';
|
|
4
|
+
import { guardWrites, type GuardedWrite } from './compile-guard.mjs';
|
|
4
5
|
import type { ImageApp } from './stack-images.mjs';
|
|
5
6
|
export declare const TEMPLATES_DIR: string;
|
|
6
7
|
/** The scopes a target is rendered for: the app root, then each side. */
|
|
@@ -22,6 +23,7 @@ export interface Target {
|
|
|
22
23
|
template: string;
|
|
23
24
|
mode: string;
|
|
24
25
|
slot?: string;
|
|
26
|
+
group?: string;
|
|
25
27
|
markdown?: boolean;
|
|
26
28
|
}
|
|
27
29
|
/** The markers around a managed block. */
|
|
@@ -39,6 +41,8 @@ export interface RenderedTarget {
|
|
|
39
41
|
scripts?: Record<string, unknown>;
|
|
40
42
|
markdown?: boolean | undefined;
|
|
41
43
|
markers?: Markers;
|
|
44
|
+
/** The managed group of a slot's file (`managedBy`), which sync writes or holds whole (compile-guard.mjs). */
|
|
45
|
+
group?: string;
|
|
42
46
|
}
|
|
43
47
|
/** What comparing a rendered target with the disk answers. */
|
|
44
48
|
export interface TargetCheck {
|
|
@@ -229,9 +233,13 @@ export declare function renderRepo(root: string, { presets, parseYaml, notices,
|
|
|
229
233
|
export declare function retiredFiles(root: string, hfs: {
|
|
230
234
|
edition?: string;
|
|
231
235
|
} | null | undefined): string[];
|
|
232
|
-
|
|
236
|
+
/** The writes of `targets` and the deletions of `retired` the compile guard judges: every file sync would change, with its managed group. */
|
|
237
|
+
export declare function guardedWrites(root: string, targets: readonly RenderedTarget[], retired: readonly string[]): GuardedWrite[];
|
|
238
|
+
export declare function runSync(argv: string[], { cwd, out, presets, parseYaml, loadTs }?: {
|
|
233
239
|
cwd?: string;
|
|
234
240
|
out?: (line: string) => unknown;
|
|
235
241
|
presets?: Presets | undefined;
|
|
236
242
|
parseYaml?: ParseYaml | undefined;
|
|
243
|
+
/** The TypeScript of a side folder the compile guard compiles with; the app's own install by default. */
|
|
244
|
+
loadTs?: Parameters<typeof guardWrites>[2];
|
|
237
245
|
}): Promise<number>;
|
package/sync/index.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { feAppPackageName } from '../runtime/scripts/hfs/rules/monorepo.mjs';
|
|
|
29
29
|
import { DEFAULT_PORT, NODE_IMAGE, dockerfilePath } from '../runtime/scripts/hfs/rules/docker.mjs';
|
|
30
30
|
import { isFile } from '../runtime/scripts/lib/fs-kind.mjs';
|
|
31
31
|
import { readDeclaredSonarKey } from './sonar-key.mjs';
|
|
32
|
+
import { RETIRED_GROUP, guardWrites, heldLines, isGuardedSource } from './compile-guard.mjs';
|
|
32
33
|
import { readDeclaredStack, stackWorkflowVariables } from './stack-images.mjs';
|
|
33
34
|
import { DB_DOCKERFILE, DB_IMAGE_APP, RETIRED_DB_DOCKERFILE } from '../db/image.mjs';
|
|
34
35
|
import { byCodeUnit } from '../report/order.mjs';
|
|
@@ -99,7 +100,7 @@ function slotTargets(slot, scope, edition, manifest, hasTemplate, enabled) {
|
|
|
99
100
|
const template = managedTemplate(scope, group, file, hasTemplate);
|
|
100
101
|
if (!template)
|
|
101
102
|
throw new SyncError('HFS_SYNC_TEMPLATE_MISSING', `slot ${slot.id} manages ${file} for ${scope}, but templates/{${scope},common}/${group} has no template for it`);
|
|
102
|
-
return { path: file, template, mode: MODE_OF_GROUP[group] ?? 'file', slot: slot.id };
|
|
103
|
+
return { path: file, template, mode: MODE_OF_GROUP[group] ?? 'file', slot: slot.id, group };
|
|
103
104
|
});
|
|
104
105
|
}
|
|
105
106
|
function unlistedTargets(scope, edition) {
|
|
@@ -548,7 +549,7 @@ function renderOnce(hfs, presets, { sonarKey, readTemplate = readBundled, manife
|
|
|
548
549
|
const markers = target.markdown ? { begin: '<!-- >>> starci app sync (managed block; do not edit) >>> -->', end: '<!-- <<< starci app sync <<< -->' } : { begin: BLOCK_BEGIN, end: BLOCK_END };
|
|
549
550
|
const spacing = target.markdown ? '\n' : '';
|
|
550
551
|
const managed = target.mode === 'block' ? `${markers.begin}\n${spacing}${trailingNewline(body)}${spacing}${markers.end}\n` : body;
|
|
551
|
-
return { path: where, scope, mode: target.mode, markdown: target.markdown, markers, content: managed, hash: hashOf(managed) };
|
|
552
|
+
return { path: where, scope, mode: target.mode, markdown: target.markdown, markers, content: managed, hash: hashOf(managed), ...(target.group === undefined ? {} : { group: target.group }) };
|
|
552
553
|
})];
|
|
553
554
|
});
|
|
554
555
|
}
|
|
@@ -714,6 +715,16 @@ export function retiredFiles(root, hfs) {
|
|
|
714
715
|
walk(file);
|
|
715
716
|
return found;
|
|
716
717
|
}
|
|
718
|
+
/** The writes of `targets` and the deletions of `retired` the compile guard judges: every file sync would change, with its managed group. */
|
|
719
|
+
export function guardedWrites(root, targets, retired) {
|
|
720
|
+
const changed = checkTargets(root, targets).filter(result => result.status !== 'ok' && isGuardedSource(result.path));
|
|
721
|
+
const writes = changed.map((result) => {
|
|
722
|
+
const target = targets.find(candidate => candidate.path === result.path);
|
|
723
|
+
const file = path.join(root, target.path);
|
|
724
|
+
return { path: target.path, group: target.group ?? target.path, text: writtenContent(target, fs.existsSync(file) ? lf(fs.readFileSync(file, 'utf8')) : '') };
|
|
725
|
+
});
|
|
726
|
+
return [...writes, ...retired.map((file) => ({ path: file, group: RETIRED_GROUP, text: null }))];
|
|
727
|
+
}
|
|
717
728
|
/** `starci app sync [--execute] [--cwd <dir>]` at the app root: the dry run reports drift, --execute writes; returns 0 clean, 1 drift, 2 usage. */
|
|
718
729
|
function syncOptions(argv) {
|
|
719
730
|
const write = argv.includes('--execute');
|
|
@@ -738,7 +749,7 @@ function reportSyncCheck(root, targets, out, retired = []) {
|
|
|
738
749
|
out(`starci app sync: ${results.length - bad.length} of ${results.length} in sync${bad.length ? '; run "npx --no-install starci app sync --execute"' : ''}`);
|
|
739
750
|
return bad.length || retired.length ? 1 : 0;
|
|
740
751
|
}
|
|
741
|
-
export async function runSync(argv, { cwd = process.cwd(), out = (line) => process.stdout.write(`${line}\n`), presets, parseYaml } = {}) {
|
|
752
|
+
export async function runSync(argv, { cwd = process.cwd(), out = (line) => process.stdout.write(`${line}\n`), presets, parseYaml, loadTs } = {}) {
|
|
742
753
|
const options = syncOptions(argv);
|
|
743
754
|
if (!options) {
|
|
744
755
|
out('usage: starci app sync [--execute] [--cwd <dir>]');
|
|
@@ -754,18 +765,27 @@ export async function runSync(argv, { cwd = process.cwd(), out = (line) => proce
|
|
|
754
765
|
for (const notice of new Set(notices))
|
|
755
766
|
out(notice);
|
|
756
767
|
if (write) {
|
|
757
|
-
const
|
|
768
|
+
const { held, unchecked } = guardWrites(root, guardedWrites(root, targets, retired), loadTs);
|
|
769
|
+
const holds = new Set(held.flatMap(group => group.files));
|
|
770
|
+
const free = targets.filter(target => !holds.has(target.path));
|
|
771
|
+
const written = writeTargets(root, free);
|
|
772
|
+
const removed = retired.filter(file => !holds.has(file));
|
|
758
773
|
for (const file of written)
|
|
759
774
|
out(`wrote ${file}`);
|
|
760
|
-
for (const file of
|
|
775
|
+
for (const file of removed) {
|
|
761
776
|
fs.rmSync(path.join(root, file));
|
|
762
777
|
out(`removed ${file} (retired)`);
|
|
763
778
|
}
|
|
764
779
|
for (const dir of LITE_RETIRED_DIRS)
|
|
765
|
-
if (
|
|
780
|
+
if (removed.length && fs.existsSync(path.join(root, dir)) && !fs.readdirSync(path.join(root, dir)).length)
|
|
766
781
|
fs.rmdirSync(path.join(root, dir));
|
|
767
|
-
|
|
768
|
-
|
|
782
|
+
for (const project of unchecked)
|
|
783
|
+
out(`notice: the writes compiled by ${project} were not type-checked: its TypeScript is not installed (run npm install, then "npx --no-install starci app check")`);
|
|
784
|
+
for (const group of held)
|
|
785
|
+
for (const line of heldLines(group))
|
|
786
|
+
out(line);
|
|
787
|
+
out(`starci app sync: ${written.length} written, ${free.length - written.length} already in sync${holds.size ? `, ${holds.size} held` : ''}`);
|
|
788
|
+
return held.length ? 1 : 0;
|
|
769
789
|
}
|
|
770
790
|
return reportSyncCheck(root, targets, out, retired);
|
|
771
791
|
}
|
|
@@ -25,7 +25,8 @@ end;
|
|
|
25
25
|
$$;
|
|
26
26
|
|
|
27
27
|
-- The device sessions and rotating refresh tokens of @starci/auth. A token is stored by its sha256 only; a rotated token stays,
|
|
28
|
-
-- marked used, so a replay is detected
|
|
28
|
+
-- marked used, so a replay is detected: a replay inside the grace window gets a successor of its own (raced_count bounds how many),
|
|
29
|
+
-- an older one ends the session and every token of it.
|
|
29
30
|
-- identity_attempts counts the sign-in attempts of one address in a window every replica shares (the budget of failures).
|
|
30
31
|
-- The app owns auth.users (db/bootstrap.sql) and the role app_be.
|
|
31
32
|
|
|
@@ -48,6 +49,7 @@ create table public.identity_refresh_tokens (
|
|
|
48
49
|
created_at timestamptz not null default now(),
|
|
49
50
|
expires_at timestamptz not null,
|
|
50
51
|
used_at timestamptz,
|
|
52
|
+
raced_count integer not null default 0,
|
|
51
53
|
constraint identity_refresh_tokens_hash_key unique (token_hash)
|
|
52
54
|
);
|
|
53
55
|
|
|
@@ -31,4 +31,8 @@ export class IdentityRefreshTokenEntity {
|
|
|
31
31
|
/** When the token was rotated away, or null while it is the live one. */
|
|
32
32
|
@Column({ name: "used_at", type: "timestamptz", nullable: true })
|
|
33
33
|
usedAt!: Date | null
|
|
34
|
+
|
|
35
|
+
/** How many successors replays of this token obtained within the grace window after its rotation (bounded). */
|
|
36
|
+
@Column({ name: "raced_count", type: "integer", default: 0 })
|
|
37
|
+
racedCount!: number
|
|
34
38
|
}
|
|
@@ -19,6 +19,7 @@ const row = (usedAt: Date | null): IdentityRefreshTokenEntity => ({
|
|
|
19
19
|
createdAt: NOW,
|
|
20
20
|
expiresAt: LATER,
|
|
21
21
|
usedAt,
|
|
22
|
+
racedCount: 0,
|
|
22
23
|
})
|
|
23
24
|
|
|
24
25
|
const build = async (entityManager: MockEntityManager = mockEntityManager()) => {
|
|
@@ -39,11 +40,11 @@ describe("IdentitySessionStoreService.transaction", () => {
|
|
|
39
40
|
})
|
|
40
41
|
|
|
41
42
|
expect(entityManager.insert).toHaveBeenCalledWith(IdentitySessionEntity, { id: "s-1", userId: "u-1", createdAt: NOW, refreshedAt: NOW, userAgent: "agent", ip: "203.0.113.9" })
|
|
42
|
-
expect(entityManager.insert).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: null })
|
|
43
|
+
expect(entityManager.insert).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: null, racedCount: 0 })
|
|
43
44
|
expect(transaction.commits).toBe(1)
|
|
44
45
|
})
|
|
45
46
|
|
|
46
|
-
it("reads the token for update, marks it used and drops the expired ones", async () => {
|
|
47
|
+
it("reads the token for update, marks it used or raced and drops the expired ones", async () => {
|
|
47
48
|
const { subject, entityManager } = await build(
|
|
48
49
|
mockEntityManager({
|
|
49
50
|
findOne: [[IdentityRefreshTokenEntity, row(NOW)], [IdentityRefreshTokenEntity, null]],
|
|
@@ -56,13 +57,15 @@ describe("IdentitySessionStoreService.transaction", () => {
|
|
|
56
57
|
const missing = await subject.transaction((tx) => tx.findTokenForUpdate("h-2"))
|
|
57
58
|
await subject.transaction(async (tx) => {
|
|
58
59
|
await tx.markTokenUsed("t-1", NOW)
|
|
60
|
+
await tx.markTokenRaced("t-1", 2)
|
|
59
61
|
await tx.deleteExpiredTokens("u-1", NOW)
|
|
60
62
|
})
|
|
61
63
|
|
|
62
|
-
expect(found).toEqual({ id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: NOW })
|
|
64
|
+
expect(found).toEqual({ id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: NOW, racedCount: 0 })
|
|
63
65
|
expect(missing).toBeNull()
|
|
64
66
|
expect(entityManager.findOne).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { where: { tokenHash: "h-1" }, lock: { mode: "pessimistic_write" } })
|
|
65
67
|
expect(entityManager.update).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1" }, { usedAt: NOW })
|
|
68
|
+
expect(entityManager.update).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1" }, { racedCount: 2 })
|
|
66
69
|
expect(entityManager.delete).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { userId: "u-1", expiresAt: LessThan(NOW) })
|
|
67
70
|
})
|
|
68
71
|
|
|
@@ -13,6 +13,7 @@ const recordOf = (row: IdentityRefreshTokenEntity): RefreshTokenRecord => ({
|
|
|
13
13
|
tokenHash: row.tokenHash,
|
|
14
14
|
expiresAt: row.expiresAt,
|
|
15
15
|
usedAt: row.usedAt,
|
|
16
|
+
racedCount: row.racedCount,
|
|
16
17
|
})
|
|
17
18
|
|
|
18
19
|
const transactionOf = (scoped: EntityManager): SessionTransaction => ({
|
|
@@ -34,6 +35,7 @@ const transactionOf = (scoped: EntityManager): SessionTransaction => ({
|
|
|
34
35
|
tokenHash: token.tokenHash,
|
|
35
36
|
expiresAt: token.expiresAt,
|
|
36
37
|
usedAt: null,
|
|
38
|
+
racedCount: 0,
|
|
37
39
|
})
|
|
38
40
|
},
|
|
39
41
|
findTokenForUpdate: async (tokenHash) => {
|
|
@@ -43,6 +45,9 @@ const transactionOf = (scoped: EntityManager): SessionTransaction => ({
|
|
|
43
45
|
markTokenUsed: async (tokenId, at) => {
|
|
44
46
|
await scoped.update(IdentityRefreshTokenEntity, { id: tokenId }, { usedAt: at })
|
|
45
47
|
},
|
|
48
|
+
markTokenRaced: async (tokenId, racedCount) => {
|
|
49
|
+
await scoped.update(IdentityRefreshTokenEntity, { id: tokenId }, { racedCount })
|
|
50
|
+
},
|
|
46
51
|
deleteExpiredTokens: async (userId, before) => {
|
|
47
52
|
await scoped.delete(IdentityRefreshTokenEntity, { userId, expiresAt: LessThan(before) })
|
|
48
53
|
},
|
|
@@ -69,7 +74,7 @@ export class IdentitySessionStoreService implements SessionStore {
|
|
|
69
74
|
await this.entityManager.update(IdentitySessionEntity, { id: sessionId }, { refreshedAt: now })
|
|
70
75
|
}
|
|
71
76
|
|
|
72
|
-
/** Ends one session
|
|
77
|
+
/** Ends one session and revokes every token of it (the foreign key cascades). */
|
|
73
78
|
async closeSession(sessionId: string): Promise<void> {
|
|
74
79
|
await this.entityManager.delete(IdentitySessionEntity, { id: sessionId })
|
|
75
80
|
}
|