@cosmicdrift/kumiko-framework 0.400.0 → 0.402.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/AGENTS.md +8 -5
- package/agents/api/framework-engine.md +31 -5
- package/agents/api/testing-stack.md +1 -0
- package/agents/api/types.md +16 -1
- package/agents/api.md +2 -2
- package/agents/boot-rules.md +98 -0
- package/agents/examples/relations.md +13 -3
- package/dist/config/index.d.ts +1 -1
- package/dist/db/read-gate-clause.js +23 -10
- package/dist/db/replay-migration-sql.js +1 -1
- package/dist/engine/boot-validator/api-ext-rules.d.ts +27 -0
- package/dist/engine/boot-validator/api-ext-rules.js +27 -0
- package/dist/engine/boot-validator/api-ext.js +25 -12
- package/dist/engine/boot-validator/boot-check-rules.d.ts +7 -0
- package/dist/engine/boot-validator/boot-check-rules.js +7 -0
- package/dist/engine/boot-validator/boot-check.js +13 -1
- package/dist/engine/boot-validator/boot-options.d.ts +5 -0
- package/dist/engine/boot-validator/boot-phases-app.js +5 -1
- package/dist/engine/boot-validator/boot-report.d.ts +1 -0
- package/dist/engine/boot-validator/boot-report.js +58 -7
- package/dist/engine/boot-validator/boot-rule.d.ts +20 -0
- package/dist/engine/boot-validator/boot-rule.js +33 -2
- package/dist/engine/boot-validator/boot-rules.d.ts +210 -0
- package/dist/engine/boot-validator/boot-rules.js +17 -0
- package/dist/engine/boot-validator/card-meta-overflow-rules.d.ts +7 -0
- package/dist/engine/boot-validator/card-meta-overflow-rules.js +7 -0
- package/dist/engine/boot-validator/card-meta-overflow.js +5 -1
- package/dist/engine/boot-validator/entity-list-screens-rules.d.ts +32 -0
- package/dist/engine/boot-validator/entity-list-screens-rules.js +32 -0
- package/dist/engine/boot-validator/entity-list-screens.js +74 -41
- package/dist/engine/boot-validator/field-conditions.js +19 -1
- package/dist/engine/boot-validator/i18n-keys-rules.d.ts +27 -0
- package/dist/engine/boot-validator/i18n-keys-rules.js +27 -0
- package/dist/engine/boot-validator/i18n-keys.d.ts +1 -0
- package/dist/engine/boot-validator/i18n-keys.js +43 -16
- package/dist/engine/boot-validator/pii-retention-rules.d.ts +107 -0
- package/dist/engine/boot-validator/pii-retention-rules.js +107 -0
- package/dist/engine/boot-validator/pii-retention.js +88 -34
- package/dist/engine/boot-validator/read-gate.js +12 -7
- package/dist/engine/boot-validator/screens-actions-rules.d.ts +7 -0
- package/dist/engine/boot-validator/screens-actions-rules.js +7 -0
- package/dist/engine/boot-validator/screens-actions.js +7 -3
- package/dist/engine/entity-handlers.d.ts +12 -5
- package/dist/engine/factories.d.ts +13 -1
- package/dist/engine/factories.js +18 -0
- package/dist/engine/index.d.ts +3 -1
- package/dist/engine/index.js +3 -1
- package/dist/engine/user-entity-reference.d.ts +2 -0
- package/dist/engine/user-entity-reference.js +7 -0
- package/dist/files/index.d.ts +2 -0
- package/dist/files/index.js +1 -0
- package/dist/files/rebind-file-ref.d.ts +41 -0
- package/dist/files/rebind-file-ref.js +170 -0
- package/dist/i18n/required-surface-keys.d.ts +6 -0
- package/dist/i18n/required-surface-keys.js +64 -0
- package/dist/schema-cli/validate-boot-input.js +2 -0
- package/dist/schema-cli/validate-command.js +4 -1
- package/package.json +3 -3
- package/src/changes.json +25 -0
package/AGENTS.md
CHANGED
|
@@ -15,6 +15,7 @@ not exist in your version:
|
|
|
15
15
|
| `agents/api/<entry>.md` | Signatures of one entry point; options and screen types as property lists |
|
|
16
16
|
| `agents/api/types.md` | Types the entry points use but do not export, such as nested option shapes |
|
|
17
17
|
| `agents/widgets.md` | Every value export of `@cosmicdrift/kumiko-renderer/web` with its short description |
|
|
18
|
+
| `agents/boot-rules.md` | Every boot rule by id: summary, fix and source file; a boot violation links its entry |
|
|
18
19
|
| `agents/examples.md` | Index of the worked examples, one per concept |
|
|
19
20
|
| `agents/examples/<name>.md` | One tested sample app: README plus every source and test file |
|
|
20
21
|
|
|
@@ -33,7 +34,7 @@ Order of work for a new feature:
|
|
|
33
34
|
`agents/api.md` instead of `dist/*.d.ts`.
|
|
34
35
|
4. Build the feature with the declarative building blocks below.
|
|
35
36
|
5. Write an integration test against the real stack.
|
|
36
|
-
6. Run boot validation and the tests. Boot errors name the fix; apply it.
|
|
37
|
+
6. Run boot validation and the tests. Boot errors name the fix; apply it. Each violation carries a rule id whose entry is in `agents/boot-rules.md`.
|
|
37
38
|
|
|
38
39
|
## Mental model
|
|
39
40
|
|
|
@@ -113,8 +114,9 @@ the field types reject a text field without that stance.
|
|
|
113
114
|
`personal: false, reason: "is_business_data"` (a snake_case reason) for
|
|
114
115
|
business data. For personal data name the subject, `personal: "self"`,
|
|
115
116
|
`personal: { of: "<ownerField>" }` or `personal: "tenant"`, plus
|
|
116
|
-
`find: "exact" | "fuzzy" | "none" | "secret"`. A
|
|
117
|
-
is `
|
|
117
|
+
`find: "exact" | "fuzzy" | "none" | "secret"`. A foreign key to a user
|
|
118
|
+
is `createReferenceField({ entity: "user:user" })`, which defaults to
|
|
119
|
+
`personal: "ref"`; `personal: false` on it fails at boot. `personal: "self"` is for the row that is the user.
|
|
118
120
|
When the record itself is the person, such as a contact or a customer that
|
|
119
121
|
is not a user, use `personal: { of: "id" }`; the entity then needs a uuid
|
|
120
122
|
`id`. The framework encrypts personal fields per subject and
|
|
@@ -292,7 +294,8 @@ produces code that compiles and then fails in production.
|
|
|
292
294
|
unknown field in a column, a nav pointing at a missing screen, an
|
|
293
295
|
encrypted field marked searchable. The message names the
|
|
294
296
|
feature, the place and the fix. Apply the fix the message names instead
|
|
295
|
-
of working around the check.
|
|
297
|
+
of working around the check. Each violation shows a rule id and, for
|
|
298
|
+
classified rules, a `Rule:` link to its entry in `agents/boot-rules.md`.
|
|
296
299
|
|
|
297
300
|
## I need X, use Y
|
|
298
301
|
|
|
@@ -306,7 +309,7 @@ as bundled features.
|
|
|
306
309
|
| Business logic beyond CRUD | `r.writeHandler` / `r.queryHandler` with `createEntityExecutor` | `agents/examples/custom-handlers.md` |
|
|
307
310
|
| Filtered lists and counts | Executor `list` with `filter` and `totalCount` | `agents/examples/custom-handlers.md` |
|
|
308
311
|
| Key figures on a dashboard | `dashboard` screen with `stat` / `stat-group` panels and a query handler | `agents/examples/dashboard-stats.md` |
|
|
309
|
-
| Relations between entities | `r.relation(entity, name, { type, target, foreignKey, onDelete })` | `agents/examples/relations.md` |
|
|
312
|
+
| Relations between entities | `createReferenceField({ entity })` for the foreign key (user ids: `entity: "user:user"`), `r.relation(entity, name, { type, target, foreignKey, onDelete })` | `agents/examples/relations.md` |
|
|
310
313
|
| Structured sub-objects in a field | Embedded fields | `agents/examples/embedded.md` |
|
|
311
314
|
| List, edit, custom screens and navs | `r.screen`, `r.nav` | `agents/examples/screens-nav.md` |
|
|
312
315
|
| A record page with header, metrics, tabs | `projectionDetail` with `header`, `metrics`, `layout.mode: "tabs"` | `agents/examples/record-detail-layout.md` |
|
|
@@ -11,6 +11,7 @@ function assertNotificationVariables(registry: Pick<Registry, "getAllNotificatio
|
|
|
11
11
|
function assignableAppRolesFromUsages(usages: { entityName: string; options?: Record<string, unknown> | undefined; }[]): AssignableAppRoles;
|
|
12
12
|
function assignableAppRolesOf(registry: Pick<Registry, "getExtensionUsages">): AssignableAppRoles;
|
|
13
13
|
function attachOutsideTransaction<T extends object, O extends object | undefined>(db: T, outsideTransaction: O): T & { outsideTransaction: O; };
|
|
14
|
+
function bootRuleError(rule: BootRule, details: { message: string; }): BootRuleViolation;
|
|
14
15
|
function bridgeStub(opts?: { user?: SessionUser; log?: HandlerObservability["log"]; dispatch?: Partial<HandlerDispatch>; services?: Partial<ContextServices>; }): Pick<HandlerContext, "dispatch" | "events" | "auth" | "obs" | "hasFeature" | "tz" | "locale" | "user" | "services" | "lifecycle" | "tenants">;
|
|
15
16
|
function buildAppSchema(registry: Registry, options?: BuildAppSchemaOptions): AppSchema;
|
|
16
17
|
function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchema;
|
|
@@ -20,6 +21,7 @@ function buildPipelineSteps<TPayload, TAwaits extends Record<string, AwaitedEven
|
|
|
20
21
|
function buildSessionRoles(globalRoles: string[], membershipRoles: string[]): string[];
|
|
21
22
|
function buildUpdateSchema(entity: EntityDefinition, currencies?: string[], excludedFields?: string[]): z.ZodObject<Record<string, z.ZodTypeAny>>;
|
|
22
23
|
function canActorAssignRole(actorRoles: string[], role: string, assignableAppRoles?: AssignableAppRoles): boolean;
|
|
24
|
+
function collectBootRuleViolations<T>(items: Iterable<T>, run: (item: T) => void): void;
|
|
23
25
|
function collectCardMetaOverflow(features: FeatureDefinition[]): CardMetaOverflow[];
|
|
24
26
|
function collectWriteHandlerQns(features: FeatureDefinition[]): Set<string>;
|
|
25
27
|
function compareVersions(a: string, b: string): number;
|
|
@@ -45,6 +47,7 @@ function createLongTextField<R extends true | false = false>(overrides: Partial<
|
|
|
45
47
|
function createMoneyField<R extends true | false = false>(overrides?: Partial<Omit<MoneyFieldDef, "type" | "required">> & { required?: R; }): MoneyFieldDef & { required: R; };
|
|
46
48
|
function createMultiSelectField<const TOptions extends string[]>(opts: { options: TOptions; } & Partial<Omit<MultiSelectFieldDef<TOptions>, "type" | "options" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): MultiSelectFieldDef<TOptions>; // Multi-select field — N values from a fixed option list.
|
|
47
49
|
function createNumberField<R extends true | false = false>(overrides?: Partial<Omit<NumberFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & { required?: R; }): NumberFieldDef & { required: R; }; // Numeric field — `double precision` column by default (fractional values allowed end to end).
|
|
50
|
+
function createReferenceField<R extends true | false = false>(opts: { entity: string; } & Partial<Omit<ReferenceFieldDef, "type" | "entity" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & { required?: R; }): ReferenceFieldDef & { required: R; }; // Foreign key to another entity (`"customer"` or `"feature:entity"`), stored as a UUID.
|
|
48
51
|
function createRegistry(rawFeatures: FeatureDefinition[]): Registry;
|
|
49
52
|
function createSeed(opts: CreateSeedOptions): ConfigSeedDef;
|
|
50
53
|
function createSelectField<const TOptions extends string[], R extends true | false = false>(opts: { options: TOptions; } & Partial<Omit<SelectFieldDef<NoInfer<TOptions>>, "type" | "options" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & { required?: R; }): SelectFieldDef<TOptions> & { required: R; };
|
|
@@ -60,14 +63,14 @@ function createUserConfig<T extends ConfigKeyType>(type: T, opts?: ConfigKeyOpti
|
|
|
60
63
|
function declareEscapeHatch(declaration: EscapeHatchDeclaration): void;
|
|
61
64
|
function dedupeFeatures(features: FeatureDefinition[]): FeatureDefinition[];
|
|
62
65
|
function defineApply<TPayload = Record<string, unknown>>(fn: (event: StoredEvent<TPayload>, tx: ExecutorManagedRunner, table: ProjectionTable) => Promise<void>): SingleStreamApplyFn;
|
|
63
|
-
function defineEntityCreateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions):
|
|
64
|
-
function defineEntityDeleteHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions):
|
|
66
|
+
function defineEntityCreateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
|
|
67
|
+
function defineEntityDeleteHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): EntityWriteHandlerDef;
|
|
65
68
|
function defineEntityDetailHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
|
|
66
69
|
function defineEntityListHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
|
|
67
70
|
function defineEntityQueryHandler(name: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
|
|
68
|
-
function defineEntityRestoreHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions):
|
|
69
|
-
function defineEntityUpdateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions):
|
|
70
|
-
function defineEntityWriteHandler(name: string, entity: EntityDefinition, options: EntityWriteHandlerOptions):
|
|
71
|
+
function defineEntityRestoreHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): EntityWriteHandlerDef;
|
|
72
|
+
function defineEntityUpdateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
|
|
73
|
+
function defineEntityWriteHandler(name: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
|
|
71
74
|
function defineFeature<const TName extends string, TExports = undefined>(name: TName, setup: (r: FeatureRegistrar<TName>) => TExports, settings?: DefineFeatureSettings): FeatureDefinition & { exports: TExports; };
|
|
72
75
|
function defineMspApply<TPayload = Record<string, unknown>>(fn: (event: StoredEvent<TPayload>, tx: ExecutorManagedRunner, ctx: MultiStreamApplyContext) => Promise<void>): MultiStreamApplyFn;
|
|
73
76
|
function definePagedQueryHandler<const TName extends string, TSchema extends ZodType, TRow = unknown, TMap extends object = KumikoEventTypeMap>(def: QueryHandlerDefinition<TName, TSchema, CursorResult<TRow>, TMap>, ..._noSecretInResponse: true extends ContainsSecret<CursorResult<TRow>> ? [ secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field." ] : [ ]): PagedQueryHandlerDefinition<TName, TSchema, TRow, TMap>;
|
|
@@ -224,6 +227,14 @@ const templateVariable: { text; link; richText; money; date; percent; images; li
|
|
|
224
227
|
## Classes
|
|
225
228
|
|
|
226
229
|
```ts
|
|
230
|
+
class BootRuleViolation extends Error {
|
|
231
|
+
constructor(rule: BootRule, message: string);
|
|
232
|
+
name: unknown;
|
|
233
|
+
}
|
|
234
|
+
class BootValidationError extends Error {
|
|
235
|
+
constructor(violations: BootViolation[]);
|
|
236
|
+
name: unknown;
|
|
237
|
+
}
|
|
227
238
|
class ProbeFailure extends Error {
|
|
228
239
|
constructor(text: ProbeText);
|
|
229
240
|
}
|
|
@@ -314,6 +325,19 @@ type BeforeTransactionResult<TPrepared = unknown> = // Outcome of a write handle
|
|
|
314
325
|
| WriteFailure
|
|
315
326
|
;
|
|
316
327
|
type BootOptionName = "mail" | "kmsMode" | "fileStorage" | "billing" | "featureToggles" | "rateLimit"; // runProdApp options a stack can depend on; runProdApp checks the set at boot.
|
|
328
|
+
type BootRule = {
|
|
329
|
+
id: string;
|
|
330
|
+
summary: string;
|
|
331
|
+
fix: string;
|
|
332
|
+
};
|
|
333
|
+
type BootRuleTable = Readonly<Record<string, BootRule>>;
|
|
334
|
+
type BootViolation = {
|
|
335
|
+
ruleId: string;
|
|
336
|
+
check: string;
|
|
337
|
+
feature?: string;
|
|
338
|
+
message: string;
|
|
339
|
+
fix?: string;
|
|
340
|
+
};
|
|
317
341
|
type BundledListsOptions = { // One `table` block for every entityList, projectionList and relatedList of the bundled features.
|
|
318
342
|
table: ListTableOptions;
|
|
319
343
|
pinFirstColumn?: boolean; // Pins the first column left on every bundled list where no column is pinned.
|
|
@@ -426,6 +450,7 @@ type ContentCollectionDefinition = {
|
|
|
426
450
|
variableSchema?: Readonly<Record<string, string>>;
|
|
427
451
|
nav: {
|
|
428
452
|
label: string;
|
|
453
|
+
entryLabel?: string;
|
|
429
454
|
icon?: NavIconKey;
|
|
430
455
|
parent?: string;
|
|
431
456
|
order?: number;
|
|
@@ -1713,6 +1738,7 @@ type ValidateBootOptions = {
|
|
|
1713
1738
|
env?: Readonly<Record<string, string | undefined>>; // Env the encrypted-field master-key probe reads.
|
|
1714
1739
|
entityFieldCipher?: EnvelopeCipher; // The cipher the runner injects after validation (built from the app's `masterKey` or the env keyring).
|
|
1715
1740
|
requiredLocales?: string[]; // Locales every translation key must carry across all mounted features (locale packages count).
|
|
1741
|
+
requireFieldValueTranslations?: boolean; // Require translations for the values that entityList columns and entityEdit fields display: select/multiSelect option labels and the column headers and select options of embedded lists.
|
|
1716
1742
|
};
|
|
1717
1743
|
type ValidationError = {
|
|
1718
1744
|
field: string;
|
|
@@ -13,6 +13,7 @@ function createRequestHelper(app: Hono, jwt: JwtHelper, options?: RequestHelperO
|
|
|
13
13
|
function createTestDb(arg?: string | CreateTestDbOptions): Promise<TestDb>;
|
|
14
14
|
function createTestRedis(opts?: CreateTestRedisOptions): Promise<TestRedis>;
|
|
15
15
|
function drainEventConsumers(stack: Pick<TestStack, "db" | "eventDispatcher">, consumerNames: [ string, ...string[] ], options?: { maxPasses?: number; }): Promise<void>;
|
|
16
|
+
function dropTestDb(baseUrl: string, dbName: string): Promise<void>;
|
|
16
17
|
function pushEntityProjectionTables(stack: DbHolder, registry: Registry): Promise<void>; // Push all implicit-projection tables — one per `r.entity()` — that the registry knows about.
|
|
17
18
|
function resetEventStore(stack: { db: unknown; eventDispatcher?: EventDispatcher; }, extraTables?: (unknown | string)[]): Promise<void>; // Wipes event store + framework-state + the given feature read-models in one TRUNCATE, then re-registers the event-consumer state rows.
|
|
18
19
|
function setupTestStack(options: TestStackOptions): Promise<TestStack>;
|
package/agents/api/types.md
CHANGED
|
@@ -903,6 +903,16 @@ type WorkflowTrigger = // Trigger configuration for a workflow.
|
|
|
903
903
|
type NumericDerivedOp = Exclude<EmbeddedDerivedCellDef["op"], "inRange">;
|
|
904
904
|
```
|
|
905
905
|
|
|
906
|
+
## packages/framework/src/engine/entity-handlers.ts
|
|
907
|
+
|
|
908
|
+
```ts
|
|
909
|
+
type EntityWriteHandlerDef = WriteHandlerDef & { // The phase slots are always unset on entity handlers, so a consumer can spread one into its own handler and add typed phases without inheriting the slot types.
|
|
910
|
+
reserveBeforeTransaction?: undefined;
|
|
911
|
+
beforeTransaction?: undefined;
|
|
912
|
+
beforeTransactionWhenNested?: undefined;
|
|
913
|
+
};
|
|
914
|
+
```
|
|
915
|
+
|
|
906
916
|
## packages/framework/src/engine/event-helpers.ts
|
|
907
917
|
|
|
908
918
|
```ts
|
|
@@ -3162,10 +3172,14 @@ type ReadGateDef = {
|
|
|
3162
3172
|
entity: string;
|
|
3163
3173
|
field: string;
|
|
3164
3174
|
}; // Row readable only if at least one live row of `entity` with `field` = row.id is readable through that entity's own parentRef gate (host visibility).
|
|
3175
|
+
allLinks?: {
|
|
3176
|
+
entity: string;
|
|
3177
|
+
field: string;
|
|
3178
|
+
}; // Like `anyLink`, but every live link row must be readable, and there must be at least one.
|
|
3165
3179
|
fileOwner?: {
|
|
3166
3180
|
field: string;
|
|
3167
3181
|
}; // Row readable only if the file_ref in `field` is live and readable through its owner: owner row readable (ownership, parentRef, handler gate) and the field read access on the file_ref's fieldName passes.
|
|
3168
|
-
linkExemptRoles?: string[]; // Roles that skip `anyLink` (they also see unlinked rows).
|
|
3182
|
+
linkExemptRoles?: string[]; // Roles that skip `anyLink` / `allLinks` (they also see unlinked rows).
|
|
3169
3183
|
};
|
|
3170
3184
|
```
|
|
3171
3185
|
|
|
@@ -4769,6 +4783,7 @@ type MetricSpec = {
|
|
|
4769
4783
|
navigate?: MetricNavigate;
|
|
4770
4784
|
tone?: DashboardStatTone; // Tile color as on dashboard stat panels; not applied while the value is 0.
|
|
4771
4785
|
icon?: NavIconKey; // Icon chip of the tile; only drawn with `metricsStyle: "tiles"`.
|
|
4786
|
+
format?: FormatSpec; // Formats the displayed value (e.g. `date`, `enumOption`); the tone still resolves on the raw value.
|
|
4772
4787
|
};
|
|
4773
4788
|
type RecordHeaderSpec = {
|
|
4774
4789
|
title: string;
|
package/agents/api.md
CHANGED
|
@@ -8,13 +8,13 @@ Format: one line per function or constant, object and options types as property
|
|
|
8
8
|
|
|
9
9
|
| Import path | File | Exports |
|
|
10
10
|
| --- | --- | --- |
|
|
11
|
-
| `@cosmicdrift/kumiko-framework/engine` | `api/framework-engine.md` |
|
|
11
|
+
| `@cosmicdrift/kumiko-framework/engine` | `api/framework-engine.md` | 419 |
|
|
12
12
|
| `@cosmicdrift/kumiko-framework/errors` | `api/framework-errors.md` | 25 |
|
|
13
13
|
| `@cosmicdrift/kumiko-framework/ui-types` | `api/framework-ui-types.md` | 154 |
|
|
14
14
|
| `@cosmicdrift/kumiko-framework/db` | `api/framework-db.md` | 150 |
|
|
15
15
|
| `@cosmicdrift/kumiko-framework/event-store` | `api/framework-event-store.md` | 38 |
|
|
16
16
|
| `@cosmicdrift/kumiko-framework/seeding` | `api/framework-seeding.md` | 6 |
|
|
17
17
|
| `@cosmicdrift/kumiko-renderer/web` | `api/renderer-web.md` | 298 |
|
|
18
|
-
| `@cosmicdrift/kumiko-testing/stack` | `api/testing-stack.md` |
|
|
18
|
+
| `@cosmicdrift/kumiko-testing/stack` | `api/testing-stack.md` | 22 |
|
|
19
19
|
| `@cosmicdrift/kumiko-testing/helpers` | `api/testing-helpers.md` | 71 |
|
|
20
20
|
| `@cosmicdrift/kumiko-dev-server/testing` | `api/dev-server-testing.md` | 5 |
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from boot rule tables (`*-rules.ts`, `boot-rules.ts`). Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# Boot rules
|
|
4
|
+
|
|
5
|
+
`validateBoot` reports every violation as `- [<id>] <message>`, followed by a `Fix:` and a `Rule:` line that links the entry below. Violations with an `unclassified/<check>` id have no entry yet. `kumiko-schema validate --json` returns the same ids.
|
|
6
|
+
|
|
7
|
+
## api
|
|
8
|
+
|
|
9
|
+
| Rule | Summary | Fix | Source |
|
|
10
|
+
| --- | --- | --- | --- |
|
|
11
|
+
| <a id="api-provider-not-mounted"></a>`api/provider-not-mounted` | The feature exposing a used API is part of the app's feature list. | Register the provider feature in the app's feature list. | `packages/framework/src/engine/boot-validator/api-ext-rules.ts` |
|
|
12
|
+
| <a id="api-provider-not-required"></a>`api/provider-not-required` | A feature using an API lists the exposing feature in requires or optionalRequires. | Add the exposing feature via r.requires({ features: [...] }). | `packages/framework/src/engine/boot-validator/api-ext-rules.ts` |
|
|
13
|
+
| <a id="api-unknown-api"></a>`api/unknown-api` | Every r.usesApi name is exposed by some mounted feature. | Mount the feature that exposes the API or fix the API name. | `packages/framework/src/engine/boot-validator/api-ext-rules.ts` |
|
|
14
|
+
|
|
15
|
+
## boot-check
|
|
16
|
+
|
|
17
|
+
| Rule | Summary | Fix | Source |
|
|
18
|
+
| --- | --- | --- | --- |
|
|
19
|
+
| <a id="boot-check-failed"></a>`boot-check/failed` | A feature's r.bootCheck invariant holds for the mounted feature set. | Satisfy the invariant named in the message or unmount the feature that declares it. | `packages/framework/src/engine/boot-validator/boot-check-rules.ts` |
|
|
20
|
+
|
|
21
|
+
## entity-list
|
|
22
|
+
|
|
23
|
+
| Rule | Summary | Fix | Source |
|
|
24
|
+
| --- | --- | --- | --- |
|
|
25
|
+
| <a id="entity-list-default-sort-not-listed"></a>`entity-list/default-sort-not-listed` | defaultSort.field is one of the listed columns. | Pick a field from columns or add it there. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
|
|
26
|
+
| <a id="entity-list-default-sort-not-sortable"></a>`entity-list/default-sort-not-sortable` | The defaultSort column is sortable. | Mark the field sortable or pick another defaultSort field. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
|
|
27
|
+
| <a id="entity-list-default-sort-required"></a>`entity-list/default-sort-required` | A list with active search or filterable fields declares a defaultSort. | Set defaultSort: { field, direction } on the screen. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
|
|
28
|
+
| <a id="entity-list-detail-without-navigate"></a>`entity-list/detail-without-navigate` | A list whose entity has a detail screen offers a navigate row action. | Add a navigate rowAction with id "view" or "edit". | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
|
|
29
|
+
| <a id="entity-list-searchable-false"></a>`entity-list/searchable-false` | Operator lists are searchable unless they are on the searchable-false whitelist. | Set searchable: true or add the screen id to the whitelist. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
|
|
30
|
+
| <a id="entity-list-unknown-column"></a>`entity-list/unknown-column` | Every column without a label names an entity field, derived field or row-meta column. | Fix the column field name or give the column a label. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
|
|
31
|
+
|
|
32
|
+
## extension
|
|
33
|
+
|
|
34
|
+
| Rule | Summary | Fix | Source |
|
|
35
|
+
| --- | --- | --- | --- |
|
|
36
|
+
| <a id="extension-provider-not-required"></a>`extension/provider-not-required` | A feature using an extension lists the defining feature in requires or optionalRequires. | Add the defining feature via r.requires({ features: [...] }). | `packages/framework/src/engine/boot-validator/api-ext-rules.ts` |
|
|
37
|
+
| <a id="extension-unknown-extension"></a>`extension/unknown-extension` | Every extension a feature uses is defined by some mounted feature. | Register the feature that defines the extension or fix the extension name. | `packages/framework/src/engine/boot-validator/api-ext-rules.ts` |
|
|
38
|
+
|
|
39
|
+
## i18n
|
|
40
|
+
|
|
41
|
+
| Rule | Summary | Fix | Source |
|
|
42
|
+
| --- | --- | --- | --- |
|
|
43
|
+
| <a id="i18n-locale-inconsistent"></a>`i18n/locale-inconsistent` | Every locale a feature uses is maintained for every one of its translation keys. | Add the missing locale to the key, or move that locale into a locale package. | `packages/framework/src/engine/boot-validator/i18n-keys-rules.ts` |
|
|
44
|
+
| <a id="i18n-missing-feature-key"></a>`i18n/missing-feature-key` | Every translation key a feature surface requires is defined. | Add the key via r.translations(...) in the feature. | `packages/framework/src/engine/boot-validator/i18n-keys-rules.ts` |
|
|
45
|
+
| <a id="i18n-missing-field-value-translation"></a>`i18n/missing-field-value-translation` | With requireFieldValueTranslations, select options and embedded cells are translated. | Add the keys via r.translations(...) in the owning feature. | `packages/framework/src/engine/boot-validator/i18n-keys-rules.ts` |
|
|
46
|
+
| <a id="i18n-missing-required-locale"></a>`i18n/missing-required-locale` | Every translation key exists in every locale the app requires. | Load the matching locale package or add the missing translations. | `packages/framework/src/engine/boot-validator/i18n-keys-rules.ts` |
|
|
47
|
+
| <a id="i18n-missing-settings-hub-key"></a>`i18n/missing-settings-hub-key` | Every translation key the generated settings hub requires is defined. | Add the key via r.translations(...) in the owning feature. | `packages/framework/src/engine/boot-validator/i18n-keys-rules.ts` |
|
|
48
|
+
|
|
49
|
+
## list
|
|
50
|
+
|
|
51
|
+
| Rule | Summary | Fix | Source |
|
|
52
|
+
| --- | --- | --- | --- |
|
|
53
|
+
| <a id="list-card-meta-overflow"></a>`list/card-meta-overflow` | A card list shows at most the maximum number of meta columns. | Reduce each list to the maximum number of meta columns or mark the others hideOnNarrow: true. | `packages/framework/src/engine/boot-validator/card-meta-overflow-rules.ts` |
|
|
54
|
+
|
|
55
|
+
## pii
|
|
56
|
+
|
|
57
|
+
| Rule | Summary | Fix | Source |
|
|
58
|
+
| --- | --- | --- | --- |
|
|
59
|
+
| <a id="pii-lookupable-non-text"></a>`pii/lookupable-non-text` | Blind-index lookups apply to text fields only. | Use a text field or set find: "none". | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
60
|
+
| <a id="pii-lookupable-without-subject"></a>`pii/lookupable-without-subject` | A blind-index lookup requires a subject annotation on the field. | Add a personal annotation or set find: "none". | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
61
|
+
| <a id="pii-multiple-subject-annotations"></a>`pii/multiple-subject-annotations` | A field carries at most one subject-key annotation. | Keep a single personal annotation on the field. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
62
|
+
| <a id="pii-personal-jsonb-custom-fields"></a>`pii/personal-jsonb-custom-fields` | The custom-fields jsonb column cannot carry a personal annotation. | Drop the personal annotation. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
63
|
+
| <a id="pii-personal-jsonb-flag"></a>`pii/personal-jsonb-flag` | A personal jsonb field cannot be sortable, filterable, searchable or lookupable, its value is a ciphertext string. | Remove the flag or the personal annotation. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
64
|
+
| <a id="pii-personal-jsonb-index"></a>`pii/personal-jsonb-index` | A personal jsonb field is not part of an entity index. | Drop the index or the personal annotation. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
65
|
+
| <a id="pii-personal-of-invalid-when-absent"></a>`pii/personal-of-invalid-when-absent` | personal.whenAbsent is either "tenant" or "self". | Remove whenAbsent or set it to "tenant" or "self". | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
66
|
+
| <a id="pii-personal-of-missing-owner-name"></a>`pii/personal-of-missing-owner-name` | personal: { of } names the owner field. | Pass the name of the owner field: personal: { of: "<ownerField>" }. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
67
|
+
| <a id="pii-personal-of-optional-owner"></a>`pii/personal-of-optional-owner` | An optional owner field of personal: { of } declares a whenAbsent fallback. | Set whenAbsent to "tenant" or "self", or make the owner field required. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
68
|
+
| <a id="pii-personal-of-owner-type"></a>`pii/personal-of-owner-type` | The owner field named by personal: { of } is a reference or text field. | Use a reference or text field as owner. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
69
|
+
| <a id="pii-personal-of-unknown-owner"></a>`pii/personal-of-unknown-owner` | The owner field named by personal: { of } exists on the entity. | Point personal.of at an existing field of the entity. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
70
|
+
| <a id="pii-plaintext-user-reference"></a>`pii/plaintext-user-reference` | A reference to the user entity is never plaintext. | Use the default reference field or personal: "ref" instead of personal: false. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
71
|
+
| <a id="pii-sensitive-searchable"></a>`pii/sensitive-searchable` | A sensitive field is never searchable. | Drop searchable, or add writeOnly to never return the value. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
72
|
+
| <a id="pii-sensitive-without-ciphertext"></a>`pii/sensitive-without-ciphertext` | A sensitive field is stored as ciphertext at rest. | Add a subject annotation or encrypted: true. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
73
|
+
| <a id="pii-subject-key-sortable"></a>`pii/subject-key-sortable` | A field with a subject-key annotation is not sortable, its column is ciphertext. | Drop sortable, use find: "exact" for equality lookups, or keep the field plaintext. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
74
|
+
| <a id="pii-text-without-personal-stance"></a>`pii/text-without-personal-stance` | Every text and longText field declares whether it holds personal data. | Declare a personal stance, or personal: false with a reason stating why the value is not personal data. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
75
|
+
| <a id="pii-write-only-conflicting-flag"></a>`pii/write-only-conflicting-flag` | A writeOnly field cannot be defaulted, searched, sorted, filtered or looked up. | Remove either writeOnly or the conflicting flag. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
76
|
+
| <a id="pii-write-only-embedded-sub-field"></a>`pii/write-only-embedded-sub-field` | writeOnly is only supported on top-level text fields, not on embedded sub-fields. | Remove writeOnly from the sub-field or declare it on a top-level text field. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
77
|
+
| <a id="pii-write-only-non-text"></a>`pii/write-only-non-text` | writeOnly applies to text fields only. | Use a text field or remove writeOnly. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
78
|
+
| <a id="pii-write-only-without-sensitive"></a>`pii/write-only-without-sensitive` | A writeOnly field is also sensitive. | Declare the field with find: "secret". | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
79
|
+
|
|
80
|
+
## retention
|
|
81
|
+
|
|
82
|
+
| Rule | Summary | Fix | Source |
|
|
83
|
+
| --- | --- | --- | --- |
|
|
84
|
+
| <a id="retention-unknown-reference"></a>`retention/unknown-reference` | retention.reference names an existing field or a framework timestamp. | Point retention.reference at an existing field or a framework-managed timestamp. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
|
|
85
|
+
|
|
86
|
+
## screen
|
|
87
|
+
|
|
88
|
+
| Rule | Summary | Fix | Source |
|
|
89
|
+
| --- | --- | --- | --- |
|
|
90
|
+
| <a id="screen-action-icon"></a>`screen/action-icon` | Every screen action resolves to a registered icon. | Set icon to a registered NAV_ICONS key or use an action id that resolves one via the default icon map. | `packages/framework/src/engine/boot-validator/screens-actions-rules.ts` |
|
|
91
|
+
|
|
92
|
+
## user-data-rights
|
|
93
|
+
|
|
94
|
+
| Rule | Summary | Fix | Source |
|
|
95
|
+
| --- | --- | --- | --- |
|
|
96
|
+
| <a id="user-data-rights-empty-hook"></a>`user-data-rights/empty-hook` | An EXT_USER_DATA registration declares at least one of an export or a delete hook. | Register at least one hook. | `packages/bundled-features/src/user-data-rights/boot-rules.ts` |
|
|
97
|
+
| <a id="user-data-rights-export-without-delete"></a>`user-data-rights/export-without-delete` | An entity exported via EXT_USER_DATA also registers a delete hook. | Add a delete hook, or a documented no-op delete if erasure is handled elsewhere. | `packages/bundled-features/src/user-data-rights/boot-rules.ts` |
|
|
98
|
+
| <a id="user-data-rights-missing-hook"></a>`user-data-rights/missing-hook` | An entity with user-subject fields registers an EXT_USER_DATA hook. | Register r.useExtension(EXT_USER_DATA, ...) for the entity, or a documented no-op hook if it is covered elsewhere. | `packages/bundled-features/src/user-data-rights/boot-rules.ts` |
|
|
@@ -217,7 +217,12 @@ describe("cascade: deleting member removes tasks", () => {
|
|
|
217
217
|
|
|
218
218
|
```ts
|
|
219
219
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
220
|
-
import {
|
|
220
|
+
import {
|
|
221
|
+
createEntity,
|
|
222
|
+
createReferenceField,
|
|
223
|
+
createTextField,
|
|
224
|
+
defineFeature,
|
|
225
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
221
226
|
|
|
222
227
|
export const teamEntity = createEntity({
|
|
223
228
|
table: "read_sample_teams",
|
|
@@ -230,7 +235,12 @@ export const memberEntity = createEntity({
|
|
|
230
235
|
table: "read_sample_members",
|
|
231
236
|
fields: {
|
|
232
237
|
name: createTextField({ personal: { of: "id" }, find: "none", required: true }),
|
|
233
|
-
teamId:
|
|
238
|
+
teamId: createReferenceField({
|
|
239
|
+
entity: "team",
|
|
240
|
+
personal: false,
|
|
241
|
+
reason: "is_system_identifier",
|
|
242
|
+
required: true,
|
|
243
|
+
}),
|
|
234
244
|
role: createTextField({ personal: false, reason: "is_business_data" }),
|
|
235
245
|
},
|
|
236
246
|
});
|
|
@@ -239,7 +249,7 @@ export const taskEntity = createEntity({
|
|
|
239
249
|
table: "read_sample_member_tasks",
|
|
240
250
|
fields: {
|
|
241
251
|
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
242
|
-
memberId:
|
|
252
|
+
memberId: createReferenceField({ entity: "member", personal: "ref", required: true }),
|
|
243
253
|
},
|
|
244
254
|
});
|
|
245
255
|
|
package/dist/config/index.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type AppPorts = {
|
|
|
20
20
|
readonly infra?: AppInfraPorts;
|
|
21
21
|
};
|
|
22
22
|
/** The boot-validator options an app owns; `env` and the cipher are injected by the runners. */
|
|
23
|
-
export type AppValidateBootOptions = Pick<ValidateBootOptions, "navAllowlist" | "navAllowlistExempt" | "requiredLocales" | "warnOnUniqueAccessRoles">;
|
|
23
|
+
export type AppValidateBootOptions = Pick<ValidateBootOptions, "navAllowlist" | "navAllowlistExempt" | "requiredLocales" | "requireFieldValueTranslations" | "warnOnUniqueAccessRoles">;
|
|
24
24
|
export type AppConfig = {
|
|
25
25
|
/** Features mounted in every environment: dev, tests, prod. */
|
|
26
26
|
readonly features: readonly FeatureDefinition[];
|
|
@@ -42,7 +42,9 @@ function liveRowSql(entity, table, alias) {
|
|
|
42
42
|
function isRoleExempt(gate, user) {
|
|
43
43
|
return user.roles.some((role) => gate.linkExemptRoles?.includes(role) === true);
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
// "any": one readable live link suffices. "all": additionally no live link may be unreadable;
|
|
46
|
+
// COALESCE keeps a NULL from the ownership SQL from counting as readable.
|
|
47
|
+
function linkClause(input, link, mode) {
|
|
46
48
|
const { option, user, db, tableName } = input;
|
|
47
49
|
const linkEntity = option.entities.get(link.entity);
|
|
48
50
|
if (linkEntity?.parentRef === undefined)
|
|
@@ -56,18 +58,24 @@ function anyLinkClause(input, link) {
|
|
|
56
58
|
if (linkOwnership.kind === "empty")
|
|
57
59
|
return EMPTY;
|
|
58
60
|
const sql = new GateSql();
|
|
59
|
-
const
|
|
61
|
+
const linkRowsOfThisRow = () => [
|
|
60
62
|
`"${LINK_ALIAS}"."${physicalColumnName(linkTable, link.field)}"::text = "${tableName}"."${physicalColumnName(input.table, "id")}"::text`,
|
|
61
63
|
...tenantScopeSql(sql, db, linkTable, LINK_ALIAS),
|
|
62
64
|
...liveRowSql(linkEntity, linkTable, LINK_ALIAS),
|
|
63
65
|
];
|
|
66
|
+
const existsLink = (conditions) => `EXISTS (SELECT 1 FROM "${linkTableName}" AS "${LINK_ALIAS}" WHERE ${conditions.join(" AND ")})`;
|
|
67
|
+
const readable = linkRowsOfThisRow();
|
|
64
68
|
if (linkOwnership.kind === "sql")
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
readable.push(sql.embed(linkOwnership));
|
|
70
|
+
let sqlText = existsLink(readable);
|
|
71
|
+
if (mode === "all" && linkOwnership.kind === "sql") {
|
|
72
|
+
const unreadable = [
|
|
73
|
+
...linkRowsOfThisRow(),
|
|
74
|
+
`NOT COALESCE((${sql.embed(linkOwnership)}), FALSE)`,
|
|
75
|
+
];
|
|
76
|
+
sqlText = `(${sqlText} AND NOT ${existsLink(unreadable)})`;
|
|
77
|
+
}
|
|
78
|
+
return { kind: "sql", sqlText, params: sql.params };
|
|
71
79
|
}
|
|
72
80
|
// Every registered entity with a file field can own a file. Join rows and gated entities
|
|
73
81
|
// are left out: their readability is not resolvable without recursion.
|
|
@@ -178,8 +186,13 @@ export function buildReadGateClause(entity, table, tableName, user, db, option)
|
|
|
178
186
|
return EMPTY;
|
|
179
187
|
const input = { entity, table, tableName, user, db, option };
|
|
180
188
|
let clause = PASS;
|
|
181
|
-
if (
|
|
182
|
-
|
|
189
|
+
if (!isRoleExempt(gate, user)) {
|
|
190
|
+
if (gate.anyLink !== undefined) {
|
|
191
|
+
clause = combineClauses(clause, linkClause(input, gate.anyLink, "any"));
|
|
192
|
+
}
|
|
193
|
+
if (gate.allLinks !== undefined) {
|
|
194
|
+
clause = combineClauses(clause, linkClause(input, gate.allLinks, "all"));
|
|
195
|
+
}
|
|
183
196
|
}
|
|
184
197
|
if (gate.fileOwner !== undefined) {
|
|
185
198
|
clause = combineClauses(clause, fileOwnerClause(input, gate.fileOwner.field));
|
|
@@ -151,7 +151,7 @@ function applyStatement(schema, statement, context) {
|
|
|
151
151
|
// skip: DO block fully handled above
|
|
152
152
|
return;
|
|
153
153
|
}
|
|
154
|
-
const createAs = statement.match(new RegExp(`^CREATE\\s+(?:UNLOGGED\\s+)?TABLE\\s+(IF NOT EXISTS\\s+)?${IDENT}
|
|
154
|
+
const createAs = statement.match(new RegExp(`^CREATE\\s+(?:UNLOGGED\\s+)?TABLE\\s+(IF NOT EXISTS\\s+)?${IDENT}(?:\\s*\\(([^)]*)\\)\\s*|\\s+)AS\\b([\\s\\S]*)$`, "i"));
|
|
155
155
|
if (createAs?.[2] !== undefined && createAs[4] !== undefined) {
|
|
156
156
|
if (!(createAs[1] !== undefined && schema.has(createAs[2]))) {
|
|
157
157
|
const explicitColumns = createAs[3] === undefined ? undefined : parseColumnNames(createAs[3]);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const API_EXT_RULES: {
|
|
2
|
+
readonly unknownApi: {
|
|
3
|
+
readonly id: "api/unknown-api";
|
|
4
|
+
readonly summary: "Every r.usesApi name is exposed by some mounted feature.";
|
|
5
|
+
readonly fix: "Mount the feature that exposes the API or fix the API name.";
|
|
6
|
+
};
|
|
7
|
+
readonly providerNotRequired: {
|
|
8
|
+
readonly id: "api/provider-not-required";
|
|
9
|
+
readonly summary: "A feature using an API lists the exposing feature in requires or optionalRequires.";
|
|
10
|
+
readonly fix: "Add the exposing feature via r.requires({ features: [...] }).";
|
|
11
|
+
};
|
|
12
|
+
readonly providerNotMounted: {
|
|
13
|
+
readonly id: "api/provider-not-mounted";
|
|
14
|
+
readonly summary: "The feature exposing a used API is part of the app's feature list.";
|
|
15
|
+
readonly fix: "Register the provider feature in the app's feature list.";
|
|
16
|
+
};
|
|
17
|
+
readonly unknownExtension: {
|
|
18
|
+
readonly id: "extension/unknown-extension";
|
|
19
|
+
readonly summary: "Every extension a feature uses is defined by some mounted feature.";
|
|
20
|
+
readonly fix: "Register the feature that defines the extension or fix the extension name.";
|
|
21
|
+
};
|
|
22
|
+
readonly extensionProviderNotRequired: {
|
|
23
|
+
readonly id: "extension/provider-not-required";
|
|
24
|
+
readonly summary: "A feature using an extension lists the defining feature in requires or optionalRequires.";
|
|
25
|
+
readonly fix: "Add the defining feature via r.requires({ features: [...] }).";
|
|
26
|
+
};
|
|
27
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const API_EXT_RULES = {
|
|
2
|
+
unknownApi: {
|
|
3
|
+
id: "api/unknown-api",
|
|
4
|
+
summary: "Every r.usesApi name is exposed by some mounted feature.",
|
|
5
|
+
fix: "Mount the feature that exposes the API or fix the API name.",
|
|
6
|
+
},
|
|
7
|
+
providerNotRequired: {
|
|
8
|
+
id: "api/provider-not-required",
|
|
9
|
+
summary: "A feature using an API lists the exposing feature in requires or optionalRequires.",
|
|
10
|
+
fix: "Add the exposing feature via r.requires({ features: [...] }).",
|
|
11
|
+
},
|
|
12
|
+
providerNotMounted: {
|
|
13
|
+
id: "api/provider-not-mounted",
|
|
14
|
+
summary: "The feature exposing a used API is part of the app's feature list.",
|
|
15
|
+
fix: "Register the provider feature in the app's feature list.",
|
|
16
|
+
},
|
|
17
|
+
unknownExtension: {
|
|
18
|
+
id: "extension/unknown-extension",
|
|
19
|
+
summary: "Every extension a feature uses is defined by some mounted feature.",
|
|
20
|
+
fix: "Register the feature that defines the extension or fix the extension name.",
|
|
21
|
+
},
|
|
22
|
+
extensionProviderNotRequired: {
|
|
23
|
+
id: "extension/provider-not-required",
|
|
24
|
+
summary: "A feature using an extension lists the defining feature in requires or optionalRequires.",
|
|
25
|
+
fix: "Add the defining feature via r.requires({ features: [...] }).",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { createFallbackLogger } from "../../logging/utils.js";
|
|
2
|
+
import { API_EXT_RULES } from "./api-ext-rules.js";
|
|
3
|
+
import { collectBootRuleViolations } from "./boot-report.js";
|
|
4
|
+
import { bootRuleError } from "./boot-rule.js";
|
|
2
5
|
const log = createFallbackLogger("kumiko:boot");
|
|
3
6
|
// --- Cross-feature API exposure / usage matching ---
|
|
4
7
|
//
|
|
@@ -13,42 +16,52 @@ const log = createFallbackLogger("kumiko:boot");
|
|
|
13
16
|
// apiNames must be globally unique across features; validateBoot() checks
|
|
14
17
|
// this before the per-feature walk.
|
|
15
18
|
export function validateApiExposureMatching(feature, allExposedApis, featureMap) {
|
|
16
|
-
|
|
19
|
+
collectBootRuleViolations(feature.usedApis, (apiName) => {
|
|
17
20
|
const providerFeature = allExposedApis.get(apiName);
|
|
18
21
|
if (!providerFeature) {
|
|
19
22
|
const known = [...allExposedApis.keys()].sort().join(", ") || "(none)";
|
|
20
|
-
throw
|
|
23
|
+
throw bootRuleError(API_EXT_RULES.unknownApi, {
|
|
24
|
+
message: `[Feature ${feature.name}] r.usesApi("${apiName}") but no feature exposes that API. Known exposed APIs: ${known}`,
|
|
25
|
+
});
|
|
21
26
|
}
|
|
22
27
|
if (providerFeature === feature.name) {
|
|
23
28
|
log.warn(`[Feature ${feature.name}] r.usesApi("${apiName}") on its own r.exposesApi — typically a refactor leftover. Call the impl directly instead.`);
|
|
24
|
-
|
|
29
|
+
return;
|
|
25
30
|
}
|
|
26
31
|
const allDeps = [...feature.requires, ...feature.optionalRequires];
|
|
27
32
|
if (!allDeps.includes(providerFeature)) {
|
|
28
|
-
throw
|
|
33
|
+
throw bootRuleError(API_EXT_RULES.providerNotRequired, {
|
|
34
|
+
message: `[Feature ${feature.name}] r.usesApi("${apiName}") is exposed by "${providerFeature}" but feature is not in requires/optionalRequires. Add r.requires({ features: ["${providerFeature}"] }).`,
|
|
35
|
+
});
|
|
29
36
|
}
|
|
30
37
|
// Sanity: the provider feature must exist in this app's feature set.
|
|
31
38
|
// This should always hold when allExposedApis was built from `features`,
|
|
32
39
|
// but remains defensive for unclear constructor paths.
|
|
33
40
|
if (!featureMap.has(providerFeature)) {
|
|
34
|
-
throw
|
|
41
|
+
throw bootRuleError(API_EXT_RULES.providerNotMounted, {
|
|
42
|
+
message: `[Feature ${feature.name}] internal: r.usesApi("${apiName}") points to provider "${providerFeature}" which is not in feature map. Register the provider feature in the app's feature list.`,
|
|
43
|
+
});
|
|
35
44
|
}
|
|
36
|
-
}
|
|
45
|
+
});
|
|
37
46
|
}
|
|
38
47
|
// --- Extension usage validation ---
|
|
39
48
|
export function validateExtensionUsages(feature, extensionProviders) {
|
|
40
|
-
|
|
49
|
+
collectBootRuleViolations(feature.extensionUsages, (usage) => {
|
|
41
50
|
const providerFeature = extensionProviders.get(usage.extensionName);
|
|
42
51
|
if (!providerFeature) {
|
|
43
|
-
throw
|
|
52
|
+
throw bootRuleError(API_EXT_RULES.unknownExtension, {
|
|
53
|
+
message: `Feature "${feature.name}" uses extension "${usage.extensionName}" on entity "${usage.entityName}" but no feature defines that extension. Register the feature that defines it or fix the extension name.`,
|
|
54
|
+
});
|
|
44
55
|
}
|
|
45
|
-
// self-extension is legitimate: requires(self) would be circular.
|
|
46
56
|
if (providerFeature === feature.name) {
|
|
47
|
-
|
|
57
|
+
// skip: self-extension is legitimate, requires(self) would be circular
|
|
58
|
+
return;
|
|
48
59
|
}
|
|
49
60
|
const allDeps = [...feature.requires, ...feature.optionalRequires];
|
|
50
61
|
if (!allDeps.includes(providerFeature)) {
|
|
51
|
-
throw
|
|
62
|
+
throw bootRuleError(API_EXT_RULES.extensionProviderNotRequired, {
|
|
63
|
+
message: `Feature "${feature.name}" uses extension "${usage.extensionName}" but missing requires("${providerFeature}"). Add r.requires({ features: ["${providerFeature}"] }).`,
|
|
64
|
+
});
|
|
52
65
|
}
|
|
53
|
-
}
|
|
66
|
+
});
|
|
54
67
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const BOOT_CHECK_RULES: {
|
|
2
|
+
readonly failed: {
|
|
3
|
+
readonly id: "boot-check/failed";
|
|
4
|
+
readonly summary: "A feature's r.bootCheck invariant holds for the mounted feature set.";
|
|
5
|
+
readonly fix: "Satisfy the invariant named in the message or unmount the feature that declares it.";
|
|
6
|
+
};
|
|
7
|
+
};
|
|
@@ -1,16 +1,28 @@
|
|
|
1
|
+
import { BOOT_CHECK_RULES } from "./boot-check-rules.js";
|
|
2
|
+
import { BootRuleFailures, BootRuleViolation, bootRuleError } from "./boot-rule.js";
|
|
1
3
|
// r.bootCheck(fn) lets a feature declare its own mount-invariant instead of
|
|
2
4
|
// relying on framework-internal knowledge (the gdpr-storage.ts guards are
|
|
3
5
|
// the framework-owned version of this same idea). Each registered fn gets
|
|
4
6
|
// the full mounted-feature set and throws to fail the boot; we wrap the
|
|
5
7
|
// message with the owning feature's name so the error text points back at
|
|
6
8
|
// the feature that declared the check.
|
|
9
|
+
function prefixFailure(failure, prefix) {
|
|
10
|
+
const rule = failure instanceof BootRuleViolation ? failure.rule : BOOT_CHECK_RULES.failed;
|
|
11
|
+
return new BootRuleViolation(rule, prefix + failure.message);
|
|
12
|
+
}
|
|
7
13
|
export function runFeatureBootCheck(feature, check, features) {
|
|
8
14
|
const ctx = { features };
|
|
15
|
+
const prefix = `[Feature ${feature.name}] r.bootCheck failed: `;
|
|
9
16
|
try {
|
|
10
17
|
check(ctx);
|
|
11
18
|
}
|
|
12
19
|
catch (err) {
|
|
20
|
+
if (err instanceof BootRuleViolation)
|
|
21
|
+
throw prefixFailure(err, prefix);
|
|
22
|
+
if (err instanceof BootRuleFailures) {
|
|
23
|
+
throw new BootRuleFailures(err.failures.map((failure) => prefixFailure(failure, prefix)));
|
|
24
|
+
}
|
|
13
25
|
const message = err instanceof Error ? err.message : String(err);
|
|
14
|
-
throw
|
|
26
|
+
throw bootRuleError(BOOT_CHECK_RULES.failed, { message: prefix + message });
|
|
15
27
|
}
|
|
16
28
|
}
|