@starci/hfs 4.7.44 → 4.7.45
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 +4 -0
- package/lint/run.mjs +46 -10
- package/package.json +4 -1
- package/release/supabase-bucket.mjs +8 -0
- package/runtime/knowledge/grammars/starci/playbook.yaml +11 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +14 -7
- package/runtime/knowledge/hfs/fe-stack.yaml +12 -1
- package/runtime/knowledge/hfs/rules.yaml +6 -2
- package/runtime/knowledge/hfs/slots.yaml +5 -5
- package/runtime/knowledge/patterns/be/realtime.yaml +71 -12
- package/runtime/modules/kernel/failure-codes.yaml +10 -0
- package/runtime/scripts/api/git/commit-tree.mjs +5 -0
- package/runtime/scripts/api/git/update-ref.mjs +8 -0
- package/runtime/scripts/api/git/write-tree.mjs +5 -0
- package/runtime/scripts/guards/release-definition.mjs +204 -204
- package/runtime/scripts/hfs/architecture/index.mjs +2 -1
- package/runtime/scripts/hfs/architecture/type-context.mjs +38 -23
- package/runtime/scripts/hfs/example-secrets.mjs +61 -8
- package/runtime/scripts/hfs/typescript-programs.mjs +30 -3
- package/runtime/scripts/lib/release-version.mjs +134 -134
- package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +1 -3
- package/templates/be/patterns/idempotency/platform/inbox/inbox.port.ts.tpl +1 -3
- package/templates/be/patterns/realtime/realtime-config.mapper.ts.tpl +32 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.7.45 - 2026-10-26
|
|
4
|
+
|
|
5
|
+
- Changed: the alpha.31.1 round folded into alpha.31; the bundled canon pins and runtime copies follow the final package versions. No rule changes.
|
|
6
|
+
|
|
3
7
|
## 4.7.44 - 2026-10-26
|
|
4
8
|
- Changed: the bundled canon pins follow the lint family bumps (canon-be 3.4.51, canon-fe 9.1.6, Grammar 0.12.10) and the bundled runtime copy carries the wave version. No rule changes.
|
|
5
9
|
|
package/lint/run.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// turbo `lint` task); the root `npm run lint` stays the whole app. There is no second linter: a workspace lint is this lint, scoped.
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
|
-
import { createRequire
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
21
|
import { spawn } from 'node:child_process';
|
|
22
22
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
23
23
|
import { linterReport, mergeReports, sonarReport, sourceRootsOf } from '../report/sonar.mjs';
|
|
@@ -168,17 +168,53 @@ export function canonScope(config) {
|
|
|
168
168
|
};
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/** The conditions the import below resolves with: `node` and `import`, plus `default` as exports' terminal fallback. */
|
|
172
|
+
const IMPORT_CONDITIONS = new Set(['node', 'import', 'default']);
|
|
173
|
+
|
|
174
|
+
/** The first import target of an exports entry: a string, a fallback array, or a conditions object matched in its own key order. */
|
|
175
|
+
function exportsTarget(entry) {
|
|
176
|
+
if (typeof entry === 'string') return entry;
|
|
177
|
+
if (Array.isArray(entry)) {
|
|
178
|
+
for (const item of entry) {
|
|
179
|
+
const target = exportsTarget(item);
|
|
180
|
+
if (target !== null) return target;
|
|
181
|
+
}
|
|
182
|
+
} else if (entry !== null && typeof entry === 'object') {
|
|
183
|
+
for (const [condition, target] of Object.entries(entry)) {
|
|
184
|
+
if (IMPORT_CONDITIONS.has(condition)) {
|
|
185
|
+
const resolved = exportsTarget(target);
|
|
186
|
+
if (resolved !== null) return resolved;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The import entry of a package found by the app's own node_modules lookup (the folders require.resolve.paths lists from cwd). */
|
|
194
|
+
export function importEntry(require, packageName) {
|
|
195
|
+
for (const dir of require.resolve.paths(packageName) ?? []) {
|
|
196
|
+
const manifest = path.join(dir, packageName, 'package.json');
|
|
197
|
+
if (!fs.existsSync(manifest)) continue;
|
|
198
|
+
const published = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
199
|
+
if (published.exports === undefined) return path.join(dir, packageName, published.main ?? 'index.js');
|
|
200
|
+
const exports = published.exports;
|
|
201
|
+
const entry = exports !== null && typeof exports === 'object' && !Array.isArray(exports)
|
|
202
|
+
&& Object.keys(exports).some((key) => key.startsWith('.')) ? exports['.'] : exports;
|
|
203
|
+
const target = exportsTarget(entry);
|
|
204
|
+
if (target === null) throw new Error(`no "exports" import target for "." in ${manifest}`);
|
|
205
|
+
const file = path.join(dir, packageName, target);
|
|
206
|
+
if (!fs.existsSync(file)) throw new Error(`the "exports" import target of ${manifest} does not exist: ${target}`);
|
|
207
|
+
return file;
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`Cannot find package '${packageName}'`);
|
|
210
|
+
}
|
|
211
|
+
|
|
171
212
|
/** Resolve one canon from this app's install; a package from an enclosing repository cannot prove its lint contract. */
|
|
172
|
-
function ownCanonEntry({ repoRoot, cwd }, packageName) {
|
|
213
|
+
export function ownCanonEntry({ repoRoot, cwd }, packageName) {
|
|
173
214
|
const require = createRequire(path.join(cwd, 'package.json'));
|
|
174
|
-
// The entry is imported below, so
|
|
175
|
-
//
|
|
176
|
-
const
|
|
177
|
-
? { ...context, conditions: [...new Set([...context.conditions.filter(condition => condition !== 'require'), 'import'])] }
|
|
178
|
-
: context) });
|
|
179
|
-
let entry;
|
|
180
|
-
try { entry = require.resolve(packageName); }
|
|
181
|
-
finally { hook.deregister(); }
|
|
215
|
+
// The entry is imported below, so the package's import condition applies; require.resolve cannot be told so on every
|
|
216
|
+
// supported Node (its registerHooks resolve hook skips require.resolve before Node 24), so "." is matched directly.
|
|
217
|
+
const entry = importEntry(require, packageName);
|
|
182
218
|
// The canon must be the app's own install: resolved inside the app root, or inside the app's node_modules when that folder is a link to the install
|
|
183
219
|
// (the runtime's examples lint in a sealed copy whose node_modules links the example's own install, scripts/hfs/example-secrets.mjs).
|
|
184
220
|
const ownInstall = [repoRoot, path.join(repoRoot, 'node_modules')].filter((dir) => fs.existsSync(dir)).map((dir) => fs.realpathSync(dir));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/hfs",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.45",
|
|
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",
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"smol-toml": "1.9.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
+
"@typescript-eslint/eslint-plugin": "8.71.0",
|
|
20
|
+
"@typescript-eslint/parser": "8.71.0",
|
|
19
21
|
"@graphql-codegen/cli": "7.4.6",
|
|
20
22
|
"@graphql-codegen/client-preset": "6.2.3",
|
|
21
23
|
"@nestjs/common": "11.2.5",
|
|
@@ -23,6 +25,7 @@
|
|
|
23
25
|
"@nestjs/graphql": "13.4.5",
|
|
24
26
|
"@nestjs/testing": "11.2.5",
|
|
25
27
|
"class-validator": "0.15.1",
|
|
28
|
+
"eslint": "10.11.0",
|
|
26
29
|
"graphql": "16.14.2",
|
|
27
30
|
"prettier": "3.9.6",
|
|
28
31
|
"reflect-metadata": "0.2.2",
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// supabase-bucket.mjs - select storage configuration from a local stack's migrated bucket ids.
|
|
2
|
+
|
|
3
|
+
/** Returns no bucket variable for no ids, selects the sole id, and refuses ambiguous storage with every id listed. */
|
|
4
|
+
export function storageBucketEnv(ids) {
|
|
5
|
+
if (ids.length === 0) return {};
|
|
6
|
+
if (ids.length === 1) return { STORAGE_S3_BUCKET: ids[0] };
|
|
7
|
+
throw new Error(`Local Supabase storage has multiple buckets: ${ids.map((id) => JSON.stringify(id)).join(', ')}; app configuration must name which bucket to use`);
|
|
8
|
+
}
|
|
@@ -187,7 +187,17 @@ shapes:
|
|
|
187
187
|
compose: |
|
|
188
188
|
Every bar control uses `size="control"`, the 2.75rem shared height. Icon uses the family
|
|
189
189
|
size-5 token (20px) throughout the region. SearchField or its trigger shows Ctrl K and opens
|
|
190
|
-
on Ctrl/Cmd+K; inside a surface its variant resolves through the nesting context.
|
|
190
|
+
on Ctrl/Cmd+K; inside a surface its variant resolves through the nesting context. A compact
|
|
191
|
+
language switch in a bar is SegmentedControl size="control" (the HeroUI lg group held to
|
|
192
|
+
2.75rem); md and lg keep HeroUI's own sizes.
|
|
193
|
+
- id: site-chrome-bar
|
|
194
|
+
implements: [FE-APP-SHELL-3]
|
|
195
|
+
compose: |
|
|
196
|
+
A public site composes its bar inside the flow shell, in one module (the scaffold SiteShell):
|
|
197
|
+
WorkspaceShell viewport="flow" header={TopBar brand navigation actions}. navigation is a
|
|
198
|
+
NavLandmark layout="bar" of TextAction links; actions is the language SegmentedControl
|
|
199
|
+
size="control", then the sign-in Button intent="neutral" and the call-to-action Button, all
|
|
200
|
+
size="control". The app right cluster (menu, theme Switch, bell, account) is for the app shell only.
|
|
191
201
|
- id: workspace-identity
|
|
192
202
|
implements: [FE-APP-SHELL-2]
|
|
193
203
|
compose: |
|
|
@@ -22,36 +22,43 @@ pins:
|
|
|
22
22
|
side: fe
|
|
23
23
|
source: packages/heroicons/package.json
|
|
24
24
|
why: 'StarCi custom Heroicons-compatible cuts; eslint-grammar-starci (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
|
|
25
|
+
'@starci/event-stream':
|
|
26
|
+
version: 0.1.0
|
|
27
|
+
group: starci
|
|
28
|
+
install: registry
|
|
29
|
+
side: fe
|
|
30
|
+
source: packages/event-stream/package.json
|
|
31
|
+
why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
|
|
25
32
|
'@starci/grammar':
|
|
26
|
-
version: 0.12.
|
|
33
|
+
version: 0.12.12
|
|
27
34
|
group: starci
|
|
28
35
|
install: registry
|
|
29
36
|
side: fe
|
|
30
37
|
source: packages/grammar/package.json
|
|
31
38
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
32
39
|
'@starci/eslint-canon-be':
|
|
33
|
-
version: 3.4.
|
|
40
|
+
version: 3.4.54
|
|
34
41
|
group: starci
|
|
35
42
|
install: registry
|
|
36
43
|
side: be
|
|
37
44
|
source: packages/eslint/be/package.json
|
|
38
45
|
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.'
|
|
39
46
|
'@starci/eslint-canon-fe':
|
|
40
|
-
version: 9.1.
|
|
47
|
+
version: 9.1.8
|
|
41
48
|
group: starci
|
|
42
49
|
install: registry
|
|
43
50
|
side: fe
|
|
44
51
|
source: packages/eslint/fe/package.json
|
|
45
52
|
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.'
|
|
46
53
|
'@starci/eslint-grammar-starci':
|
|
47
|
-
version: 0.12.
|
|
54
|
+
version: 0.12.12
|
|
48
55
|
group: starci
|
|
49
56
|
install: registry
|
|
50
57
|
side: fe
|
|
51
58
|
source: packages/eslint/grammar-starci/package.json
|
|
52
59
|
why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
|
|
53
60
|
'@starci/eslint-grammar-offset-pop':
|
|
54
|
-
version: 0.12.
|
|
61
|
+
version: 0.12.12
|
|
55
62
|
group: starci
|
|
56
63
|
install: registry
|
|
57
64
|
side: fe
|
|
@@ -91,14 +98,14 @@ pins:
|
|
|
91
98
|
source: packages/test-world/package.json
|
|
92
99
|
why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
|
|
93
100
|
'@starci/cli':
|
|
94
|
-
version: 1.7.
|
|
101
|
+
version: 1.7.45
|
|
95
102
|
group: starci
|
|
96
103
|
install: registry
|
|
97
104
|
side: both
|
|
98
105
|
source: packages/cli/package.json
|
|
99
106
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
100
107
|
'@starci/hfs':
|
|
101
|
-
version: 4.7.
|
|
108
|
+
version: 4.7.45
|
|
102
109
|
group: starci
|
|
103
110
|
install: registry
|
|
104
111
|
side: both
|
|
@@ -82,7 +82,18 @@ concerns:
|
|
|
82
82
|
home: [fe.hooks.socketio]
|
|
83
83
|
pattern: FE-REALTIME
|
|
84
84
|
forbids: [subscriptions-transport-ws, '@supabase/realtime-js', pusher-js, ably]
|
|
85
|
-
why: one socket.io-client singleton per namespace and one side-effect component that dispatches to redux and SWR; both editions run the gateway in the be api process. A one-way channel served as server-sent events
|
|
85
|
+
why: one socket.io-client singleton per namespace and one side-effect component that dispatches to redux and SWR; both editions run the gateway in the be api process. A one-way channel served as server-sent events is the event-stream concern below (FE-REALTIME).
|
|
86
|
+
- id: event-stream
|
|
87
|
+
tier: opt
|
|
88
|
+
packages: ['@starci/event-stream']
|
|
89
|
+
home: [fe.transport.stream]
|
|
90
|
+
pattern: FE-REALTIME
|
|
91
|
+
forbids: ['@microsoft/fetch-event-source', eventsource, event-source-polyfill, eventsource-parser, reconnecting-eventsource, 'sse.js', 'fetch-sse', 'sse-ts']
|
|
92
|
+
why: >-
|
|
93
|
+
the one server-sent-events client (owner:event-stream-package): the app creates one instance in modules/api/sse/clients/event-stream.ts
|
|
94
|
+
with createEventStream (the bearer token source, one renewal on a 401, backoff, heartbeat timeout), each stream operation
|
|
95
|
+
(sse/<area>/stream-<channel>.ts) follows on it, and the useSubscribe<Channel>Swr hook reads that operation. No app keeps a
|
|
96
|
+
hand-written reader (read-event-stream.ts) and no second SSE library is installed.
|
|
86
97
|
- id: auth
|
|
87
98
|
tier: core
|
|
88
99
|
packages: []
|
|
@@ -5576,22 +5576,26 @@ rules:
|
|
|
5576
5576
|
templateNa: "The app composes its shell from product destinations and identity; full and lite example shells provide the reference."
|
|
5577
5577
|
- id: R318
|
|
5578
5578
|
code: "FE_NESTED_VARIANT"
|
|
5579
|
-
law: "Apps compose Grammar with layout-only CSS; paint utilities, arbitrary paint properties and inline style paint belong to Grammar. Grammar context owns all layer-aware field variants, nested Tabs defaults and the content edge of panel lists; apps omit static field variants, do not force primary Tabs inside a painted surface, do not paint raw surfaces, and add no horizontal list-wrapper padding within the nearest painted Grammar surface."
|
|
5579
|
+
law: "Apps compose Grammar with layout-only CSS; paint utilities, arbitrary paint properties and inline style paint belong to Grammar. Grammar context owns all layer-aware field variants, nested Tabs defaults and the content edge of panel lists; apps omit static field variants, do not force primary Tabs inside a painted surface, do not paint raw surfaces, and add no horizontal list-wrapper padding within the nearest painted Grammar surface. BOUNDARY-1 case-5 owns Divider full-bleed composition."
|
|
5580
5580
|
kinds: [lint, design]
|
|
5581
5581
|
gates: [pre-commit, settle, land, ci]
|
|
5582
|
-
failureCodes: ["FE_NESTED_VARIANT", "FE_LIST_PANEL_PADDING", "FE_GRAMMAR_LAYOUT_ONLY"]
|
|
5582
|
+
failureCodes: ["FE_NESTED_VARIANT", "FE_LIST_PANEL_PADDING", "FE_GRAMMAR_LAYOUT_ONLY", "FE_DIVIDER_FULL_BLEED"]
|
|
5583
5583
|
enforcers:
|
|
5584
5584
|
- {kind: eslint-fe, id: nested-control-variant}
|
|
5585
5585
|
- {kind: eslint-fe, id: no-list-panel-padding}
|
|
5586
5586
|
- {kind: eslint-fe, id: no-app-surface-paint}
|
|
5587
|
+
- {kind: eslint-fe, id: divider-full-bleed}
|
|
5587
5588
|
editions: [lite, full]
|
|
5588
5589
|
docs: ["docs/code-pattern-enforcement.md#r318-app-grammar-composition"]
|
|
5589
5590
|
templateNa: "Product composition is proved by the FE canon's Grammar import and surface fixtures; a template cannot choose product slots or lists."
|
|
5590
5591
|
proves:
|
|
5592
|
+
- {path: packages/eslint/fe/divider-full-bleed.spec.mjs, editions: [lite, full], codes: [FE_DIVIDER_FULL_BLEED]}
|
|
5591
5593
|
- {path: packages/eslint/fe/surface-rules.spec.mjs, editions: [lite, full], codes: [FE_NESTED_VARIANT, FE_LIST_PANEL_PADDING, FE_GRAMMAR_LAYOUT_ONLY]}
|
|
5592
5594
|
exemplifiedBy:
|
|
5595
|
+
- {path: packages/eslint/fe/divider-full-bleed.spec.mjs, editions: [lite, full], codes: [FE_DIVIDER_FULL_BLEED]}
|
|
5593
5596
|
- {path: packages/eslint/fe/surface-rules.spec.mjs, editions: [lite, full], codes: [FE_NESTED_VARIANT, FE_LIST_PANEL_PADDING, FE_GRAMMAR_LAYOUT_ONLY]}
|
|
5594
5597
|
witness:
|
|
5598
|
+
- {path: packages/eslint/fe/divider-full-bleed.spec.mjs, editions: [lite, full], codes: [FE_DIVIDER_FULL_BLEED]}
|
|
5595
5599
|
- {path: packages/eslint/fe/surface-rules.spec.mjs, editions: [lite, full], codes: [FE_NESTED_VARIANT, FE_LIST_PANEL_PADDING, FE_GRAMMAR_LAYOUT_ONLY]}
|
|
5596
5600
|
cases:
|
|
5597
5601
|
- id: case-1
|
|
@@ -305,7 +305,7 @@ ruleParams:
|
|
|
305
305
|
- {typeSuffix: Options, names: [options], nameSuffix: Options}
|
|
306
306
|
# R89 BE_SOURCE_FORM: the closed role-suffix vocabulary of a source file name (<kebab-name>.<suffix>.ts; BE-CONVENTION 1.15)
|
|
307
307
|
# and the suffixes that are never a role. index.ts, main.ts, app.module.ts and the migrations of be.persistence are the
|
|
308
|
-
# only names outside it.
|
|
308
|
+
# only names outside it. Typed realtime topic builders use the existing policy role (BE-REALTIME-3 of knowledge/patterns/be/realtime.yaml).
|
|
309
309
|
suffixes: [module, module-definition, decorators, options, config, connection, service, schema, command, query, handler, templates, compensation, saga-state, saga-step, saga, contracts, port, resolver, controller, gateway, webhook, subscription, consumer, cli, event, queue, projection, projection-entity, processor, step, input, type, args, request, response, mapper, policy, entity, sql, rows, error, messages, log-events, cache-keys, guard, interceptor, filter, client, adapter, spec, integration-spec, e2e-spec, contract-spec, global-setup, builder]
|
|
310
310
|
# R47 BE_CONTRACT_UNGUARDED: the world's shape vocabulary. `helper` names the function a contract spec imports to erase the values of a JSON
|
|
311
311
|
# payload (`shapeOf`); a contract spec proves a fake's fixtures by asserting helper(real) equals helper(fixture).
|
|
@@ -1617,7 +1617,7 @@ slots:
|
|
|
1617
1617
|
requires: [index.ts]
|
|
1618
1618
|
# <name>.templates.ts holds the capability's static templates, copy and presets as TS constants (owner ruling 2026-10-13);
|
|
1619
1619
|
# user-editable ones are seed rows, large or user media live in object storage.
|
|
1620
|
-
allows: ["<capability>.module.ts", "<capability>.module-definition.ts", "<capability>.config.ts", "<capability>.options.ts", errors/, messages/, persistence/, policies/, "*.service.ts", "*.service.spec.ts", "*.contracts.ts", "*.templates.ts", "*.schema.ts", "*.schema.spec.ts"]
|
|
1620
|
+
allows: ["<capability>.module.ts", "<capability>.module-definition.ts", "<capability>.config.ts", "<capability>.options.ts", errors/, messages/, persistence/, policies/, "*.service.ts", "*.service.spec.ts", "*.contracts.ts", "*.templates.ts", "*.schema.ts", "*.schema.spec.ts", "*.policy.ts", "*.policy.spec.ts"]
|
|
1621
1621
|
forbids: [testing/, exceptions/, utils/, helpers/, shared/]
|
|
1622
1622
|
tests: unit-beside # <name>.service.spec.ts beside each <name>.service.ts, nothing else
|
|
1623
1623
|
coverage: required
|
|
@@ -2336,8 +2336,8 @@ slots:
|
|
|
2336
2336
|
# The browser's calls to the back end (owner ruling 2026-10-13, fe-stack graphql/rest/codegen, FE-API-GRAPHQL): the auth and
|
|
2337
2337
|
# no-auth Apollo clients with their link chain (graphql/clients), one file per operation (graphql/queries/query-<x>.ts,
|
|
2338
2338
|
# graphql/mutations/mutation-<x>.ts and graphql/subscriptions/subscription-<x>.ts, each with its gql document map and typed request/response in types/), the generated wire types
|
|
2339
|
-
# (graphql/__generated__), the axios calls (rest/<area>/*.ts) for uploads, presigned URLs and auth endpoints and the
|
|
2340
|
-
# server-sent-events streams (sse/<area>/stream-<channel>.ts, one operation per stream, run on the stream
|
|
2339
|
+
# (graphql/__generated__), the axios calls (rest/<area>/*.ts) for uploads, presigned URLs and auth endpoints and the
|
|
2340
|
+
# server-sent-events streams (sse/<area>/stream-<channel>.ts, one operation per stream, run on the app's @starci/event-stream instance in sse/clients). A repository has
|
|
2341
2341
|
# exactly ONE client folder and ONE GraphQLResponse union: the app's (fe.transport.client/outcome) when one app calls the back end,
|
|
2342
2342
|
# or the api package's (fe.package.api.client/outcome) when several do (FE_TRANSPORT_OWNER, machine).
|
|
2343
2343
|
requires: [index.ts]
|
|
@@ -2381,7 +2381,7 @@ slots:
|
|
|
2381
2381
|
tier: transport
|
|
2382
2382
|
allows: ["*.ts"]
|
|
2383
2383
|
tests: none
|
|
2384
|
-
why: "the
|
|
2384
|
+
why: "the event-stream client of the one app that calls the back end, the stream half of the transport client layer (FE-REALTIME): event-stream.ts creates the app's one @starci/event-stream instance (createEventStream with the bearer access token source, which opens the stream with fetch, parses the events and reopens it with Last-Event-ID after a capped, doubling wait); a stream operation (sse/<area>/stream-<channel>.ts) follows on it and a useSubscribe hook of hooks/swr reads that operation; no hand-written reader lives here (owner:event-stream-package) and only a stream operation imports this folder (FE_TRANSPORT_OWNER); a repository where several apps stream keeps one instance in each of them"
|
|
2385
2385
|
rules: [FE_TRANSPORT_OWNER]
|
|
2386
2386
|
exemplifiedBy:
|
|
2387
2387
|
- {path: packages/eslint/fe/r50-transport-owner-coverage.spec.mjs, editions: [lite, full]}
|
|
@@ -7,7 +7,7 @@ purpose: |-
|
|
|
7
7
|
(`transport/sse/<channel>.controller.ts`), a socket gateway (`<channel>.gateway.ts`) or a GraphQL subscription
|
|
8
8
|
(`<x>.subscription.ts`), that READS and PUSHES only. It never writes. The push is fed by a reactor: an event consumer in
|
|
9
9
|
`features/reactors/` receives the domain event and publishes to a topic of the `RealtimeHub` of `platform/realtime` (an app
|
|
10
|
-
with no event bus lets the domain service that commits the change publish
|
|
10
|
+
with no event bus lets the domain service that commits the change publish with the hub's commit ordering, BE-REALTIME-7); the door only subscribes the
|
|
11
11
|
client to a topic of that hub, scoped to the client's principal. SSE is the default door for one-way server-to-client push
|
|
12
12
|
(job progress, a status that changes, streamed tokens), in lite and in full (owner decision 2026-10-24): it is plain HTTP
|
|
13
13
|
behind the ingress, reconnects by itself and resumes from `Last-Event-ID`. The socket.io gateway stays for a channel that also
|
|
@@ -28,7 +28,8 @@ provenance:
|
|
|
28
28
|
- owner decision 2026-10-24 (the lite edition may serve SSE, and SSE is the default door for one-way push; ruling:R212/case-5, amended by owner:lite-graphql-subscription-door and ruling:R212/case-7 for the GraphQL subscription door)
|
|
29
29
|
limitations: |-
|
|
30
30
|
Static checks prove the door's role, that the hub is the only thing injected and that a subscribed topic is built from the
|
|
31
|
-
principal. They do not prove the
|
|
31
|
+
principal. They do not prove the publisher's membership read, that a non-Postgres publish follows the domain commit,
|
|
32
|
+
that the topic naming is collision free across channels, that an initial snapshot is consistent with
|
|
32
33
|
the stream, or the behaviour of the socket under reconnects; the three `realtime/*` scenarios prove delivery, isolation and
|
|
33
34
|
cleanup on the real stack. Nothing proves the ingress settings of an SSE channel (no compression, no buffering, HTTP/2, an
|
|
34
35
|
idle timeout above the heartbeat): they are reviewed against the stack files and a real stream. The memory hub is
|
|
@@ -127,8 +128,8 @@ rules:
|
|
|
127
128
|
needs is pushed by a reactor (or by the domain service that commits the change, in an app with no event bus); the socket
|
|
128
129
|
library and the pub/sub engine (`socket.io`, `@socket.io/postgres-adapter`, `@socket.io/redis-adapter`, `graphql-subscriptions`, `graphql-ws`, `pg` for LISTEN and `ioredis`) are imported only by `platform/realtime`,
|
|
129
130
|
and so is the heartbeat timer (`setInterval` is owned by `platform/realtime`, `infraOwners`). Because the door reads only the
|
|
130
|
-
hub, a snapshot cannot be sent on connect from the door: the client reads the current state through a query operation
|
|
131
|
-
(or again on every open), and the stream carries the changes.
|
|
131
|
+
hub, a snapshot cannot be sent on connect from the door: the client reads the current state through a query operation that
|
|
132
|
+
checks resource membership first (or again on every open), and the stream carries the changes (BE-REALTIME-3).
|
|
132
133
|
rationale: |-
|
|
133
134
|
A door that can only touch the hub cannot change business state, so the biggest risk of a long-lived unauthenticated-looking
|
|
134
135
|
connection, a write through it, is removed by type.
|
|
@@ -143,13 +144,26 @@ rules:
|
|
|
143
144
|
- id: BE-REALTIME-3
|
|
144
145
|
title: The topic is built from the principal
|
|
145
146
|
kind: mandatory
|
|
146
|
-
hfsRules: [R186, R41]
|
|
147
|
+
hfsRules: [R186, R41, R89, R204]
|
|
147
148
|
requirement: |-
|
|
148
149
|
Every `@SubscribeMessage`, `@Subscription` and `@Sse` handler takes `@CurrentPrincipal()` of `domain/identity` and builds the topic it
|
|
149
150
|
passes to `hub.subscribe` from the principal's id (plus a client-supplied id only inside that prefix) with the topic
|
|
150
|
-
builder of the owning domain contract, so one client cannot
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
builder of the owning domain contract, so one client cannot listen to another's channel. A typed realtime topic builder is
|
|
152
|
+
a policy: it lives in `<name>.policy.ts` of slot `be.domain`, is exported through the capability's `index.ts`, and returns
|
|
153
|
+
`RealtimeTopic<Frame>` with the principal-prefixed `name` and the frame's narrowing `accepts` predicate. The named frame
|
|
154
|
+
contract lives in `*.contracts.ts`. The builder and predicate are pure functions of their inputs, with no injection, database
|
|
155
|
+
read or membership lookup; `policy` keeps the logic-role coverage of R204. The publisher and every door import this one
|
|
156
|
+
builder from the owning domain contract, never a hand-written topic string.
|
|
157
|
+
|
|
158
|
+
Resource membership is enforced on the publish side. The domain service that owns the change, or the domain service called
|
|
159
|
+
by a reactor's command, reads the current entitled members and fans out to one principal-prefixed topic per member. A client-supplied room
|
|
160
|
+
or conversation id stays inside that principal's prefix: a non-member can open only its own idle topic, which no publisher
|
|
161
|
+
targets. A removed member's already-open stream stays open but receives no newly published frames from the next publish onward, because
|
|
162
|
+
the refreshed member list no longer targets it. Frames already delivered or retained in its own topic are not revoked;
|
|
163
|
+
replay remains subject to the hub's bounds. Snapshot and history queries independently check current membership before
|
|
164
|
+
returning rows; the door injects only the hub (BE-REALTIME-2), never a membership-reading service or guard.
|
|
165
|
+
|
|
166
|
+
The default-deny guards apply to the door like to any other; an SSE stream is never `@Public`. The
|
|
153
167
|
browser `EventSource` cannot send an `Authorization` header, so the front end reads the stream with `fetch` and the bearer
|
|
154
168
|
access token (an operation of modules/api/sse read by a useSubscribe<Channel>Swr hook); a token in the URL is refused because it ends up in logs. The `Last-Event-ID` header is client
|
|
155
169
|
input like any other: it is passed as `{ after }` to `hub.subscribe` and only picks where the replay resumes inside the
|
|
@@ -160,12 +174,25 @@ rules:
|
|
|
160
174
|
- id: case-1
|
|
161
175
|
when: Subscribing to one order's status
|
|
162
176
|
write: 'hub.subscribe(orderStatusTopic(principal.id, orderId)); the reactor publishes to orderStatusTopic(buyerId, orderId) of the order owner, so a different principal receives nothing.'
|
|
177
|
+
- id: case-2
|
|
178
|
+
when: A room or conversation has several members and membership can change
|
|
179
|
+
write: |-
|
|
180
|
+
The owning domain exports roomMessageTopic(memberId, roomId) from its room-message.policy.ts, returning
|
|
181
|
+
{ name: `room-message:${memberId}:${roomId}`, accepts: isRoomMessageFrame } with the declared return type RealtimeTopic<RoomMessageFrame>.
|
|
182
|
+
The SSE door calls hub.stream(roomMessageTopic(principal.id, roomId), { after: lastEventId }) and injects only the hub.
|
|
183
|
+
The publisher reads the current members and publishes a frame to roomMessageTopic(member.id, roomId) for each of them,
|
|
184
|
+
following the hub's commit ordering (BE-REALTIME-7). A non-member's topic receives nothing; removing a member leaves its
|
|
185
|
+
stream open and excludes it from subsequent fan-out. The snapshot query checks membership in its own read.
|
|
163
186
|
verification:
|
|
164
187
|
automated:
|
|
165
188
|
- BE_REALTIME_TOPIC_SCOPE
|
|
166
189
|
- BE_DEFAULT_DENY
|
|
190
|
+
- BE_SOURCE_FORM
|
|
191
|
+
- HFS_COVERAGE_SCOPE_DRIFT
|
|
167
192
|
manual:
|
|
168
|
-
- Review that the topic
|
|
193
|
+
- Review that the pure topic policy is exported by the owning domain and used by both the publisher and door.
|
|
194
|
+
- Prove the publisher targets exactly the current member list, including the next publish after removing a member; the non-member's stream receives no frame.
|
|
195
|
+
- Prove snapshot and history queries reject a caller whose membership is absent or removed.
|
|
169
196
|
relatedExamples: [realtime]
|
|
170
197
|
- id: BE-REALTIME-4
|
|
171
198
|
title: Three behaviours are proven on the test world
|
|
@@ -301,7 +328,7 @@ rules:
|
|
|
301
328
|
hfsRules: [R90, R266, R155]
|
|
302
329
|
requirement: |-
|
|
303
330
|
Both editions run N api replicas. `platform/realtime` keeps ONE port, `RealtimeHub` (`publish(topic, frame, within?): Promise<void>`
|
|
304
|
-
called by a reactor, or by the domain service that owns the state change
|
|
331
|
+
called by a reactor, or by the domain service that owns the state change with the commit ordering below,
|
|
305
332
|
`subscribe(topic, { after })` for GraphQL and socket doors, `stream(topic, { after })` for the SSE door), so a door never
|
|
306
333
|
changes between a single replica and N replicas: only the hub binding does. Three adapters implement the port:
|
|
307
334
|
`MemoryRealtimeHub` (non-scale: one process, tests and a single-replica dev run, replay from an in-process ring buffer),
|
|
@@ -318,8 +345,16 @@ rules:
|
|
|
318
345
|
replay reads the stream with XRANGE and resumes after the newest entry carrying the id; when the subscriber connection comes
|
|
319
346
|
back the hub reloads each followed topic after the minimum last stream id of its open subscriptions, with the same per-subscription
|
|
320
347
|
duplicate suppression and paused retry policy as Postgres; ioredis, every key and channel carries the
|
|
321
|
-
app prefix `REALTIME_REDIS_PREFIX`).
|
|
322
|
-
`
|
|
348
|
+
app prefix `REALTIME_REDIS_PREFIX`). Only the Postgres hub can publish inside the domain transaction: pass that
|
|
349
|
+
transaction as `within` when the domain write and realtime table use the same database. Redis and memory push immediately,
|
|
350
|
+
even if a caller passes `within`; they do not defer delivery until commit. With either hub the domain service awaits its
|
|
351
|
+
successful transaction before calling `hub.publish(topic, frame)` without `within`. A rollback therefore publishes nothing;
|
|
352
|
+
a publish failure after commit cannot roll back the domain write, and the client resyncs through its membership-checked
|
|
353
|
+
snapshot query. A service whose hub binding can vary uses this after-commit sequence unless it is bound to Postgres.
|
|
354
|
+
The app selects the hub with `REALTIME_HUB=memory|postgres|redis`, parsed once by
|
|
355
|
+
`parseRealtimeConfig` in `platform/realtime/realtime-config.mapper.ts` (the unit-tested mapping role of BE-NAMING-1),
|
|
356
|
+
with its unit spec in `realtime-config.mapper.spec.ts`, and validated at boot; unset, the parser defaults to `redis`
|
|
357
|
+
when `REALTIME_REDIS_URL` is declared (lite
|
|
323
358
|
and full), `postgres` when lite declares no Redis, and never `memory`, so a scaled deploy cannot fall back to the in-process
|
|
324
359
|
hub unseen. All three
|
|
325
360
|
adapters are legal in both editions; select `redis` when Redis is declared (lite with Redis, and full), `postgres` when it is
|
|
@@ -381,6 +416,18 @@ rules:
|
|
|
381
416
|
RealtimeModule.register({ isGlobal: true, ...withConnection(options.realtime, ORDER_ENTITY_MANAGER, options.database.url) })
|
|
382
417
|
// REALTIME_HUB=redis, the secret REALTIME_REDIS_URL, REALTIME_REDIS_DB (and REALTIME_REDIS_PREFIX when the server is shared);
|
|
383
418
|
// Redis 7 or later for the sharded socket.io adapter.
|
|
419
|
+
- id: case-9
|
|
420
|
+
when: A domain service changes state with the Redis or memory hub, or a selectable hub binding
|
|
421
|
+
write: |-
|
|
422
|
+
Collect the frame and entitled recipients while committing the change; publish only after the awaited transaction
|
|
423
|
+
resolves. No publish runs inside its callback, so a rollback sends no frame:
|
|
424
|
+
const push = await this.entityManager.transaction(async (tx) => {
|
|
425
|
+
// Read current membership and write the domain change through tx; return its topic/frame pairs.
|
|
426
|
+
return pendingPushes
|
|
427
|
+
})
|
|
428
|
+
for (const { topic, frame } of push) await this.hub.publish(topic, frame)
|
|
429
|
+
Passing tx to the Redis or memory hub does not make its immediate push transactional. Postgres alone may use
|
|
430
|
+
hub.publish(topic, frame, tx) inside that transaction (case-2).
|
|
384
431
|
- id: case-4
|
|
385
432
|
when: GraphQL subscription, non-scale
|
|
386
433
|
write: |-
|
|
@@ -449,8 +496,14 @@ rules:
|
|
|
449
496
|
manual:
|
|
450
497
|
- Confirm the ingress holds no sticky-session declaration for the api, and that polling is off in the socket.io client configuration.
|
|
451
498
|
- Confirm a Postgres hub connects through a session-mode URL and that the realtime tables are migrations of the app.
|
|
499
|
+
- Prove a domain rollback publishes nothing; with Redis or memory, verify publish follows the awaited commit and a publish failure leaves the committed change intact.
|
|
452
500
|
relatedExamples: [realtime]
|
|
453
501
|
files:
|
|
502
|
+
- path: src/modules/domain/<capability>/<name>.policy.ts
|
|
503
|
+
slot: be.domain
|
|
504
|
+
role: The pure typed topic builder and frame predicate of BE-REALTIME-3, exported by the domain contract for publisher and door.
|
|
505
|
+
spec: src/modules/domain/<capability>/<name>.policy.spec.ts
|
|
506
|
+
optional: true
|
|
454
507
|
- path: src/features/realtime/<channel>/index.ts
|
|
455
508
|
slot: be.feature.realtime
|
|
456
509
|
template: realtime/channel.index.ts.tpl
|
|
@@ -544,6 +597,12 @@ files:
|
|
|
544
597
|
slot: be.platform
|
|
545
598
|
role: RealtimeHub, the one port of the realtime kind (publish, subscribe, stream) and the typed topic; its three adapters are the files below.
|
|
546
599
|
optional: true
|
|
600
|
+
- path: src/modules/platform/realtime/realtime-config.mapper.ts
|
|
601
|
+
slot: be.platform
|
|
602
|
+
template: realtime/realtime-config.mapper.ts.tpl
|
|
603
|
+
role: parseRealtimeConfig maps EnvSource to the hub options at boot; hub-selection logic is a covered mapper role.
|
|
604
|
+
spec: src/modules/platform/realtime/realtime-config.mapper.spec.ts
|
|
605
|
+
optional: true
|
|
547
606
|
- path: src/modules/platform/realtime/memory-realtime-hub.service.ts
|
|
548
607
|
slot: be.platform
|
|
549
608
|
role: "MemoryRealtimeHub, the non-scale adapter: one process, tests and a single-replica dev run, replay from an in-process ring buffer."
|
|
@@ -2348,6 +2348,16 @@ FE_DESTRUCTIVE_CONFIRM_IN_DIALOG:
|
|
|
2348
2348
|
owner: op-retry
|
|
2349
2349
|
kind: check-finding
|
|
2350
2350
|
|
|
2351
|
+
FE_DIVIDER_FULL_BLEED:
|
|
2352
|
+
title: "Horizontal Grammar divider is inset by wrapper padding"
|
|
2353
|
+
title_vi: "Divider bị padding ngang của wrapper làm thụt vào"
|
|
2354
|
+
meaning_vi: "Divider ngang của Grammar phải dài full và sát viền surface."
|
|
2355
|
+
causes_vi:
|
|
2356
|
+
- "Wrapper chứa Divider có padding ngang"
|
|
2357
|
+
nextStep_vi: "Chuyển padding vào các band nội dung cạnh Divider."
|
|
2358
|
+
owner: "op-retry"
|
|
2359
|
+
kind: "check-finding"
|
|
2360
|
+
|
|
2351
2361
|
FE_EFFECT_CLEANUP:
|
|
2352
2362
|
title: "What an effect starts, its cleanup releases"
|
|
2353
2363
|
title_vi: "Effect khởi động thứ không được dọn"
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// commit-tree.mjs — `git commit-tree <args>`: a commit object written from a tree (its sha on stdout).
|
|
2
|
+
import { runGit } from './lib.mjs';
|
|
3
|
+
|
|
4
|
+
/** The spawn result {status, stdout, stderr, error} of `git commit-tree ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
|
|
5
|
+
export const commitTree = (args = [], options = {}) => runGit(['commit-tree', ...args], options);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// update-ref.mjs — `git update-ref [-m <message>] <ref> <sha> [<old>]`: point a ref at a commit (a preserved worktree's
|
|
2
|
+
// refs/heads/preserved/<name>); with `old`, a compare-and-swap that fails when the ref no longer names `old`.
|
|
3
|
+
import { gitRunner } from './lib.mjs';
|
|
4
|
+
|
|
5
|
+
/** {ok, stdout, stderr}. message: the reflog line; old: the sha the ref must still name. git: the caller's runner or null. */
|
|
6
|
+
export function updateRef(repoRoot, ref, sha, { message = null, old = null, git = null } = {}) {
|
|
7
|
+
return gitRunner(git)(['update-ref', ...(message ? ['-m', message] : []), ref, sha, ...(old ? [old] : [])], { cwd: repoRoot });
|
|
8
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// write-tree.mjs — `git write-tree <args>`: a tree object written from the index (its sha on stdout).
|
|
2
|
+
import { runGit } from './lib.mjs';
|
|
3
|
+
|
|
4
|
+
/** The spawn result {status, stdout, stderr, error} of `git write-tree ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
|
|
5
|
+
export const writeTree = (args = [], options = {}) => runGit(['write-tree', ...args], options);
|