@cosmicdrift/kumiko-framework 0.221.0 → 0.223.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/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/auth-routes.ts +3 -0
- package/src/api/pii-leak-guard.ts +4 -5
- package/src/arg-parser.ts +1 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +29 -0
- package/src/db/entity-table-meta.ts +6 -1
- package/src/db/table-builder.ts +10 -3
- package/src/derivatives/__tests__/variant-key.test.ts +123 -1
- 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.test.ts +113 -0
- package/src/engine/__tests__/schema-builder.test.ts +4 -4
- package/src/engine/boot-validator/entity-handler.ts +34 -1
- package/src/engine/extensions/storage-provider.ts +28 -0
- package/src/engine/extensions/user-data.ts +4 -0
- package/src/engine/factories.ts +13 -6
- package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
- 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 +14 -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/schema-builder.ts +14 -2
- package/src/errors/__tests__/classes.test.ts +16 -2
- package/src/errors/__tests__/write-failures.test.ts +10 -0
- package/src/errors/classes.ts +14 -13
- package/src/errors/write-error-info.ts +1 -1
- package/src/files/__tests__/local-provider.contract.test.ts +14 -0
- package/src/files/in-memory-provider.ts +4 -0
- package/src/files/local-provider.ts +22 -1
- package/src/jobs/job-runner.ts +22 -10
- 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/tenant-timezone-cache.ts +92 -0
- package/src/schema-cli.ts +30 -44
- package/src/scripts/codemod/pii-personal-migration.ts +7 -7
- package/src/stack/__tests__/request-helper.test.ts +2 -2
- package/src/testing/file-provider-contract.ts +19 -0
- package/src/upgrade-cli.ts +12 -1
|
@@ -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 {
|
package/src/engine/factories.ts
CHANGED
|
@@ -162,25 +162,32 @@ export function createSelectField<
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
/**
|
|
165
|
-
* Multi-
|
|
165
|
+
* Multi-select field — N values from a fixed option list.
|
|
166
166
|
*
|
|
167
|
-
* Storage: jsonb-Array<string>.
|
|
168
|
-
*
|
|
169
|
-
*
|
|
167
|
+
* Storage: jsonb-Array<string>. Every entry must be in `options`
|
|
168
|
+
* (boot-validator). Renders as a combobox dropdown by default; set
|
|
169
|
+
* `display: "checkboxes"` to render every option as a visible checkbox with
|
|
170
|
+
* a select-all toggle instead.
|
|
170
171
|
*
|
|
171
172
|
* ```ts
|
|
172
173
|
* licenceClasses: createMultiSelectField({
|
|
173
174
|
* options: ["B", "BE", "C", "C1", "CE", "C1E", "D", "D1"] as const,
|
|
174
175
|
* default: ["B"],
|
|
175
176
|
* }),
|
|
177
|
+
*
|
|
178
|
+
* languages: createMultiSelectField({
|
|
179
|
+
* options: ["en", "de", "es", "fr", "it"] as const,
|
|
180
|
+
* display: "checkboxes",
|
|
181
|
+
* columns: 2,
|
|
182
|
+
* }),
|
|
176
183
|
* ```
|
|
177
184
|
*
|
|
178
185
|
* Caller-API:
|
|
179
186
|
* Write: `{ licenceClasses: ["B", "BE", "C1"] }`
|
|
180
187
|
* Read: `{ licenceClasses: ["B", "BE", "C1"] }`
|
|
181
188
|
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
189
|
+
* Use instead of `select` when more than one value can be chosen at once.
|
|
190
|
+
* Use instead of `embedded` with booleans once there are more than ~5 options.
|
|
184
191
|
*/
|
|
185
192
|
export function createMultiSelectField<const TOptions extends readonly string[]>(
|
|
186
193
|
opts: { options: TOptions } & Partial<
|
|
@@ -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", () => {
|
|
@@ -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;
|
|
@@ -26,7 +26,6 @@ import type {
|
|
|
26
26
|
CallExpression,
|
|
27
27
|
Expression,
|
|
28
28
|
Node,
|
|
29
|
-
ObjectLiteralExpression,
|
|
30
29
|
ParameterDeclaration,
|
|
31
30
|
SourceFile,
|
|
32
31
|
} from "ts-morph";
|
|
@@ -74,6 +73,8 @@ import {
|
|
|
74
73
|
extractWorkspace,
|
|
75
74
|
extractWriteHandler,
|
|
76
75
|
findFunctionLiteral,
|
|
76
|
+
readObjectPropertyInitializer,
|
|
77
|
+
resolveSameFileObjectLiteral,
|
|
77
78
|
} from "./extractors";
|
|
78
79
|
import type { FeaturePattern, UnknownPattern } from "./patterns";
|
|
79
80
|
import { type SourceLocation, sourceLocationFromNode } from "./source-location";
|
|
@@ -158,7 +159,13 @@ export function parseSourceFile(sourceFile: SourceFile): ParseResult {
|
|
|
158
159
|
|
|
159
160
|
walkSetupCallback(setupCallback.getBody(), registrarParamName, sourceFile, patterns, errors);
|
|
160
161
|
walkAiStepCalls(setupCallback.getBody(), registrarParamName, sourceFile, patterns, errors);
|
|
161
|
-
|
|
162
|
+
// Tie-break on filePath so cross-file registrar wrappers don't interleave by
|
|
163
|
+
// foreign line numbers alone.
|
|
164
|
+
patterns.sort((a, b) => {
|
|
165
|
+
const fileCmp = a.source.file.localeCompare(b.source.file);
|
|
166
|
+
if (fileCmp !== 0) return fileCmp;
|
|
167
|
+
return a.source.start.line - b.source.start.line;
|
|
168
|
+
});
|
|
162
169
|
|
|
163
170
|
return { featureName, patterns, errors };
|
|
164
171
|
}
|
|
@@ -387,28 +394,6 @@ function extractRegistrarMethodName(
|
|
|
387
394
|
return propAccess.getName();
|
|
388
395
|
}
|
|
389
396
|
|
|
390
|
-
function readObjectPropertyInitializer(
|
|
391
|
-
obj: ObjectLiteralExpression,
|
|
392
|
-
propertyName: string,
|
|
393
|
-
): Expression | undefined {
|
|
394
|
-
const prop = obj.getProperty(propertyName);
|
|
395
|
-
if (!prop) return undefined;
|
|
396
|
-
const assign = prop.asKind(SyntaxKind.PropertyAssignment);
|
|
397
|
-
if (assign) return assign.getInitializer();
|
|
398
|
-
const shorthand = prop.asKind(SyntaxKind.ShorthandPropertyAssignment);
|
|
399
|
-
if (shorthand) return shorthand.getNameNode();
|
|
400
|
-
return undefined;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
function resolveSameFileObjectLiteralArg(node: Node): ObjectLiteralExpression | undefined {
|
|
404
|
-
const direct = node.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
405
|
-
if (direct) return direct;
|
|
406
|
-
const identifier = node.asKind(SyntaxKind.Identifier);
|
|
407
|
-
if (!identifier) return undefined;
|
|
408
|
-
const varDecl = node.getSourceFile().getVariableDeclaration(identifier.getText());
|
|
409
|
-
return varDecl?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
397
|
function resolveStepsArrayRoot(stepsInit: Expression): Node | undefined {
|
|
413
398
|
const directArray = stepsInit.asKind(SyntaxKind.ArrayLiteralExpression);
|
|
414
399
|
if (directArray) return directArray;
|
|
@@ -444,7 +429,7 @@ function collectWorkflowStepArrayRoots(body: Node): Node[] {
|
|
|
444
429
|
const roots: Node[] = [];
|
|
445
430
|
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
446
431
|
if (call.getExpression().getText() !== "defineWorkflow") continue;
|
|
447
|
-
const obj =
|
|
432
|
+
const obj = resolveSameFileObjectLiteral(call.getArguments()[0] ?? call);
|
|
448
433
|
if (!obj) continue;
|
|
449
434
|
const stepsInit = readObjectPropertyInitializer(obj, "steps");
|
|
450
435
|
if (!stepsInit) continue;
|
|
@@ -30,9 +30,8 @@
|
|
|
30
30
|
// roadmap C-Notes for the canonical-comment-attach Pattern that would
|
|
31
31
|
// preserve prefixed `// kumiko-comment:` markers across roundtrips.
|
|
32
32
|
|
|
33
|
-
import type { ObjectLiteralExpression } from "ts-morph";
|
|
34
33
|
import { type CallExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
35
|
-
import { readNameLiteral, readNameOrRef } from "./extractors/shared";
|
|
34
|
+
import { readNameLiteral, readNameOrRef, resolveSameFileObjectLiteral } from "./extractors/shared";
|
|
36
35
|
import type { FeaturePattern, FeaturePatternKind } from "./patterns";
|
|
37
36
|
import { indent, PATTERN_INDENT, renderPattern } from "./render";
|
|
38
37
|
|
|
@@ -204,7 +203,9 @@ export function replacePattern(
|
|
|
204
203
|
const startNode = isAiStepId(id) ? call : (enclosingStatement ?? call);
|
|
205
204
|
|
|
206
205
|
const startPos = startNode.getStart();
|
|
207
|
-
|
|
206
|
+
// AI steps live in array literals — replace only the CallExpression so the
|
|
207
|
+
// surrounding comma stays (comma-eating belongs to removePattern alone).
|
|
208
|
+
const endPos = isAiStepId(id) ? call.getEnd() : startNode.getEnd();
|
|
208
209
|
|
|
209
210
|
// Detect column of the original call's first non-whitespace character;
|
|
210
211
|
// the rendered pattern starts at column 0 and gets indented to match.
|
|
@@ -366,17 +367,6 @@ const AI_STEP_FACTORY: Readonly<Record<"ai.generate" | "ai.extract" | "ai.classi
|
|
|
366
367
|
"ai.classify": "aiClassifyStep",
|
|
367
368
|
};
|
|
368
369
|
|
|
369
|
-
function resolveSameFileObjectLiteral(
|
|
370
|
-
node: import("ts-morph").Node,
|
|
371
|
-
): ObjectLiteralExpression | undefined {
|
|
372
|
-
const direct = node.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
373
|
-
if (direct) return direct;
|
|
374
|
-
const identifier = node.asKind(SyntaxKind.Identifier);
|
|
375
|
-
if (!identifier) return undefined;
|
|
376
|
-
const varDecl = node.getSourceFile().getVariableDeclaration(identifier.getText());
|
|
377
|
-
return varDecl?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
370
|
function readAiStepKey(call: CallExpression): string | undefined {
|
|
381
371
|
const arg = call.getArguments()[0];
|
|
382
372
|
if (!arg) return undefined;
|
|
@@ -400,11 +390,19 @@ function findAiStepCall(sourceFile: SourceFile, id: PatternId): CallExpression |
|
|
|
400
390
|
return undefined;
|
|
401
391
|
}
|
|
402
392
|
const factory = AI_STEP_FACTORY[id.kind];
|
|
393
|
+
const matches: CallExpression[] = [];
|
|
403
394
|
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
404
395
|
if (call.getExpression().getText() !== factory) continue;
|
|
405
|
-
if (callMatchesId(call, id))
|
|
396
|
+
if (callMatchesId(call, id)) matches.push(call);
|
|
406
397
|
}
|
|
407
|
-
|
|
398
|
+
if (matches.length > 1) {
|
|
399
|
+
throw new Error(
|
|
400
|
+
`findAiStepCall: ambiguous ${id.kind} stepKey=${JSON.stringify(
|
|
401
|
+
"stepKey" in id ? id.stepKey : undefined,
|
|
402
|
+
)} — ${matches.length} matches; disambiguate or include workflowName in PatternId`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
return matches[0];
|
|
408
406
|
}
|
|
409
407
|
|
|
410
408
|
function callMatchesId(call: CallExpression, id: PatternId): boolean {
|
|
@@ -481,7 +481,7 @@ function renderAiGenerate(p: AiGeneratePattern): string {
|
|
|
481
481
|
const lines: string[] = ["aiGenerateStep({"];
|
|
482
482
|
renderAiStepCommonFields(lines, p);
|
|
483
483
|
if (p.inputBody !== undefined) lines.push(` input: ${p.inputBody.raw},`);
|
|
484
|
-
lines.push("})
|
|
484
|
+
lines.push("})");
|
|
485
485
|
return lines.join("\n");
|
|
486
486
|
}
|
|
487
487
|
|
|
@@ -496,7 +496,7 @@ function renderAiExtract(p: AiExtractPattern): string {
|
|
|
496
496
|
lines.push(` instructions: ${p.instructionsBody.raw},`);
|
|
497
497
|
}
|
|
498
498
|
if (p.documentBody !== undefined) lines.push(` document: ${p.documentBody.raw},`);
|
|
499
|
-
lines.push("})
|
|
499
|
+
lines.push("})");
|
|
500
500
|
return lines.join("\n");
|
|
501
501
|
}
|
|
502
502
|
|
|
@@ -506,7 +506,7 @@ function renderAiClassify(p: AiClassifyPattern): string {
|
|
|
506
506
|
renderAiStepCommonFields(lines, p);
|
|
507
507
|
if (p.actions !== undefined) lines.push(` actions: ${renderValue(p.actions)},`);
|
|
508
508
|
if (p.inputBody !== undefined) lines.push(` input: ${p.inputBody.raw},`);
|
|
509
|
-
lines.push("})
|
|
509
|
+
lines.push("})");
|
|
510
510
|
return lines.join("\n");
|
|
511
511
|
}
|
|
512
512
|
|
|
@@ -22,7 +22,7 @@ export const DEFAULT_CURRENCIES = [
|
|
|
22
22
|
|
|
23
23
|
// --- Locale ---
|
|
24
24
|
|
|
25
|
-
export const DEFAULT_LOCALES = ["de", "en"] as const;
|
|
25
|
+
export const DEFAULT_LOCALES = ["de", "en", "es"] as const;
|
|
26
26
|
|
|
27
27
|
export function isFileField(field: FieldDefinition | undefined): field is AnyFileFieldDef {
|
|
28
28
|
if (!field) return false;
|
package/src/engine/index.ts
CHANGED
|
@@ -96,6 +96,11 @@ export {
|
|
|
96
96
|
EXT_USER_DATA_ORDER,
|
|
97
97
|
FILE_PROVIDER_CONFIG_KEY,
|
|
98
98
|
} from "./extension-names";
|
|
99
|
+
export type {
|
|
100
|
+
StorageProviderDestroyTenantHook,
|
|
101
|
+
StorageProviderExtensionHooks,
|
|
102
|
+
StorageProviderHookCtx,
|
|
103
|
+
} from "./extensions/storage-provider";
|
|
99
104
|
export type {
|
|
100
105
|
TenantDataDestroyHook,
|
|
101
106
|
TenantDataExtensionHooks,
|
|
@@ -243,6 +243,12 @@ const aiStepCommonFields = [
|
|
|
243
243
|
input: "text",
|
|
244
244
|
required: true,
|
|
245
245
|
},
|
|
246
|
+
{
|
|
247
|
+
path: "promptFallback",
|
|
248
|
+
label: { en: "Prompt fallback", de: "Prompt-Fallback" },
|
|
249
|
+
input: "text",
|
|
250
|
+
required: true,
|
|
251
|
+
},
|
|
246
252
|
{
|
|
247
253
|
path: "defaults",
|
|
248
254
|
label: { en: "Defaults", de: "Defaults" },
|
|
@@ -86,6 +86,7 @@ function embeddedSubFieldToZod(subField: EmbeddedSubFieldDef): z.ZodTypeAny {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// kumiko-lint-ignore complexity-budget field→zod switch is the single schema source of truth
|
|
89
90
|
export function fieldToZod(
|
|
90
91
|
field: FieldDefinition,
|
|
91
92
|
currencies: readonly string[],
|
|
@@ -315,8 +316,19 @@ function applyTotalsMatchRefinements(
|
|
|
315
316
|
for (const [subFieldName, siblingFieldName] of Object.entries(totalsMatch)) {
|
|
316
317
|
const rawRows = values[fieldName];
|
|
317
318
|
const siblingRaw = values[siblingFieldName];
|
|
318
|
-
//
|
|
319
|
-
if (rawRows === undefined
|
|
319
|
+
// Neither side sent → nothing to check (unrelated partial update).
|
|
320
|
+
if (rawRows === undefined && siblingRaw === undefined) continue;
|
|
321
|
+
// One side of a totalsMatch pair without the other → reject. Update
|
|
322
|
+
// payloads only carry `changes`, so omitting the sibling would otherwise
|
|
323
|
+
// silently leave sum ≠ total (fw#1841).
|
|
324
|
+
if (rawRows === undefined || siblingRaw === undefined) {
|
|
325
|
+
ctx.addIssue({
|
|
326
|
+
code: "custom",
|
|
327
|
+
path: [rawRows === undefined ? fieldName : siblingFieldName],
|
|
328
|
+
message: `totalsMatch requires both "${fieldName}" and "${siblingFieldName}" in the same payload`,
|
|
329
|
+
});
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
320
332
|
// Not an array -> a different refinement already rejects the shape;
|
|
321
333
|
// this check isn't the right place to report it.
|
|
322
334
|
if (!Array.isArray(rawRows)) continue;
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
KumikoError,
|
|
10
10
|
NotFoundError,
|
|
11
11
|
serializeError,
|
|
12
|
+
UnconfiguredError,
|
|
12
13
|
UnprocessableError,
|
|
13
14
|
ValidationError,
|
|
14
15
|
VersionConflictError,
|
|
@@ -329,9 +330,9 @@ describe("UnprocessableError", () => {
|
|
|
329
330
|
expect(err.i18nKey).toBe("orders.errors.alreadyCancelled");
|
|
330
331
|
});
|
|
331
332
|
|
|
332
|
-
test("positional reason
|
|
333
|
+
test("positional reason is the sole details.reason (extras allowed)", () => {
|
|
333
334
|
const err = new UnprocessableError("order.already_cancelled", {
|
|
334
|
-
details: {
|
|
335
|
+
details: { orderId: 7 },
|
|
335
336
|
});
|
|
336
337
|
expect(err.details).toEqual({ reason: "order.already_cancelled", orderId: 7 });
|
|
337
338
|
expect(err.docsUrl).toBe("https://docs.kumiko.rocks/errors/order.already_cancelled");
|
|
@@ -426,3 +427,16 @@ class KumikoErrorStub extends KumikoError {
|
|
|
426
427
|
super({ message: "stub", i18nKey: "stub", cause: opts.cause });
|
|
427
428
|
}
|
|
428
429
|
}
|
|
430
|
+
|
|
431
|
+
describe("UnconfiguredError", () => {
|
|
432
|
+
test("docsUrl uses stable unconfigured slug, not the freestext message", () => {
|
|
433
|
+
const err = new UnconfiguredError({ feature: "billing", key: "apiKey" });
|
|
434
|
+
expect(err.docsUrl).toBe("https://docs.kumiko.rocks/errors/unconfigured");
|
|
435
|
+
expect(err.details).toMatchObject({
|
|
436
|
+
reason: "unconfigured",
|
|
437
|
+
feature: "billing",
|
|
438
|
+
key: "apiKey",
|
|
439
|
+
});
|
|
440
|
+
expect(String((err.details as { message?: string }).message)).toContain("apiKey");
|
|
441
|
+
});
|
|
442
|
+
});
|
|
@@ -19,9 +19,19 @@ describe("failUnprocessable", () => {
|
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
test("positional reason survives a conflicting details.reason from the caller", () => {
|
|
22
|
+
// @ts-expect-error callers must not pass details.reason; positional arg wins at runtime
|
|
22
23
|
const f = failUnprocessable("custom_business_rule", { reason: "raw cause text", extra: 42 });
|
|
23
24
|
expect(f.error.details).toEqual({ reason: "custom_business_rule", extra: 42 });
|
|
24
25
|
});
|
|
26
|
+
|
|
27
|
+
test("details may carry extras but reason comes only from the positional arg", () => {
|
|
28
|
+
const f = failUnprocessable("custom_business_rule", { extra: 42, causeNote: "use opts.cause" });
|
|
29
|
+
expect(f.error.details).toEqual({
|
|
30
|
+
reason: "custom_business_rule",
|
|
31
|
+
extra: 42,
|
|
32
|
+
causeNote: "use opts.cause",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
25
35
|
});
|
|
26
36
|
|
|
27
37
|
describe("failTransition", () => {
|
package/src/errors/classes.ts
CHANGED
|
@@ -192,8 +192,9 @@ export class UniqueViolationError extends ConflictError {
|
|
|
192
192
|
|
|
193
193
|
// Business-rule violation. The human-readable reason lives in details.reason
|
|
194
194
|
// so the client can key off it without overloading the top-level code.
|
|
195
|
-
export type UnprocessableOpts = Pick<ErrorOpts, "i18nKey" | "i18nParams" | "cause"> & {
|
|
196
|
-
|
|
195
|
+
export type UnprocessableOpts = Pick<ErrorOpts, "i18nKey" | "i18nParams" | "cause" | "message"> & {
|
|
196
|
+
// `reason` is owned by the positional ctor arg — callers put cause text in `opts.cause`.
|
|
197
|
+
readonly details?: Readonly<Record<string, unknown>> & { readonly reason?: never };
|
|
197
198
|
};
|
|
198
199
|
|
|
199
200
|
export class UnprocessableError extends KumikoError {
|
|
@@ -204,7 +205,7 @@ export class UnprocessableError extends KumikoError {
|
|
|
204
205
|
|
|
205
206
|
constructor(reason: string, opts?: UnprocessableOpts) {
|
|
206
207
|
super({
|
|
207
|
-
message: `unprocessable: ${reason}`,
|
|
208
|
+
message: opts?.message ?? `unprocessable: ${reason}`,
|
|
208
209
|
i18nKey: opts?.i18nKey ?? "errors.unprocessable",
|
|
209
210
|
...(opts?.i18nParams && { i18nParams: opts.i18nParams }),
|
|
210
211
|
details: { ...opts?.details, reason },
|
|
@@ -226,16 +227,16 @@ export class UnconfiguredError extends UnprocessableError {
|
|
|
226
227
|
override readonly code: string = "unconfigured";
|
|
227
228
|
|
|
228
229
|
constructor(details: UnconfiguredDetails, opts?: Pick<ErrorOpts, "i18nKey" | "cause">) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
},
|
|
238
|
-
);
|
|
230
|
+
const message = `${details.feature}: '${details.key}' is empty — tenant must configure it before use.${
|
|
231
|
+
details.hint ? ` ${details.hint}` : ""
|
|
232
|
+
}`;
|
|
233
|
+
// Stable slug for docsUrl — freestext is the Error.message + details.message.
|
|
234
|
+
super("unconfigured", {
|
|
235
|
+
message,
|
|
236
|
+
i18nKey: opts?.i18nKey ?? "errors.unconfigured",
|
|
237
|
+
details: { ...details, message },
|
|
238
|
+
...(opts?.cause && { cause: opts.cause }),
|
|
239
|
+
});
|
|
239
240
|
}
|
|
240
241
|
}
|
|
241
242
|
|