@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 +21 -0
- package/README.md +32 -0
- package/dist/as-wf-state-DD5Ot4HG.mjs +24 -0
- package/dist/index.d.mts +152 -0
- package/dist/index.mjs +334 -0
- package/dist/plugin.d.mts +22 -0
- package/dist/plugin.mjs +108 -0
- package/dist/store.d.mts +180 -0
- package/dist/store.mjs +239 -0
- package/package.json +78 -0
- package/src/store/as-wf-state.as +44 -0
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 };
|
package/dist/index.d.mts
ADDED
|
@@ -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 };
|
package/dist/plugin.mjs
ADDED
|
@@ -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 };
|
package/dist/store.d.mts
ADDED
|
@@ -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
|
+
}
|