@starci/hfs 4.2.14 → 4.2.16
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 +10 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +14 -9
- package/runtime/knowledge/hfs/fe-stack.yaml +6 -3
- package/runtime/knowledge/hfs/rules.yaml +2 -2
- package/runtime/modules/kernel/removed-vocabulary.yaml +0 -5
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +20 -5
- package/templates/be/inbox/webhook-door.spec.ts.tpl +11 -12
- package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +11 -12
- package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +1 -1
- package/templates/be/platform/graphql/graphql.module.ts +1 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +3 -3
- package/templates/be/table/service.api.ts.tpl +15 -9
- package/templates/be/table/service.ts.tpl +7 -5
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +8 -3
- package/templates/fe/table/graphql/mutation.ts.tpl +2 -2
- package/templates/fe/table/graphql/mutation.types.ts.tpl +2 -5
- package/templates/fe/table/graphql/query.ts.tpl +2 -2
- package/templates/fe/table/graphql/query.types.ts.tpl +2 -5
- package/templates/fe/table/graphql/useMutateSwr.ts.tpl +5 -4
- package/templates/fe/table/graphql/useQuerySwr.ts.tpl +3 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.2.16 - 2026-10-14
|
|
4
|
+
|
|
5
|
+
- Fixed: the `add api`, `add table` and `add webhook` templates render code the published canons (eslint-canon-be 3.2.17, eslint-canon-fe 8.2.16) accept, as examples/lite-app shows. The GraphQL error formatter spec builds its malformed-operation error from a code (`GRAPHQL_VALIDATION_FAILED`), not prose (`user-copy-through-catalog`); `platform/graphql`'s context factory types its parameter with the named `GraphqlContext` (`no-inline-param-type`); both webhook door spec templates register `HttpSecurityModule` with the module's own default and override `WebhookSignatureService` with the real verifier over a fake clock instead of a global `ClockModule` (`is-global-only-in-app`).
|
|
6
|
+
- Fixed: two tables no longer render duplicate code (`HFS_DUPLICATE_CODE`). The table service template answers `Pick<Row, "id">` through a named result type, throws its not-found error from one module-level factory handed to `requireOwnedRow` (whose factory now receives the id) and maps the insert's rows with a module-level `createdRow`.
|
|
7
|
+
- Fixed: `add table --fe` renders no dead or aliased operation types (`HFS_UNUSED_EXPORT`, `HFS_ALIAS_REEXPORT`): the operation files type their request with the generated variables, the types files export only the answer, which the SWR hooks name. `useQuery<X>Swr` answers the operation's GraphQLResponse as its data (as the ecommerce example does) and `useMutateCreate<X>Swr` primes that query with the response.
|
|
8
|
+
|
|
9
|
+
## 4.2.15 - 2026-10-13
|
|
10
|
+
|
|
11
|
+
- Changed: the heroicons family is the one application icon set (owner ruling 2026-10-13): the scaffold installs `@heroicons/react` 2.2.0 and `@starci/heroicons` 0.3.3 (fe-stack `icons`, tier core) in every product fe app in place of `@phosphor-icons/react`, and the `modules/toast` skeleton draws its status indicators with `@heroicons/react/24/outline` (CheckCircleIcon, XCircleIcon, ExclamationTriangleIcon, InformationCircleIcon). Carry the canon pins of eslint-canon-be 3.2.17, eslint-canon-fe 8.2.16 and CLI 1.2.15.
|
|
12
|
+
|
|
3
13
|
## 4.2.14 - 2026-10-13
|
|
4
14
|
|
|
5
15
|
- Breaking: HFS manifest 3.0.0 (schema `starci/hfs-slots@3`, rule catalog `starci/hfs-rules@3` 3.0.0). Removing the front-end database slots and the Supabase realtime feature slot and narrowing `fe.route` change answers for existing apps, so the manifest takes a major: every app pins `"hfs": 3` (scaffold writes it), and an app still pinned to 2 is refused with `HFS_MANIFEST_MAJOR_MISMATCH`.
|
package/package.json
CHANGED
|
@@ -14,8 +14,13 @@ purpose: >-
|
|
|
14
14
|
pins:
|
|
15
15
|
# --- @starci packages. Every @starci package is a PUBLISHED npm package (install: registry): a product repository
|
|
16
16
|
# installs the exact pinned version from the npm registry in the root and every workspace package.json, never `file:`.
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
'@starci/heroicons':
|
|
18
|
+
version: 0.3.4
|
|
19
|
+
group: starci
|
|
20
|
+
install: registry
|
|
21
|
+
side: fe
|
|
22
|
+
source: packages/heroicons/package.json
|
|
23
|
+
why: 'StarCi custom Heroicons-compatible cuts; eslint-canon-fe (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
|
|
19
24
|
'@starci/grammar':
|
|
20
25
|
version: 0.9.14
|
|
21
26
|
group: starci
|
|
@@ -24,14 +29,14 @@ pins:
|
|
|
24
29
|
source: packages/grammar/package.json
|
|
25
30
|
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.'
|
|
26
31
|
'@starci/eslint-canon-be':
|
|
27
|
-
version: 3.2.
|
|
32
|
+
version: 3.2.18
|
|
28
33
|
group: starci
|
|
29
34
|
install: registry
|
|
30
35
|
side: be
|
|
31
36
|
source: packages/eslint/be/package.json
|
|
32
37
|
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.'
|
|
33
38
|
'@starci/eslint-canon-fe':
|
|
34
|
-
version: 8.2.
|
|
39
|
+
version: 8.2.17
|
|
35
40
|
group: starci
|
|
36
41
|
install: registry
|
|
37
42
|
side: fe
|
|
@@ -71,14 +76,14 @@ pins:
|
|
|
71
76
|
source: packages/test-world/package.json
|
|
72
77
|
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.'
|
|
73
78
|
'@starci/cli':
|
|
74
|
-
version: 1.2.
|
|
79
|
+
version: 1.2.16
|
|
75
80
|
group: starci
|
|
76
81
|
install: registry
|
|
77
82
|
side: both
|
|
78
83
|
source: packages/cli/package.json
|
|
79
84
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
80
85
|
'@starci/hfs':
|
|
81
|
-
version: 4.2.
|
|
86
|
+
version: 4.2.16
|
|
82
87
|
group: starci
|
|
83
88
|
install: registry
|
|
84
89
|
side: both
|
|
@@ -360,11 +365,11 @@ pins:
|
|
|
360
365
|
group: framework
|
|
361
366
|
side: fe
|
|
362
367
|
why: 'the fan-out emitter behind the socket side-effect component (fe-stack realtime); version read from the starci-academy package-lock on 2026-10-13.'
|
|
363
|
-
'@
|
|
364
|
-
version: 2.
|
|
368
|
+
'@heroicons/react':
|
|
369
|
+
version: 2.2.0
|
|
365
370
|
group: ui
|
|
366
371
|
side: fe
|
|
367
|
-
why: 'the one application icon set (fe-stack icons
|
|
372
|
+
why: 'the base of the one application icon set (fe-stack icons, with @starci/heroicons for the glyphs it lacks); eslint-canon-fe (icon.mjs) admits @heroicons/react/24/outline and /16/solid.'
|
|
368
373
|
'next-themes':
|
|
369
374
|
version: 0.4.6
|
|
370
375
|
group: ui
|
|
@@ -99,11 +99,14 @@ concerns:
|
|
|
99
99
|
why: the grammar renderers over HeroUI and Tailwind; no second UI kit and no CSS-in-JS.
|
|
100
100
|
- id: icons
|
|
101
101
|
tier: core
|
|
102
|
-
packages: ['@
|
|
102
|
+
packages: ['@heroicons/react', '@starci/heroicons']
|
|
103
103
|
home: [fe.components]
|
|
104
104
|
pattern: FE-ICON
|
|
105
|
-
forbids: [react-icons, lucide-react, '@iconify/react', '@gravity-ui/icons', '@tabler/icons-react', '@fortawesome/react-fontawesome']
|
|
106
|
-
why:
|
|
105
|
+
forbids: ['@phosphor-icons/react', react-icons, lucide-react, '@iconify/react', '@gravity-ui/icons', '@tabler/icons-react', '@fortawesome/react-fontawesome']
|
|
106
|
+
why: >-
|
|
107
|
+
one application icon set, the heroicons family (owner ruling 2026-10-13), on the 24/outline and 16/solid cuts. @heroicons/react
|
|
108
|
+
is the base set and is used first; @starci/heroicons holds only the glyphs heroicons lacks (brand or domain glyphs drawn in
|
|
109
|
+
heroicons style), never duplicates a heroicons glyph, and a missing glyph is added to it.
|
|
107
110
|
- id: theme
|
|
108
111
|
tier: core
|
|
109
112
|
packages: [next-themes]
|
|
@@ -987,13 +987,13 @@ rules:
|
|
|
987
987
|
- {kind: eslint-fe, id: component-purity}
|
|
988
988
|
- id: R93
|
|
989
989
|
code: "FE_VENDOR_BOUNDARY"
|
|
990
|
-
law: "Vendor primitives and icons reach components only through their named owner: the one icon set of knowledge/hfs/fe-stack.yaml through the icon leaf at the glyph scale, every vendor primitive behind a named owner; the Grammar package is imported only through its selected code and style entry and keeps its declared contract."
|
|
990
|
+
law: "Vendor primitives and icons reach components only through their named owner: the one icon set of knowledge/hfs/fe-stack.yaml (the heroicons family) through the icon leaf at the glyph scale, every vendor primitive behind a named owner; the Grammar package is imported only through its selected code and style entry and keeps its declared contract."
|
|
991
991
|
kinds: [lint, codemod]
|
|
992
992
|
gates: [pre-commit, pre-push, settle, land, ci]
|
|
993
993
|
failureCodes: ["FE_VENDOR_BOUNDARY", "ARCH_GRAMMAR_CONTRACT_INVALID", "ARCH_GRAMMAR_EXPORT_BYPASS"]
|
|
994
994
|
enforcers:
|
|
995
995
|
- {kind: eslint-fe, id: no-vendor-icon-outside-icon-leaf}
|
|
996
|
-
- {kind: eslint-fe, id:
|
|
996
|
+
- {kind: eslint-fe, id: heroicons-is-the-glyph-vendor}
|
|
997
997
|
- {kind: eslint-fe, id: no-off-scale-glyph-size}
|
|
998
998
|
- {kind: eslint-fe, id: no-decorative-icon-in-metric-cell}
|
|
999
999
|
- {kind: eslint-fe, id: vendor-primitive-has-named-owner}
|
|
@@ -309,8 +309,3 @@ removed:
|
|
|
309
309
|
name: anonymousActions
|
|
310
310
|
use: 'the front end holds no Server Action (R243 FE_SERVER_ACTION_FORBIDDEN), so no slot lists anonymous ones'
|
|
311
311
|
since: 1.0.0-alpha.13
|
|
312
|
-
- id: lint-heroicons-is-the-glyph-vendor
|
|
313
|
-
kind: vocabulary
|
|
314
|
-
name: heroicons-is-the-glyph-vendor
|
|
315
|
-
use: 'the eslint-fe rule is one-icon-set: the application icon set is @phosphor-icons/react (knowledge/hfs/fe-stack.yaml icons), imported only by the Icon leaf; the grammar Icon takes whatever glyph the app supplies'
|
|
316
|
-
since: 1.0.0-alpha.13
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Owner = every graph.ownerRoots unit whose tier is not `app` and whose public entry file is in the graph (index.ts or
|
|
11
11
|
* index.tsx, a package's src/index.ts). The `entries` its slot declares (slot data: a second public entry file of the owner) are further
|
|
12
|
-
* public entries of the same owner, judged
|
|
12
|
+
* public entries of the same owner, judged like the entry, with one addition: a name another declared entry of the owner
|
|
13
|
+
* imports (not re-exports) is used, because it is part of that entry's public signature (an operation file
|
|
14
|
+
* `graphql/queries/query-<x>.ts` typing its request and answer with `graphql/queries/types/<x>.ts`). A barrel or any other
|
|
15
|
+
* non-entry file of the owner still does not count. The exported names of an entry are its named exports, `export { a as b }`,
|
|
13
16
|
* `export { x } from`, declarations with `export`, and `default` for `export default`. `export *` is refused by owners.mjs
|
|
14
17
|
* and contributes no name here.
|
|
15
18
|
*
|
|
@@ -330,11 +333,22 @@ const unusedExportViolation = (publicEntry, name, line, owner) => ({
|
|
|
330
333
|
message: `${publicEntry} exports ${name}, but no production file outside ${owner.root || 'the repository root'} imports it; remove the export (only a unit role spec, a fixture builder, a test world file, or an integration spec of this integration's own provider folder counts besides production files).`,
|
|
331
334
|
});
|
|
332
335
|
|
|
333
|
-
/**
|
|
334
|
-
const
|
|
336
|
+
/** Adds to `used` what another declared entry of the same owner imports from `publicEntry`: that entry's signature makes it public. */
|
|
337
|
+
const addSiblingEntryUses = (context, publicEntry, declared, used) => {
|
|
338
|
+
for (const edge of context.edgesTo.get(publicEntry) ?? []) {
|
|
339
|
+
if (edge.reexport || edge.from === publicEntry || !declared.has(edge.from)) continue;
|
|
340
|
+
const bindings = bindingsOf(context.ts, edge.edge.declaration);
|
|
341
|
+
if (bindings.all) used.all = true;
|
|
342
|
+
for (const pair of bindings.pairs) used.names.add(pair.imported);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/** The unused-export violations and the export count of one public entry of an owner (`declared`: its slot's extra entries). */
|
|
347
|
+
const publicEntryExports = (context, key, owner, publicEntry, declared) => {
|
|
335
348
|
const names = exportedNames(context.ts, context.graph.files.get(publicEntry).sourceFile);
|
|
336
349
|
const used = consumedNames(context, publicEntry, key, 0, new Set([publicEntry]));
|
|
337
350
|
addReexportedUses(context, key, publicEntry, used);
|
|
351
|
+
if (declared.has(publicEntry)) addSiblingEntryUses(context, publicEntry, declared, used);
|
|
338
352
|
const violations = [...names]
|
|
339
353
|
.filter(([name]) => !(used.all || used.names.has(name)))
|
|
340
354
|
.map(([name, line]) => unusedExportViolation(publicEntry, name, line, owner));
|
|
@@ -351,8 +365,9 @@ const ownerExportFindings = (context, config) => {
|
|
|
351
365
|
const entry = entryOf(graph, config, key, owner);
|
|
352
366
|
if (!entry) continue;
|
|
353
367
|
owners += 1;
|
|
354
|
-
|
|
355
|
-
|
|
368
|
+
const declared = new Set(extraEntries(graph, entry));
|
|
369
|
+
for (const publicEntry of [entry, ...declared]) {
|
|
370
|
+
const found = publicEntryExports(context, key, owner, publicEntry, declared);
|
|
356
371
|
exports += found.exports;
|
|
357
372
|
violations.push(...found.violations);
|
|
358
373
|
}
|
|
@@ -4,9 +4,9 @@ import type { RawBodyRequest } from "@nestjs/common"
|
|
|
4
4
|
import { Test } from "@nestjs/testing"
|
|
5
5
|
import type { Request } from "express"
|
|
6
6
|
import { @@Provider@@InboxService } from "@modules/domain/@@provider@@-inbox"
|
|
7
|
-
import { CLOCK, ClockModule } from "@modules/platform/clock"
|
|
8
7
|
import { Secret } from "@modules/platform/config"
|
|
9
|
-
import { HttpSecurityModule } from "@modules/platform/http-security"
|
|
8
|
+
import { HttpSecurityModule, WebhookSignatureService } from "@modules/platform/http-security"
|
|
9
|
+
import type { HttpSecurityOptions } from "@modules/platform/http-security"
|
|
10
10
|
import { @@Event@@Request } from "./dto/@@event@@.request"
|
|
11
11
|
import { @@Provider@@Webhook } from "./@@provider@@.webhook"
|
|
12
12
|
|
|
@@ -26,26 +26,25 @@ const DELIVERY = deliveryOf()
|
|
|
26
26
|
const RAW_BODY = Buffer.from(JSON.stringify(DELIVERY))
|
|
27
27
|
const REQUEST = mock<RawBodyRequest<Request>>({ rawBody: RAW_BODY })
|
|
28
28
|
|
|
29
|
+
const OPTIONS: HttpSecurityOptions = {
|
|
30
|
+
allowedOrigins: [],
|
|
31
|
+
rateLimit: { windowMs: 60_000, defaultLimit: 100, strictLimit: 100 },
|
|
32
|
+
webhooks: { "@@provider@@": { secret: new Secret(SECRET), toleranceMs: 300_000 } },
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
const sign = (rawBody: Buffer, timestamp: string): string =>
|
|
30
36
|
`sha256=${createHmac("sha256", SECRET).update(`${timestamp}.`).update(rawBody).digest("hex")}`
|
|
31
37
|
|
|
32
38
|
const build = async (deliveries: @@Provider@@InboxService) => {
|
|
33
39
|
const moduleRef = await Test.createTestingModule({
|
|
34
|
-
imports: [
|
|
35
|
-
ClockModule.register({ isGlobal: true }),
|
|
36
|
-
HttpSecurityModule.register({
|
|
37
|
-
allowedOrigins: [],
|
|
38
|
-
rateLimit: { windowMs: 60_000, defaultLimit: 100, strictLimit: 100 },
|
|
39
|
-
webhooks: { "@@provider@@": { secret: new Secret(SECRET), toleranceMs: 300_000 } },
|
|
40
|
-
}),
|
|
41
|
-
],
|
|
40
|
+
imports: [HttpSecurityModule.register(OPTIONS)],
|
|
42
41
|
controllers: [@@Provider@@Webhook],
|
|
43
42
|
providers: [
|
|
44
43
|
{ provide: @@Provider@@InboxService, useValue: deliveries },
|
|
45
44
|
],
|
|
46
45
|
})
|
|
47
|
-
.overrideProvider(
|
|
48
|
-
.useValue(new FakeClock(AT))
|
|
46
|
+
.overrideProvider(WebhookSignatureService)
|
|
47
|
+
.useValue(new WebhookSignatureService(OPTIONS, new FakeClock(AT)))
|
|
49
48
|
.compile()
|
|
50
49
|
return moduleRef.get(@@Provider@@Webhook)
|
|
51
50
|
}
|
|
@@ -4,9 +4,9 @@ import type { RawBodyRequest } from "@nestjs/common"
|
|
|
4
4
|
import { Test } from "@nestjs/testing"
|
|
5
5
|
import type { Request } from "express"
|
|
6
6
|
import { @@service@@ } from "@@serviceModule@@"
|
|
7
|
-
import { CLOCK, ClockModule } from "@modules/platform/clock"
|
|
8
7
|
import { Secret } from "@modules/platform/config"
|
|
9
|
-
import { HttpSecurityModule } from "@modules/platform/http-security"
|
|
8
|
+
import { HttpSecurityModule, WebhookSignatureService } from "@modules/platform/http-security"
|
|
9
|
+
import type { HttpSecurityOptions } from "@modules/platform/http-security"
|
|
10
10
|
import { @@Event@@Request } from "./dto/@@event@@.request"
|
|
11
11
|
import { @@Provider@@Webhook } from "./@@provider@@.webhook"
|
|
12
12
|
|
|
@@ -25,26 +25,25 @@ const DELIVERY = deliveryOf()
|
|
|
25
25
|
const RAW_BODY = Buffer.from(JSON.stringify(DELIVERY))
|
|
26
26
|
const REQUEST = mock<RawBodyRequest<Request>>({ rawBody: RAW_BODY })
|
|
27
27
|
|
|
28
|
+
const OPTIONS: HttpSecurityOptions = {
|
|
29
|
+
allowedOrigins: [],
|
|
30
|
+
rateLimit: { windowMs: 60_000, defaultLimit: 100, strictLimit: 100 },
|
|
31
|
+
webhooks: { "@@provider@@": { secret: new Secret(SECRET), toleranceMs: 300_000 } },
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
const sign = (rawBody: Buffer, timestamp: string): string =>
|
|
29
35
|
`sha256=${createHmac("sha256", SECRET).update(`${timestamp}.`).update(rawBody).digest("hex")}`
|
|
30
36
|
|
|
31
37
|
const build = async (deliveries: @@service@@) => {
|
|
32
38
|
const moduleRef = await Test.createTestingModule({
|
|
33
|
-
imports: [
|
|
34
|
-
ClockModule.register({ isGlobal: true }),
|
|
35
|
-
HttpSecurityModule.register({
|
|
36
|
-
allowedOrigins: [],
|
|
37
|
-
rateLimit: { windowMs: 60_000, defaultLimit: 100, strictLimit: 100 },
|
|
38
|
-
webhooks: { "@@provider@@": { secret: new Secret(SECRET), toleranceMs: 300_000 } },
|
|
39
|
-
}),
|
|
40
|
-
],
|
|
39
|
+
imports: [HttpSecurityModule.register(OPTIONS)],
|
|
41
40
|
controllers: [@@Provider@@Webhook],
|
|
42
41
|
providers: [
|
|
43
42
|
{ provide: @@service@@, useValue: deliveries },
|
|
44
43
|
],
|
|
45
44
|
})
|
|
46
|
-
.overrideProvider(
|
|
47
|
-
.useValue(new FakeClock(AT))
|
|
45
|
+
.overrideProvider(WebhookSignatureService)
|
|
46
|
+
.useValue(new WebhookSignatureService(OPTIONS, new FakeClock(AT)))
|
|
48
47
|
.compile()
|
|
49
48
|
return moduleRef.get(@@Provider@@Webhook)
|
|
50
49
|
}
|
|
@@ -9,7 +9,7 @@ describe("formatGraphqlError", () => {
|
|
|
9
9
|
it("keeps the message of a malformed operation and marks it invalid without describing it", () => {
|
|
10
10
|
const errors = mock<ErrorsService>()
|
|
11
11
|
|
|
12
|
-
expect(formatGraphqlError(errors, formatted, new GraphQLError("
|
|
12
|
+
expect(formatGraphqlError(errors, formatted, new GraphQLError("GRAPHQL_VALIDATION_FAILED"))).toEqual({
|
|
13
13
|
message: "raw",
|
|
14
14
|
path: ["item"],
|
|
15
15
|
locations: [{ line: 1, column: 3 }],
|
|
@@ -3,7 +3,6 @@ import type { ApolloDriverConfig } from "@nestjs/apollo"
|
|
|
3
3
|
import { Module } from "@nestjs/common"
|
|
4
4
|
import type { DynamicModule } from "@nestjs/common"
|
|
5
5
|
import { GraphQLModule as NestGraphQLModule } from "@nestjs/graphql"
|
|
6
|
-
import type { Request, Response } from "express"
|
|
7
6
|
import { ERRORS_SERVICE, formatGraphqlError } from "@modules/platform/errors"
|
|
8
7
|
import type { ErrorsService } from "@modules/platform/errors"
|
|
9
8
|
import { REQUEST_LOCALE } from "@modules/platform/i18n"
|
|
@@ -37,7 +36,7 @@ export class GraphqlModule extends ConfigurableModuleClass {
|
|
|
37
36
|
path: "/graphql",
|
|
38
37
|
playground: false,
|
|
39
38
|
introspection: true,
|
|
40
|
-
context: ({ req, res }:
|
|
39
|
+
context: ({ req, res }: GraphqlContext): GraphqlContext => ({ req, res }),
|
|
41
40
|
formatError: (formatted, error) => formatGraphqlError(errors, formatted, error),
|
|
42
41
|
validationRules: [depthLimitRule(GRAPHQL_DEPTH_MAX)],
|
|
43
42
|
plugins: [localizeErrorsPlugin(errors, requestLocale)],
|
|
@@ -33,9 +33,9 @@ function assertSqlIdent(_value: string): asserts _value is SqlIdent {
|
|
|
33
33
|
/** Checks a dynamic identifier against the names the caller allows and brands it. */
|
|
34
34
|
{{> be/common/sql-ident.ts.partial}}
|
|
35
35
|
|
|
36
|
-
type NotFoundFactory = () => never
|
|
36
|
+
type NotFoundFactory = (id: string) => never
|
|
37
37
|
|
|
38
|
-
/** Requires the row selected by an id-and-owner statement and lets the capability own its typed not-found error. */
|
|
38
|
+
/** Requires the row selected by an id-and-owner statement and lets the capability own its typed not-found error of that id. */
|
|
39
39
|
export const requireOwnedRow = async <T>(
|
|
40
40
|
entityManager: EntityManager,
|
|
41
41
|
statement: SqlText,
|
|
@@ -44,6 +44,6 @@ export const requireOwnedRow = async <T>(
|
|
|
44
44
|
notFound: NotFoundFactory,
|
|
45
45
|
): Promise<T> => {
|
|
46
46
|
const row = (await entityManager.query<Array<T>>(statement, [id, ownerId]))[0]
|
|
47
|
-
if (row === undefined) return notFound()
|
|
47
|
+
if (row === undefined) return notFound(id)
|
|
48
48
|
return row
|
|
49
49
|
}
|
|
@@ -5,8 +5,18 @@ import { {{Name}}Error, {{Name}}ErrorCode } from "./errors/{{name}}.error"
|
|
|
5
5
|
import type { {{Name}}Row } from "./persistence/{{name}}.rows"
|
|
6
6
|
import { FIND_{{upper}}, INSERT_{{upper}} } from "./persistence/{{name}}.sql"
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/** What the capability answers for one row: its id. */
|
|
9
|
+
type {{Name}}Result = Pick<{{Name}}Row, "id">
|
|
10
|
+
|
|
11
|
+
/** Throws the typed not-found error of one id: absent and denied rows share it. */
|
|
12
|
+
const notFound = (id: string): never => {
|
|
13
|
+
throw new {{Name}}Error({ code: {{Name}}ErrorCode.NotFound, params: { id } })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The row an insert answered; an insert that answered none throws the typed not-created error. */
|
|
17
|
+
const createdRow = ([row]: ReadonlyArray<{{Name}}Result>): {{Name}}Result => {
|
|
18
|
+
if (row === undefined) throw new {{Name}}Error({ code: {{Name}}ErrorCode.NotCreated })
|
|
19
|
+
return row
|
|
10
20
|
}
|
|
11
21
|
|
|
12
22
|
@Injectable()
|
|
@@ -16,15 +26,11 @@ export class {{Name}}Service {
|
|
|
16
26
|
|
|
17
27
|
/** Answers one row owned by the authenticated principal; absent and denied rows share one typed not-found result. */
|
|
18
28
|
{{nameCamel}}(principalId: string, id: string): Promise<{{Name}}Result> {
|
|
19
|
-
return requireOwnedRow<
|
|
20
|
-
throw new {{Name}}Error({ code: {{Name}}ErrorCode.NotFound, params: { id } })
|
|
21
|
-
})
|
|
29
|
+
return requireOwnedRow<{{Name}}Result>(this.entityManager, FIND_{{upper}}, id, principalId, notFound)
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
/** Creates one row owned by the authenticated principal and answers its id. */
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (row === undefined) throw new {{Name}}Error({ code: {{Name}}ErrorCode.NotCreated })
|
|
28
|
-
return { id: row.id }
|
|
33
|
+
create{{Singular}}(principalId: string): Promise<{{Name}}Result> {
|
|
34
|
+
return this.entityManager.query<Array<{{Name}}Result>>(INSERT_{{upper}}, [principalId]).then(createdRow)
|
|
29
35
|
}
|
|
30
36
|
}
|
|
@@ -5,8 +5,12 @@ import { {{Name}}Error, {{Name}}ErrorCode } from "./errors/{{name}}.error"
|
|
|
5
5
|
import type { {{Name}}Row } from "./persistence/{{name}}.rows"
|
|
6
6
|
import { FIND_{{upper}} } from "./persistence/{{name}}.sql"
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/** What the capability answers for one row: its id. */
|
|
9
|
+
type {{Name}}Result = Pick<{{Name}}Row, "id">
|
|
10
|
+
|
|
11
|
+
/** Throws the typed not-found error of one id: absent and denied rows share it. */
|
|
12
|
+
const notFound = (id: string): never => {
|
|
13
|
+
throw new {{Name}}Error({ code: {{Name}}ErrorCode.NotFound, params: { id } })
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
@Injectable()
|
|
@@ -16,8 +20,6 @@ export class {{Name}}Service {
|
|
|
16
20
|
|
|
17
21
|
/** Answers one row owned by the authenticated principal; absent and denied rows share one typed not-found result. */
|
|
18
22
|
{{nameCamel}}(principalId: string, id: string): Promise<{{Name}}Result> {
|
|
19
|
-
return requireOwnedRow<
|
|
20
|
-
throw new {{Name}}Error({ code: {{Name}}ErrorCode.NotFound, params: { id } })
|
|
21
|
-
})
|
|
23
|
+
return requireOwnedRow<{{Name}}Result>(this.entityManager, FIND_{{upper}}, id, principalId, notFound)
|
|
22
24
|
}
|
|
23
25
|
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CheckCircleIcon,
|
|
3
|
+
ExclamationTriangleIcon,
|
|
4
|
+
InformationCircleIcon,
|
|
5
|
+
XCircleIcon,
|
|
6
|
+
} from "@heroicons/react/24/outline"
|
|
1
7
|
import { toast as heroToast } from "@heroui/react"
|
|
2
|
-
import { CheckCircleIcon, InfoIcon, WarningIcon, XCircleIcon } from "@phosphor-icons/react"
|
|
3
8
|
import type { ReactNode } from "react"
|
|
4
9
|
|
|
5
10
|
/** The options a status toast takes: HeroUI's, minus the variant the status already names. */
|
|
@@ -9,8 +14,8 @@ type StatusToastOptions = Parameters<typeof heroToast.success>[1]
|
|
|
9
14
|
const INDICATOR: Record<"success" | "danger" | "warning" | "info", ReactNode> = {
|
|
10
15
|
success: <CheckCircleIcon className="size-6 text-success-soft-foreground" />,
|
|
11
16
|
danger: <XCircleIcon className="size-6 text-danger-soft-foreground" />,
|
|
12
|
-
warning: <
|
|
13
|
-
info: <
|
|
17
|
+
warning: <ExclamationTriangleIcon className="size-6 text-warning-soft-foreground" />,
|
|
18
|
+
info: <InformationCircleIcon className="size-6 text-accent" />,
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
/** The app's toast: HeroUI's imperative toast with the status indicator injected; the region is mounted by the providers. */
|
|
@@ -3,7 +3,7 @@ import type { TypedDocumentNode } from "@apollo/client"
|
|
|
3
3
|
import type { Create{{Singular}}Mutation, Create{{Singular}}MutationVariables } from "../__generated__/graphql"
|
|
4
4
|
import { createAuthApolloClient, graphqlResponseOf } from "../clients"
|
|
5
5
|
import type { GraphQLResponse, OperationParams } from "../types"
|
|
6
|
-
import type { MutateCreate{{Singular}}Data
|
|
6
|
+
import type { MutateCreate{{Singular}}Data } from "./types/create-{{singular}}"
|
|
7
7
|
|
|
8
8
|
/** The `create{{Singular}}` mutation of the back end (be/src/features/api/{{name}}, create-{{singular}}). */
|
|
9
9
|
const MUTATION_CREATE_{{singularUpper}}: TypedDocumentNode<Create{{Singular}}Mutation, Create{{Singular}}MutationVariables> = gql`
|
|
@@ -18,7 +18,7 @@ const MUTATION_CREATE_{{singularUpper}}: TypedDocumentNode<Create{{Singular}}Mut
|
|
|
18
18
|
export const mutateCreate{{Singular}} = async ({
|
|
19
19
|
request,
|
|
20
20
|
signal,
|
|
21
|
-
}: OperationParams<
|
|
21
|
+
}: OperationParams<Create{{Singular}}MutationVariables>): Promise<GraphQLResponse<MutateCreate{{Singular}}Data>> =>
|
|
22
22
|
graphqlResponseOf(
|
|
23
23
|
await createAuthApolloClient({ signal }).mutate({ mutation: MUTATION_CREATE_{{singularUpper}}, variables: request }),
|
|
24
24
|
(data) => data.create{{Singular}},
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import type { Create{{Singular}}Mutation
|
|
1
|
+
import type { Create{{Singular}}Mutation } from "../../__generated__/graphql"
|
|
2
2
|
|
|
3
|
-
/** What mutateCreate{{Singular}}
|
|
4
|
-
export type MutateCreate{{Singular}}Request = Create{{Singular}}MutationVariables
|
|
5
|
-
|
|
6
|
-
/** What mutateCreate{{Singular}} answers: the `create{{Singular}}` field. */
|
|
3
|
+
/** What mutateCreate{{Singular}} answers: the `create{{Singular}}` field (generated from the back end's contract). */
|
|
7
4
|
export type MutateCreate{{Singular}}Data = Create{{Singular}}Mutation["create{{Singular}}"]
|
|
@@ -3,7 +3,7 @@ import type { TypedDocumentNode } from "@apollo/client"
|
|
|
3
3
|
import type { {{Singular}}Query, {{Singular}}QueryVariables } from "../__generated__/graphql"
|
|
4
4
|
import { createAuthApolloClient, graphqlResponseOf } from "../clients"
|
|
5
5
|
import type { GraphQLResponse, OperationParams } from "../types"
|
|
6
|
-
import type { Query{{Singular}}Data
|
|
6
|
+
import type { Query{{Singular}}Data } from "./types/{{singular}}"
|
|
7
7
|
|
|
8
8
|
/** The `{{singularCamel}}` query of the back end (be/src/features/api/{{name}}, get-{{singular}}). */
|
|
9
9
|
const QUERY_{{singularUpper}}: TypedDocumentNode<{{Singular}}Query, {{Singular}}QueryVariables> = gql`
|
|
@@ -18,7 +18,7 @@ const QUERY_{{singularUpper}}: TypedDocumentNode<{{Singular}}Query, {{Singular}}
|
|
|
18
18
|
export const query{{Singular}} = async ({
|
|
19
19
|
request,
|
|
20
20
|
signal,
|
|
21
|
-
}: OperationParams<
|
|
21
|
+
}: OperationParams<{{Singular}}QueryVariables["input"]>): Promise<GraphQLResponse<Query{{Singular}}Data>> =>
|
|
22
22
|
graphqlResponseOf(
|
|
23
23
|
await createAuthApolloClient({ signal }).query({ query: QUERY_{{singularUpper}}, variables: { input: request } }),
|
|
24
24
|
(data) => data.{{singularCamel}},
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import type { {{Singular}}Query
|
|
1
|
+
import type { {{Singular}}Query } from "../../__generated__/graphql"
|
|
2
2
|
|
|
3
|
-
/** What query{{Singular}}
|
|
4
|
-
export type Query{{Singular}}Request = {{Singular}}QueryVariables["input"]
|
|
5
|
-
|
|
6
|
-
/** What query{{Singular}} answers: the `{{singularCamel}}` field. */
|
|
3
|
+
/** What query{{Singular}} answers: the `{{singularCamel}}` field (generated from the back end's contract). */
|
|
7
4
|
export type Query{{Singular}}Data = {{Singular}}Query["{{singularCamel}}"]
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useSWRConfig } from "swr"
|
|
2
2
|
import useSWRMutation from "swr/mutation"
|
|
3
|
+
import type { GraphQLResponse } from "@/modules/api"
|
|
3
4
|
import { mutateCreate{{Singular}} } from "@/modules/api/graphql/mutations/mutation-create-{{singular}}"
|
|
5
|
+
import type { MutateCreate{{Singular}}Data } from "@/modules/api/graphql/mutations/types/create-{{singular}}"
|
|
4
6
|
import { QUERY_{{singularUpper}}_SWR_KEY } from "../queries/useQuery{{Singular}}Swr"
|
|
5
7
|
|
|
6
8
|
/** The SWR key of the create-{{singular}} mutation. */
|
|
@@ -8,14 +10,13 @@ export const MUTATE_CREATE_{{singularUpper}}_SWR_KEY = "MUTATE_CREATE_{{singular
|
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Creates one {{singular}} row; answers the GraphQLResponse so the caller reports it through modules/toast
|
|
11
|
-
* (useGraphQLWithToast). A success primes the {{singular}} query of the new row
|
|
13
|
+
* (useGraphQLWithToast). A success primes the {{singular}} query of the new row with that response.
|
|
12
14
|
*/
|
|
13
15
|
export const useMutateCreate{{Singular}}Swr = () => {
|
|
14
16
|
const { mutate } = useSWRConfig()
|
|
15
17
|
return useSWRMutation(MUTATE_CREATE_{{singularUpper}}_SWR_KEY, async () => {
|
|
16
|
-
const response = await mutateCreate{{Singular}}({ request: {} })
|
|
17
|
-
if (response.success)
|
|
18
|
-
await mutate([QUERY_{{singularUpper}}_SWR_KEY, response.data.id], response.data, { revalidate: false })
|
|
18
|
+
const response: GraphQLResponse<MutateCreate{{Singular}}Data> = await mutateCreate{{Singular}}({ request: {} })
|
|
19
|
+
if (response.success) await mutate([QUERY_{{singularUpper}}_SWR_KEY, response.data.id], response, { revalidate: false })
|
|
19
20
|
return response
|
|
20
21
|
})
|
|
21
22
|
}
|
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
import useSWR from "swr"
|
|
2
|
+
import type { GraphQLResponse } from "@/modules/api"
|
|
2
3
|
import { query{{Singular}} } from "@/modules/api/graphql/queries/query-{{singular}}"
|
|
3
4
|
import type { Query{{Singular}}Data } from "@/modules/api/graphql/queries/types/{{singular}}"
|
|
4
5
|
|
|
5
6
|
/** The SWR key of the {{singular}} query; the full key carries the row id, the identity of its result. */
|
|
6
7
|
export const QUERY_{{singularUpper}}_SWR_KEY = "QUERY_{{singularUpper}}_SWR"
|
|
7
8
|
|
|
8
|
-
/** One {{singular}} row of the signed-in reader by id; no id, no request.
|
|
9
|
+
/** One {{singular}} row of the signed-in reader by id, as its GraphQLResponse; no id, no request. */
|
|
9
10
|
export const useQuery{{Singular}}Swr = (id: string | undefined) =>
|
|
10
11
|
useSWR(
|
|
11
12
|
id === undefined ? null : ([QUERY_{{singularUpper}}_SWR_KEY, id] as const),
|
|
12
|
-
|
|
13
|
-
const response = await query{{Singular}}({ request: { id: rowId } })
|
|
14
|
-
if (!response.success) throw new Error(response.message, { cause: response })
|
|
15
|
-
return response.data
|
|
16
|
-
},
|
|
13
|
+
([, rowId]): Promise<GraphQLResponse<Query{{Singular}}Data>> => query{{Singular}}({ request: { id: rowId } }),
|
|
17
14
|
)
|