@cosmicdrift/kumiko-framework 0.220.1 → 0.222.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/package.json +7 -3
- package/src/__tests__/store-table.integration.test.ts +2 -1
- package/src/__tests__/upgrade-cli.test.ts +81 -12
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +1 -3
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +1 -3
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -3
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +17 -5
- package/src/api/api-constants.ts +10 -0
- package/src/api/auth-routes.ts +3 -0
- package/src/api/index.ts +1 -0
- package/src/api/pii-leak-guard.ts +4 -5
- package/src/api/server.ts +17 -1
- package/src/arg-parser.ts +1 -1
- package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
- package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +30 -14
- package/src/crypto/index.ts +1 -0
- package/src/crypto/is-self-pii-field.ts +8 -0
- package/src/crypto/subject-resolver.ts +4 -3
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
- package/src/db/__tests__/migrate-generator.test.ts +12 -0
- package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
- package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +29 -0
- package/src/db/blind-index-cleanup.ts +36 -19
- package/src/db/entity-table-meta.ts +6 -1
- package/src/db/event-store-executor-context.ts +2 -2
- package/src/db/event-store-executor-read.ts +7 -6
- package/src/db/event-store-executor-write.ts +103 -49
- package/src/db/index.ts +2 -0
- package/src/db/migrate-generator.ts +14 -0
- package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
- package/src/db/queries/backfill-pii.ts +13 -10
- package/src/db/queries/raw-sql.ts +14 -2
- package/src/db/queries/seed-context.ts +8 -4
- package/src/db/table-builder.ts +10 -3
- package/src/derivatives/__tests__/variant-key.test.ts +123 -1
- package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
- package/src/derivatives/derivatives-context.ts +4 -0
- package/src/derivatives/index.ts +9 -1
- package/src/derivatives/variant-key.ts +68 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
- package/src/engine/__tests__/boot-validator.test.ts +29 -3
- package/src/engine/__tests__/role-assignment.test.ts +41 -17
- package/src/engine/__tests__/schema-builder.test.ts +7 -7
- package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
- package/src/engine/boot-validator/entity-handler.ts +5 -0
- package/src/engine/boot-validator/pii-retention.ts +16 -4
- package/src/engine/boot-validator/screens.ts +9 -2
- package/src/engine/embedded-derived.ts +11 -10
- package/src/engine/extensions/storage-provider.ts +28 -0
- package/src/engine/extensions/user-data.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +1 -3
- package/src/engine/feature-ast/extractors/ai-steps.ts +26 -40
- package/src/engine/feature-ast/extractors/index.ts +2 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -1
- package/src/engine/feature-ast/parse.ts +10 -25
- package/src/engine/feature-ast/patch.ts +23 -16
- package/src/engine/feature-ast/render.ts +3 -3
- package/src/engine/field-helpers.ts +1 -1
- package/src/engine/index.ts +5 -0
- package/src/engine/pattern-library/mixed-schemas.ts +6 -0
- package/src/engine/role-assignment.ts +36 -18
- package/src/engine/schema-builder.ts +14 -2
- package/src/errors/__tests__/classes.test.ts +21 -2
- package/src/errors/__tests__/write-failures.test.ts +13 -3
- package/src/errors/classes.ts +14 -13
- package/src/errors/kumiko-error.ts +11 -11
- package/src/errors/write-error-info.ts +1 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
- package/src/event-store/__tests__/perf.integration.test.ts +5 -1
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
- package/src/event-store/event-store.ts +7 -0
- package/src/event-store/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +181 -2
- package/src/files/__tests__/local-provider.contract.test.ts +14 -0
- package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
- package/src/files/file-routes.ts +53 -6
- package/src/files/in-memory-provider.ts +4 -0
- package/src/files/local-provider.ts +22 -1
- package/src/i18n/__tests__/mail-registry.test.ts +13 -1
- package/src/i18n/__tests__/request-locale.test.ts +19 -0
- package/src/i18n/index.ts +7 -1
- package/src/i18n/mail-registry.ts +10 -0
- package/src/i18n/request-locale.ts +11 -2
- package/src/i18n/required-surface-keys.ts +3 -1
- package/src/jobs/job-runner.ts +22 -10
- package/src/lifecycle/signal-handlers.ts +2 -0
- package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +22 -38
- package/src/pipeline/__tests__/tenant-timezone-cache.test.ts +89 -0
- package/src/pipeline/dispatch-shared.ts +39 -2
- package/src/pipeline/dispatch-write.ts +48 -0
- package/src/pipeline/dispatcher.ts +5 -0
- package/src/pipeline/distributed-lock.ts +3 -0
- package/src/pipeline/tenant-timezone-cache.ts +92 -0
- package/src/schema-cli.ts +39 -48
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
- package/src/scripts/codemod/pii-personal-migration.ts +7 -7
- package/src/search/purge-subject.ts +4 -3
- package/src/search/reindex-entity.ts +2 -2
- package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
- package/src/stack/__tests__/request-helper.test.ts +2 -2
- package/src/testing/file-provider-contract.ts +19 -0
- package/src/ui-types/index.ts +1 -0
- package/src/upgrade-cli.ts +112 -15
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildConfigFeatureSchema } from "../../build-config-feature-schema";
|
|
2
3
|
import { access, createTenantConfig } from "../../config-helpers";
|
|
3
4
|
import { defineFeature } from "../../define-feature";
|
|
5
|
+
import { createRegistry } from "../../registry";
|
|
6
|
+
import { isFieldsEditSection } from "../../screen-helpers";
|
|
7
|
+
import type { ConfigEditScreenDefinition } from "../../types";
|
|
4
8
|
import { validateBoot } from "../index";
|
|
5
9
|
|
|
10
|
+
// Mirrors packages/bundled-features/src/config/i18n.ts — the audience-parent
|
|
11
|
+
// labels every masked config key's generated hub requires regardless of
|
|
12
|
+
// which feature owns the key or which scope its keys are visible at
|
|
13
|
+
// (requiredKeysFromGeneratedConfigHub adds all four unconditionally once
|
|
14
|
+
// any masked key exists, see i18n-keys.ts:35-38).
|
|
15
|
+
const configHub = defineFeature("config", (r) => {
|
|
16
|
+
r.translations({
|
|
17
|
+
keys: {
|
|
18
|
+
"config.settings.title": { en: "Settings" },
|
|
19
|
+
"config.settings.system": { en: "Platform" },
|
|
20
|
+
"config.settings.tenant": { en: "Tenant" },
|
|
21
|
+
"config.settings.user": { en: "Personal" },
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
6
26
|
// fw#2260: the Settings-Hub generator (buildConfigFeatureSchema) labels a
|
|
7
27
|
// masked config key's generated nav entry / configEdit section with the
|
|
8
28
|
// dot-form key `${feature}.settings` — never colon-form. isI18nKey's
|
|
@@ -10,20 +30,6 @@ import { validateBoot } from "../index";
|
|
|
10
30
|
// feature could ship a generated Settings screen whose label was never
|
|
11
31
|
// translated and boot validation stayed silent.
|
|
12
32
|
describe("validateI18nSurfaceKeys — Settings-Hub generated dot-form label (fw#2260)", () => {
|
|
13
|
-
// Mirrors packages/bundled-features/src/config/i18n.ts — the audience-parent
|
|
14
|
-
// labels every masked config key's generated hub requires regardless of
|
|
15
|
-
// which feature owns the key.
|
|
16
|
-
const configHub = defineFeature("config", (r) => {
|
|
17
|
-
r.translations({
|
|
18
|
-
keys: {
|
|
19
|
-
"config.settings.title": { en: "Settings" },
|
|
20
|
-
"config.settings.system": { en: "Platform" },
|
|
21
|
-
"config.settings.tenant": { en: "Organization" },
|
|
22
|
-
"config.settings.user": { en: "Personal" },
|
|
23
|
-
},
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
33
|
function billingFeature(translationKeys: Record<string, { readonly en: string }>) {
|
|
28
34
|
return defineFeature("billing", (r) => {
|
|
29
35
|
r.config({
|
|
@@ -61,4 +67,131 @@ describe("validateI18nSurfaceKeys — Settings-Hub generated dot-form label (fw#
|
|
|
61
67
|
});
|
|
62
68
|
expect(() => validateBoot([configHub, billing])).not.toThrow();
|
|
63
69
|
});
|
|
70
|
+
|
|
71
|
+
test("group namespace ≠ feature name: blank translation key satisfies hub label", () => {
|
|
72
|
+
const billing = defineFeature("billing", (r) => {
|
|
73
|
+
r.config({
|
|
74
|
+
keys: {
|
|
75
|
+
apiKey: createTenantConfig("text", {
|
|
76
|
+
write: access.roles("TenantAdmin"),
|
|
77
|
+
group: "tenant-settings",
|
|
78
|
+
mask: { title: "billing.api-key" },
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
r.translations({
|
|
83
|
+
keys: {
|
|
84
|
+
"billing.api-key": { en: "API Key" },
|
|
85
|
+
"screen:tenant-settings-tenant.title": { en: "Tenant Settings" },
|
|
86
|
+
"tenant-settings.settings": { en: "Tenant Settings" },
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
expect(() => validateBoot([configHub, billing])).not.toThrow();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// PR #2314 idx2: buildConfigFeatureSchema varies the generated dot-form label
|
|
95
|
+
// along two more paths that fw#2260's tests above never exercise — a tenant
|
|
96
|
+
// key with an elevated (SystemAdmin) write role surfaces the same feature
|
|
97
|
+
// under a second, broader scope (build-config-feature-schema.ts:142-158), and
|
|
98
|
+
// a feature can opt into a configEdit section description
|
|
99
|
+
// (build-config-feature-schema.ts:246-249). The `group`-namespace path is
|
|
100
|
+
// deliberately left untested here — fw#2314 idx1 tracks it as broken
|
|
101
|
+
// (treatDotFormAsKey requires an unsatisfiable `${group}.settings` key for a
|
|
102
|
+
// namespace that has no matching feature), and a test would either fail on
|
|
103
|
+
// the open bug or paper over it; it belongs with the idx1 fix, not this batch.
|
|
104
|
+
describe("validateI18nSurfaceKeys — scoped label fallback for elevated write roles (PR #2314 idx2)", () => {
|
|
105
|
+
function opsFeature(translationKeys: Record<string, { readonly en: string }>) {
|
|
106
|
+
return defineFeature("ops", (r) => {
|
|
107
|
+
r.config({
|
|
108
|
+
keys: {
|
|
109
|
+
// Home scope tenant + an elevated SystemAdmin write role surfaces
|
|
110
|
+
// "ops" under BOTH the tenant audience (home, full write set) and
|
|
111
|
+
// the system audience (cascade, write set ∩ ELEVATED_ROLES.system)
|
|
112
|
+
// — two navs/screens for one feature.
|
|
113
|
+
maintenanceMode: createTenantConfig("boolean", {
|
|
114
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
115
|
+
mask: { title: "ops.maintenance-mode" },
|
|
116
|
+
}),
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
if (Object.keys(translationKeys).length > 0) {
|
|
120
|
+
r.translations({ keys: translationKeys });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
test("elevated write role surfaces the feature at two scopes; the scoped override labels the system nav while the tenant nav falls back to the plain key", () => {
|
|
126
|
+
const translations = {
|
|
127
|
+
"ops.maintenance-mode": { en: "Maintenance Mode" },
|
|
128
|
+
"screen:ops-tenant.title": { en: "Ops Settings" },
|
|
129
|
+
"screen:ops-system.title": { en: "Ops Settings (Platform)" },
|
|
130
|
+
// Required unconditionally: both screens' section title is always
|
|
131
|
+
// `${feature}.settings` (build-config-feature-schema.ts:248).
|
|
132
|
+
"ops.settings": { en: "Ops" },
|
|
133
|
+
// Opt-in scoped override — only the system (cascade) nav uses it.
|
|
134
|
+
"ops.settings.system": { en: "Ops (Platform Cascade)" },
|
|
135
|
+
};
|
|
136
|
+
const ops = opsFeature(translations);
|
|
137
|
+
|
|
138
|
+
const schema = buildConfigFeatureSchema(createRegistry([configHub, ops]));
|
|
139
|
+
expect(schema.navs.find((n) => n.id === "ops-system")?.label).toBe("ops.settings.system");
|
|
140
|
+
expect(schema.navs.find((n) => n.id === "ops-tenant")?.label).toBe("ops.settings");
|
|
141
|
+
|
|
142
|
+
expect(() => validateBoot([configHub, ops])).not.toThrow();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// isI18nKeys.ts's requiredKeysFromScreen never reads EditFieldsSection.description
|
|
147
|
+
// (only title + field labels, see required-surface-keys.ts:210-224), so this
|
|
148
|
+
// gate is invisible to validateBoot either way — a validateBoot(...).not.toThrow()
|
|
149
|
+
// assertion alone would pass identically with build-config-feature-schema.ts:249
|
|
150
|
+
// deleted outright. Assert on the generated schema itself instead, the only
|
|
151
|
+
// artifact the gate actually changes.
|
|
152
|
+
describe("validateI18nSurfaceKeys — gated configEdit section description (PR #2314 idx2)", () => {
|
|
153
|
+
function reportingFeature(translationKeys: Record<string, { readonly en: string }>) {
|
|
154
|
+
return defineFeature("reporting", (r) => {
|
|
155
|
+
r.config({
|
|
156
|
+
keys: {
|
|
157
|
+
retentionDays: createTenantConfig("number", {
|
|
158
|
+
write: access.roles("TenantAdmin"),
|
|
159
|
+
mask: { title: "reporting.retention-days" },
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
r.translations({ keys: translationKeys });
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function reportingSection(translationKeys: Record<string, { readonly en: string }>) {
|
|
168
|
+
const reporting = reportingFeature(translationKeys);
|
|
169
|
+
const schema = buildConfigFeatureSchema(createRegistry([configHub, reporting]));
|
|
170
|
+
const screen = schema.screens.find(
|
|
171
|
+
(s): s is ConfigEditScreenDefinition => s.type === "configEdit",
|
|
172
|
+
);
|
|
173
|
+
if (screen === undefined) throw new Error("expected a generated configEdit screen");
|
|
174
|
+
const section = screen.layout.sections[0];
|
|
175
|
+
if (section === undefined || !isFieldsEditSection(section)) {
|
|
176
|
+
throw new Error("expected a fields section");
|
|
177
|
+
}
|
|
178
|
+
return section;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
test("declaring 'reporting.settings.description' adds it to the generated section; omitting it leaves the section without one", () => {
|
|
182
|
+
const withDescription = reportingSection({
|
|
183
|
+
"reporting.retention-days": { en: "Retention (days)" },
|
|
184
|
+
"screen:reporting-tenant.title": { en: "Reporting Settings" },
|
|
185
|
+
"reporting.settings": { en: "Reporting" },
|
|
186
|
+
"reporting.settings.description": { en: "Configure how long reports are kept." },
|
|
187
|
+
});
|
|
188
|
+
expect(withDescription.description).toBe("reporting.settings.description");
|
|
189
|
+
|
|
190
|
+
const withoutDescription = reportingSection({
|
|
191
|
+
"reporting.retention-days": { en: "Retention (days)" },
|
|
192
|
+
"screen:reporting-tenant.title": { en: "Reporting Settings" },
|
|
193
|
+
"reporting.settings": { en: "Reporting" },
|
|
194
|
+
});
|
|
195
|
+
expect(withoutDescription.description).toBeUndefined();
|
|
196
|
+
});
|
|
64
197
|
});
|
|
@@ -587,6 +587,11 @@ function validateEmbeddedDerivedCells(
|
|
|
587
587
|
`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading unknown sub-field "${sourceName}".`,
|
|
588
588
|
);
|
|
589
589
|
}
|
|
590
|
+
if (sourceName in field.derived) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading derived sub-field "${sourceName}".`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
590
595
|
}
|
|
591
596
|
}
|
|
592
597
|
}
|
|
@@ -218,20 +218,32 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
if (retention.strategy === "blockDelete") {
|
|
221
|
-
// blockDelete
|
|
222
|
-
//
|
|
223
|
-
|
|
221
|
+
// blockDelete with no field that can carry `anonymize` is fine;
|
|
222
|
+
// subjectRef-only entities are covered by the V3 EXT_USER_DATA guard
|
|
223
|
+
// instead (#1622, #2336).
|
|
224
|
+
const entityHasAnonymizableSubjectField = Object.values(fieldsByName).some(
|
|
224
225
|
(f) => hasAnonymizableSubjectField(f as ResolvedPiiFlags), // @cast-boundary schema-walk
|
|
225
226
|
);
|
|
226
227
|
const hasAnonymize = Object.values(fieldsByName).some((f) => {
|
|
227
228
|
const a = f as ResolvedPiiFlags; // @cast-boundary schema-walk
|
|
228
229
|
return Boolean(a.anonymize);
|
|
229
230
|
});
|
|
230
|
-
if (
|
|
231
|
+
if (entityHasAnonymizableSubjectField && !hasAnonymize) {
|
|
231
232
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
232
233
|
console.warn(
|
|
233
234
|
`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. User-Forget cannot anonymize — Forget will return error. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`,
|
|
234
235
|
);
|
|
236
|
+
} else if (!entityHasAnonymizableSubjectField) {
|
|
237
|
+
const hasSubjectRef = Object.values(fieldsByName).some((f) => {
|
|
238
|
+
const a = f as ResolvedPiiFlags; // @cast-boundary schema-walk
|
|
239
|
+
return Boolean(a.subjectRef);
|
|
240
|
+
});
|
|
241
|
+
if (hasSubjectRef) {
|
|
242
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
243
|
+
console.warn(
|
|
244
|
+
`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" with subjectRef-only PII relies on the EXT_USER_DATA delete hook for Art.17 — make sure it is not a no-op.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
235
247
|
}
|
|
236
248
|
}
|
|
237
249
|
}
|
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
EditLayout,
|
|
21
21
|
FieldCondition,
|
|
22
22
|
RowAction,
|
|
23
|
-
|
|
23
|
+
RowActionNavigateBase,
|
|
24
24
|
RowFieldExtractor,
|
|
25
25
|
ScreenDefinition,
|
|
26
26
|
ToolbarAction,
|
|
@@ -345,12 +345,19 @@ function validateToolbarDrawerAction(
|
|
|
345
345
|
// call sites so the mutual-exclusivity check and the entity→detailFor
|
|
346
346
|
// resolution don't drift between them (same drift risk
|
|
347
347
|
// validateRowActionNavigateParams above is already shared to avoid).
|
|
348
|
+
// Runtime-loose shape: boot still rejects both/neither for untyped schemas;
|
|
349
|
+
// authors get the exclusive union via RowActionNavigate (#2303).
|
|
350
|
+
type RowActionNavigateRuntime = RowActionNavigateBase & {
|
|
351
|
+
readonly screen?: string;
|
|
352
|
+
readonly entity?: string;
|
|
353
|
+
};
|
|
354
|
+
|
|
348
355
|
function resolveRowActionNavigateTarget(
|
|
349
356
|
featureName: string,
|
|
350
357
|
screenId: string,
|
|
351
358
|
screenType: "entityList" | "projectionList" | "projectionDetail",
|
|
352
359
|
actionLabel: "rowAction" | "action",
|
|
353
|
-
action:
|
|
360
|
+
action: RowActionNavigateRuntime,
|
|
354
361
|
allScreenQns: ReadonlySet<string>,
|
|
355
362
|
navTargetShortIds: ReadonlySet<string>,
|
|
356
363
|
screensByShortId: ReadonlyMap<
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import type { EmbeddedDerivedCellDef, EmbeddedSubFieldDef } from "./types";
|
|
2
2
|
|
|
3
3
|
/** Computes a derived cell from its source values. Missing/non-numeric
|
|
4
|
-
* sources
|
|
5
|
-
* missing
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `withDerivedCells` rounds the result to the target sub-field's declared
|
|
10
|
-
* precision afterward. */
|
|
4
|
+
* sources: "multiply" with any missing source → undefined; "sum"/"subtract"
|
|
5
|
+
* treat missing as 0 only when at least one source is present — if every
|
|
6
|
+
* source is missing, return undefined (do not invent 0). Money cells are
|
|
7
|
+
* minor-unit integers — unit-agnostic arithmetic; `withDerivedCells` rounds
|
|
8
|
+
* to the target sub-field's declared precision afterward. */
|
|
11
9
|
export function computeDerivedCellValue(
|
|
12
10
|
op: EmbeddedDerivedCellDef["op"],
|
|
13
11
|
values: readonly (number | undefined)[],
|
|
@@ -16,6 +14,7 @@ export function computeDerivedCellValue(
|
|
|
16
14
|
if (values.some((value) => value === undefined)) return undefined;
|
|
17
15
|
return (values as readonly number[]).reduce((product, value) => product * value, 1);
|
|
18
16
|
}
|
|
17
|
+
if (values.every((value) => value === undefined)) return undefined;
|
|
19
18
|
const numeric = values.map((value) => value ?? 0);
|
|
20
19
|
if (op === "sum") return numeric.reduce((sum, value) => sum + value, 0);
|
|
21
20
|
// subtract: first value minus every subsequent value.
|
|
@@ -60,8 +59,8 @@ function roundHalfAwayFromZero(value: number, decimals: number): number {
|
|
|
60
59
|
/** Recomputes every derived cell of an embedded-list row from its raw
|
|
61
60
|
* values, overwriting whatever the client sent instead of merely checking
|
|
62
61
|
* it — the server is the authority for derived cells. Reads source values
|
|
63
|
-
* from the
|
|
64
|
-
*
|
|
62
|
+
* from the working copy so already-recomputed sources feed later cells;
|
|
63
|
+
* boot-validator rejects derived-from-derived chains. A row that isn't a
|
|
65
64
|
* plain object (already invalid, or not this field's shape) passes through
|
|
66
65
|
* untouched — validation downstream rejects it. The computed value is
|
|
67
66
|
* rounded to the target sub-field's declared precision (`schema`) before
|
|
@@ -77,11 +76,13 @@ export function withDerivedCells(
|
|
|
77
76
|
const copy: Record<string, unknown> = { ...source };
|
|
78
77
|
for (const [cellName, def] of Object.entries(derived)) {
|
|
79
78
|
const sourceValues = def.from.map((sourceField) => {
|
|
80
|
-
const value =
|
|
79
|
+
const value = copy[sourceField];
|
|
81
80
|
return typeof value === "number" ? value : undefined;
|
|
82
81
|
});
|
|
83
82
|
const computed = computeDerivedCellValue(def.op, sourceValues);
|
|
84
83
|
if (computed === undefined) {
|
|
84
|
+
// Always clear — keeping a client value would let callers spoof required
|
|
85
|
+
// derived cells when sources are missing (server must stay authoritative).
|
|
85
86
|
delete copy[cellName];
|
|
86
87
|
} else {
|
|
87
88
|
const target = schema[cellName];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Hook signature types for EXT_STORAGE_PROVIDER (tenant-destroy binary cleanup).
|
|
2
|
+
//
|
|
3
|
+
// Mirror of tenant-data.ts, but the destroyTenant hook takes (tenantId, ctx)
|
|
4
|
+
// rather than just (ctx) — runExtensionDestroyHooks (tenant-lifecycle/stages.ts)
|
|
5
|
+
// passes tenantId as its own positional arg for every EXT_*_RESOURCE-style
|
|
6
|
+
// extension point, not just this one. ctx here only guarantees tenantId plus
|
|
7
|
+
// the optional fileProviderResolver/log; the richer stage-runner ctx
|
|
8
|
+
// (tenant-lifecycle's DestructionStageCtx) is a structural superset, so a hook
|
|
9
|
+
// typed against this minimal ctx is safely assignable wherever that richer ctx
|
|
10
|
+
// is passed.
|
|
11
|
+
|
|
12
|
+
import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
|
|
13
|
+
import type { TenantId } from "../types";
|
|
14
|
+
|
|
15
|
+
export interface StorageProviderHookCtx {
|
|
16
|
+
readonly tenantId: TenantId;
|
|
17
|
+
readonly fileProviderResolver?: FileProviderResolver;
|
|
18
|
+
readonly log?: (message: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type StorageProviderDestroyTenantHook = (
|
|
22
|
+
tenantId: TenantId,
|
|
23
|
+
ctx: StorageProviderHookCtx,
|
|
24
|
+
) => Promise<void>;
|
|
25
|
+
|
|
26
|
+
export interface StorageProviderExtensionHooks {
|
|
27
|
+
readonly destroyTenant: StorageProviderDestroyTenantHook;
|
|
28
|
+
}
|
|
@@ -66,6 +66,10 @@ export type TenantUserModel = "single-user" | "multi-user";
|
|
|
66
66
|
*/
|
|
67
67
|
export interface UserDataStorageProvider {
|
|
68
68
|
delete(storageKey: string): Promise<void>;
|
|
69
|
+
// Needed so a forget/tenant-destroy hook can find derived/variant keys
|
|
70
|
+
// (thumbnails, resized variants) that are never tracked anywhere but the
|
|
71
|
+
// storage layer itself — see fileRefDeleteHook.
|
|
72
|
+
list(prefix: string): Promise<readonly string[]>;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
export interface UserDataHookCtx {
|
|
@@ -2588,7 +2588,7 @@ describe("cross-file registrar-wrapper resolution against a real filesystem Proj
|
|
|
2588
2588
|
|
|
2589
2589
|
test("resolves the imported wrapper and recognises the nav pattern it registers", () => {
|
|
2590
2590
|
expect(result.errors).toEqual([]);
|
|
2591
|
-
expect(result.patterns.map((p) => p.kind)).toEqual(["
|
|
2591
|
+
expect(result.patterns.map((p) => p.kind)).toEqual(["requires", "nav"]);
|
|
2592
2592
|
});
|
|
2593
2593
|
|
|
2594
2594
|
test("the resolved pattern's source points at the imported file, not the entry file", () => {
|
|
@@ -391,6 +391,16 @@ describe("addPattern — error paths", () => {
|
|
|
391
391
|
const sf = makeSourceFile("export const x = 1;");
|
|
392
392
|
expect(() => addPattern(sf, newSecretPattern)).toThrow(/no defineFeature/);
|
|
393
393
|
});
|
|
394
|
+
|
|
395
|
+
test("throws for ai.* kinds (must target a workflow steps array)", () => {
|
|
396
|
+
const sf = makeSourceFile(`
|
|
397
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
398
|
+
defineFeature("ai-host", (r) => {});
|
|
399
|
+
`);
|
|
400
|
+
expect(() =>
|
|
401
|
+
addPattern(sf, { kind: "ai.generate", stepKey: "generate" } as FeaturePattern),
|
|
402
|
+
).toThrow(/ai\.generate steps must be inserted into a defineWorkflow/);
|
|
403
|
+
});
|
|
394
404
|
});
|
|
395
405
|
|
|
396
406
|
describe("callMatchesId — positional and object-form lookups", () => {
|
|
@@ -869,9 +869,7 @@ describe("render → parse roundtrip — AI pipeline steps", () => {
|
|
|
869
869
|
|
|
870
870
|
test("inline + const-ref ai steps round-trip structurally", () => {
|
|
871
871
|
const aiPatterns = initial.patterns.filter((p) => p.kind.startsWith("ai."));
|
|
872
|
-
const stepCalls = aiPatterns
|
|
873
|
-
.map((p) => indent(renderPattern(p), " ").replace(/;\s*$/, ","))
|
|
874
|
-
.join("\n");
|
|
872
|
+
const stepCalls = aiPatterns.map((p) => `${indent(renderPattern(p), " ")},`).join("\n");
|
|
875
873
|
const wrapped = `
|
|
876
874
|
import { defineFeature, defineWorkflow, stepsPipeline } from "@cosmicdrift/kumiko-framework/engine";
|
|
877
875
|
import { z } from "zod";
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type { CallExpression,
|
|
2
|
-
import { SyntaxKind } from "ts-morph";
|
|
1
|
+
import type { CallExpression, ObjectLiteralExpression, SourceFile } from "ts-morph";
|
|
3
2
|
import type {
|
|
4
3
|
AiClassifyPattern,
|
|
5
4
|
AiExtractPattern,
|
|
@@ -17,7 +16,10 @@ import {
|
|
|
17
16
|
isRawRefSentinel,
|
|
18
17
|
ok,
|
|
19
18
|
readDataLiteralNode,
|
|
19
|
+
readNameLiteral,
|
|
20
|
+
readObjectPropertyInitializer,
|
|
20
21
|
readStringOrRaw,
|
|
22
|
+
resolveSameFileObjectLiteral,
|
|
21
23
|
} from "./shared";
|
|
22
24
|
|
|
23
25
|
type AiStepKind = AiGeneratePattern["kind"] | AiExtractPattern["kind"] | AiClassifyPattern["kind"];
|
|
@@ -32,33 +34,11 @@ type AiStepCommonExtracted = {
|
|
|
32
34
|
readonly paramsSchemaSource?: SourceLocation;
|
|
33
35
|
};
|
|
34
36
|
|
|
35
|
-
function resolveObjectLiteralArg(node: Node): ObjectLiteralExpression | undefined {
|
|
36
|
-
const direct = node.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
37
|
-
if (direct) return direct;
|
|
38
|
-
const identifier = node.asKind(SyntaxKind.Identifier);
|
|
39
|
-
if (!identifier) return undefined;
|
|
40
|
-
const varDecl = node.getSourceFile().getVariableDeclaration(identifier.getText());
|
|
41
|
-
return varDecl?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function readPropertyInitializer(
|
|
45
|
-
obj: ObjectLiteralExpression,
|
|
46
|
-
propertyName: string,
|
|
47
|
-
): import("ts-morph").Expression | undefined {
|
|
48
|
-
const prop = obj.getProperty(propertyName);
|
|
49
|
-
if (!prop) return undefined;
|
|
50
|
-
const assign = prop.asKind(SyntaxKind.PropertyAssignment);
|
|
51
|
-
if (assign) return assign.getInitializer();
|
|
52
|
-
const shorthand = prop.asKind(SyntaxKind.ShorthandPropertyAssignment);
|
|
53
|
-
if (shorthand) return shorthand.getNameNode();
|
|
54
|
-
return undefined;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
37
|
function readEditableStringProp(
|
|
58
38
|
obj: ObjectLiteralExpression,
|
|
59
39
|
propertyName: string,
|
|
60
40
|
): string | AiStepOpaqueArgs | undefined {
|
|
61
|
-
const init =
|
|
41
|
+
const init = readObjectPropertyInitializer(obj, propertyName);
|
|
62
42
|
if (!init) return undefined;
|
|
63
43
|
const value = readStringOrRaw(init);
|
|
64
44
|
if (value === undefined) return undefined;
|
|
@@ -108,7 +88,7 @@ function extractAiStepCommon(
|
|
|
108
88
|
return fail(kind, source, "expected one argument object");
|
|
109
89
|
}
|
|
110
90
|
|
|
111
|
-
const obj =
|
|
91
|
+
const obj = resolveSameFileObjectLiteral(arg);
|
|
112
92
|
if (!obj) {
|
|
113
93
|
if (isRawRefSentinel(readDataLiteralNode(arg))) {
|
|
114
94
|
return ok({
|
|
@@ -123,15 +103,21 @@ function extractAiStepCommon(
|
|
|
123
103
|
);
|
|
124
104
|
}
|
|
125
105
|
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
106
|
+
const stepKeyInit = readObjectPropertyInitializer(obj, "stepKey");
|
|
107
|
+
if (!stepKeyInit) {
|
|
128
108
|
return fail(kind, source, "missing `stepKey` property");
|
|
129
109
|
}
|
|
110
|
+
// PatternId needs a concrete string — resolve identifiers like patch.readAiStepKey.
|
|
111
|
+
const stepKeyLiteral = readNameLiteral(stepKeyInit);
|
|
112
|
+
const stepKey = stepKeyLiteral ?? readStringOrRaw(stepKeyInit);
|
|
113
|
+
if (stepKey === undefined) {
|
|
114
|
+
return fail(kind, source, "`stepKey` must be a string literal or resolvable identifier");
|
|
115
|
+
}
|
|
130
116
|
const promptKey = readEditableStringProp(obj, "promptKey");
|
|
131
117
|
if (promptKey === undefined) {
|
|
132
118
|
return fail(kind, source, "missing `promptKey` property");
|
|
133
119
|
}
|
|
134
|
-
const promptFallbackInit =
|
|
120
|
+
const promptFallbackInit = readObjectPropertyInitializer(obj, "promptFallback");
|
|
135
121
|
if (!promptFallbackInit) {
|
|
136
122
|
return fail(kind, source, "missing `promptFallback` property");
|
|
137
123
|
}
|
|
@@ -140,7 +126,7 @@ function extractAiStepCommon(
|
|
|
140
126
|
return fail(kind, source, "`promptFallback` must be a string literal or identifier reference");
|
|
141
127
|
}
|
|
142
128
|
|
|
143
|
-
const defaultsInit =
|
|
129
|
+
const defaultsInit = readObjectPropertyInitializer(obj, "defaults");
|
|
144
130
|
if (!defaultsInit) {
|
|
145
131
|
return fail(kind, source, "missing `defaults` property");
|
|
146
132
|
}
|
|
@@ -149,7 +135,7 @@ function extractAiStepCommon(
|
|
|
149
135
|
return fail(kind, source, "`defaults` could not be read as a StepPolicy literal");
|
|
150
136
|
}
|
|
151
137
|
|
|
152
|
-
const paramsSchemaInit =
|
|
138
|
+
const paramsSchemaInit = readObjectPropertyInitializer(obj, "paramsSchema");
|
|
153
139
|
if (!paramsSchemaInit) {
|
|
154
140
|
return fail(kind, source, "missing `paramsSchema` property");
|
|
155
141
|
}
|
|
@@ -206,11 +192,11 @@ export function extractAiGenerate(
|
|
|
206
192
|
}
|
|
207
193
|
|
|
208
194
|
const arg = call.getArguments()[0];
|
|
209
|
-
const obj = arg ?
|
|
195
|
+
const obj = arg ? resolveSameFileObjectLiteral(arg) : undefined;
|
|
210
196
|
if (!obj) {
|
|
211
197
|
return fail("ai.generate", common.pattern.source, "expected resolvable argument object");
|
|
212
198
|
}
|
|
213
|
-
const inputInit =
|
|
199
|
+
const inputInit = readObjectPropertyInitializer(obj, "input");
|
|
214
200
|
if (!inputInit) {
|
|
215
201
|
return fail("ai.generate", common.pattern.source, "missing `input` property");
|
|
216
202
|
}
|
|
@@ -252,16 +238,16 @@ export function extractAiExtract(
|
|
|
252
238
|
}
|
|
253
239
|
|
|
254
240
|
const arg = call.getArguments()[0];
|
|
255
|
-
const obj = arg ?
|
|
241
|
+
const obj = arg ? resolveSameFileObjectLiteral(arg) : undefined;
|
|
256
242
|
if (!obj) {
|
|
257
243
|
return fail("ai.extract", common.pattern.source, "expected resolvable argument object");
|
|
258
244
|
}
|
|
259
245
|
|
|
260
|
-
const outputSchemaInit =
|
|
246
|
+
const outputSchemaInit = readObjectPropertyInitializer(obj, "outputSchema");
|
|
261
247
|
if (!outputSchemaInit) {
|
|
262
248
|
return fail("ai.extract", common.pattern.source, "missing `outputSchema` property");
|
|
263
249
|
}
|
|
264
|
-
const instructionsInit =
|
|
250
|
+
const instructionsInit = readObjectPropertyInitializer(obj, "instructions");
|
|
265
251
|
if (!instructionsInit) {
|
|
266
252
|
return fail("ai.extract", common.pattern.source, "missing `instructions` property");
|
|
267
253
|
}
|
|
@@ -274,7 +260,7 @@ export function extractAiExtract(
|
|
|
274
260
|
);
|
|
275
261
|
}
|
|
276
262
|
|
|
277
|
-
const documentInit =
|
|
263
|
+
const documentInit = readObjectPropertyInitializer(obj, "document");
|
|
278
264
|
let documentBody: SourceLocation | undefined;
|
|
279
265
|
if (documentInit) {
|
|
280
266
|
const documentFn = findFunctionLiteral(documentInit);
|
|
@@ -319,12 +305,12 @@ export function extractAiClassify(
|
|
|
319
305
|
}
|
|
320
306
|
|
|
321
307
|
const arg = call.getArguments()[0];
|
|
322
|
-
const obj = arg ?
|
|
308
|
+
const obj = arg ? resolveSameFileObjectLiteral(arg) : undefined;
|
|
323
309
|
if (!obj) {
|
|
324
310
|
return fail("ai.classify", common.pattern.source, "expected resolvable argument object");
|
|
325
311
|
}
|
|
326
312
|
|
|
327
|
-
const actionsInit =
|
|
313
|
+
const actionsInit = readObjectPropertyInitializer(obj, "actions");
|
|
328
314
|
if (!actionsInit) {
|
|
329
315
|
return fail("ai.classify", common.pattern.source, "missing `actions` property");
|
|
330
316
|
}
|
|
@@ -337,7 +323,7 @@ export function extractAiClassify(
|
|
|
337
323
|
);
|
|
338
324
|
}
|
|
339
325
|
|
|
340
|
-
const inputInit =
|
|
326
|
+
const inputInit = readObjectPropertyInitializer(obj, "input");
|
|
341
327
|
if (!inputInit) {
|
|
342
328
|
return fail("ai.classify", common.pattern.source, "missing `input` property");
|
|
343
329
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CallExpression, Node } from "ts-morph";
|
|
1
|
+
import type { CallExpression, Node, ObjectLiteralExpression } from "ts-morph";
|
|
2
2
|
import { SyntaxKind } from "ts-morph";
|
|
3
3
|
import { isPlainObject } from "../../../utils/is-plain-object";
|
|
4
4
|
import type { ParseError } from "../parse";
|
|
@@ -264,6 +264,31 @@ export function readNameOrRef(node: Node): string | undefined {
|
|
|
264
264
|
return undefined;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
/** Resolve an inline object literal or a same-file `const` that initializes to one. */
|
|
268
|
+
export function resolveSameFileObjectLiteral(node: Node): ObjectLiteralExpression | undefined {
|
|
269
|
+
const direct = node.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
270
|
+
if (direct) return direct;
|
|
271
|
+
const identifier = node.asKind(SyntaxKind.Identifier);
|
|
272
|
+
if (!identifier) return undefined;
|
|
273
|
+
const valueDecl = identifier.getSymbol()?.getValueDeclaration();
|
|
274
|
+
const fromSymbol = valueDecl?.asKind(SyntaxKind.VariableDeclaration);
|
|
275
|
+
const varDecl = fromSymbol ?? node.getSourceFile().getVariableDeclaration(identifier.getText());
|
|
276
|
+
return varDecl?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function readObjectPropertyInitializer(
|
|
280
|
+
obj: ObjectLiteralExpression,
|
|
281
|
+
propertyName: string,
|
|
282
|
+
): import("ts-morph").Expression | undefined {
|
|
283
|
+
const prop = obj.getProperty(propertyName);
|
|
284
|
+
if (!prop) return undefined;
|
|
285
|
+
const assign = prop.asKind(SyntaxKind.PropertyAssignment);
|
|
286
|
+
if (assign) return assign.getInitializer();
|
|
287
|
+
const shorthand = prop.asKind(SyntaxKind.ShorthandPropertyAssignment);
|
|
288
|
+
if (shorthand) return shorthand.getNameNode();
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
267
292
|
export function findFunctionLiteral(node: Node): Node | undefined {
|
|
268
293
|
if (node.getKind() === SyntaxKind.ArrowFunction) return node;
|
|
269
294
|
if (node.getKind() === SyntaxKind.FunctionExpression) return node;
|