@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 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}`)) || !names.some((name) => AUTH_GRANT.test(statementsOf(root, 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 { loadPrettier } from '../sync/format.mjs';
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
- /** Preview or apply the rewrite in one app: every `*.module-definition.ts` under its sides, then the dependency in the root package.json. */
45
- export declare function migrateModuleDefinition({ root, write, ts }: {
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
- }): ModuleDefinitionReport;
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 (break the arguments of a call that takes a token, else after the `=`). */
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
- return reading.token === null ? `${head}\n ${call}` : `${head} ${helper}<${reading.options}>({\n optionsInjectionToken: ${reading.token},\n})`;
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 import declaration of `@nestjs/common` without the builder names, or null when nothing else is imported from it. */
142
- function nestImport(ts, source, statement) {
143
- const bindings = statement.importClause?.namedBindings;
144
- if (bindings === undefined || !ts.isNamedImports(bindings))
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) => !['ConfigurableModuleBuilder', 'ConfigurableModuleHost'].includes((element.propertyName ?? element.name).text));
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 && statement.importClause?.name === undefined)
157
+ if (kept.length === 0 && clause.name === undefined)
150
158
  return null;
151
- return `import { ${kept.map((element) => element.getText(source)).join(', ')} } from "@nestjs/common"`;
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
- const nest = ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === '@nestjs/common' ? nestImport(ts, source, statement) : statement.getText(source);
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
- /** Preview or apply the rewrite in one app: every `*.module-definition.ts` under its sides, then the dependency in the root package.json. */
190
- export function migrateModuleDefinition({ root, write = false, ts = loadTypescript(root, PACKAGE_ROOT) }) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.15
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.0
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.0
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.15
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.15
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.0
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.0
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.3.0
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
- export declare function runSync(argv: string[], { cwd, out, presets, parseYaml }?: {
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 written = writeTargets(root, targets);
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 retired) {
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 (retired.length && fs.existsSync(path.join(root, dir)) && !fs.readdirSync(path.join(root, dir)).length)
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
- out(`starci app sync: ${written.length} written, ${targets.length - written.length} already in sync`);
768
- return 0;
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 (a replay inside the grace window is a racing tab, an older one ends the session).
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; its tokens go with it (the foreign key cascades). */
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
  }