@atscript/moost-wf 0.1.58

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Artem Maltsev <artem@maltsev.nl>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @atscript/moost-wf
2
+
3
+ Server-side workflow integration for [Moost](https://github.com/moostjs/moost) — decorators, interceptors, and serialization that pair with [`@atscript/vue-wf`](../vue-wf) to drive multi-step forms from atscript-annotated `.as` types.
4
+
5
+ Part of the [atscript-ui](https://github.com/moostjs/atscript-ui) monorepo.
6
+
7
+ ## What it provides
8
+
9
+ - Workflow decorators that wrap Moost handlers and expose them as `@atscript/vue-wf`-compatible endpoints
10
+ - Interceptors that serialize / deserialize workflow state across HTTP requests
11
+ - `@AsWfState` storage abstraction with a default `@atscript/db`-backed implementation
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pnpm add @atscript/moost-wf
17
+ ```
18
+
19
+ Peer requirements: `moost`, `@moostjs/event-wf`, `@atscript/core`, `@atscript/typescript`.
20
+
21
+ ## Entry points
22
+
23
+ | Subpath | What it exports |
24
+ | ----------------------------- | ---------------------------------------------------------------------------------- |
25
+ | `@atscript/moost-wf` | Workflow decorators, interceptors, runtime |
26
+ | `@atscript/moost-wf/plugin` | atscript build-time plugin |
27
+ | `@atscript/moost-wf/store` | Generated runtime for `AsWfStateRecord` |
28
+ | `@atscript/moost-wf/store.as` | Raw `.as` source for the workflow-state record — re-import if you customize fields |
29
+
30
+ ## License
31
+
32
+ MIT © Artem Maltsev
@@ -0,0 +1,24 @@
1
+ import { defineAnnotatedType, throwFeatureDisabled } from "@atscript/typescript/utils";
2
+ //#region src/store/as-wf-state.as
3
+ var JsonValue = class {
4
+ static __is_atscript_annotated_type = true;
5
+ static type = {};
6
+ static metadata = /* @__PURE__ */ new Map();
7
+ static id = "JsonValue";
8
+ static toJsonSchema() {
9
+ throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
10
+ }
11
+ };
12
+ var AsWfStateRecord = class {
13
+ static __is_atscript_annotated_type = true;
14
+ static type = {};
15
+ static metadata = /* @__PURE__ */ new Map();
16
+ static id = "AsWfStateRecord";
17
+ static toJsonSchema() {
18
+ throwFeatureDisabled("JSON Schema", "jsonSchema", "emit.jsonSchema");
19
+ }
20
+ };
21
+ defineAnnotatedType("union", JsonValue).item(defineAnnotatedType().designType("string").tags("string").$type).item(defineAnnotatedType().designType("number").tags("number").$type).item(defineAnnotatedType().designType("boolean").tags("boolean").$type).item(defineAnnotatedType().designType("null").tags("null").$type).item(defineAnnotatedType("array").of(defineAnnotatedType().refTo(JsonValue).$type).$type).item(defineAnnotatedType("object").propPattern(/^.+$/, defineAnnotatedType().refTo(JsonValue).$type).$type);
22
+ defineAnnotatedType("object", AsWfStateRecord).prop("handle", defineAnnotatedType().designType("string").tags("string").annotate("ui.table.hidden", true).annotate("db.index.unique", "handle_idx", true).annotate("expect.maxLength", { length: 256 }).$type).prop("schemaId", defineAnnotatedType().designType("string").tags("string").annotate("db.index.plain", { name: "schema_idx" }, true).annotate("expect.maxLength", { length: 256 }).$type).prop("state", defineAnnotatedType("object").prop("context", defineAnnotatedType().refTo(JsonValue).$type).prop("indexes", defineAnnotatedType("array").of(defineAnnotatedType().designType("number").tags("number").$type).$type).prop("meta", defineAnnotatedType("object").propPattern(/^.+$/, defineAnnotatedType().refTo(JsonValue).$type).optional().$type).annotate("ui.table.hidden", true).annotate("db.json", true).$type).prop("expiresAt", defineAnnotatedType().designType("number").tags("timestamp", "number").annotate("db.index.plain", { name: "expires_idx" }, true).annotate("expect.int", true).optional().$type).prop("updatedAt", defineAnnotatedType().designType("number").tags("timestamp", "number").annotate("db.default.now", true).annotate("db.index.plain", { name: "updated_idx" }, true).annotate("expect.int", true).$type).prop("createdAt", defineAnnotatedType().designType("number").tags("timestamp", "number").annotate("expect.int", true).$type).prop("createdBy", defineAnnotatedType().designType("string").tags("string").annotate("ui.table.hidden", true).annotate("expect.maxLength", { length: 128 }).optional().$type).prop("lastUpdatedBy", defineAnnotatedType().designType("string").tags("string").annotate("ui.table.hidden", true).annotate("expect.maxLength", { length: 128 }).optional().$type);
23
+ //#endregion
24
+ export { AsWfStateRecord as t };
@@ -0,0 +1,152 @@
1
+ import { TInterceptorDef } from "moost";
2
+ import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
3
+
4
+ //#region src/form-input/required.d.ts
5
+ /**
6
+ * Thrown by @FormInput() to signal that the workflow should pause
7
+ * and request form input from the client.
8
+ *
9
+ * Caught by {@link formInputInterceptor} and converted to an
10
+ * `inputRequired` outlet response.
11
+ */
12
+ declare class FormInputRequired {
13
+ readonly schema: unknown;
14
+ readonly errors?: Record<string, string> | undefined;
15
+ readonly context?: Record<string, unknown> | undefined;
16
+ constructor(schema: unknown, errors?: Record<string, string> | undefined, context?: Record<string, unknown> | undefined);
17
+ }
18
+ //#endregion
19
+ //#region src/form-input/use.d.ts
20
+ /**
21
+ * Composable that provides access to form data and the `requireInput()` helper
22
+ * inside workflow step handlers.
23
+ *
24
+ * Called by the `@FormInput()` Resolve callback. Can also be used standalone
25
+ * when you need to manually re-pause with errors:
26
+ *
27
+ * ```ts
28
+ * const { requireInput } = useFormInput()
29
+ * throw requireInput({ password: 'Invalid credentials' })
30
+ * ```
31
+ */
32
+ declare function useFormInput(type?: TAtscriptAnnotatedType): {
33
+ data: <T = unknown>() => T | undefined;
34
+ requireInput: (errors?: Record<string, string>) => FormInputRequired;
35
+ };
36
+ //#endregion
37
+ //#region src/form-input/decorator.d.ts
38
+ /**
39
+ * Parameter decorator for workflow steps that need form input.
40
+ *
41
+ * Combines parameter injection (via Resolve) with a method interceptor
42
+ * (via Intercept) that validates input before the step handler executes.
43
+ *
44
+ * The injected value is `{ data(), requireInput(errors?) }`.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * @Step('login')
49
+ * async login(@FormInput() form: TFormInput<LoginForm>) {
50
+ * const input = form.data()
51
+ * try {
52
+ * await this.auth.login(input.username, input.password)
53
+ * } catch (e) {
54
+ * throw form.requireInput({ password: 'Invalid credentials' })
55
+ * }
56
+ * }
57
+ * ```
58
+ */
59
+ declare function FormInput(): ParameterDecorator;
60
+ type TFormInput<_T = unknown> = ReturnType<typeof useFormInput>;
61
+ //#endregion
62
+ //#region src/form-input/alt-action.decorator.d.ts
63
+ /**
64
+ * Parameter decorator that resolves the action name from the current
65
+ * workflow event context. Returns `undefined` for normal form submits.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * @Step('mfa-verify')
70
+ * async mfaVerify(
71
+ * @FormInput() form: TFormInput<PincodeForm>,
72
+ * @AltAction() action: string | undefined,
73
+ * ) {
74
+ * if (action === 'resend') {
75
+ * await this.sendOtp(ctx.email)
76
+ * return
77
+ * }
78
+ * await this.verifyCode(form.data().code)
79
+ * }
80
+ * ```
81
+ */
82
+ declare const AltAction: () => ParameterDecorator & PropertyDecorator;
83
+ //#endregion
84
+ //#region src/form-input/interceptor.d.ts
85
+ /**
86
+ * Global interceptor that catches {@link FormInputRequired} signals
87
+ * thrown by step handlers (via `form.requireInput()`) and converts them
88
+ * to `inputRequired` outlet responses.
89
+ *
90
+ * Apply globally:
91
+ * ```ts
92
+ * app.applyGlobalInterceptors(formInputInterceptor())
93
+ * ```
94
+ */
95
+ declare function formInputInterceptor(): TInterceptorDef;
96
+ //#endregion
97
+ //#region src/form-input/serialize.d.ts
98
+ /**
99
+ * Serialize an atscript annotated type to a JSON-transportable form schema.
100
+ *
101
+ * Delegates to `serializeAnnotatedType` from `@atscript/typescript`,
102
+ * stripping server-only `@wf.context.pass` annotations from the output.
103
+ * Results are cached per type identity.
104
+ *
105
+ * FK fields ship with a shallow ref: the target's identity (`id`) and
106
+ * interface-level `metadata` (including `db.http.path` when present) are
107
+ * included, but the target's structural body (`kind`, `props`, etc.) is
108
+ * omitted. This is sufficient for client pickers to resolve value-help
109
+ * endpoints without dragging the full target tree onto the wire. See the
110
+ * `wf-serialize-shallow-ref` design for rationale.
111
+ */
112
+ declare function serializeFormSchema(type: TAtscriptAnnotatedType): unknown;
113
+ //#endregion
114
+ //#region src/form-input/context.d.ts
115
+ interface TFormActions {
116
+ actions: string[];
117
+ actionsWithData: string[];
118
+ }
119
+ /**
120
+ * Extract whitelisted context keys from workflow state.
121
+ * Reads `@wf.context.pass` annotations from the form type metadata.
122
+ */
123
+ declare function extractPassContext(type: TAtscriptAnnotatedType, wfContext: Record<string, unknown>): Record<string, unknown>;
124
+ /**
125
+ * Read declared action names from `@ui.form.action` and `@wf.action.withData` annotations.
126
+ * Also reads legacy `@ui.altAction` as a stateless action fallback.
127
+ * Results are cached per type identity.
128
+ */
129
+ declare function getFormActions(type: TAtscriptAnnotatedType): TFormActions;
130
+ //#endregion
131
+ //#region src/form-input/use-wf-action.d.ts
132
+ /**
133
+ * Composable that reads and writes the workflow action from the event context.
134
+ *
135
+ * **In the HTTP trigger** (to set the action from the request body):
136
+ * ```ts
137
+ * const { setAction } = useWfAction()
138
+ * setAction(body.action)
139
+ * ```
140
+ *
141
+ * **In step handlers** (to read the action — prefer `@AltAction()` decorator):
142
+ * ```ts
143
+ * const { getAction } = useWfAction()
144
+ * const action = getAction()
145
+ * ```
146
+ */
147
+ declare function useWfAction(): {
148
+ getAction: () => string | undefined;
149
+ setAction: (action: string | undefined) => void;
150
+ };
151
+ //#endregion
152
+ export { AltAction, FormInput, FormInputRequired, type TFormInput, extractPassContext, formInputInterceptor, getFormActions, serializeFormSchema, useFormInput, useWfAction };
package/dist/index.mjs ADDED
@@ -0,0 +1,334 @@
1
+ import { useWfState } from "@moostjs/event-wf";
2
+ import { Intercept, Resolve, TInterceptorPriority, useControllerContext } from "moost";
3
+ import { isAnnotatedType, serializeAnnotatedType } from "@atscript/typescript/utils";
4
+ import { current, key } from "@wooksjs/event-core";
5
+ //#region src/form-input/context.ts
6
+ const WF_CONTEXT_PASS = "wf.context.pass";
7
+ const UI_FORM_ACTION = "ui.form.action";
8
+ const WF_ACTION_WITH_DATA = "wf.action.withData";
9
+ const UI_ALT_ACTION = "ui.altAction";
10
+ const formActionsCache = /* @__PURE__ */ new WeakMap();
11
+ /**
12
+ * Extract whitelisted context keys from workflow state.
13
+ * Reads `@wf.context.pass` annotations from the form type metadata.
14
+ */
15
+ function extractPassContext(type, wfContext) {
16
+ const passKeys = type.metadata.get(WF_CONTEXT_PASS);
17
+ if (!passKeys?.length) return {};
18
+ const context = {};
19
+ for (const key of passKeys) if (key in wfContext) context[key] = wfContext[key];
20
+ return context;
21
+ }
22
+ /**
23
+ * Read declared action names from `@ui.form.action` and `@wf.action.withData` annotations.
24
+ * Also reads legacy `@ui.altAction` as a stateless action fallback.
25
+ * Results are cached per type identity.
26
+ */
27
+ function getFormActions(type) {
28
+ const cached = formActionsCache.get(type);
29
+ if (cached) return cached;
30
+ const actions = [];
31
+ const actionsWithData = [];
32
+ if (type.type.kind !== "object") {
33
+ const result = {
34
+ actions,
35
+ actionsWithData
36
+ };
37
+ formActionsCache.set(type, result);
38
+ return result;
39
+ }
40
+ const objectType = type;
41
+ for (const [, fieldType] of objectType.type.props) {
42
+ const formAction = fieldType.metadata.get(UI_FORM_ACTION);
43
+ if (formAction) {
44
+ actions.push(typeof formAction === "string" ? formAction : formAction.id);
45
+ continue;
46
+ }
47
+ const wfAction = fieldType.metadata.get(WF_ACTION_WITH_DATA);
48
+ if (wfAction) {
49
+ actionsWithData.push(wfAction);
50
+ continue;
51
+ }
52
+ const altAction = fieldType.metadata.get(UI_ALT_ACTION);
53
+ if (altAction) actions.push(typeof altAction === "string" ? altAction : altAction.id);
54
+ }
55
+ const result = {
56
+ actions,
57
+ actionsWithData
58
+ };
59
+ formActionsCache.set(type, result);
60
+ return result;
61
+ }
62
+ //#endregion
63
+ //#region src/form-input/serialize.ts
64
+ const schemaCache = /* @__PURE__ */ new WeakMap();
65
+ /**
66
+ * Serialize an atscript annotated type to a JSON-transportable form schema.
67
+ *
68
+ * Delegates to `serializeAnnotatedType` from `@atscript/typescript`,
69
+ * stripping server-only `@wf.context.pass` annotations from the output.
70
+ * Results are cached per type identity.
71
+ *
72
+ * FK fields ship with a shallow ref: the target's identity (`id`) and
73
+ * interface-level `metadata` (including `db.http.path` when present) are
74
+ * included, but the target's structural body (`kind`, `props`, etc.) is
75
+ * omitted. This is sufficient for client pickers to resolve value-help
76
+ * endpoints without dragging the full target tree onto the wire. See the
77
+ * `wf-serialize-shallow-ref` design for rationale.
78
+ */
79
+ function serializeFormSchema(type) {
80
+ const cached = schemaCache.get(type);
81
+ if (cached) return cached;
82
+ const schema = serializeAnnotatedType(type, {
83
+ ignoreAnnotations: [WF_CONTEXT_PASS],
84
+ refDepth: .5
85
+ });
86
+ schemaCache.set(type, schema);
87
+ return schema;
88
+ }
89
+ //#endregion
90
+ //#region src/form-input/required.ts
91
+ /**
92
+ * Thrown by @FormInput() to signal that the workflow should pause
93
+ * and request form input from the client.
94
+ *
95
+ * Caught by {@link formInputInterceptor} and converted to an
96
+ * `inputRequired` outlet response.
97
+ */
98
+ var FormInputRequired = class {
99
+ constructor(schema, errors, context) {
100
+ this.schema = schema;
101
+ this.errors = errors;
102
+ this.context = context;
103
+ }
104
+ };
105
+ //#endregion
106
+ //#region src/form-input/use.ts
107
+ /**
108
+ * Composable that provides access to form data and the `requireInput()` helper
109
+ * inside workflow step handlers.
110
+ *
111
+ * Called by the `@FormInput()` Resolve callback. Can also be used standalone
112
+ * when you need to manually re-pause with errors:
113
+ *
114
+ * ```ts
115
+ * const { requireInput } = useFormInput()
116
+ * throw requireInput({ password: 'Invalid credentials' })
117
+ * ```
118
+ */
119
+ function useFormInput(type) {
120
+ const wfState = useWfState();
121
+ /**
122
+ * Returns the current form input data from the workflow event.
123
+ */
124
+ function data() {
125
+ return wfState.input();
126
+ }
127
+ /**
128
+ * Creates a FormInputRequired signal that re-pauses the workflow
129
+ * with the serialized form schema, whitelisted context, and optional errors.
130
+ *
131
+ * Usage: `throw requireInput({ fieldName: 'Error message' })`
132
+ */
133
+ function requireInput(errors) {
134
+ if (!type || !isAnnotatedType(type)) throw new Error("useFormInput(): no atscript type available. Ensure @FormInput() is applied on the same method parameter.");
135
+ const wfContext = wfState.ctx();
136
+ return new FormInputRequired(serializeFormSchema(type), errors, extractPassContext(type, wfContext));
137
+ }
138
+ return {
139
+ data,
140
+ requireInput
141
+ };
142
+ }
143
+ //#endregion
144
+ //#region src/form-input/wf-keys.ts
145
+ /**
146
+ * Event context key for the workflow action name.
147
+ *
148
+ * The HTTP trigger should set this before calling `wf.resume()`:
149
+ * ```ts
150
+ * import { current } from '@wooksjs/event-core'
151
+ * import { actionKey } from '@atscript/moost-wf'
152
+ * current().set(actionKey, body.action)
153
+ * ```
154
+ *
155
+ * `@AltAction()` and `@FormInput()` read from this key.
156
+ */
157
+ const actionKey = key("wf.action");
158
+ //#endregion
159
+ //#region src/form-input/use-wf-action.ts
160
+ /**
161
+ * Composable that reads and writes the workflow action from the event context.
162
+ *
163
+ * **In the HTTP trigger** (to set the action from the request body):
164
+ * ```ts
165
+ * const { setAction } = useWfAction()
166
+ * setAction(body.action)
167
+ * ```
168
+ *
169
+ * **In step handlers** (to read the action — prefer `@AltAction()` decorator):
170
+ * ```ts
171
+ * const { getAction } = useWfAction()
172
+ * const action = getAction()
173
+ * ```
174
+ */
175
+ function useWfAction() {
176
+ const ctx = current();
177
+ return {
178
+ getAction: () => ctx.has(actionKey) ? ctx.get(actionKey) : void 0,
179
+ setAction: (action) => ctx.set(actionKey, action)
180
+ };
181
+ }
182
+ //#endregion
183
+ //#region src/form-input/decorator.ts
184
+ /**
185
+ * Parameter decorator for workflow steps that need form input.
186
+ *
187
+ * Combines parameter injection (via Resolve) with a method interceptor
188
+ * (via Intercept) that validates input before the step handler executes.
189
+ *
190
+ * The injected value is `{ data(), requireInput(errors?) }`.
191
+ *
192
+ * @example
193
+ * ```ts
194
+ * @Step('login')
195
+ * async login(@FormInput() form: TFormInput<LoginForm>) {
196
+ * const input = form.data()
197
+ * try {
198
+ * await this.auth.login(input.username, input.password)
199
+ * } catch (e) {
200
+ * throw form.requireInput({ password: 'Invalid credentials' })
201
+ * }
202
+ * }
203
+ * ```
204
+ */
205
+ function FormInput() {
206
+ return (target, key, index) => {
207
+ if (typeof index !== "number") return;
208
+ Resolve((metas) => {
209
+ const type = metas?.targetMeta?.type;
210
+ return useFormInput(type);
211
+ }, "FormInput")(target, key, index);
212
+ Intercept(formInputCheckFn())(target, key, Object.getOwnPropertyDescriptor(target, key));
213
+ };
214
+ }
215
+ function formInputCheckFn() {
216
+ return {
217
+ priority: TInterceptorPriority.INTERCEPTOR,
218
+ async before(reply) {
219
+ const wfState = useWfState();
220
+ const input = wfState.input();
221
+ const action = useWfAction().getAction();
222
+ const { getMethodMeta } = useControllerContext();
223
+ const paramMetas = getMethodMeta()?.params;
224
+ let type;
225
+ if (paramMetas) {
226
+ for (const param of paramMetas) if (param?.targetMeta?.type && isAnnotatedType(param.targetMeta.type)) {
227
+ type = param.targetMeta.type;
228
+ break;
229
+ }
230
+ }
231
+ if (!type) return;
232
+ if (input === void 0 && !action) {
233
+ reply(toInputRequired(type, wfState));
234
+ return;
235
+ }
236
+ if (action) {
237
+ const { actions, actionsWithData } = getFormActions(type);
238
+ if (actionsWithData.includes(action)) {
239
+ validateOrReply(type, wfState, input ?? {}, {
240
+ partial: "deep",
241
+ unknownProps: "strip"
242
+ }, reply);
243
+ return;
244
+ }
245
+ if (actions.includes(action)) return;
246
+ reply(toInputRequired(type, wfState, { __form: `Action "${action}" is not supported` }));
247
+ return;
248
+ }
249
+ validateOrReply(type, wfState, input, { unknownProps: "strip" }, reply);
250
+ }
251
+ };
252
+ }
253
+ function validateOrReply(type, wfState, input, validatorOpts, reply) {
254
+ const validator = type.validator(validatorOpts);
255
+ try {
256
+ validator.validate(input);
257
+ } catch (err) {
258
+ if (isValidatorError(err)) {
259
+ reply(toInputRequired(type, wfState, flattenErrors(err)));
260
+ return;
261
+ }
262
+ throw err;
263
+ }
264
+ }
265
+ function toInputRequired(type, wfState, errors) {
266
+ const passedContext = extractPassContext(type, wfState.ctx());
267
+ return { inputRequired: {
268
+ payload: serializeFormSchema(type),
269
+ transport: "http",
270
+ context: errors ? {
271
+ ...passedContext,
272
+ errors
273
+ } : { ...passedContext }
274
+ } };
275
+ }
276
+ function flattenErrors(err) {
277
+ const errors = {};
278
+ for (const e of err.errors) errors[e.path] = e.message;
279
+ return errors;
280
+ }
281
+ function isValidatorError(err) {
282
+ return err !== null && typeof err === "object" && "errors" in err && Array.isArray(err.errors);
283
+ }
284
+ //#endregion
285
+ //#region src/form-input/alt-action.decorator.ts
286
+ /**
287
+ * Parameter decorator that resolves the action name from the current
288
+ * workflow event context. Returns `undefined` for normal form submits.
289
+ *
290
+ * @example
291
+ * ```ts
292
+ * @Step('mfa-verify')
293
+ * async mfaVerify(
294
+ * @FormInput() form: TFormInput<PincodeForm>,
295
+ * @AltAction() action: string | undefined,
296
+ * ) {
297
+ * if (action === 'resend') {
298
+ * await this.sendOtp(ctx.email)
299
+ * return
300
+ * }
301
+ * await this.verifyCode(form.data().code)
302
+ * }
303
+ * ```
304
+ */
305
+ const AltAction = () => Resolve(() => useWfAction().getAction(), "AltAction");
306
+ //#endregion
307
+ //#region src/form-input/interceptor.ts
308
+ /**
309
+ * Global interceptor that catches {@link FormInputRequired} signals
310
+ * thrown by step handlers (via `form.requireInput()`) and converts them
311
+ * to `inputRequired` outlet responses.
312
+ *
313
+ * Apply globally:
314
+ * ```ts
315
+ * app.applyGlobalInterceptors(formInputInterceptor())
316
+ * ```
317
+ */
318
+ function formInputInterceptor() {
319
+ return {
320
+ priority: TInterceptorPriority.CATCH_ERROR,
321
+ error(error, reply) {
322
+ if (error instanceof FormInputRequired) reply({ inputRequired: {
323
+ payload: error.schema,
324
+ transport: "http",
325
+ context: error.errors ? {
326
+ ...error.context,
327
+ errors: error.errors
328
+ } : { ...error.context }
329
+ } });
330
+ }
331
+ };
332
+ }
333
+ //#endregion
334
+ export { AltAction, FormInput, FormInputRequired, extractPassContext, formInputInterceptor, getFormActions, serializeFormSchema, useFormInput, useWfAction };
@@ -0,0 +1,22 @@
1
+ import { TAtscriptPlugin } from "@atscript/core";
2
+
3
+ //#region src/plugin.d.ts
4
+ /**
5
+ * ATScript plugin that registers workflow-specific annotations:
6
+ * - `@wf.context.pass` — whitelist context keys to send to the client form
7
+ * - `@wf.action.withData` — action that sends form data with deep-partial validation
8
+ * - `@wf.store.fromContext` — wf-store: copy a context value into a top-level
9
+ * column on every `set()` (shadow column for indexable queries)
10
+ *
11
+ * Install in your `atscript.config.ts`:
12
+ * ```ts
13
+ * import wfPlugin from '@atscript/moost-wf/plugin'
14
+ *
15
+ * export default {
16
+ * plugins: [wfPlugin()],
17
+ * }
18
+ * ```
19
+ */
20
+ declare function wfPlugin(): TAtscriptPlugin;
21
+ //#endregion
22
+ export { wfPlugin as default };
@@ -0,0 +1,108 @@
1
+ import { AnnotationSpec, isPrimitive, isRef } from "@atscript/core";
2
+ //#region src/plugin.ts
3
+ const PATH_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/;
4
+ const COPY_PRIMITIVES = [
5
+ "string",
6
+ "number",
7
+ "boolean"
8
+ ];
9
+ /**
10
+ * ATScript plugin that registers workflow-specific annotations:
11
+ * - `@wf.context.pass` — whitelist context keys to send to the client form
12
+ * - `@wf.action.withData` — action that sends form data with deep-partial validation
13
+ * - `@wf.store.fromContext` — wf-store: copy a context value into a top-level
14
+ * column on every `set()` (shadow column for indexable queries)
15
+ *
16
+ * Install in your `atscript.config.ts`:
17
+ * ```ts
18
+ * import wfPlugin from '@atscript/moost-wf/plugin'
19
+ *
20
+ * export default {
21
+ * plugins: [wfPlugin()],
22
+ * }
23
+ * ```
24
+ */
25
+ function wfPlugin() {
26
+ return {
27
+ name: "moost-wf",
28
+ config() {
29
+ return { annotations: { wf: {
30
+ context: { pass: new AnnotationSpec({
31
+ description: "Whitelist a workflow context key to pass to the client form. Only keys listed here are extracted from workflow state and included in the `inputRequired` response. Prevents accidental leakage of internal state to the browser.",
32
+ nodeType: ["interface", "type"],
33
+ multiple: true,
34
+ mergeStrategy: "append",
35
+ argument: {
36
+ name: "key",
37
+ type: "string",
38
+ description: "Context key name to whitelist"
39
+ }
40
+ }) },
41
+ action: { withData: new AnnotationSpec({
42
+ description: "Form action that sends partial form data with deep-partial validation. Workflow-only — the server validates filled fields but allows missing ones. Use for actions like 'save draft' where partial data is useful.",
43
+ nodeType: ["prop", "type"],
44
+ argument: {
45
+ name: "id",
46
+ type: "string",
47
+ description: "The action name"
48
+ }
49
+ }) },
50
+ store: { fromContext: new AnnotationSpec({
51
+ description: "Copy a value from `state.context` to a top-level column on every `set()`. Use to add indexable shadow columns (e.g. `approver`) for UIs and queries without forking the base wf-state schema. The JSON `state` blob remains the source of truth — columns are derived. Path: dot-notation only (`approval.approver`); no array indices or wildcards. Field type must be string | number | boolean. Field must be optional or have a default — context shape varies between flow steps and a path-miss writes null.",
52
+ nodeType: ["prop"],
53
+ multiple: false,
54
+ argument: {
55
+ name: "path",
56
+ type: "string",
57
+ description: "Dot-notation path into state.context (e.g. 'approver' or 'approval.approver')."
58
+ },
59
+ validate: validateStoreFromContext
60
+ }) }
61
+ } } };
62
+ }
63
+ };
64
+ }
65
+ const validateStoreFromContext = (token, args, doc) => {
66
+ const errors = [];
67
+ const field = token.parentNode;
68
+ if (!field) return errors;
69
+ const ann = "@wf.store.fromContext";
70
+ const path = args[0]?.text;
71
+ if (path !== void 0 && !PATH_RE.test(path)) errors.push({
72
+ message: `${ann} '${path}': invalid path — only plain dot-notation is supported (e.g. 'a.b'); arrays, wildcards, and bracket access are not`,
73
+ severity: 1,
74
+ range: token.range
75
+ });
76
+ if (field.countAnnotations("meta.id") > 0) errors.push({
77
+ message: `${ann} cannot be applied to @meta.id (primary key) fields — shadow columns must not overwrite the row identifier`,
78
+ severity: 1,
79
+ range: token.range
80
+ });
81
+ const isOptional = field.has("optional");
82
+ const hasDefault = field.countAnnotations("meta.default") > 0 || field.countAnnotations("db.default") > 0;
83
+ if (!isOptional && !hasDefault) errors.push({
84
+ message: `${ann}: field must be optional (\`?:\`) or carry @meta.default / @db.default — workflow context shape varies between steps and path-misses write null`,
85
+ severity: 1,
86
+ range: token.range
87
+ });
88
+ const def = field.getDefinition();
89
+ if (def && isRef(def) && def.id !== void 0) {
90
+ const unwound = doc.unwindType(def.id, def.chain);
91
+ if (unwound && isPrimitive(unwound.def)) {
92
+ const ct = unwound.def.config.type;
93
+ const baseType = typeof ct === "object" && ct !== null ? ct.kind === "final" ? ct.value : ct.kind : ct;
94
+ if (!COPY_PRIMITIVES.includes(baseType)) errors.push({
95
+ message: `${ann} is not compatible with type "${baseType}" — only ${COPY_PRIMITIVES.join(" | ")} are supported (no arrays, objects, decimal, or timestamp)`,
96
+ severity: 1,
97
+ range: token.range
98
+ });
99
+ } else if (unwound && !isPrimitive(unwound.def)) errors.push({
100
+ message: `${ann}: field must be a primitive — got non-primitive type`,
101
+ severity: 1,
102
+ range: token.range
103
+ });
104
+ }
105
+ return errors;
106
+ };
107
+ //#endregion
108
+ export { wfPlugin as default };
@@ -0,0 +1,180 @@
1
+ import { t as AsWfStateRecord } from "./as-wf-state-DD5Ot4HG.mjs";
2
+ import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
3
+ import { AtscriptDbTable } from "@atscript/db";
4
+
5
+ //#region ../../node_modules/.pnpm/@prostojs+wf@0.1.1/node_modules/@prostojs/wf/dist/types-DQ7U9zZ5.d.mts
6
+ interface TFlowState<T> {
7
+ schemaId: string;
8
+ context: T;
9
+ indexes: number[];
10
+ meta?: Record<string, unknown>;
11
+ }
12
+ //#endregion
13
+ //#region ../../node_modules/.pnpm/@prostojs+wf@0.1.1/node_modules/@prostojs/wf/dist/outlets/index.d.mts
14
+ //#region src/outlets/types.d.ts
15
+ type WfState = TFlowState<unknown>;
16
+ //#endregion
17
+ //#region src/outlets/state/store.d.ts
18
+ interface WfStateStore {
19
+ set(handle: string, state: WfState, expiresAt?: number): Promise<void>;
20
+ get(handle: string): Promise<{
21
+ state: WfState;
22
+ expiresAt?: number;
23
+ } | null>;
24
+ delete(handle: string): Promise<void>;
25
+ getAndDelete(handle: string): Promise<{
26
+ state: WfState;
27
+ expiresAt?: number;
28
+ } | null>;
29
+ cleanup?(): Promise<number>;
30
+ } //#endregion
31
+ //#region src/outlets/state/handle.d.ts
32
+ //#endregion
33
+ //#region src/store/wf-store.d.ts
34
+ /** Internal row shape — only the columns the store reads back from the table. */
35
+ type StoredRow = {
36
+ schemaId: string;
37
+ state: Omit<WfState, "schemaId">;
38
+ expiresAt?: number | null;
39
+ createdAt: number;
40
+ createdBy?: string;
41
+ };
42
+ /** Per-field spec built once by {@link AsWfStore.scanShadowFields}. */
43
+ interface ShadowFieldSpec {
44
+ /** Column name on the row. */
45
+ field: string;
46
+ /** Pre-split dot-path into `state.context`. */
47
+ path: string[];
48
+ /** Expected primitive type, validated against the runtime value. */
49
+ expectedType: "string" | "number" | "boolean";
50
+ /** Whether the field is declared optional (`?:`) on the schema. */
51
+ optional: boolean;
52
+ }
53
+ /**
54
+ * Options for {@link AsWfStore}.
55
+ *
56
+ * The store implements `WfStateStore` from `@prostojs/wf/outlets` against a
57
+ * consumer-provided `AtscriptDbTable` whose row shape extends
58
+ * `AsWfStateRecord` with their own `@meta.id`-bearing primary key column.
59
+ */
60
+ interface AsWfStoreOptions {
61
+ /**
62
+ * Consumer's `@meta.id`-bearing extension of `AsWfStateRecord`.
63
+ *
64
+ * Typed as `AtscriptDbTable<any>` because the consumer's extended class
65
+ * (e.g. `extends AsWfStateRecord` plus `@meta.id`) is structurally a
66
+ * different annotated type than the base — `AtscriptDbTable<typeof
67
+ * AsWfStateRecord>` would not accept the subtype, and there is no public
68
+ * variance helper. The store only touches base columns + any
69
+ * `@wf.store.fromContext`-annotated shadow columns, so the loose generic is safe.
70
+ */
71
+ table: AtscriptDbTable<any>;
72
+ /** Optional clock for testability. Default: `{ now: () => Date.now() }`. */
73
+ clock?: {
74
+ now(): number;
75
+ };
76
+ /**
77
+ * Returns the actor stamping `createdBy` / `lastUpdatedBy` on each write.
78
+ * Invoked at write time. If the resolver is omitted or returns `undefined`,
79
+ * the columns stay unset (null).
80
+ */
81
+ actor?: () => string | undefined;
82
+ }
83
+ /**
84
+ * Persistent {@link WfStateStore} backed by an atscript-db table.
85
+ *
86
+ * The full `WfState` is stored as-is in the `state` column (`@db.json` blob).
87
+ * `state.schemaId` is lifted to a top-level indexed column so `schema_idx` can
88
+ * enumerate flows by schema. Consumers may add **shadow columns** by annotating
89
+ * fields on their schema extension with `@wf.store.fromContext 'path.in.context'` —
90
+ * those columns are populated from `state.context` on every `set()` and made
91
+ * available for filtering, sorting, and indexing.
92
+ *
93
+ * Subclass-friendly: most behaviour lives in `protected` methods. When
94
+ * overriding `findRow`, preserve the `getAndDelete` contract (deleteMany +
95
+ * `deletedCount === 1` race gate) — see method docstring.
96
+ */
97
+ declare class AsWfStore implements WfStateStore {
98
+ #private;
99
+ protected readonly table: AtscriptDbTable<any>;
100
+ protected readonly clock: {
101
+ now(): number;
102
+ };
103
+ constructor(opts: AsWfStoreOptions);
104
+ set(handle: string, state: WfState, expiresAt?: number): Promise<void>;
105
+ get(handle: string): Promise<{
106
+ state: WfState;
107
+ expiresAt?: number;
108
+ } | null>;
109
+ delete(handle: string): Promise<void>;
110
+ /**
111
+ * Race-safe single-use consume.
112
+ *
113
+ * **Contract** (do not violate when overriding `findRow`):
114
+ * `findRow` → `deleteMany({ handle })` → `deletedCount === 1` gate.
115
+ * Two concurrent callers: only one's delete returns `1`; the other returns
116
+ * `null` (its delete found 0 rows).
117
+ */
118
+ getAndDelete(handle: string): Promise<{
119
+ state: WfState;
120
+ expiresAt?: number;
121
+ } | null>;
122
+ /**
123
+ * Delete expired rows.
124
+ *
125
+ * - `retention` absent or `0`: drop rows where `expiresAt <= now()`.
126
+ * - `retention > 0`: drop rows where `expiresAt <= now() - retention` (grace).
127
+ * - `retention === Number.POSITIVE_INFINITY`: no-op (return 0).
128
+ */
129
+ cleanup(opts?: {
130
+ retention?: number;
131
+ }): Promise<number>;
132
+ /**
133
+ * Re-apply `@wf.store.fromContext` shadow columns to existing rows.
134
+ *
135
+ * Use after adding a new annotation to backfill old rows without waiting for
136
+ * each workflow to next pause. Returns the count of rows whose shadows were
137
+ * (re-)written. Filter narrows the scan; defaults to all rows.
138
+ *
139
+ * No-op when the schema declares no `@wf.store.fromContext` fields.
140
+ */
141
+ heal(opts?: {
142
+ filter?: Record<string, unknown>;
143
+ batchSize?: number;
144
+ }): Promise<number>;
145
+ /** Resolve the actor stamping createdBy/lastUpdatedBy. Override for custom auth. */
146
+ protected getActor(): string | undefined;
147
+ /** Storage primitive: load a row by handle. Override for sharded/multi-tenant tables. */
148
+ protected findRow(handle: string): Promise<StoredRow | null>;
149
+ /** Re-attach `schemaId` to the JSON `state` blob and add expiresAt if set. */
150
+ protected assembleResult(row: StoredRow): {
151
+ state: WfState;
152
+ expiresAt?: number;
153
+ };
154
+ /** Compose the row payload written on `set()`. Override to add custom columns. */
155
+ protected buildSetPayload(handle: string, state: WfState, opts: {
156
+ expiresAt?: number;
157
+ existing: StoredRow | null;
158
+ now: number;
159
+ actor?: string;
160
+ }): Record<string, unknown>;
161
+ /**
162
+ * Copy values from `state.context` onto the payload using cached specs.
163
+ * Optional fields get `null` on path-miss / type-mismatch (clears stale
164
+ * values). Non-optional default-bearing fields are *omitted* on miss — DB
165
+ * defaults fire on insert, prior value sticks on update.
166
+ */
167
+ protected applyShadows(payload: Record<string, unknown>, state: WfState): void;
168
+ /** Dot-path resolver. Returns `undefined` on miss, array hit, or non-object. */
169
+ protected resolvePath(obj: unknown, path: string[]): unknown;
170
+ /** Validate primitive type. `undefined` means "do not write" — applyShadows decides null vs omit. */
171
+ protected coerceShadowValue(raw: unknown, spec: ShadowFieldSpec): unknown;
172
+ /** Type-mismatch diagnostic. Fires once per field per store instance. */
173
+ protected onShadowTypeMismatch(field: string, expected: string, actual: unknown): void;
174
+ /** Lazily build shadow specs from `@wf.store.fromContext` annotations. Override for a different source annotation. */
175
+ protected scanShadowFields(): ShadowFieldSpec[];
176
+ /** Resolve a field's runtime primitive type, or `undefined` if not a copy-supported primitive. */
177
+ protected resolveFieldPrimitive(fieldType: TAtscriptAnnotatedType): "string" | "number" | "boolean" | undefined;
178
+ }
179
+ //#endregion
180
+ export { AsWfStateRecord, AsWfStore };
package/dist/store.mjs ADDED
@@ -0,0 +1,239 @@
1
+ import { t as AsWfStateRecord } from "./as-wf-state-DD5Ot4HG.mjs";
2
+ //#region src/store/wf-store.ts
3
+ const defaultClock = { now: () => Date.now() };
4
+ /**
5
+ * Persistent {@link WfStateStore} backed by an atscript-db table.
6
+ *
7
+ * The full `WfState` is stored as-is in the `state` column (`@db.json` blob).
8
+ * `state.schemaId` is lifted to a top-level indexed column so `schema_idx` can
9
+ * enumerate flows by schema. Consumers may add **shadow columns** by annotating
10
+ * fields on their schema extension with `@wf.store.fromContext 'path.in.context'` —
11
+ * those columns are populated from `state.context` on every `set()` and made
12
+ * available for filtering, sorting, and indexing.
13
+ *
14
+ * Subclass-friendly: most behaviour lives in `protected` methods. When
15
+ * overriding `findRow`, preserve the `getAndDelete` contract (deleteMany +
16
+ * `deletedCount === 1` race gate) — see method docstring.
17
+ */
18
+ var AsWfStore = class {
19
+ table;
20
+ clock;
21
+ #actor;
22
+ #shadowFieldsCache = null;
23
+ #warnedFields = /* @__PURE__ */ new Set();
24
+ constructor(opts) {
25
+ this.table = opts.table;
26
+ this.clock = opts.clock ?? defaultClock;
27
+ this.#actor = opts.actor;
28
+ }
29
+ async set(handle, state, expiresAt) {
30
+ const now = this.clock.now();
31
+ const actor = this.getActor();
32
+ const existing = await this.findRow(handle);
33
+ const payload = this.buildSetPayload(handle, state, {
34
+ expiresAt,
35
+ existing,
36
+ now,
37
+ actor
38
+ });
39
+ if (existing) await this.table.replaceMany({ handle }, payload);
40
+ else await this.table.insertOne(payload);
41
+ }
42
+ async get(handle) {
43
+ const row = await this.findRow(handle);
44
+ if (!row) return null;
45
+ const expiresAt = row.expiresAt ?? void 0;
46
+ if (expiresAt !== void 0 && expiresAt <= this.clock.now()) {
47
+ this.delete(handle);
48
+ return null;
49
+ }
50
+ return this.assembleResult(row);
51
+ }
52
+ async delete(handle) {
53
+ await this.table.deleteMany({ handle });
54
+ }
55
+ /**
56
+ * Race-safe single-use consume.
57
+ *
58
+ * **Contract** (do not violate when overriding `findRow`):
59
+ * `findRow` → `deleteMany({ handle })` → `deletedCount === 1` gate.
60
+ * Two concurrent callers: only one's delete returns `1`; the other returns
61
+ * `null` (its delete found 0 rows).
62
+ */
63
+ async getAndDelete(handle) {
64
+ const row = await this.findRow(handle);
65
+ if (!row) return null;
66
+ if ((await this.table.deleteMany({ handle })).deletedCount !== 1) return null;
67
+ const expiresAt = row.expiresAt ?? void 0;
68
+ if (expiresAt !== void 0 && expiresAt <= this.clock.now()) return null;
69
+ return this.assembleResult(row);
70
+ }
71
+ /**
72
+ * Delete expired rows.
73
+ *
74
+ * - `retention` absent or `0`: drop rows where `expiresAt <= now()`.
75
+ * - `retention > 0`: drop rows where `expiresAt <= now() - retention` (grace).
76
+ * - `retention === Number.POSITIVE_INFINITY`: no-op (return 0).
77
+ */
78
+ async cleanup(opts) {
79
+ const retention = opts?.retention;
80
+ if (retention === Number.POSITIVE_INFINITY) return 0;
81
+ const now = this.clock.now();
82
+ const cutoff = retention && retention > 0 ? now - retention : now;
83
+ return (await this.table.deleteMany({ expiresAt: { $lte: cutoff } })).deletedCount;
84
+ }
85
+ /**
86
+ * Re-apply `@wf.store.fromContext` shadow columns to existing rows.
87
+ *
88
+ * Use after adding a new annotation to backfill old rows without waiting for
89
+ * each workflow to next pause. Returns the count of rows whose shadows were
90
+ * (re-)written. Filter narrows the scan; defaults to all rows.
91
+ *
92
+ * No-op when the schema declares no `@wf.store.fromContext` fields.
93
+ */
94
+ async heal(opts) {
95
+ if (this.scanShadowFields().length === 0) return 0;
96
+ const batchSize = opts?.batchSize ?? 100;
97
+ const baseFilter = opts?.filter ?? {};
98
+ let healed = 0;
99
+ let skip = 0;
100
+ while (true) {
101
+ const rows = await this.table.findMany({
102
+ filter: baseFilter,
103
+ controls: {
104
+ $skip: skip,
105
+ $limit: batchSize,
106
+ $sort: { handle: 1 },
107
+ $select: [
108
+ "handle",
109
+ "schemaId",
110
+ "state"
111
+ ]
112
+ }
113
+ });
114
+ if (rows.length === 0) break;
115
+ for (const row of rows) {
116
+ const wfState = {
117
+ schemaId: row.schemaId,
118
+ ...row.state
119
+ };
120
+ const shadowPatch = {};
121
+ this.applyShadows(shadowPatch, wfState);
122
+ if (Object.keys(shadowPatch).length > 0) {
123
+ await this.table.updateMany({ handle: row.handle }, shadowPatch);
124
+ healed++;
125
+ }
126
+ }
127
+ if (rows.length < batchSize) break;
128
+ skip += rows.length;
129
+ }
130
+ return healed;
131
+ }
132
+ /** Resolve the actor stamping createdBy/lastUpdatedBy. Override for custom auth. */
133
+ getActor() {
134
+ return this.#actor?.();
135
+ }
136
+ /** Storage primitive: load a row by handle. Override for sharded/multi-tenant tables. */
137
+ async findRow(handle) {
138
+ return await this.table.findOne({ filter: { handle } });
139
+ }
140
+ /** Re-attach `schemaId` to the JSON `state` blob and add expiresAt if set. */
141
+ assembleResult(row) {
142
+ const state = {
143
+ schemaId: row.schemaId,
144
+ ...row.state
145
+ };
146
+ const expiresAt = row.expiresAt ?? void 0;
147
+ return expiresAt === void 0 ? { state } : {
148
+ state,
149
+ expiresAt
150
+ };
151
+ }
152
+ /** Compose the row payload written on `set()`. Override to add custom columns. */
153
+ buildSetPayload(handle, state, opts) {
154
+ const { schemaId, ...stateBlob } = state;
155
+ const { existing, actor } = opts;
156
+ const createdBy = existing ? existing.createdBy : actor;
157
+ const payload = {
158
+ handle,
159
+ schemaId,
160
+ state: stateBlob,
161
+ updatedAt: opts.now,
162
+ createdAt: existing ? existing.createdAt : opts.now
163
+ };
164
+ if (opts.expiresAt !== void 0) payload.expiresAt = opts.expiresAt;
165
+ if (actor !== void 0) payload.lastUpdatedBy = actor;
166
+ if (createdBy !== void 0) payload.createdBy = createdBy;
167
+ this.applyShadows(payload, state);
168
+ return payload;
169
+ }
170
+ /**
171
+ * Copy values from `state.context` onto the payload using cached specs.
172
+ * Optional fields get `null` on path-miss / type-mismatch (clears stale
173
+ * values). Non-optional default-bearing fields are *omitted* on miss — DB
174
+ * defaults fire on insert, prior value sticks on update.
175
+ */
176
+ applyShadows(payload, state) {
177
+ const specs = this.scanShadowFields();
178
+ if (specs.length === 0) return;
179
+ const ctx = state.context;
180
+ for (const spec of specs) {
181
+ const raw = this.resolvePath(ctx, spec.path);
182
+ const coerced = this.coerceShadowValue(raw, spec);
183
+ if (coerced !== void 0) payload[spec.field] = coerced;
184
+ else if (spec.optional) payload[spec.field] = null;
185
+ }
186
+ }
187
+ /** Dot-path resolver. Returns `undefined` on miss, array hit, or non-object. */
188
+ resolvePath(obj, path) {
189
+ let cur = obj;
190
+ for (const seg of path) {
191
+ if (cur === null || cur === void 0) return void 0;
192
+ if (typeof cur !== "object") return void 0;
193
+ if (Array.isArray(cur)) return void 0;
194
+ cur = cur[seg];
195
+ }
196
+ return cur;
197
+ }
198
+ /** Validate primitive type. `undefined` means "do not write" — applyShadows decides null vs omit. */
199
+ coerceShadowValue(raw, spec) {
200
+ if (raw === void 0 || raw === null) return void 0;
201
+ if (typeof raw === spec.expectedType) return raw;
202
+ this.onShadowTypeMismatch(spec.field, spec.expectedType, raw);
203
+ }
204
+ /** Type-mismatch diagnostic. Fires once per field per store instance. */
205
+ onShadowTypeMismatch(field, expected, actual) {
206
+ if (this.#warnedFields.has(field)) return;
207
+ this.#warnedFields.add(field);
208
+ console.warn(`[AsWfStore] @wf.store.fromContext field "${field}" expected ${expected} but got ${typeof actual} — writing null. Subsequent mismatches on this field are silent.`);
209
+ }
210
+ /** Lazily build shadow specs from `@wf.store.fromContext` annotations. Override for a different source annotation. */
211
+ scanShadowFields() {
212
+ if (this.#shadowFieldsCache !== null) return this.#shadowFieldsCache;
213
+ const specs = [];
214
+ const tableType = this.table.type;
215
+ if (tableType?.type?.kind === "object") for (const [fieldName, fieldType] of tableType.type.props) {
216
+ const path = fieldType.metadata.get("wf.store.fromContext");
217
+ if (!path) continue;
218
+ const expectedType = this.resolveFieldPrimitive(fieldType);
219
+ if (expectedType === void 0) continue;
220
+ specs.push({
221
+ field: fieldName,
222
+ path: path.split("."),
223
+ expectedType,
224
+ optional: fieldType.optional === true
225
+ });
226
+ }
227
+ this.#shadowFieldsCache = specs;
228
+ return specs;
229
+ }
230
+ /** Resolve a field's runtime primitive type, or `undefined` if not a copy-supported primitive. */
231
+ resolveFieldPrimitive(fieldType) {
232
+ const def = fieldType.type;
233
+ if (def.kind !== "") return void 0;
234
+ const { designType } = def;
235
+ if (designType === "string" || designType === "number" || designType === "boolean") return designType;
236
+ }
237
+ };
238
+ //#endregion
239
+ export { AsWfStateRecord, AsWfStore };
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@atscript/moost-wf",
3
+ "version": "0.1.58",
4
+ "description": "Workflow form integration for moost — decorators, interceptors, and serialization driven by atscript type metadata",
5
+ "keywords": [
6
+ "atscript",
7
+ "decorators",
8
+ "interceptors",
9
+ "metadata",
10
+ "moost",
11
+ "type-driven",
12
+ "typescript",
13
+ "wf",
14
+ "workflow"
15
+ ],
16
+ "homepage": "https://github.com/moostjs/atscript-ui/tree/main/packages/moost-wf#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/moostjs/atscript-ui/issues"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Artem Maltsev <artem@maltsev.nl>",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/moostjs/atscript-ui.git",
25
+ "directory": "packages/moost-wf"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "src/store/as-wf-state.as"
30
+ ],
31
+ "type": "module",
32
+ "main": "dist/index.mjs",
33
+ "types": "dist/index.d.mts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.mts",
37
+ "import": "./dist/index.mjs"
38
+ },
39
+ "./plugin": {
40
+ "types": "./dist/plugin.d.mts",
41
+ "import": "./dist/plugin.mjs"
42
+ },
43
+ "./store": {
44
+ "types": "./dist/store.d.mts",
45
+ "import": "./dist/store.mjs"
46
+ },
47
+ "./store.as": "./src/store/as-wf-state.as",
48
+ "./package.json": "./package.json"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "devDependencies": {
54
+ "@atscript/core": "^0.1.54",
55
+ "@atscript/db": "^0.1.75",
56
+ "@atscript/db-sqlite": "^0.1.75",
57
+ "@atscript/typescript": "^0.1.54",
58
+ "@moostjs/event-wf": "^0.6.9",
59
+ "@prostojs/wf": "^0.1.1",
60
+ "@wooksjs/event-core": "^0.7.11",
61
+ "moost": "^0.6.9",
62
+ "unplugin-atscript": "^0.1.54",
63
+ "vitest": "npm:@voidzero-dev/vite-plus-test@latest",
64
+ "@atscript/ui": "^0.1.58"
65
+ },
66
+ "peerDependencies": {
67
+ "@atscript/core": "^0.1.54",
68
+ "@atscript/typescript": "^0.1.54",
69
+ "@moostjs/event-wf": "^0.6.9",
70
+ "moost": "^0.6.9"
71
+ },
72
+ "scripts": {
73
+ "build": "vp pack",
74
+ "dev": "vp pack --watch",
75
+ "test": "vp test",
76
+ "check": "vp check"
77
+ }
78
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Stand-in for `unknown` (atscript has no such primitive). Used inside
3
+ * the `@db.json` blob below, so this widens the typing surface only —
4
+ * the column stores opaque JSON and runtime never validates per-key.
5
+ */
6
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [/^.+$/]: JsonValue }
7
+
8
+ export interface AsWfStateRecord {
9
+ // Opaque correlation token — uninteresting in a list view.
10
+ @ui.table.hidden
11
+ @db.index.unique 'handle_idx'
12
+ @expect.maxLength 256
13
+ handle: string
14
+
15
+ @db.index.plain 'schema_idx'
16
+ @expect.maxLength 256
17
+ schemaId: string
18
+
19
+ // Large JSON snapshot — too noisy for a list view; fetch via getOne.
20
+ @ui.table.hidden
21
+ @db.json
22
+ state: {
23
+ context: JsonValue
24
+ indexes: number[]
25
+ meta?: { [/^.+$/]: JsonValue }
26
+ }
27
+
28
+ @db.index.plain 'expires_idx'
29
+ expiresAt?: number.timestamp
30
+
31
+ @db.default.now
32
+ @db.index.plain 'updated_idx'
33
+ updatedAt: number.timestamp
34
+
35
+ createdAt: number.timestamp
36
+
37
+ @ui.table.hidden
38
+ @expect.maxLength 128
39
+ createdBy?: string
40
+
41
+ @ui.table.hidden
42
+ @expect.maxLength 128
43
+ lastUpdatedBy?: string
44
+ }