@starci/hfs 4.13.2 → 4.15.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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.15.0 - 2026-10-31
4
+
5
+ - Added: R345 `FE_PANE_GRID_CHILD_NOT_PANE`, R346 `FE_PANE_CHILD_NOT_SECTION`, R347 `FE_PANE_SECTION_PADDING` and R348 `FE_PANE_GRID_FRAME` (eslint-grammar-core `pane-grid-children`, `pane-children`, `no-pane-section-padding`, `pane-grid-page`) and R349 `FE_HAND_BUILT_PANES` (eslint-canon-fe `no-hand-built-panes`) in the bundled rule catalog.
6
+ - Changed: the bundled canon pins name the cohort hfs 4.15.0, eslint-canon-be 3.11.0, eslint-canon-fe 9.7.0, @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.17.0.
7
+
8
+ ## 4.14.0 - 2026-10-31
9
+
10
+ - Added: `ruleParams.be.sqlPassThrough` and hfs.json `sides.be.sqlPassThrough` (a list of `{file, reason}`, side-relative forward-slash paths, unique): the files whose `.query(` passes through SQL text that is not the app's own (R334). The shape validators (`manifest-shape`, `rule-params-shape`, `declaration-shape`) accept it on the be side only, the resolver merges the app's list into the canon's like `dbFunctions`, and the canon lists the seed runner `src/modules/platform/database/seed-runner.service.ts`.
11
+ - Changed: R334 `BE_PERSISTENCE_NO_RAW_SQL` is one style: lite TypeScript holds no SQL text outside the test world (entities, or a listed function through `callDbFunction`; the world keeps raw SQL for probes only), the failure code text follows, R68 names the call and LISTEN statements, and the `be.tests.world` slot allows `<name>.sql.ts`.
12
+ - Changed: the lite skeleton's `platform/database/database.client.ts` writes no literal `.query(` but the call template: `advisoryXactLock` is `callDbFunction` of `hashtextextended(key, 0)` then `pg_advisory_xact_lock(hash)`, and `notify` is `callDbFunction` of `pg_notify`; its spec follows.
13
+ - Changed: the bundled canon pins name the cohort hfs 4.14.0, eslint-canon-be 3.11.0, eslint-canon-fe 9.6.7, @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.16.6.
14
+
15
+ ## 4.13.3 - 2026-10-31
16
+
17
+ - Changed: the canon pins name @starci/ai-router 0.5.0 (many providers behind one virtual key, the persisted key blacklist) and the cohort that carries them: hfs 4.13.3, eslint-canon-be 3.10.1, eslint-canon-fe 9.6.6, eslint-grammar-core and eslint-grammar-offset-pop 0.16.6; no rule changed.
18
+
3
19
  ## 4.13.2 - 2026-10-31
4
20
 
5
21
  - Added: R344 `BE_AI_VIRTUAL_KEY` in the bundled rule catalog and on the `be.integrations` slot: an app's ai-router gateway has one client key, `STARCI_AI_KEY`, read by `integrations/ai`, and no admin token; `@starci/eslint-canon-be` `ai-virtual-key-name` enforces it (ruling be-ai-one-virtual-key).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.13.2",
3
+ "version": "4.15.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",
@@ -31,35 +31,35 @@ pins:
31
31
  source: packages/event-stream/package.json
32
32
  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.'
33
33
  '@starci/grammar':
34
- version: 0.16.5
34
+ version: 0.17.0
35
35
  group: starci
36
36
  install: registry
37
37
  side: fe
38
38
  source: packages/grammar/package.json
39
39
  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.'
40
40
  '@starci/eslint-canon-be':
41
- version: 3.10.0
41
+ version: 3.11.0
42
42
  group: starci
43
43
  install: registry
44
44
  side: be
45
45
  source: packages/eslint-canon-be/package.json
46
46
  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.'
47
47
  '@starci/eslint-canon-fe':
48
- version: 9.6.5
48
+ version: 9.7.0
49
49
  group: starci
50
50
  install: registry
51
51
  side: fe
52
52
  source: packages/eslint-canon-fe/package.json
53
53
  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.'
54
54
  '@starci/eslint-grammar-core':
55
- version: 0.16.5
55
+ version: 0.17.0
56
56
  group: starci
57
57
  install: registry
58
58
  side: fe
59
59
  source: packages/eslint-grammar-core/package.json
60
60
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
61
61
  '@starci/eslint-grammar-offset-pop':
62
- version: 0.16.5
62
+ version: 0.17.0
63
63
  group: starci
64
64
  install: registry
65
65
  side: fe
@@ -99,14 +99,14 @@ pins:
99
99
  source: packages/test-world/package.json
100
100
  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. A fake declared https (defineHttpFake) serves TLS with the run''s ephemeral in-memory fake CA and publishes its PEM as w.fake.<name>.ca for the app''s own client to trust; verification is never turned off. 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.'
101
101
  '@starci/ai-router':
102
- version: 0.4.1
102
+ version: 0.5.0
103
103
  group: starci
104
104
  install: registry
105
105
  side: be
106
106
  source: packages/ai-router/package.json
107
107
  why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
108
108
  '@starci/hfs':
109
- version: 4.13.2
109
+ version: 4.15.0
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -1407,7 +1407,7 @@ rules:
1407
1407
  witness:
1408
1408
  - {"path":"packages/eslint/be/legacy-sql-http-log-proof.spec.mjs","edition":"lite","codes":["BE_SQL_INTERPOLATED"]}
1409
1409
  - {"path":"packages/eslint/be/legacy-sql-http-log-proof.spec.mjs","edition":"full","codes":["BE_SQL_INTERPOLATED"]}
1410
- law: "SQL text carries no runtime substitution; values are numbered parameters. The one exception is R334's lite call statement in platform/database/database.client.ts, which splices only the name parts and parameter list its own validator checked."
1410
+ law: "SQL text carries no runtime substitution; values are numbered parameters. The one exception is R334's call and LISTEN statements in platform/database/database.client.ts, which splice only the name parts and parameter list its own validator checked."
1411
1411
  kinds: [lint, codemod]
1412
1412
  gates: [pre-commit, settle, land, ci]
1413
1413
  failureCodes: ["BE_SQL_INTERPOLATED"]
@@ -5828,7 +5828,7 @@ rules:
5828
5828
  ruling: "Lite entities only map tables the migrations create, so each entity column is checked against the migrated schema by starci app db drift, which CI runs in the build job and the release gate runs as its db-drift row."
5829
5829
  - id: R334
5830
5830
  code: "BE_PERSISTENCE_NO_RAW_SQL"
5831
- law: "Lite domain, feature and test code reads and writes through TypeORM entities on an EntityManager (domain code the injected one, integration and e2e specs, builders and fixtures the world's `world.db.<connection>`) (find, findOne, findBy, count, exists, insert, save, update, delete, upsert, increment, and createQueryBuilder(Entity, alias) with named parameters for joins, aggregates, FOR UPDATE / SKIP LOCKED and jsonb or vector operators; never a table-name builder, a builder with no entity or a Repository); no .query( call and no sql template outside the two raw SQL owners, platform/database and the test world src/tests/world, which wraps only what entities cannot express (a trigger, a database function, a row-level-security probe, a pgmq queue) in a named, typed world helper specs call; a database function TypeORM cannot express is called, in domain and test code alike, through the callDbFunction, advisoryXactLock and notify helpers of platform/database/database.client.ts, and callDbFunction names only a function ruleParams.be.dbFunctions or hfs.json sides.be.dbFunctions lists with a reason: a function of a migration schema-qualified (public.job_claim), a pg built-in bare (pg_notify, called in pg_catalog). That file alone composes the call statement, from the name parts its validator checked and double-quotes and a numbered parameter list. Entities only map tables db/migrations created (synchronize stays false); entity-versus-schema drift is BE_ENTITY_SCHEMA_DRIFT of starci app db drift."
5831
+ law: "Lite TypeScript holds no SQL text, outside the test world. A table is read and written through its TypeORM entity on an EntityManager (domain code the injected one; specs, builders, fixtures and the test world the world's `world.db.<connection>`): find, save, update, delete, upsert, a pessimistic `lock` option, `createQueryBuilder(Entity, \"alias\")` with named parameters; never a table-name builder or a Repository. Every table a migration creates has an entity. Anything else is a database function, called through `callDbFunction` of `platform/database/database.client.ts`, which names only a function that `ruleParams.be.dbFunctions` or hfs.json `sides.be.dbFunctions` lists with a reason: a function of a migration, schema-qualified (`public.job_claim`); an extension function, schema-qualified; a pg built-in of the canon's list, bare (`pg_notify`, called in pg_catalog). `database.client.ts` alone composes SQL text (the call statement and LISTEN), from parts its validator checked; `advisoryXactLock` and `notify` are `callDbFunction` calls of built-ins there. The only other `.query(` outside the world is the pass-through of a file that `ruleParams.be.sqlPassThrough` or hfs.json `sides.be.sqlPassThrough` lists with a reason, whose text is not the app's own (the tracked seed, a customer data-source read, an outside session's setup), with a plain string first argument, never a template or a `+`. The test world `src/tests/world` may hold raw SQL, as `sql`-tagged text with numbered `$n` parameters (and `<name>.sql.ts` files), only for what neither an entity nor a listed function expresses: a privilege or row-level-security probe, catalog, cron or trigger introspection, a pgmq queue table, a lock or failure probe; in the world too plain CRUD on an application table goes through its entity, an application function through `callDbFunction`, and seeded rows come from builders (R98). No `sql` template, `*.sql.ts`, `sql`/`ident` import or other `.query(` exists anywhere else."
5832
5832
  kinds: [lint]
5833
5833
  gates: [pre-commit, settle, land, ci]
5834
5834
  failureCodes: ["BE_PERSISTENCE_NO_RAW_SQL"]
@@ -6069,3 +6069,118 @@ rules:
6069
6069
  source: owner
6070
6070
  quote: "Xóa ai api key và openclaw ai key chỉnh thành starci ai key là key ảo"
6071
6071
  ruling: "Per app and environment the ai member holds the upstream provider keys and one random virtual key STARCI_AI_KEY that the gateway mounts and every client uses; the gateway has no admin API (ruling be-ai-one-virtual-key)."
6072
+ - id: R345
6073
+ code: "FE_PANE_GRID_CHILD_NOT_PANE"
6074
+ law: "A child of a Grammar PaneGrid is a literal Pane element, written directly or returned by a conditional (`cond ? <Pane/> : null`, `cond && <Pane/>`) or a `.map`; a wrapper component, a raw element, a fragment or a variable is refused, because PaneGrid places each child by the area of a Pane and drops the rest, leaving its cell blank. A Freestyle subtree leaves the raw elements inside it alone."
6075
+ kinds: [lint]
6076
+ gates: [pre-commit, settle, land, ci]
6077
+ failureCodes: ["FE_PANE_GRID_CHILD_NOT_PANE"]
6078
+ enforcers:
6079
+ - {kind: eslint-grammar-core, id: pane-grid-children}
6080
+ editions: [lite, full]
6081
+ docs: [contexts/ui/components/pane-grid.md]
6082
+ templateNa: "A template draws no pane grid; an app adds one with its multi-pane screens."
6083
+ proves:
6084
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_CHILD_NOT_PANE]}
6085
+ exemplifiedBy:
6086
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_CHILD_NOT_PANE]}
6087
+ witness:
6088
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_CHILD_NOT_PANE]}
6089
+ cases:
6090
+ - id: case-1
6091
+ date: "2026-10-31"
6092
+ source: owner
6093
+ quote: "trò thiết kế hệ thống padding đồng nhất cho grammar chứ mỗi nơi 1 padding trò thấy k"
6094
+ ruling: "A Pane owns its content inset and every band in it is a PaneSection; a PaneGrid holds Panes only, so the lint refuses a wrapper component that the grid would drop."
6095
+ - id: R346
6096
+ code: "FE_PANE_CHILD_NOT_SECTION"
6097
+ law: "A Grammar Pane holds PaneSection elements or one nested PaneGrid; a raw element, or a Grammar component other than PaneSection, PaneGrid and Freestyle, placed directly in a Pane skips the inset the pane publishes to its bands. A project component is judged in its own file, and a Freestyle subtree leaves its raw elements alone."
6098
+ kinds: [lint]
6099
+ gates: [pre-commit, settle, land, ci]
6100
+ failureCodes: ["FE_PANE_CHILD_NOT_SECTION"]
6101
+ enforcers:
6102
+ - {kind: eslint-grammar-core, id: pane-children}
6103
+ editions: [lite, full]
6104
+ docs: [contexts/ui/components/pane-grid.md]
6105
+ templateNa: "A template draws no pane grid; an app adds one with its multi-pane screens."
6106
+ proves:
6107
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_CHILD_NOT_SECTION]}
6108
+ exemplifiedBy:
6109
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_CHILD_NOT_SECTION]}
6110
+ witness:
6111
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_CHILD_NOT_SECTION]}
6112
+ cases:
6113
+ - id: case-1
6114
+ date: "2026-10-31"
6115
+ source: owner
6116
+ quote: "trò thiết kế hệ thống padding đồng nhất cho grammar chứ mỗi nơi 1 padding trò thấy k"
6117
+ ruling: "A Pane owns its content inset and every band in it is a PaneSection whose boxes align to that edge, so the lint refuses a band hand-built in a pane."
6118
+ - id: R347
6119
+ code: "FE_PANE_SECTION_PADDING"
6120
+ law: "The outermost element a Grammar PaneSection renders, and the root of a same-file component returned as its child, carries no padding or margin utility (`p-*`, `px-*`, `py-*`, `ps/pe/pt/pb/pl/pr-*`, `m-*`, `mx/my/ms/me/mt/mb/ml/mr-*`, their negative forms, or an arbitrary padding or margin property; zero and auto values place or reset and are not one), because the Pane owns the content inset and the section applies it. A Freestyle subtree leaves its raw elements alone."
6121
+ kinds: [lint]
6122
+ gates: [pre-commit, settle, land, ci]
6123
+ failureCodes: ["FE_PANE_SECTION_PADDING"]
6124
+ enforcers:
6125
+ - {kind: eslint-grammar-core, id: no-pane-section-padding}
6126
+ editions: [lite, full]
6127
+ docs: [contexts/ui/components/pane-grid.md]
6128
+ templateNa: "A template draws no pane grid; an app adds one with its multi-pane screens."
6129
+ proves:
6130
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_SECTION_PADDING]}
6131
+ exemplifiedBy:
6132
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_SECTION_PADDING]}
6133
+ witness:
6134
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_SECTION_PADDING]}
6135
+ cases:
6136
+ - id: case-1
6137
+ date: "2026-10-31"
6138
+ source: owner
6139
+ quote: "trò thiết kế hệ thống padding đồng nhất cho grammar chứ mỗi nơi 1 padding trò thấy k"
6140
+ ruling: "One padding system for everything placed in a PaneGrid: the Pane publishes the inset and the PaneSection applies it, so no band adds its own."
6141
+ - id: R348
6142
+ code: "FE_PANE_GRID_FRAME"
6143
+ law: "A Grammar PaneGrid is the one child of `Page chrome=\"panes\"` or stands inside a Pane; a PaneGrid drawn in a FillViewport, a SurfaceCard, SurfaceListCard or SurfaceAccordionCard, a Page of another chrome, or wrapped inside `Page chrome=\"panes\"` re-decides the frame and the bottom edge that Page owns. A grid whose frame lives in another file is judged where the frame is, and a Freestyle subtree is not judged."
6144
+ kinds: [lint]
6145
+ gates: [pre-commit, settle, land, ci]
6146
+ failureCodes: ["FE_PANE_GRID_FRAME"]
6147
+ enforcers:
6148
+ - {kind: eslint-grammar-core, id: pane-grid-page}
6149
+ editions: [lite, full]
6150
+ docs: [contexts/ui/components/pane-grid.md]
6151
+ templateNa: "A template draws no pane grid; an app adds one with its multi-pane screens."
6152
+ proves:
6153
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_FRAME]}
6154
+ exemplifiedBy:
6155
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_FRAME]}
6156
+ witness:
6157
+ - {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_FRAME]}
6158
+ cases:
6159
+ - id: case-1
6160
+ date: "2026-10-31"
6161
+ source: owner
6162
+ quote: "sao trang lan agent có gap 1 khoảng trên vậy??? … phải dính vào chứ"
6163
+ ruling: "A tabbed app page's PaneGrid attaches flush under the tab line, full width and to the bottom, with no card, radius or gap: Page chrome=\"panes\"."
6164
+ - id: R349
6165
+ code: "FE_HAND_BUILT_PANES"
6166
+ law: "A multi-pane frame is a Grammar PaneGrid of Panes, which draws the dividers between panes; a vertical Divider used as a column seam (among the plain columns of a flex row, or anywhere inside a SurfaceCard composition=\"joined\") and a joined SurfaceCard that holds a flex row of two or more plain columns are a pane frame drawn by hand and are refused. A short vertical separator between controls and a row whose classes cannot be read in the file are not judged."
6167
+ kinds: [lint]
6168
+ gates: [pre-commit, settle, land, ci]
6169
+ failureCodes: ["FE_HAND_BUILT_PANES"]
6170
+ enforcers:
6171
+ - {kind: eslint-fe, id: no-hand-built-panes}
6172
+ editions: [lite, full]
6173
+ docs: [contexts/ui/components/pane-grid.md]
6174
+ templateNa: "A template draws no multi-pane frame; an app adds one with its multi-pane screens."
6175
+ proves:
6176
+ - {path: packages/eslint-canon-fe/src/surface-rules.spec.ts, editions: [lite, full], codes: [FE_HAND_BUILT_PANES]}
6177
+ exemplifiedBy:
6178
+ - {path: packages/eslint-canon-fe/src/surface-rules.spec.ts, editions: [lite, full], codes: [FE_HAND_BUILT_PANES]}
6179
+ witness:
6180
+ - {path: packages/eslint-canon-fe/src/surface-rules.spec.ts, editions: [lite, full], codes: [FE_HAND_BUILT_PANES]}
6181
+ cases:
6182
+ - id: case-1
6183
+ date: "2026-10-31"
6184
+ source: owner
6185
+ quote: "sao trang lan agent có gap 1 khoảng trên vậy??? … phải dính vào chứ"
6186
+ ruling: "A multi-pane frame is the Grammar's PaneGrid, never columns and a vertical Divider composed by hand inside a card."
@@ -467,6 +467,10 @@ ruleParams:
467
467
  - {name: public.event_bus_bury, reason: "platform/event-bus (add webhook, lite): the move of an exhausted or malformed message to its consumer's dead-letter queue"}
468
468
  - {name: public.event_bus_dead_letters, reason: "platform/event-bus (add webhook, lite): the operator read of a consumer's dead-letter queue"}
469
469
  - {name: public.event_bus_requeue, reason: "platform/event-bus (add webhook, lite): the operator requeue of one dead letter"}
470
+ # R334 (lite): the only `.query(` outside platform/database's composer and the test world is the pass-through of a file listed here
471
+ # (or in hfs.json sides.be.sqlPassThrough), whose SQL text is not the app's own. Paths are relative to the side folder.
472
+ sqlPassThrough:
473
+ - {file: src/modules/platform/database/seed-runner.service.ts, reason: "the tracked seed file's text, run as it is (db/seed.sql); the app writes no SQL there"}
470
474
 
471
475
  lite:
472
476
  schemaAuthority: sql
@@ -2070,7 +2074,7 @@ slots:
2070
2074
  own; use-test-world.ts exports useTestWorld({ modules: [...] }) -> world.db.<connection> (the shared EntityManager) and
2071
2075
  world.fake.<provider> (a network-edge fake of an external SaaS). There is no world.apps (no e2e) and no world.infra
2072
2076
  (no self-hosted service to cut)
2073
- allows: [global-setup.ts, global-teardown.ts, use-test-world.ts, "fakes/<provider>/server.ts", fakes/] # jest globalSetup/globalTeardown default-export (Jest API); fakes/<provider>/server.ts is the fake server of a provider, a literal file name BE_SOURCE_FORM reads as its role; plus root files with a role suffix (test-world-files, R47)
2077
+ allows: [global-setup.ts, global-teardown.ts, use-test-world.ts, "fakes/<provider>/server.ts", fakes/, "<name>.sql.ts"] # jest globalSetup/globalTeardown default-export (Jest API); fakes/<provider>/server.ts is the fake server of a provider, a literal file name BE_SOURCE_FORM reads as its role; plus root files with a role suffix (test-world-files, R47)
2074
2078
  tests: none
2075
2079
  coverage: none
2076
2080
  why: >-
@@ -5634,12 +5634,12 @@ LITE_DB_MIGRATION:
5634
5634
  kind: check-finding
5635
5635
 
5636
5636
  BE_PERSISTENCE_NO_RAW_SQL:
5637
- title: "Lite domain, feature or test code runs raw SQL instead of TypeORM entities"
5638
- title_vi: "Mã domain, feature hoặc test của app lite chạy SQL thô thay vì entity TypeORM"
5639
- meaning_vi: "File `<path>` gọi `.query(` hoặc dùng template `sql` ngoài hai chủ của SQL thô là platform/database và test world src/tests/world, hoặc gọi callDbFunction với một hàm không có trong ruleParams.be.dbFunctions hay hfs.json sides.be.dbFunctions."
5637
+ title: "Lite TypeScript runs or holds SQL text outside the test world"
5638
+ title_vi: "TypeScript của app lite chạy hoặc chứa văn bản SQL ngoài test world"
5639
+ meaning_vi: "File `<path>` gọi `.query(`, dùng template `sql`, là file `*.sql.ts`, import `sql`/`ident`/`SqlText`/`SqlIdent` ngoài src/tests/world (trừ bộ soạn câu lệnh của platform/database/database.client.ts và file có tên trong ruleParams.be.sqlPassThrough hay hfs.json sides.be.sqlPassThrough), gọi callDbFunction với một hàm không có trong ruleParams.be.dbFunctions hay hfs.json sides.be.dbFunctions, hoặc trong test world viết SQL thô cho điều entity hoặc hàm đã khai báo diễn đạt được (gọi hàm, ghi bảng ứng dụng)."
5640
5640
  causes_vi:
5641
- - "Vi phạm R334: dữ liệu của app lite, kể cả spec, builder và fixture, đi qua entity TypeORM trên EntityManager (find, save, update, upsert, createQueryBuilder); spec dùng world.db.<connection>."
5642
- nextStep_vi: "Đọc/ghi bằng entity trên EntityManager hoặc createQueryBuilder với tham số có tên (trong test là world.db.<connection>); hàm database TypeORM không diễn đạt được thì gọi qua callDbFunction với hàm đã khai kèm lý do; điều entity không diễn đạt được trong test (trigger, thăm dò row-level security, hàng đợi pgmq) thì gọi một helper có tên của src/tests/world."
5641
+ - "Vi phạm R334: dữ liệu của app lite, kể cả spec, builder, fixture và test world, đi qua entity TypeORM trên EntityManager (find, save, update, upsert, createQueryBuilder); hàm database đi qua callDbFunction; SQL thô chỉ còn ở test world cho thăm dò (quyền, row-level security, catalog, cron, trigger, bảng pgmq, khóa/lỗi)."
5642
+ nextStep_vi: "Đọc/ghi bằng entity trên EntityManager hoặc createQueryBuilder với tham số có tên (trong test là world.db.<connection>); hàm database thì gọi qua callDbFunction với hàm đã khai kèm lý do; file chỉ chuyển tiếp văn bản SQL không phải của app thì khai trong hfs.json sides.be.sqlPassThrough kèm lý do; thăm dò chỉ test world mới được viết SQL thô."
5643
5643
  owner: op-retry
5644
5644
  kind: check-finding
5645
5645
 
@@ -1,6 +1,6 @@
1
1
  import { isPlainObject } from '../../engine/plain-object.mjs';
2
2
  import { APP_KIND, connectionShapeProblems, NAME, PACKAGE_NAME, RUNTIME_KIND, SLOT_ID } from './manifest-shape.mjs';
3
- import { dbFunctionsOk } from './rule-params-shape.mjs';
3
+ import { dbFunctionsOk, sqlPassThroughOk } from './rule-params-shape.mjs';
4
4
  import { kindShapeProblems, patternShapeProblems } from './declaration-slots.mjs';
5
5
  import { byCodeUnit } from '../lib/list.mjs';
6
6
  /** Shape problems of the optional hfs.json `db` block of a lite app: the environment variable naming its database, its image, forced RLS and its application role. */
@@ -91,10 +91,12 @@ function declarationSideProblems(side, value, names) {
91
91
  if (!isPlainObject(value))
92
92
  return [`${at} must be an object`];
93
93
  for (const key of Object.keys(value))
94
- if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', 'dbFunctions'].includes(key))
94
+ if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', 'dbFunctions', 'sqlPassThrough'].includes(key))
95
95
  bad.push(`${at} has unknown key ${key}`);
96
96
  if (value.dbFunctions !== undefined && (side !== 'be' || !dbFunctionsOk(value.dbFunctions, { bare: true })))
97
97
  bad.push(`${at}.dbFunctions belongs to the be side and lists {name, reason} database functions, each schema-qualified (schema.name, never pg_catalog) or a bare pg built-in of the canon's list, with unique names`);
98
+ if (value.sqlPassThrough !== undefined && (side !== 'be' || !sqlPassThroughOk(value.sqlPassThrough)))
99
+ bad.push(`${at}.sqlPassThrough belongs to the be side and lists {file, reason} files relative to the side folder, forward slashes, unique, each with the reason its SQL text is not the app's own`);
98
100
  bad.push(...appShapeProblems(value.apps, at, side, names));
99
101
  if (isBadUniqueList(value.optionalSlots, (slot) => SLOT_ID.test(String(slot))))
100
102
  bad.push(`${at}.optionalSlots must be a unique list of slot ids`);
@@ -92,6 +92,11 @@ export interface DbFunction {
92
92
  name: string;
93
93
  reason: string;
94
94
  }
95
+ /** A file whose `.query(` passes through SQL text that is not the app's own (ruleParams.be.sqlPassThrough, hfs.json sides.be.sqlPassThrough). */
96
+ export interface SqlPassThrough {
97
+ file: string;
98
+ reason: string;
99
+ }
95
100
  /** The rule parameters of one profile (ruleParams.common merged with ruleParams.<profile>); the keys not named here are parsed manifest data. */
96
101
  export interface RuleParams {
97
102
  [key: string]: Parsed;
@@ -107,6 +112,7 @@ export interface RuleParams {
107
112
  suffixes?: string[];
108
113
  bannedSuffixes?: string[];
109
114
  dbFunctions?: DbFunction[];
115
+ sqlPassThrough?: SqlPassThrough[];
110
116
  }
111
117
  /** ruleParams.be: the back end's own parameters, which the validators of this folder require. */
112
118
  export interface BeRuleParams extends RuleParams {
@@ -6,5 +6,10 @@ import type { Parsed } from './manifest-shape.mjs';
6
6
  export declare const dbFunctionsOk: (list: Parsed, { bare }?: {
7
7
  bare?: boolean;
8
8
  }) => boolean;
9
+ /**
10
+ * True for an absent list or a list of {file, reason} SQL pass-through files (R334): `file` is a forward-slash path relative to the
11
+ * side folder (no leading slash, no `..`), unique, and the reason names whose text the file passes through.
12
+ */
13
+ export declare const sqlPassThroughOk: (list: Parsed) => boolean;
9
14
  /** The shape problems of `m.ruleParams`. */
10
15
  export declare function ruleParamsProblems(m: Parsed): string[];
@@ -73,6 +73,11 @@ const dbFunctionNameOk = (name, bare) => typeof name === 'string' && (/^[a-z_][a
73
73
  * a bare name, which only the canon's list (ruleParams.be.dbFunctions) uses, for the pg built-ins its helpers call.
74
74
  */
75
75
  export const dbFunctionsOk = (list, { bare = false } = {}) => list === undefined || (Array.isArray(list) && list.every((entry) => isPlainObject(entry) && Object.keys(entry).sort().join() === 'name,reason' && dbFunctionNameOk(entry.name, bare) && typeof entry.reason === 'string' && entry.reason.trim().length > 0) && new Set(list.map((entry) => entry.name)).size === list.length);
76
+ /**
77
+ * True for an absent list or a list of {file, reason} SQL pass-through files (R334): `file` is a forward-slash path relative to the
78
+ * side folder (no leading slash, no `..`), unique, and the reason names whose text the file passes through.
79
+ */
80
+ export const sqlPassThroughOk = (list) => list === undefined || (Array.isArray(list) && list.every((entry) => isPlainObject(entry) && Object.keys(entry).sort().join() === 'file,reason' && typeof entry.file === 'string' && /^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*$/.test(entry.file) && !entry.file.split('/').includes('..') && typeof entry.reason === 'string' && entry.reason.trim().length > 0) && new Set(list.map((entry) => entry.file)).size === list.length);
76
81
  /** The shape problems of `m.ruleParams`. */
77
82
  export function ruleParamsProblems(m) {
78
83
  const bad = [];
@@ -84,8 +89,8 @@ export function ruleParamsProblems(m) {
84
89
  // Edition lite: a side's optional `lite` map overrides keys of the same side (never `lite` itself) when the app declares edition lite.
85
90
  if (!sideOk(rp.common, []) || !fileLinesOk(rp.common.fileLines) || !blockOk(rp.common.duplicateBlock))
86
91
  bad.push('ruleParams.common needs fileLines {soft, hardGrowth} and duplicateBlock {lines >= 2, tokens >= 1}; optional functionLines and featureFiles must be positive integers');
87
- if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite', 'dbFunctions']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !dbFunctionsOk(rp.be.dbFunctions, { bare: true }) || !liteOk(rp.be.lite, [...BE_OWN, ...SHARED_KEYS, 'schemaAuthority', 'dbFunctions']))
88
- bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns, addKinds and backing (shared parameters live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + '), dbFunctions ([{name, reason}]: a pg built-in bare, every other function schema.name) and lite (overrides of the same keys)');
92
+ if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite', 'dbFunctions', 'sqlPassThrough']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !dbFunctionsOk(rp.be.dbFunctions, { bare: true }) || !sqlPassThroughOk(rp.be.sqlPassThrough) || !liteOk(rp.be.lite, [...BE_OWN, ...SHARED_KEYS, 'schemaAuthority', 'dbFunctions', 'sqlPassThrough']))
93
+ bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns, addKinds and backing (shared parameters live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + '), dbFunctions ([{name, reason}]: a pg built-in bare, every other function schema.name), sqlPassThrough ([{file, reason}]: a file whose SQL text is not written by the app) and lite (overrides of the same keys)');
89
94
  else
90
95
  bad.push(...unitRolesProblems(rp.be), ...roleListProblems(rp.be));
91
96
  bad.push(...beSpecificProblems(rp.be, m));
@@ -3,7 +3,7 @@ import type { Side } from './slot-manifest-shape.mjs';
3
3
  import type { AppRepo } from './slot-app-view.mjs';
4
4
  import { loadRuleCatalog } from './rule-catalog.mjs';
5
5
  import type { RuleCatalog } from './rule-catalog.mjs';
6
- import type { DbFunction, Parsed, RuleParams, Slot, SlotManifest, SlotTracking } from './manifest-shape.mjs';
6
+ import type { DbFunction, Parsed, RuleParams, Slot, SlotManifest, SlotTracking, SqlPassThrough } from './manifest-shape.mjs';
7
7
  import type { ClassifiedPath, OwnerUnit } from './slot-classify.mjs';
8
8
  export type { Bindings } from './slot-match.mjs';
9
9
  export type { ClassifiedPath, OwnerUnit, PlacedPath, AmbiguousPath, UndeclaredPath } from './slot-classify.mjs';
@@ -41,6 +41,7 @@ export interface RepoView {
41
41
  providers?: readonly string[];
42
42
  reads: readonly string[];
43
43
  dbFunctions?: readonly DbFunction[];
44
+ sqlPassThrough?: readonly SqlPassThrough[];
44
45
  grammar?: string;
45
46
  extraPackages?: readonly {
46
47
  name: string;
@@ -125,6 +125,7 @@ function sideDeclaration(manifest, declaration, name, edition, providers) {
125
125
  providers,
126
126
  reads: Object.freeze([...(s.reads ?? [])]),
127
127
  dbFunctions: Object.freeze((s.dbFunctions ?? []).map((entry) => Object.freeze({ ...entry }))),
128
+ sqlPassThrough: Object.freeze((s.sqlPassThrough ?? []).map((entry) => Object.freeze({ ...entry }))),
128
129
  ...(s.grammar === undefined ? {} : { grammar: s.grammar }),
129
130
  ...(s.extraPackages === undefined ? {} : { extraPackages: Object.freeze(s.extraPackages.map((entry) => Object.freeze({ ...entry }))) }),
130
131
  manifestVersion: manifest.version,
@@ -251,8 +252,8 @@ function createScopeResolver(manifest, repo) {
251
252
  trackingOf,
252
253
  /** True only for a path a slot owns and requires to be committed. */
253
254
  isTracked: (p) => trackingOf(p) === 'tracked',
254
- // The app's own database functions (hfs.json sides.be.dbFunctions) join the canon's list (R334).
255
- ruleParams: () => withDeclaredDbFunctions(ruleParams(manifest, profile, edition), repo.dbFunctions),
255
+ // The app's own database functions and SQL pass-through files (hfs.json sides.be.dbFunctions, sides.be.sqlPassThrough) join the canon's lists (R334).
256
+ ruleParams: () => withDeclaredLists(ruleParams(manifest, profile, edition), repo),
256
257
  allowedImports: (tier) => manifest.tiers[profile]?.[tier]?.mayImport ?? null,
257
258
  });
258
259
  }
@@ -274,9 +275,18 @@ export function createSlotResolver(manifest, repo) {
274
275
  const sides = Object.fromEntries(PROFILES.map((side) => [side, createScopeResolver(manifest, app.sides[side])]));
275
276
  return appResolver(app, root, sides);
276
277
  }
277
- /** The rule parameters with the app's declared database functions appended to `dbFunctions` (be only), frozen. */
278
- const withDeclaredDbFunctions = (params, declared = []) => (params.dbFunctions === undefined && !declared.length ? params
279
- : Object.freeze({ ...params, dbFunctions: Object.freeze([...(params.dbFunctions ?? []), ...declared]) }));
278
+ /** The rule parameters with the app's declared database functions and SQL pass-through files appended to `dbFunctions` and `sqlPassThrough` (be only), the result frozen. */
279
+ const withDeclaredLists = (params, repo) => {
280
+ const functions = repo.dbFunctions ?? [];
281
+ const passThrough = repo.sqlPassThrough ?? [];
282
+ if ((params.dbFunctions === undefined && !functions.length) && (params.sqlPassThrough === undefined && !passThrough.length))
283
+ return params;
284
+ return Object.freeze({
285
+ ...params,
286
+ ...(params.dbFunctions === undefined && !functions.length ? {} : { dbFunctions: [...(params.dbFunctions ?? []), ...functions] }),
287
+ ...(params.sqlPassThrough === undefined && !passThrough.length ? {} : { sqlPassThrough: [...(params.sqlPassThrough ?? []), ...passThrough] }),
288
+ });
289
+ };
280
290
  /** The rule parameters of one profile (be: infraOwners, suffixes, bannedSuffixes and the rest over the shared ruleParams.common fileLines and duplicateBlock; fe: common alone or with its overrides; runtime: ruleParams.runtime of a runtime manifest), as a frozen deep copy. Under `edition` lite the `lite` overrides of ruleParams.<profile> merge over the base (the `lite` key itself is never returned). */
281
291
  export function ruleParams(manifest, profile, edition = 'full') {
282
292
  const profiles = manifestKind(manifest) === RUNTIME_KIND ? [RUNTIME_KIND] : PROFILES;
@@ -74,23 +74,42 @@ describe("callDbFunction", () => {
74
74
  })
75
75
 
76
76
  describe("advisoryXactLock", () => {
77
- it("locks the 64-bit hash of the key for the transaction", async () => {
77
+ it("hashes the key, then locks the hash for the transaction, both as function calls", async () => {
78
78
  const client = await setup()
79
- const manager = mockEntityManager({ query: ["SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [{}]] })
79
+ const manager = mockEntityManager({
80
+ query: [
81
+ ['SELECT * FROM "pg_catalog"."hashtextextended"($1, $2)', [{ hashtextextended: "42" }]],
82
+ ['SELECT * FROM "pg_catalog"."pg_advisory_xact_lock"($1)', [{}]],
83
+ ],
84
+ })
80
85
 
81
86
  await expect(client.advisoryXactLock(manager, "jobs:mail")).resolves.toBeUndefined()
82
87
 
83
- expect(manager.query).toHaveBeenCalledWith("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", ["jobs:mail"])
88
+ expect(manager.query).toHaveBeenNthCalledWith(1, 'SELECT * FROM "pg_catalog"."hashtextextended"($1, $2)', ["jobs:mail", 0])
89
+ expect(manager.query).toHaveBeenNthCalledWith(2, 'SELECT * FROM "pg_catalog"."pg_advisory_xact_lock"($1)', ["42"])
90
+ })
91
+
92
+ it.each([
93
+ ["no row", []],
94
+ ["a row with no hash", [{}]],
95
+ ["a hash that is not a number", [{ hashtextextended: null }]],
96
+ ])("refuses %s before it locks", async (_case, rows) => {
97
+ const client = await setup()
98
+ const manager = mockEntityManager({ query: ['SELECT * FROM "pg_catalog"."hashtextextended"($1, $2)', rows] })
99
+
100
+ await expect(client.advisoryXactLock(manager, "jobs:mail")).rejects.toMatchObject({ code: DatabaseErrorCode.ReturningRowRejected })
101
+
102
+ expect(manager.query).toHaveBeenCalledTimes(1)
84
103
  })
85
104
  })
86
105
 
87
106
  describe("notify", () => {
88
- it("sends the payload on the channel", async () => {
107
+ it("sends the payload on the channel as a function call", async () => {
89
108
  const client = await setup()
90
- const manager = mockEntityManager({ query: ["SELECT pg_notify($1, $2)", [{}]] })
109
+ const manager = mockEntityManager({ query: ['SELECT * FROM "pg_catalog"."pg_notify"($1, $2)', [{}]] })
91
110
 
92
111
  await expect(client.notify(manager, "jobs", '{"id":"job-1"}')).resolves.toBeUndefined()
93
112
 
94
- expect(manager.query).toHaveBeenCalledWith("SELECT pg_notify($1, $2)", ["jobs", '{"id":"job-1"}'])
113
+ expect(manager.query).toHaveBeenCalledWith('SELECT * FROM "pg_catalog"."pg_notify"($1, $2)', ["jobs", '{"id":"job-1"}'])
95
114
  })
96
115
  })
@@ -82,12 +82,22 @@ export const callDbFunction = <T>(manager: EntityManager, name: string, args: Re
82
82
  return manager.query<Array<T>>(`SELECT * FROM "${target.schema}"."${target.name}"(${parameters})`, [...args])
83
83
  }
84
84
 
85
- /** Takes a transaction-scoped advisory lock on the 64-bit hash of `key`; it is released when the manager's transaction ends. */
85
+ /** The refusal of a hash answer that is not the number the lock takes. */
86
+ const hashRejected = (key: string): DatabaseError =>
87
+ new DatabaseError({ code: DatabaseErrorCode.ReturningRowRejected, params: { name: "hashtextextended", key } })
88
+
89
+ /**
90
+ * Takes a transaction-scoped advisory lock on the 64-bit hash of `key`; it is released when the manager's transaction ends.
91
+ * Both steps are `callDbFunction` calls of listed pg built-ins, so no SQL text is written here (R334).
92
+ */
86
93
  export const advisoryXactLock = async (manager: EntityManager, key: string): Promise<void> => {
87
- await manager.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [key])
94
+ const rows = await callDbFunction<Record<string, unknown>>(manager, "hashtextextended", [key, 0])
95
+ const hash = rows[0]?.["hashtextextended"]
96
+ if (typeof hash !== "string" && typeof hash !== "number" && typeof hash !== "bigint") throw hashRejected(key)
97
+ await callDbFunction(manager, "pg_advisory_xact_lock", [hash])
88
98
  }
89
99
 
90
100
  /** Sends one NOTIFY on `channel` with `payload`, delivered when the manager's transaction commits. */
91
101
  export const notify = async (manager: EntityManager, channel: string, payload: string): Promise<void> => {
92
- await manager.query("SELECT pg_notify($1, $2)", [channel, payload])
102
+ await callDbFunction(manager, "pg_notify", [channel, payload])
93
103
  }