@starci/eslint-canon-be 3.4.40 → 3.4.41
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 +7 -0
- package/README.md +2 -2
- package/boundary-schema.mjs +32 -4
- package/idempotency.mjs +3 -0
- package/index.mjs +2 -0
- package/infra-owner.mjs +4 -3
- package/lib/client-idempotency.mjs +403 -0
- package/lib/persistence.mjs +1 -1
- package/lib/schema-values.mjs +90 -0
- package/lib/unit-spec.mjs +6 -0
- package/operations.mjs +197 -32
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
- package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
- package/runtime/knowledge/hfs/rules.yaml +168 -47
- package/runtime/knowledge/hfs/slots.yaml +110 -52
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
- package/runtime/modules/models/registry.yaml +3 -1
- package/runtime/modules/models/runtimes.yaml +2 -0
- package/runtime/scripts/api/git/ls-tree.mjs +5 -0
- package/runtime/scripts/api/git/show.mjs +6 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
- package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
- package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
- package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
- package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
- package/runtime/scripts/hfs/declaration-slots.mjs +12 -3
- package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +16 -6
- package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +2 -2
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/view.mjs +2 -1
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +94 -1
- package/runtime/scripts/lib/package-at.mjs +62 -0
- package/runtime/scripts/lib/same-text.mjs +5 -0
- package/runtime/scripts/lib/ts-analysis.mjs +10 -0
- package/suppression.mjs +1 -1
- package/unit-spec.mjs +66 -6
- package/uploads.mjs +112 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
- Changed: `operation-table-schemas` and `operation-contract-decidable` leave alone a stub table a spec keeps to itself, but judge one a spec exports (`export const`, `export { }`, `export default`); `rest-route-declared` never counts a table made in a spec as the app's declared table. The same stub table in a production file stays refused.
|
|
5
|
+
- Changed: R95 requires named zod input/output schema exports for every `defineOperations` entry (`operation-table-schemas`) and a canon `OperationValidationPolicy` constructed with the same table on the route body (`operation-route-driven-by-table`). Schema provenance and route binding are proved in both editions, including strict empty inputs, aliases, mismatched tables and local imitations.
|
|
6
|
+
|
|
7
|
+
## 3.4.41 - 2026-10-25
|
|
8
|
+
- Changed: the bundled canon pins select @starci/stylelint-canon 2.0.11, @starci/hfs 4.7.33 and @starci/cli 1.7.33.
|
|
9
|
+
|
|
3
10
|
## 3.4.40 - 2026-10-25
|
|
4
11
|
- Changed: the bundled canon pins select @starci/grammar 0.11.0, @starci/hfs 4.7.32 and @starci/cli 1.7.32.
|
|
5
12
|
|
package/README.md
CHANGED
|
@@ -53,14 +53,14 @@ A sample, not the list:
|
|
|
53
53
|
| **Log safety** | A call on the `Logger` port (found by the receiver's type) passes no `Secret` or `Pii` value and no value named like a credential or a personal identifier (`no-secret-in-log`); a constructed error (its type derives from `Error`) carries no `Secret` value and no text value named like a credential (`no-secret-in-error`) |
|
|
54
54
|
| **Async and types** | An `async` function awaits; handlers and public methods declare a return type; no `Function` type, no `eval`; an inline object type takes a name; no `const enum`. Casts, `x!` and `any` are refused in every file, specs included, by the typescript-eslint rules the factory turns on (`async-needs-await`, `explicit-handler-return-type`, `no-function-or-eval`, `no-inline-param-type`, `no-inline-object-type`, `no-const-enum`) |
|
|
55
55
|
| **Migrations and input** | A migration's `down()` reverses its `up()`; every property of an input class has the `class-validator` decorators its type calls for (string `@MaxLength`, enum `@IsEnum`, array `@ArrayMaxSize`, nested `@ValidateNested()` and `@Type`); reads page by cursor, never by `skip`, `offset` or `OFFSET`; a door opened for `PublicReason.AuthHandshake` or `PublicReason.SignedWebhook` carries `RateTier.Strict` of `platform/http-security` on the method or its class (`migration-down-reversible`, `input-bounded`, `no-offset-pagination`, `auth-door-strict-rate-tier`) |
|
|
56
|
-
| **Boundary schema** (R304, R305) | `zod` reads only boundary data that never reaches a DTO (a provider response, model output, a queue or outbox payload read back): it is imported only from a `<name>.schema.ts` of a domain, platform or integrations owner, never in a feature, a transport door or a `dto/` folder, and a schema file imports no `class-validator`, `class-transformer` or `@nestjs/graphql` (`schema-place`); every string, array, set, record and map of a schema has an upper bound and `z.any()`, `z.unknown()` and `z.json()` do not exist (`schema-bounded`) |
|
|
56
|
+
| **Boundary schema** (R304, R305) | `zod` reads only boundary data that never reaches a DTO (a provider response, model output, a queue or outbox payload read back): it is imported only from a `<name>.schema.ts` of a domain, platform or integrations owner, never in a feature, a transport door or a `dto/` folder, and a schema file imports no `class-validator`, `class-transformer` or `@nestjs/graphql` (`schema-place`) and holds only zod values and types (`schema-file-values-only`; its spec is exempt from the testing module of R281 only while this holds); every string, array, set, record and map of a schema has an upper bound and `z.any()`, `z.unknown()` and `z.json()` do not exist (`schema-bounded`) |
|
|
57
57
|
| **Spec quality** | A spec does not read repository source with `fs` (`spec-no-source-read`); the borrowed `consistent-type-assertions` (`never`) and `no-non-null-assertion` leave no `as` and no `x!` in a spec, and `no-ambient-clock` and `no-direct-env-read` cover specs, so no `Date.now()`, argless `new Date()` or `process.env`. A unit spec is `<name>.service.spec.ts`: it builds its subject with `Test.createTestingModule({ providers })` from `@nestjs/testing`, `.compile()` and `moduleRef.get(...)` (`spec-builds-with-testing-module`), never `new` on the service (`spec-no-new-subject`), no `imports` key and no `override*` (`spec-module-definition-only-providers`), no `jest.mock`/`doMock`/`unstable_mockModule`/`requireMock` of an own module or a library (`spec-no-module-mock`), no cast-laundering helper in a spec or fixture: a return-only generic, an `unknown` handed back as another type, `JSON.parse(JSON.stringify(x))`, `Object.assign(new X(), y)` returned as another type (`no-return-only-generic`); a provider `{ provide: TOKEN, useValue: X }` takes X from the matching `@starci/jest-preset` double, the token table being `ruleParams.be.specDoubles` of the slot manifest (`spec-infra-double-from-kit`); a unit spec asserts exact values, never `expect.any(String)`, `expect.any(Number)` or `expect.any(Date)` (`expect` being the jest global or the `@jest/globals` import): ids come from `fakeIds()` and dates from `FakeClock` (`spec-exact-values`); the EntityManager double is the kit's `mockEntityManager()`/`fakeTransaction()`, never ad hoc (`spec-typed-entity-manager`); test data goes through builders, see the **Test data builders** row |
|
|
58
58
|
| **Test data builders** (R98 to R101) | Builders live only in `src/tests/fixtures/builders/<area>.builder.ts`: a module of the test tree that exports a function creating persisted rows through an EntityManager, DataSource or QueryRunner is a builder wherever it sits (`persisting-builder-in-builders-slot`, R98); a spec never runs a raw INSERT/UPDATE/DELETE or a direct write (`save`, `insert`, `upsert`, `update`, `delete`, `remove`) on a typeorm receiver and never builds the same `@Entity` class as an object literal twice (`spec-no-raw-insert`, `spec-no-repeated-row-literal`, R99); a builder never asserts and has deterministic defaults (`builder-arranges-only`, R101); nothing under `src/tests` disables or drops a constraint (`tests-keep-constraints`, R100) |
|
|
59
59
|
| **CQRS** | A message extends `Command<R>` / `Query<R>` from `@nestjs/cqrs`, carries one `readonly params: ExecuteParams<X>` (no entity reachable from `X`), a handler extends `ICQRSHandler` and overrides `process`; a handler is thin: it injects only `*Service` classes and the Logger, and `process` is one `return this.<service>.<method>(...)` with no branch, loop, EntityManager or second call (`handler-is-thin`); no `*.use-case.ts`, no forwarder service, no `EventBus`, `@nestjs/event-emitter`, `EventEmitter` or RxJS `Subject` constructed or extended, and no class that stores callbacks other code registers (`handler-overrides-process`, `message-carries-params-only`, `message-typed-result`, `execute-params-shape`, `no-use-case`, `no-event-bus`) |
|
|
60
60
|
| **Module layering** | Another owner is imported through its public `index.ts` (`@modules/domain/plan`, `@features/api/plan`), never through a path into it, and a same-owner import is relative and never through the own index (`import-owner-entry`); an owner has one `index.ts` (a nested one is refused) that holds only named `export { }` lines, with no `export *`, and a bounded number of names (`no-folder-reexport`); self-aliases and relative escapes are refused (`no-self-module-alias`, `no-relative-capability-escape`). Owners come from the HFS slots, aliases from the program's `compilerOptions.paths`. |
|
|
61
61
|
| **Data access** | The database is reached through the shared `EntityManager` only, received as a constructor parameter with the named injector of a declared connection - a lookalike name, property injection, an injected `DataSource` or `QueryRunner` outside `platform/database` and the cli (`apps/cli`, `src/features/cli/`), `getRepository` and repository types are refused, specs included (`must-inject-entity-manager`, `named-entity-manager-only`, `no-injected-repository`); a `DataSource` or `QueryRunner` is not written as a type, awaited value or `provide:` token outside those places, and a class that receives a wrapper exposing one is refused (`named-entity-manager-only`); a class outside an application handler, a domain service and a platform persistence capability takes, holds or returns no `EntityManager`, `Repository`, `DataSource` or `QueryRunner` whatever it is called, and no exported function takes an `EntityManager` first (a statement module); `src/tests` is exempt (`no-repository-class`); a `DataSource` or `QueryRunner` is not written as a type, awaited value or `provide:` token outside those places, and a class that receives a wrapper exposing one is refused (`named-entity-manager-only`); a class outside an application handler, a domain service and a platform persistence capability takes, holds or returns no `EntityManager`, `Repository`, `DataSource` or `QueryRunner`, whatever it is called, and no exported function takes an `EntityManager` first (a statement module); the test tree (`src/tests`) is exempt (`no-repository-class`); inside a transaction callback only the manager it received is used (`no-outer-manager-in-transaction`); no call through an integration, `HttpClient` or `MessagePublisher` typed receiver spans a transaction (`no-external-call-in-transaction`); the manager is injected only in application handlers, domain services and the platform database, inbox, outbox and lease capabilities (`em-injection-slots`) |
|
|
62
62
|
| **Connections** | One database is one connection and one `Inject<Conn>EntityManager()` injector: `InjectEntityManager(`, `InjectDataSource(` and `getEntityManagerToken(` are called only in `platform/database/<conn>.decorators.ts`, the cli and the test world; an exported EntityManager injector is exactly the declared connection's; `TypeOrmModule.forRoot*` and `new DataSource(` live in the platform database capability and the cli; the connection name is written once, in its `<conn>.connection.ts`; a `<conn>.config.ts` reads only keys of its `envPrefix` (`one-connection-per-database`) |
|
|
63
|
-
| **Operations** | A versioned operation is declared once in the app's typed operation table (`query
|
|
63
|
+
| **Operations** | A versioned operation is declared once in the app's typed operation table (`query({ input, output, refusal })` / `mutation(...)` registered by `defineOperations`, declared by the platform capability `operations`): bounded input and output schemas come from an owner's `*.schema.ts` file, their inferred types are closed, and refusal codes form a closed union of string literals. A route takes `OperationRequest<Table>`, validates it with the canon `OperationValidationPolicy` constructed from that same table, and answers `Promise<OperationReply<Table>>` (`operation-contract-decidable`, `operation-table-schemas`, `operation-route-driven-by-table`) |
|
|
64
64
|
| **Webhooks and realtime** | A provider webhook door injects only the `WebhookSignatureService` and domain services, verifies first (awaited when async), and hands the POST delivery to exactly one domain intake that records the inbox receipt and outbox event; a domain method may combine proof and intake. The POST returns nothing or the own-feature exported literal `PROVIDER_ACK`. One optional GET challenge handler returns only its declared domain verifier's result. The door and HTTP module import or inject no `CommandBus`, `QueryBus` or feature command handler, and its resolved domain intake dispatches no command/query bus or feature handler; a reactor or job dispatches after commit (`webhook-shape`, `webhook-verify-first`, `feature-thin`). A realtime door is one gateway or subscription that injects only the `RealtimeHub` and subscribes to a topic built from its `@CurrentPrincipal()` (`realtime-shape`, `realtime-read-only`, `realtime-topic-scope`) |
|
|
65
65
|
| **Transport** | A door injects only `CommandBus` / `QueryBus` (and `RequestLocale` in REST) and dispatches exactly one message per handler, holding no branch, loop, EntityManager or call other than the bus, and pure `*.mapper.ts` functions and `unwrapOutcome` of `platform/primitives` (`transport-is-thin`); no response envelope, no `GraphQLJSON`, no `unknown` body, no `switch (input.operation)`; a REST door lives in `transport/http` and shows a public reason, a byte stream or a redirect; a capability never imports a feature (`transport-is-thin`, `no-response-envelope`, `no-graphql-json`, `door-lives-in-features`, `rest-door-needs-a-reason`, `no-capability-imports-features`) |
|
|
66
66
|
| **Feature thin** (R203 BE_FEATURE_THIN) | A feature-tier file of a thin role (`<name>.handler.ts`, `.resolver.ts`, `.controller.ts`, `.consumer.ts`, `.processor.ts`, `.step.ts`, `.saga.ts`, `.saga-step.ts`, `.compensation.ts`, `.webhook.ts`, `.gateway.ts`, `.subscription.ts`, `.cli.ts`, `.mapper.ts`, `.service.ts`) maps its parameters and makes exactly one delegating call into the modules - an injected member, a bus `execute` or a modules function in statement position - with no branch, loop, `try`, `throw` or computation; free calls are pure `*.mapper.ts` functions, the `Logger` port and a webhook's first verification call when followed by one separate intake (a combined domain verification and intake call counts as the delegation). A webhook POST may return its declared literal `PROVIDER_ACK`; its optional GET challenge has one domain verifier call and echoes only that result. Webhook doors and their HTTP modules reach no command/query bus or feature command handler; orchestration (step order, compensation, claim, fencing, retry) belongs to the platform (`feature-thin`) |
|
package/boundary-schema.mjs
CHANGED
|
@@ -17,16 +17,17 @@
|
|
|
17
17
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
18
18
|
import { normalizePath } from "./lib/path.mjs"
|
|
19
19
|
import { isTransportSlot } from "./lib/transport-slots.mjs"
|
|
20
|
+
import { SCHEMA_TIERS, schemaFileProblems } from "./lib/schema-values.mjs"
|
|
20
21
|
|
|
21
22
|
const ZOD = /^zod(?:\/|$)/
|
|
22
23
|
/** The imports of the other validation layer, which never share a file with a schema. */
|
|
23
24
|
const MIXED = new Set(["class-validator", "class-transformer", "@nestjs/graphql"])
|
|
24
25
|
const SCHEMA_FILE = /\.schema(?:\.spec)?\.ts$/
|
|
25
|
-
/** Tiers a schema file may live in: the owners of boundary data. */
|
|
26
|
-
const SCHEMA_TIERS = new Set(["domain", "platform", "integrations"])
|
|
27
26
|
|
|
28
27
|
/** Constructors that build a string-valued schema and owe a `.max(n)` or `.length(n)`. */
|
|
29
|
-
const STRINGY = new Set(["string", "email", "url", "httpUrl", "hostname", "base64", "base64url", "jwt", "emoji", "hex"])
|
|
28
|
+
const STRINGY = new Set(["string", "email", "url", "httpUrl", "hostname", "base64", "base64url", "jwt", "emoji", "hex", "iso.datetime", "iso.date", "iso.time", "iso.duration"])
|
|
29
|
+
/** The ISO namespace constructs strings; `z.date()` instead constructs a Date-valued schema. */
|
|
30
|
+
const ISO_STRINGY = new Set(["datetime", "date", "time", "duration"])
|
|
30
31
|
/** Constructors that build a counted container: bounded by `.max(n)`, `.length(n)` or `.size(n)`. */
|
|
31
32
|
const COUNTED = new Set(["array", "set"])
|
|
32
33
|
/** Constructors whose entry count only a refinement can bound. */
|
|
@@ -66,11 +67,16 @@ const chainOf = (node) => {
|
|
|
66
67
|
return calls
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
/** Resolves a
|
|
70
|
+
/** Resolves a zod constructor, including `z.iso.datetime()` and a renamed named import of `iso`. */
|
|
70
71
|
const constructorOf = (callee, namespaces, named) => {
|
|
71
72
|
if (callee.type === "Identifier") return named.get(callee.name) ?? null
|
|
72
73
|
if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return null
|
|
73
74
|
let object = callee.object
|
|
75
|
+
if (ISO_STRINGY.has(callee.property.name)) {
|
|
76
|
+
const importedIso = object.type === "Identifier" && named.get(object.name) === "iso"
|
|
77
|
+
const namespaceIso = object.type === "MemberExpression" && !object.computed && object.property.type === "Identifier" && object.property.name === "iso" && object.object.type === "Identifier" && namespaces.has(object.object.name)
|
|
78
|
+
if (importedIso || namespaceIso) return `iso.${callee.property.name}`
|
|
79
|
+
}
|
|
74
80
|
while (object.type === "MemberExpression" && !object.computed && object.property.type === "Identifier" && PASS_THROUGH.has(object.property.name)) object = object.object
|
|
75
81
|
return object.type === "Identifier" && namespaces.has(object.name) ? callee.property.name : null
|
|
76
82
|
}
|
|
@@ -184,14 +190,36 @@ export const schemaPlace = {
|
|
|
184
190
|
},
|
|
185
191
|
}
|
|
186
192
|
|
|
193
|
+
/** What a schema file holds: zod values and types, nothing that runs. */
|
|
194
|
+
export const schemaFileValuesOnly = {
|
|
195
|
+
meta: {
|
|
196
|
+
type: "problem",
|
|
197
|
+
docs: { description: "A schema file imports zod and declares only zod schemas and their types: no class, provider, function or other logic." },
|
|
198
|
+
schema: [],
|
|
199
|
+
messages: {
|
|
200
|
+
logic: "[BE_SCHEMA_PLACE] This schema file is not a boundary schema: {{problem}}. A schema file imports zod and declares only zod values and their inferred types; logic belongs in a service, policy or mapper under its own role suffix.",
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
create(context) {
|
|
204
|
+
if (!SCHEMA_FILE.test(normalizePath(context.filename)) || context.filename.endsWith(".spec.ts")) return {}
|
|
205
|
+
return {
|
|
206
|
+
"Program:exit"(node) {
|
|
207
|
+
for (const problem of schemaFileProblems(context.sourceCode.text)) context.report({ node, messageId: "logic", data: { problem } })
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
|
|
187
213
|
/** The rules this law contributes to the plugin. */
|
|
188
214
|
export const rules = {
|
|
189
215
|
"schema-bounded": schemaBounded,
|
|
190
216
|
"schema-place": schemaPlace,
|
|
217
|
+
"schema-file-values-only": schemaFileValuesOnly,
|
|
191
218
|
}
|
|
192
219
|
|
|
193
220
|
/** Both start at error: no baseline exists. */
|
|
194
221
|
export const recommended = {
|
|
195
222
|
"starci-be/schema-bounded": "error",
|
|
196
223
|
"starci-be/schema-place": "error",
|
|
224
|
+
"starci-be/schema-file-values-only": "error",
|
|
197
225
|
}
|
package/idempotency.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { walk } from "./lib/ast.mjs"
|
|
|
29
29
|
import { baseName, isOwnedType, ownerNameOf } from "./lib/ports.mjs"
|
|
30
30
|
import { resolveVariable } from "./lib/transactions.mjs"
|
|
31
31
|
import { isPackageType, typed } from "./lib/types.mjs"
|
|
32
|
+
import { mutationIdempotent } from "./lib/client-idempotency.mjs"
|
|
32
33
|
|
|
33
34
|
/** The `Inbox` port of `platform/inbox`. */
|
|
34
35
|
const isInboxType = (context, node) => isOwnedType(context, node, { name: "Inbox", capability: "inbox", tier: "platform" })
|
|
@@ -175,9 +176,11 @@ export const inboxDedupeRequired = {
|
|
|
175
176
|
/** The rule this law contributes to the plugin. */
|
|
176
177
|
export const rules = {
|
|
177
178
|
"inbox-dedupe-required": inboxDedupeRequired,
|
|
179
|
+
"mutation-idempotent": mutationIdempotent,
|
|
178
180
|
}
|
|
179
181
|
|
|
180
182
|
/** Starts at error: a duplicate charge or a duplicate side effect is never a warning. */
|
|
181
183
|
export const recommended = {
|
|
182
184
|
"starci-be/inbox-dedupe-required": "error",
|
|
185
|
+
"starci-be/mutation-idempotent": "error",
|
|
183
186
|
}
|
package/index.mjs
CHANGED
|
@@ -62,6 +62,7 @@ import { recommended as testWorldRecommended, rules as testWorldRules } from "./
|
|
|
62
62
|
import { recommended as transportRecommended, rules as transportRules } from "./transport.mjs"
|
|
63
63
|
import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
|
|
64
64
|
import { recommended as unitSpecRecommended, rules as unitSpecRules } from "./unit-spec.mjs"
|
|
65
|
+
import { recommended as uploadsRecommended, rules as uploadsRules } from "./uploads.mjs"
|
|
65
66
|
import { recommended as userCopyRecommended, rules as userCopyRules } from "./user-copy.mjs"
|
|
66
67
|
import { recommended as webhooksRecommended, rules as webhooksRules } from "./webhooks.mjs"
|
|
67
68
|
import { recommended as realtimeRecommended, rules as realtimeRules } from "./realtime.mjs"
|
|
@@ -116,6 +117,7 @@ const CONTRIBUTIONS = [
|
|
|
116
117
|
{ law: "transport", rules: transportRules, recommended: transportRecommended },
|
|
117
118
|
{ law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
|
|
118
119
|
{ law: "unit-spec", rules: unitSpecRules, recommended: unitSpecRecommended },
|
|
120
|
+
{ law: "uploads", rules: uploadsRules, recommended: uploadsRecommended },
|
|
119
121
|
{ law: "user-copy", rules: userCopyRules, recommended: userCopyRecommended },
|
|
120
122
|
{ law: "webhooks", rules: webhooksRules, recommended: webhooksRecommended },
|
|
121
123
|
{ law: "realtime", rules: realtimeRules, recommended: realtimeRecommended },
|
package/infra-owner.mjs
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
*
|
|
14
14
|
* The test world (slot `be.tests.world`, `src/tests/world`) is an infrastructure owner like the platform capabilities: it is
|
|
15
15
|
* the TEST COMPOSITION ROOT that starts docker, polls readiness, serves the network-edge fakes over `node:http` and drives the
|
|
16
|
-
* apps over HTTP with a raw client, and it has no Nest container to inject a port from. It may use
|
|
17
|
-
* table gives an owner
|
|
16
|
+
* apps over HTTP with a raw client, and it has no Nest container to inject a port from. It may use libraries the
|
|
17
|
+
* table gives an owner except logging infrastructure, whose ambient state stays inside its sole owner. A library the
|
|
18
|
+
* table owns by nobody (`[]`, `nowhere`) is still refused there. A spec does not
|
|
18
19
|
* compose: specs stay refused and reach infrastructure through `useTestWorld(...)`.
|
|
19
20
|
*
|
|
20
21
|
* `no-framework-logger` (R40) and `no-ambient-clock` (R79) judge Nest `Logger` and `Date`; the table holds neither.
|
|
@@ -64,7 +65,7 @@ export const infraImportOwner = {
|
|
|
64
65
|
const isWorld = inTestWorld(hfs, context.filename)
|
|
65
66
|
const check = (node, name, key) => {
|
|
66
67
|
const owners = table[key]
|
|
67
|
-
if (owners.includes(here) || (isWorld && owners.length > 0)) return
|
|
68
|
+
if (owners.includes(here) || (isWorld && owners.length > 0 && !owners.includes("platform/logging"))) return
|
|
68
69
|
if (owners.length === 0) context.report({ node, messageId: "nowhere", data: { name } })
|
|
69
70
|
else context.report({ node, messageId: "foreign", data: { name, owners: owners.map((owner) => `\`${owner}\``).join(", ") } })
|
|
70
71
|
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/** BE-IDEMPOTENCY-3: marked client commands preserve their key and execute through the transactional inbox. */
|
|
2
|
+
import ts from "typescript"
|
|
3
|
+
import { hfsOf } from "./hfs.mjs"
|
|
4
|
+
import { baseName, ownerNameOf } from "./ports.mjs"
|
|
5
|
+
import { moduleOf, originsOfType, typed } from "./types.mjs"
|
|
6
|
+
import { isTransportSlot } from "./transport-slots.mjs"
|
|
7
|
+
|
|
8
|
+
/** Imported aliases and namespace access resolve to the declaring value. */
|
|
9
|
+
const valueSymbol = (checker, expression, seen = new Set()) => {
|
|
10
|
+
if (!expression || seen.has(expression)) return undefined
|
|
11
|
+
seen.add(expression)
|
|
12
|
+
const name = ts.isPropertyAccessExpression(expression) ? expression.name : expression
|
|
13
|
+
const symbol = name.parent && ts.isShorthandPropertyAssignment(name.parent) && name.parent.name === name
|
|
14
|
+
? checker.getShorthandAssignmentValueSymbol(name.parent) : checker.getSymbolAtLocation(name)
|
|
15
|
+
const target = symbol?.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
|
|
16
|
+
const alias = target?.getDeclarations()?.find((declaration) => ts.isVariableDeclaration(declaration)
|
|
17
|
+
&& declaration.parent.flags & ts.NodeFlags.Const && declaration.initializer
|
|
18
|
+
&& (ts.isIdentifier(declaration.initializer) || ts.isPropertyAccessExpression(declaration.initializer)))
|
|
19
|
+
return alias ? valueSymbol(checker, alias.initializer, seen) : target
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The genuine decorator belongs to its platform capability, rather than merely sharing a name. */
|
|
23
|
+
const ownedValue = (checker, hfs, expression, name, capability) => {
|
|
24
|
+
const symbol = valueSymbol(checker, expression)
|
|
25
|
+
return symbol?.name === name && symbol.getDeclarations()?.some((declaration) => {
|
|
26
|
+
const file = declaration.getSourceFile().fileName
|
|
27
|
+
return moduleOf(declaration, file) === null && hfs.tierOf(file) === "platform" && ownerNameOf(hfs, file) === capability
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A resolved decorator declaration and its call arguments. */
|
|
32
|
+
const decorators = (checker, node) => (ts.getDecorators(node) ?? []).map((decorator) => {
|
|
33
|
+
const call = decorator.expression
|
|
34
|
+
const expression = ts.isCallExpression(call) ? call.expression : call
|
|
35
|
+
const symbol = valueSymbol(checker, expression)
|
|
36
|
+
return { expression, symbol, args: ts.isCallExpression(call) ? call.arguments : [], called: ts.isCallExpression(call) }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/** Whether the class carries the platform inbox's effectful marker. */
|
|
40
|
+
const effectful = (checker, hfs, node) => decorators(checker, node).some((decorator) => decorator.called
|
|
41
|
+
&& decorator.args.length === 0 && ownedValue(checker, hfs, decorator.expression, "Effectful", "inbox"))
|
|
42
|
+
|
|
43
|
+
/** A package declaration establishes framework identity even through an import alias. */
|
|
44
|
+
const fromPackage = (checker, expression, name, pkg) => {
|
|
45
|
+
const symbol = valueSymbol(checker, expression)
|
|
46
|
+
return symbol?.name === name && symbol.getDeclarations()?.some((declaration) => moduleOf(declaration, declaration.getSourceFile().fileName) === pkg)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Class inheritance keeps the Nest Command identity without relying on local spelling. */
|
|
50
|
+
const commandType = (checker, type, seen = new Set()) => {
|
|
51
|
+
if (!type || seen.has(type)) return false
|
|
52
|
+
seen.add(type)
|
|
53
|
+
if (originsOfType(checker, type).some((origin) => origin.name === "Command" && origin.module === "@nestjs/cqrs")) return true
|
|
54
|
+
return type.isClassOrInterface() && (checker.getBaseTypes(type) ?? []).some((base) => commandType(checker, base, seen))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Required strings exclude optional properties, undefined, null and unconstrained types. */
|
|
58
|
+
const requiredString = (checker, type, property) => {
|
|
59
|
+
const member = checker.getPropertyOfType(type, property)
|
|
60
|
+
if (!member || member.flags & ts.SymbolFlags.Optional) return false
|
|
61
|
+
const declaration = member.valueDeclaration ?? member.declarations?.[0]
|
|
62
|
+
if (!declaration) return false
|
|
63
|
+
const value = checker.getTypeOfSymbolAtLocation(member, declaration)
|
|
64
|
+
return (value.isUnion() ? value.types : [value]).every((part) => !!(part.flags & ts.TypeFlags.StringLike))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Canonical ExecuteParams carries the request, while a direct client params object carries its own key. */
|
|
68
|
+
const keyContract = (checker, type) => {
|
|
69
|
+
if (requiredString(checker, type, "idempotencyKey")) return true
|
|
70
|
+
const request = checker.getPropertyOfType(type, "request")
|
|
71
|
+
const declaration = request?.valueDeclaration ?? request?.declarations?.[0]
|
|
72
|
+
return !!declaration && !(request.flags & ts.SymbolFlags.Optional)
|
|
73
|
+
&& requiredString(checker, checker.getTypeOfSymbolAtLocation(request, declaration), "idempotencyKey")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A sole returned expression admits an optional await and inline expression-bodied callbacks. */
|
|
77
|
+
const returned = (body) => {
|
|
78
|
+
if (!body) return null
|
|
79
|
+
let expression = ts.isBlock(body) ? body.statements.length === 1 && ts.isReturnStatement(body.statements[0]) ? body.statements[0].expression : null : body
|
|
80
|
+
while (expression && ts.isParenthesizedExpression(expression)) expression = expression.expression
|
|
81
|
+
return expression && ts.isAwaitExpression(expression) ? expression.expression : expression
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Only a literal member name has a statically proved operation. */
|
|
85
|
+
const memberName = (expression) => ts.isPropertyAccessExpression(expression) ? expression.name.text : null
|
|
86
|
+
|
|
87
|
+
/** Computed literal names have ordinary last-write semantics; dynamic names could overwrite any field. */
|
|
88
|
+
const propertyName = (name) => ts.isComputedPropertyName(name)
|
|
89
|
+
? ts.isStringLiteralLike(name.expression) ? name.expression.text : null
|
|
90
|
+
: ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name) ? name.text : null
|
|
91
|
+
|
|
92
|
+
/** Property mappings retain their last assignment, including spreads, as JavaScript does. */
|
|
93
|
+
const propertyValue = (checker, expression, name) => {
|
|
94
|
+
if (!expression) return null
|
|
95
|
+
if (!ts.isObjectLiteralExpression(expression)) return checker.getPropertyOfType(checker.getTypeAtLocation(expression), name) ? { base: expression, name } : null
|
|
96
|
+
let result = null
|
|
97
|
+
for (const property of expression.properties) {
|
|
98
|
+
if (ts.isSpreadAssignment(property)) {
|
|
99
|
+
const type = checker.getTypeAtLocation(property.expression)
|
|
100
|
+
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown) || checker.getIndexTypeOfType(type, ts.IndexKind.String)) result = null
|
|
101
|
+
else if (checker.getPropertyOfType(type, name)) result = { base: property.expression, name }
|
|
102
|
+
} else if (propertyName(property.name) === null) result = null
|
|
103
|
+
else if (propertyName(property.name) === name) {
|
|
104
|
+
result = ts.isPropertyAssignment(property) ? { expression: property.initializer }
|
|
105
|
+
: ts.isShorthandPropertyAssignment(property) ? { expression: property.name } : null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A property path is rooted in the same lexical parameter, so a renamed or shadowed value cannot invent a key. */
|
|
112
|
+
const parameterPath = (checker, expression, parameter) => {
|
|
113
|
+
const path = []
|
|
114
|
+
let current = expression
|
|
115
|
+
while (current && ts.isPropertyAccessExpression(current) && !current.questionDotToken) {
|
|
116
|
+
path.unshift(current.name.text)
|
|
117
|
+
current = current.expression
|
|
118
|
+
}
|
|
119
|
+
return current && ts.isIdentifier(current) && valueSymbol(checker, current) === valueSymbol(checker, parameter.name) ? path : null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A mapped field has the expected path from the original parameter. */
|
|
123
|
+
const mappedFrom = (checker, mapping, parameter, paths) => {
|
|
124
|
+
if (!mapping) return false
|
|
125
|
+
const path = parameterPath(checker, mapping.expression ?? mapping.base, parameter)
|
|
126
|
+
if (!path) return false
|
|
127
|
+
if (mapping.name) path.push(mapping.name)
|
|
128
|
+
return paths.some((expected) => path.length === expected.length && path.every((part, index) => part === expected[index]))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Type origins bind the transactional manager and inbox port to their actual owners. */
|
|
132
|
+
const hasOrigin = (checker, hfs, expression, name, pkg, capability) => !!expression && originsOfType(checker, checker.getTypeAtLocation(expression)).some((origin) =>
|
|
133
|
+
origin.name === name && origin.module === pkg && (!capability || hfs.tierOf(origin.file) === "platform" && ownerNameOf(hfs, origin.file) === capability))
|
|
134
|
+
|
|
135
|
+
/** Rejects visible calls, writes, constructions and explicit getter accesses before the inbox claim. */
|
|
136
|
+
const pureEnvelope = (checker, expression) => {
|
|
137
|
+
let pure = true
|
|
138
|
+
const visit = (node) => {
|
|
139
|
+
if (ts.isCallExpression(node) || ts.isNewExpression(node) || ts.isAwaitExpression(node)
|
|
140
|
+
|| ts.isBinaryExpression(node) && node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && node.operatorToken.kind <= ts.SyntaxKind.LastAssignment
|
|
141
|
+
|| ts.isPrefixUnaryExpression(node) && [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(node.operator)
|
|
142
|
+
|| ts.isPostfixUnaryExpression(node)) pure = false
|
|
143
|
+
if (ts.isPropertyAccessExpression(node) && valueSymbol(checker, node)?.getDeclarations()?.some(ts.isGetAccessorDeclaration)) pure = false
|
|
144
|
+
ts.forEachChild(node, visit)
|
|
145
|
+
}
|
|
146
|
+
visit(expression)
|
|
147
|
+
return pure
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The service returns its transaction, whose actual callback returns the inbox execution with that callback's own manager. */
|
|
151
|
+
const transactionalClient = (checker, hfs, method) => {
|
|
152
|
+
const transaction = returned(method.body)
|
|
153
|
+
if (!transaction || !ts.isCallExpression(transaction) || transaction.questionDotToken || transaction.expression.questionDotToken || memberName(transaction.expression) !== "transaction"
|
|
154
|
+
|| !hasOrigin(checker, hfs, transaction.expression.expression, "EntityManager", "typeorm")) return false
|
|
155
|
+
const first = transaction.arguments[0]
|
|
156
|
+
const callback = transaction.arguments.length === 1 ? first : transaction.arguments.length === 2
|
|
157
|
+
&& ts.isStringLiteral(first) && ["READ UNCOMMITTED", "READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"].includes(first.text) ? transaction.arguments[1] : null
|
|
158
|
+
if (!callback || ![ts.SyntaxKind.ArrowFunction, ts.SyntaxKind.FunctionExpression].includes(callback.kind)
|
|
159
|
+
|| callback.parameters.length !== 1 || !ts.isIdentifier(callback.parameters[0].name)) return false
|
|
160
|
+
const execution = returned(callback.body)
|
|
161
|
+
if (!execution || !ts.isCallExpression(execution) || execution.questionDotToken || execution.expression.questionDotToken || memberName(execution.expression) !== "runClient"
|
|
162
|
+
|| !hasOrigin(checker, hfs, execution.expression.expression, "Inbox", null, "inbox") || execution.arguments.length !== 3) return false
|
|
163
|
+
const [envelope, manager, effect] = execution.arguments
|
|
164
|
+
const original = method.parameters[0]
|
|
165
|
+
if (!original || !ts.isIdentifier(original.name) || !requiredString(checker, checker.getTypeAtLocation(original), "idempotencyKey")
|
|
166
|
+
|| !ts.isIdentifier(manager) || valueSymbol(checker, manager) !== valueSymbol(checker, callback.parameters[0].name)
|
|
167
|
+
|| !hasOrigin(checker, hfs, manager, "EntityManager", "typeorm") || !ts.isObjectLiteralExpression(envelope)
|
|
168
|
+
|| ![ts.SyntaxKind.ArrowFunction, ts.SyntaxKind.FunctionExpression].includes(effect.kind) || effect.parameters.length !== 0
|
|
169
|
+
|| !pureEnvelope(checker, envelope)) return false
|
|
170
|
+
const key = propertyValue(checker, envelope, "idempotencyKey")
|
|
171
|
+
const principal = propertyValue(checker, envelope, "principalId")
|
|
172
|
+
const operation = propertyValue(checker, envelope, "operation")?.expression
|
|
173
|
+
return mappedFrom(checker, key, original, [["idempotencyKey"]]) && !!principal?.expression
|
|
174
|
+
&& (parameterPath(checker, principal.expression, original)?.length ?? 0) > 0
|
|
175
|
+
&& !!operation && ts.isStringLiteralLike(operation) && operation.text.length > 0
|
|
176
|
+
&& !!propertyValue(checker, envelope, "request")
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The CommandHandler decorator resolves its marked command declaration. */
|
|
180
|
+
const handledCommand = (checker, hfs, node) => {
|
|
181
|
+
for (const decorator of decorators(checker, node)) {
|
|
182
|
+
if (!fromPackage(checker, decorator.expression, "CommandHandler", "@nestjs/cqrs") || decorator.args.length !== 1) continue
|
|
183
|
+
const command = valueSymbol(checker, decorator.args[0])?.getDeclarations()?.find(ts.isClassDeclaration)
|
|
184
|
+
if (command && effectful(checker, hfs, command)) return command
|
|
185
|
+
}
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A marked handler delegates once to a concrete domain service and preserves the request's key. */
|
|
190
|
+
const delegatedMethod = (checker, hfs, handler) => {
|
|
191
|
+
const process = handler.members.find((member) => ts.isMethodDeclaration(member) && member.name.getText() === "process")
|
|
192
|
+
const call = returned(process?.body)
|
|
193
|
+
if (!process || process.parameters.length !== 1 || !ts.isIdentifier(process.parameters[0].name)
|
|
194
|
+
|| !call || !ts.isCallExpression(call) || call.questionDotToken || call.expression.questionDotToken || call.arguments.length !== 1) return null
|
|
195
|
+
const method = checker.getResolvedSignature(call)?.declaration
|
|
196
|
+
if (!method || !ts.isMethodDeclaration(method) || !method.body
|
|
197
|
+
|| hfs.tierOf(method.getSourceFile().fileName) !== "domain" || !baseName(method.getSourceFile().fileName).endsWith(".service.ts")) return null
|
|
198
|
+
const mapping = propertyValue(checker, call.arguments[0], "idempotencyKey")
|
|
199
|
+
return mappedFrom(checker, mapping, process.parameters[0], [["params", "idempotencyKey"], ["params", "request", "idempotencyKey"]]) ? method : null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Every reached service method is judged when that file is linted, including changed domain files. */
|
|
203
|
+
const reachedByProgram = new WeakMap()
|
|
204
|
+
const reachedServices = (checker, program, hfs) => {
|
|
205
|
+
const cached = reachedByProgram.get(program)
|
|
206
|
+
if (cached) return cached
|
|
207
|
+
const methods = new Set()
|
|
208
|
+
for (const source of program.getSourceFiles()) {
|
|
209
|
+
if (hfs.slotOf(source.fileName) !== "be.feature.application") continue
|
|
210
|
+
const visit = (node) => {
|
|
211
|
+
if (ts.isClassDeclaration(node) && handledCommand(checker, hfs, node)) {
|
|
212
|
+
const method = delegatedMethod(checker, hfs, node)
|
|
213
|
+
if (method) methods.add(method)
|
|
214
|
+
}
|
|
215
|
+
ts.forEachChild(node, visit)
|
|
216
|
+
}
|
|
217
|
+
visit(source)
|
|
218
|
+
}
|
|
219
|
+
reachedByProgram.set(program, methods)
|
|
220
|
+
return methods
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Genuine validators must reject empty, optional and overlong wire keys. */
|
|
224
|
+
const boundedKey = (checker, declaration) => {
|
|
225
|
+
if (!declaration || !ts.isPropertyDeclaration(declaration) || declaration.questionToken
|
|
226
|
+
|| propertyName(declaration.name) === null
|
|
227
|
+
|| !requiredString(checker, checker.getTypeAtLocation(declaration.parent), propertyName(declaration.name))) return false
|
|
228
|
+
const validators = decorators(checker, declaration).filter((decorator) => decorator.called && decorator.symbol?.getDeclarations()?.some((item) => moduleOf(item, item.getSourceFile().fileName) === "class-validator"))
|
|
229
|
+
const has = (name) => validators.some((decorator) => decorator.symbol.name === name)
|
|
230
|
+
const bound = (name, accepts) => validators.some((decorator) => decorator.symbol.name === name
|
|
231
|
+
&& decorator.args.length > 0 && ts.isNumericLiteral(decorator.args[0]) && accepts(Number(decorator.args[0].text)))
|
|
232
|
+
return !has("IsOptional") && !has("ValidateIf") && (has("IsUUID") || has("IsString")
|
|
233
|
+
&& bound("MinLength", (value) => value >= 1) && bound("MaxLength", (value) => value >= 1 && value <= 64))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Follow object mappings to their original property declarations rather than trusting an annotated return type. */
|
|
237
|
+
const propertySymbol = (checker, expression, path) => {
|
|
238
|
+
if (path.length === 0) return valueSymbol(checker, expression)
|
|
239
|
+
if (ts.isObjectLiteralExpression(expression)) {
|
|
240
|
+
const mapping = propertyValue(checker, expression, path[0])
|
|
241
|
+
if (!mapping) return undefined
|
|
242
|
+
return mapping.expression ? propertySymbol(checker, mapping.expression, path.slice(1))
|
|
243
|
+
: propertySymbol(checker, mapping.base, [mapping.name, ...path.slice(1)])
|
|
244
|
+
}
|
|
245
|
+
let type = checker.getTypeAtLocation(expression)
|
|
246
|
+
let symbol
|
|
247
|
+
for (const name of path) {
|
|
248
|
+
symbol = checker.getPropertyOfType(type, name)
|
|
249
|
+
const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0]
|
|
250
|
+
if (!declaration) return undefined
|
|
251
|
+
type = checker.getTypeOfSymbolAtLocation(symbol, declaration)
|
|
252
|
+
}
|
|
253
|
+
return symbol
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A key mapped by a pure function must come from its actual argument, never a constant or captured replacement. */
|
|
257
|
+
const mappedKeySymbol = (checker, mapping, bindings = new Map()) => {
|
|
258
|
+
if (!mapping) return undefined
|
|
259
|
+
const path = mapping.name ? [mapping.name] : []
|
|
260
|
+
let expression = mapping.expression ?? mapping.base
|
|
261
|
+
while (ts.isPropertyAccessExpression(expression) && !expression.questionDotToken) {
|
|
262
|
+
path.unshift(expression.name.text)
|
|
263
|
+
expression = expression.expression
|
|
264
|
+
}
|
|
265
|
+
const actual = bindings.get(valueSymbol(checker, expression))
|
|
266
|
+
if (actual) return propertySymbol(checker, actual, path)
|
|
267
|
+
return bindings.size > 0 ? undefined : propertySymbol(checker, expression, path)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A mapper is proved by its sole pure return expression and original argument bindings, not its function name. */
|
|
271
|
+
const requestKey = (checker, expression) => {
|
|
272
|
+
if (!ts.isCallExpression(expression)) return mappedKeySymbol(checker, propertyValue(checker, expression, "idempotencyKey"))
|
|
273
|
+
const mapper = checker.getResolvedSignature(expression)?.declaration
|
|
274
|
+
const body = returned(mapper?.body)
|
|
275
|
+
if (!mapper || !body || expression.questionDotToken || mapper.parameters.length !== expression.arguments.length
|
|
276
|
+
|| mapper.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword)
|
|
277
|
+
|| !pureEnvelope(checker, body) || expression.arguments.some((argument) => !pureEnvelope(checker, argument))) return undefined
|
|
278
|
+
const bindings = new Map()
|
|
279
|
+
for (const [index, parameter] of mapper.parameters.entries()) {
|
|
280
|
+
if (!ts.isIdentifier(parameter.name) || parameter.initializer || parameter.dotDotDotToken) return undefined
|
|
281
|
+
bindings.set(valueSymbol(checker, parameter.name), expression.arguments[index])
|
|
282
|
+
}
|
|
283
|
+
return mappedKeySymbol(checker, propertyValue(checker, body, "idempotencyKey"), bindings)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Follow the constructor's key mapping to the original DTO property, including a whole ExecuteParams value. */
|
|
287
|
+
const constructionKey = (checker, construction) => {
|
|
288
|
+
const params = construction.arguments?.[0]
|
|
289
|
+
const request = propertyValue(checker, params, "request")
|
|
290
|
+
let nestedSymbol
|
|
291
|
+
if (request?.base) {
|
|
292
|
+
const property = checker.getPropertyOfType(checker.getTypeAtLocation(request.base), request.name)
|
|
293
|
+
const declaration = property?.valueDeclaration ?? property?.declarations?.[0]
|
|
294
|
+
if (declaration) nestedSymbol = checker.getPropertyOfType(checker.getTypeOfSymbolAtLocation(property, declaration), "idempotencyKey")
|
|
295
|
+
}
|
|
296
|
+
return nestedSymbol ?? (request?.expression ? requestKey(checker, request.expression)
|
|
297
|
+
: mappedKeySymbol(checker, propertyValue(checker, params, "idempotencyKey")))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Only DTO key declarations that reach an effectful command carry this rule's transport obligation. */
|
|
301
|
+
const keysByProgram = new WeakMap()
|
|
302
|
+
const reachedKeys = (checker, program, hfs) => {
|
|
303
|
+
const cached = keysByProgram.get(program)
|
|
304
|
+
if (cached) return cached
|
|
305
|
+
const keys = new Set()
|
|
306
|
+
for (const source of program.getSourceFiles()) {
|
|
307
|
+
if (!isTransportSlot(hfs.slotOf(source.fileName))) continue
|
|
308
|
+
const visit = (node) => {
|
|
309
|
+
if (ts.isNewExpression(node)) {
|
|
310
|
+
const command = valueSymbol(checker, node.expression)?.getDeclarations()?.find(ts.isClassDeclaration)
|
|
311
|
+
if (command && effectful(checker, hfs, command)) {
|
|
312
|
+
for (const declaration of constructionKey(checker, node)?.getDeclarations() ?? []) keys.add(declaration)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
ts.forEachChild(node, visit)
|
|
316
|
+
}
|
|
317
|
+
visit(source)
|
|
318
|
+
}
|
|
319
|
+
keysByProgram.set(program, keys)
|
|
320
|
+
return keys
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Effectful commands carry a bounded client key to the one atomic domain inbox execution. */
|
|
324
|
+
export const mutationIdempotent = {
|
|
325
|
+
meta: {
|
|
326
|
+
type: "problem",
|
|
327
|
+
docs: { description: "An @Effectful command requires a validated client key and delegates to a domain transaction returning Inbox.runClient with that same key and manager." },
|
|
328
|
+
schema: [],
|
|
329
|
+
messages: {
|
|
330
|
+
marker: "[BE_MUTATION_IDEMPOTENT] Use @Effectful() exported by platform/inbox on a Nest Command; a local lookalike or a marker on a query does not designate an effectful command.",
|
|
331
|
+
declaration: "[BE_MUTATION_IDEMPOTENT] This @Effectful command requires idempotency in hfs.json sides.be.patterns, including its required behavior scenarios in the app's proof lane.",
|
|
332
|
+
key: "[BE_MUTATION_IDEMPOTENT] This @Effectful command requires a non-optional string idempotencyKey in its params request. Preserve the client's key through transport and handler mapping.",
|
|
333
|
+
handler: "[BE_MUTATION_IDEMPOTENT] This effectful command handler must return one domain service call carrying the original params.request.idempotencyKey. The service owns its transaction and the inbox claim.",
|
|
334
|
+
transaction: "[BE_MUTATION_IDEMPOTENT] This effectful domain method must return an EntityManager transaction whose inline callback returns Inbox.runClient({principalId, operation, idempotencyKey, request}, callbackManager, async () => outcome). Use the original key and callback manager, and put every effect in the final callback.",
|
|
335
|
+
validation: "[BE_MUTATION_IDEMPOTENT] The client idempotencyKey must be required and validated by class-validator: @IsUUID(), or @IsString() @MinLength(1) @MaxLength(64). Optional or conditional validation cannot protect an effectful command.",
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
create(context) {
|
|
339
|
+
const hfs = hfsOf(context)
|
|
340
|
+
const slot = hfs.slotOf(context.filename)
|
|
341
|
+
if (!slot || slot.startsWith("be.tests.")) return {}
|
|
342
|
+
const { checker, program, toTs } = typed(context)
|
|
343
|
+
let reached
|
|
344
|
+
let keys
|
|
345
|
+
return {
|
|
346
|
+
Identifier(node) {
|
|
347
|
+
if (hfs.tierOf(context.filename) === "platform" && ownerNameOf(hfs, context.filename) === "inbox"
|
|
348
|
+
|| !ownedValue(checker, hfs, toTs(node), "Effectful", "inbox")) return
|
|
349
|
+
const parent = node.parent
|
|
350
|
+
const typeOnly = parent?.type?.startsWith("TS") && !["TSAsExpression", "TSTypeAssertion", "TSNonNullExpression", "TSSatisfiesExpression", "TSInstantiationExpression"].includes(parent.type)
|
|
351
|
+
if (["ImportSpecifier", "ExportSpecifier", "Decorator"].includes(parent?.type) || typeOnly) return
|
|
352
|
+
if (parent?.type === "VariableDeclarator" && parent.id === node) return
|
|
353
|
+
if (parent?.type === "Property" && parent.key === node && !parent.computed && parent.value !== node) return
|
|
354
|
+
const reference = parent?.type === "MemberExpression" && parent.property === node ? parent : node
|
|
355
|
+
if (reference.parent?.type === "CallExpression" && reference.parent.callee === reference) return
|
|
356
|
+
if (reference.parent?.type === "VariableDeclarator" && reference.parent.init === reference && reference.parent.parent.kind === "const") return
|
|
357
|
+
context.report({ node, messageId: "marker" })
|
|
358
|
+
},
|
|
359
|
+
CallExpression(node) {
|
|
360
|
+
const call = toTs(node)
|
|
361
|
+
if (ownedValue(checker, hfs, call.expression, "Effectful", "inbox") && node.parent?.type !== "Decorator") context.report({ node, messageId: "marker" })
|
|
362
|
+
},
|
|
363
|
+
ClassDeclaration(node) {
|
|
364
|
+
const declaration = toTs(node)
|
|
365
|
+
const marks = decorators(checker, declaration).filter((decorator) => decorator.symbol?.name === "Effectful")
|
|
366
|
+
if (marks.length > 0) {
|
|
367
|
+
if (!effectful(checker, hfs, declaration) || !commandType(checker, checker.getTypeAtLocation(declaration))) {
|
|
368
|
+
context.report({ node: node.id ?? node, messageId: "marker" })
|
|
369
|
+
} else {
|
|
370
|
+
if (!(hfs.patterns ?? []).includes("idempotency")) context.report({ node: node.id ?? node, messageId: "declaration" })
|
|
371
|
+
const constructor = declaration.members.find(ts.isConstructorDeclaration)
|
|
372
|
+
if (!constructor || constructor.parameters.length !== 1 || !keyContract(checker, checker.getTypeAtLocation(constructor.parameters[0]))) context.report({ node: node.id ?? node, messageId: "key" })
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (!handledCommand(checker, hfs, declaration)) return
|
|
376
|
+
const method = delegatedMethod(checker, hfs, declaration)
|
|
377
|
+
if (!method) context.report({ node: node.id ?? node, messageId: "handler" })
|
|
378
|
+
else if (!transactionalClient(checker, hfs, method)) context.report({ node: node.id ?? node, messageId: "transaction" })
|
|
379
|
+
},
|
|
380
|
+
MethodDefinition(node) {
|
|
381
|
+
if (hfs.tierOf(context.filename) !== "domain" || !baseName(context.filename).endsWith(".service.ts")) return
|
|
382
|
+
reached ??= reachedServices(checker, program, hfs)
|
|
383
|
+
const method = toTs(node)
|
|
384
|
+
if (reached.has(method) && !transactionalClient(checker, hfs, method)) context.report({ node: node.key, messageId: "transaction" })
|
|
385
|
+
},
|
|
386
|
+
PropertyDefinition(node) {
|
|
387
|
+
if (!isTransportSlot(slot)) return
|
|
388
|
+
keys ??= reachedKeys(checker, program, hfs)
|
|
389
|
+
const declaration = toTs(node)
|
|
390
|
+
if (keys.has(declaration) && !boundedKey(checker, declaration)) context.report({ node: node.key, messageId: "validation" })
|
|
391
|
+
},
|
|
392
|
+
NewExpression(node) {
|
|
393
|
+
if (!isTransportSlot(slot)) return
|
|
394
|
+
const construction = toTs(node)
|
|
395
|
+
const command = valueSymbol(checker, construction.expression)?.getDeclarations()?.find(ts.isClassDeclaration)
|
|
396
|
+
if (!command || !effectful(checker, hfs, command)) return
|
|
397
|
+
const declarations = constructionKey(checker, construction)?.getDeclarations() ?? []
|
|
398
|
+
const localProperty = declarations.some((declaration) => ts.isPropertyDeclaration(declaration) && declaration.getSourceFile() === construction.getSourceFile())
|
|
399
|
+
if (!localProperty && !declarations.some((declaration) => boundedKey(checker, declaration))) context.report({ node, messageId: "validation" })
|
|
400
|
+
},
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
}
|
package/lib/persistence.mjs
CHANGED
|
@@ -105,7 +105,7 @@ export const connectionFileOf = (hfs, file, role) => {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/** The platform capabilities that own a `persistence/` and so may hold an `EntityManager` (a pattern capability is named by its folder). */
|
|
108
|
-
const PERSISTENCE_CAPABILITIES = ["database", "inbox", "event-bus", "queue", "jobs", "saga", "outbox"]
|
|
108
|
+
const PERSISTENCE_CAPABILITIES = ["database", "inbox", "event-bus", "queue", "jobs", "saga", "outbox", "realtime"]
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
111
|
* Whether a class in this file may receive an `EntityManager`: an application handler, a domain service, a projection, a typed queue producer (it takes the transaction of its caller), a platform persistence capability.
|