rastack 0.0.49 → 0.0.50

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/components/auto-form/AutoForm.tsx +13 -0
  3. package/components/auto-form/use-auto-form.ts +28 -0
  4. package/components/types.ts +8 -0
  5. package/dist/admin.js +13 -13
  6. package/dist/compile/analyze.d.ts +26 -0
  7. package/dist/compile/analyze.js +70 -3
  8. package/dist/compile/entities.d.ts +19 -0
  9. package/dist/compile/entities.js +87 -13
  10. package/dist/compile/index.d.ts +23 -4
  11. package/dist/compile/index.js +86 -7
  12. package/dist/compile/model.d.ts +38 -0
  13. package/dist/compile/openapi.d.ts +9 -0
  14. package/dist/compile/openapi.js +14 -0
  15. package/dist/define/index.d.ts +107 -13
  16. package/dist/define/index.js +1 -1
  17. package/dist/import/tabular.d.ts +8 -2
  18. package/dist/import/tabular.js +1 -1
  19. package/dist/rastack-import.js +4 -1
  20. package/dist/validate/adapters.js +2 -0
  21. package/dist/validate/index.d.ts +1 -0
  22. package/dist/validate/index.js +1 -0
  23. package/dist/validate/machine.d.ts +23 -2
  24. package/dist/validate/machine.js +35 -2
  25. package/dist/validate/transitions.d.ts +86 -0
  26. package/dist/validate/transitions.js +199 -0
  27. package/dist/wasm/rastack_wasm.js +1 -1
  28. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  29. package/hooks/data.ts +209 -0
  30. package/hooks/entity.ts +222 -0
  31. package/hooks/form/entity-form.ts +354 -0
  32. package/hooks/form/form.ts +8 -1
  33. package/hooks/form/index.ts +7 -1
  34. package/hooks/index.ts +4 -0
  35. package/hooks/manifest.ts +41 -0
  36. package/hooks/registry.ts +39 -0
  37. package/package.json +1 -1
  38. package/provider/provider.tsx +8 -1
  39. package/provider/types.ts +7 -2
  40. package/src/compile/analyze.ts +83 -4
  41. package/src/compile/entities.ts +111 -11
  42. package/src/compile/index.ts +108 -11
  43. package/src/compile/model.ts +40 -0
  44. package/src/compile/openapi.ts +13 -1
  45. package/src/define/index.ts +148 -20
  46. package/src/import/tabular.ts +9 -2
  47. package/src/rastack-import.ts +4 -1
  48. package/src/validate/adapters.ts +1 -0
  49. package/src/validate/index.ts +1 -0
  50. package/src/validate/machine.ts +55 -3
  51. package/src/validate/transitions.ts +232 -0
  52. package/test/components.spec.ts +22 -0
  53. package/test/transitions.spec.ts +372 -0
  54. package/test/typed-hooks.spec.ts +407 -0
  55. package/wasm/rastack_wasm.js +1 -1
  56. package/wasm/rastack_wasm_bg.wasm +0 -0
@@ -22,10 +22,19 @@
22
22
  * });
23
23
  * ```
24
24
  */
25
- /** A scalar field. The literal `__rastackScalar` type is what the compiler reads. */
26
- export interface Scalar<T extends string> {
25
+ /**
26
+ * A scalar field. The literal `__rastackScalar` type is what the compiler
27
+ * reads; `Nullable` phantom-carries the `null: true` option so {@link RowOf}
28
+ * can add `| null` to the inferred row type.
29
+ */
30
+ export interface Scalar<T extends string, Nullable extends boolean = boolean> {
27
31
  readonly __rastackScalar: T;
32
+ readonly __rastackNullable?: Nullable;
28
33
  }
34
+ /** Whether an options literal declares `null: true` (as a type). */
35
+ type NullOf<O> = O extends {
36
+ null: true;
37
+ } ? true : false;
29
38
  /**
30
39
  * A foreign-key field. `App`/`Model` are carried as string-literal type
31
40
  * arguments so the compiler can recover the relation target purely from types.
@@ -64,12 +73,12 @@ export interface DateOptions {
64
73
  * runtime if a consumer wants it.
65
74
  */
66
75
  export declare const s: {
67
- string(_opts?: StringOptions): Scalar<"string">;
68
- int(_opts?: NumberOptions): Scalar<"int">;
69
- float(_opts?: NumberOptions): Scalar<"float">;
70
- bool(_opts?: BoolOptions): Scalar<"bool">;
71
- datetime(_opts?: DateOptions): Scalar<"datetime">;
72
- uuid(_opts?: StringOptions): Scalar<"uuid">;
76
+ string<const O extends StringOptions = StringOptions>(_opts?: O): Scalar<"string", NullOf<O>>;
77
+ int<const O extends NumberOptions = NumberOptions>(_opts?: O): Scalar<"int", NullOf<O>>;
78
+ float<const O extends NumberOptions = NumberOptions>(_opts?: O): Scalar<"float", NullOf<O>>;
79
+ bool<const O extends BoolOptions = BoolOptions>(_opts?: O): Scalar<"bool", NullOf<O>>;
80
+ datetime<const O extends DateOptions = DateOptions>(_opts?: O): Scalar<"datetime", NullOf<O>>;
81
+ uuid<const O extends StringOptions = StringOptions>(_opts?: O): Scalar<"uuid", NullOf<O>>;
73
82
  /**
74
83
  * A foreign key. The target is `() => SomeResource`; the compiler resolves
75
84
  * the relation from the *type* `Ref<App, Model>` this returns — it does not
@@ -105,6 +114,52 @@ export interface AccessOptions {
105
114
  scope?: "owner" | "shared";
106
115
  adminGroups?: string[];
107
116
  }
117
+ /**
118
+ * One edge of a resource's state machine. `from` is the state (or states) the
119
+ * transition may fire from, `to` the state it enters, and `set` the effect —
120
+ * field patches the engine applies alongside the state change. The block is
121
+ * static data: it compiles into the manifest and is enforced by
122
+ * `rastack-api-core` on every surface (server, Lambda, in-browser WASM), so a
123
+ * transition behaves as a declarative, typesafe backend function with no
124
+ * per-resource server code.
125
+ */
126
+ export interface TransitionEdge {
127
+ from: string | readonly string[];
128
+ to: string;
129
+ set?: Record<string, string | number | boolean | null>;
130
+ }
131
+ /**
132
+ * A resource's declarative state machine, keyed off one state field:
133
+ *
134
+ * ```ts
135
+ * transitions: {
136
+ * field: "status",
137
+ * states: ["scheduled", "boarding", "departed", "cancelled"],
138
+ * initial: "scheduled",
139
+ * on: {
140
+ * board: { from: "scheduled", to: "boarding" },
141
+ * depart: { from: "boarding", to: "departed" },
142
+ * cancel: { from: ["scheduled", "boarding"], to: "cancelled" },
143
+ * },
144
+ * }
145
+ * ```
146
+ *
147
+ * `states` becomes the field's allowed-value set (`enum` in OpenAPI, the
148
+ * `member` gate in the validation machine) and `initial` its default. A write
149
+ * that moves the field between states without a declared edge is rejected on
150
+ * every surface, and `useForm(Flight).transition("board")` exposes the edges
151
+ * as typed form actions.
152
+ */
153
+ export interface TransitionsOptions {
154
+ /** The field carrying the machine state. */
155
+ field: string;
156
+ /** Every state the field may hold. */
157
+ states: readonly string[];
158
+ /** The state new records start in. */
159
+ initial?: string;
160
+ /** Named transitions. */
161
+ on: Record<string, TransitionEdge>;
162
+ }
108
163
  export interface ResourceOptions {
109
164
  /** DRF-style search fields. */
110
165
  search?: string[];
@@ -120,19 +175,58 @@ export interface ResourceOptions {
120
175
  permission?: "authenticatedOrReadOnly" | "authenticated" | "public";
121
176
  /** Row-level security + physical tenant partitioning. */
122
177
  access?: AccessOptions;
178
+ /** Declarative state machine over one field, enforced on every write surface. */
179
+ transitions?: TransitionsOptions;
123
180
  }
124
181
  /**
125
182
  * A resource definition. `App`/`Model` are preserved as string-literal type
126
- * arguments so a {@link Ref} to this resource carries a recoverable target.
183
+ * arguments so a {@link Ref} to this resource carries a recoverable target,
184
+ * and the options object's literal type is preserved so downstream types can
185
+ * read the declared transition names (`useForm(Flight).transition("board")`
186
+ * only accepts names the resource actually declares).
127
187
  */
128
- export interface ResourceType<App extends string, Model extends string, F> {
188
+ export interface ResourceType<App extends string, Model extends string, F, O extends ResourceOptions = ResourceOptions> {
129
189
  readonly __rastack: "resource";
130
190
  readonly __app: App;
131
191
  readonly __model: Model;
132
192
  readonly app: App;
133
193
  readonly model: Model;
134
194
  readonly fields: F;
135
- readonly options: ResourceOptions;
195
+ readonly options: O;
136
196
  }
137
- /** Define a resource. Capture app/model as literal types for FK inference. */
138
- export declare function resource<App extends string, Model extends string, F extends Record<string, AnyField>>(app: App, model: Model, fields: F, options?: ResourceOptions): ResourceType<App, Model, F>;
197
+ /** Define a resource. Capture app/model (and options) as literal types. */
198
+ export declare function resource<App extends string, Model extends string, F extends Record<string, AnyField>, const O extends ResourceOptions = ResourceOptions>(app: App, model: Model, fields: F, options?: O): ResourceType<App, Model, F, O>;
199
+ /**
200
+ * The transition names an entity declares — from a `resource()` value's
201
+ * options, or from a class's `static transitions` block. Entities with no
202
+ * transitions resolve to `never`, so `form.transition(...)` is uncallable on
203
+ * them at the type level.
204
+ */
205
+ export type TransitionNamesOf<E> = E extends {
206
+ options: {
207
+ transitions: {
208
+ on: infer On;
209
+ };
210
+ };
211
+ } ? Extract<keyof On, string> : E extends {
212
+ transitions: {
213
+ on: infer On;
214
+ };
215
+ } ? Extract<keyof On, string> : E extends string ? string : never;
216
+ type ScalarTsType<T extends string> = T extends "int" | "float" ? number : T extends "bool" ? boolean : string;
217
+ /** The TypeScript value type one field of a `resource()` definition holds. */
218
+ export type FieldValueOf<F> = F extends Scalar<infer T, infer N> ? N extends true ? ScalarTsType<T> | null : ScalarTsType<T> : F extends Ref<string, string> ? number | string : unknown;
219
+ /**
220
+ * The row type a `resource()` definition implies — what `useData(Flight)`
221
+ * infers with **no type argument**: each field's scalar kind maps to its TS
222
+ * type (`null: true` adds `| null`), FKs are ids, plus the server-assigned
223
+ * `id`. Classes carry their own row type, and interfaces resolve through the
224
+ * `EntityTypes` registry (`rastack/hooks/registry`), so every reference form
225
+ * infers.
226
+ */
227
+ export type RowOf<R> = R extends ResourceType<string, string, infer F, ResourceOptions> ? {
228
+ id: number | string;
229
+ } & {
230
+ [K in keyof F]: FieldValueOf<F[K]>;
231
+ } : never;
232
+ export {};
@@ -60,7 +60,7 @@ exports.s = {
60
60
  return { __rastackRef: true };
61
61
  },
62
62
  };
63
- /** Define a resource. Capture app/model as literal types for FK inference. */
63
+ /** Define a resource. Capture app/model (and options) as literal types. */
64
64
  function resource(app, model, fields, options = {}) {
65
65
  return {
66
66
  __rastack: "resource",
@@ -9,7 +9,7 @@
9
9
  * Everything here is data-in/data-out (no filesystem), tested in
10
10
  * `tools/test/import.spec.ts`; the parsers and the CLI are the IO shell.
11
11
  */
12
- import { type FieldSpec, type FieldState, type RecordRun, type SpecRelation } from "../validate";
12
+ import { type FieldSpec, type FieldState, type RecordRun, type SpecRelation, type TransitionsModel } from "../validate";
13
13
  /** A parsed tabular source: one header row + string cell rows. */
14
14
  export interface TabularData {
15
15
  headers: string[];
@@ -24,7 +24,7 @@ export interface ColumnMapping {
24
24
  export interface ImportIssue {
25
25
  row: number;
26
26
  field: string;
27
- gate: FieldState;
27
+ gate: FieldState | "transition";
28
28
  constraint: string;
29
29
  message: string;
30
30
  }
@@ -60,6 +60,12 @@ export interface ValidateDatasetOptions {
60
60
  relatedIds?: Record<string, Set<string>>;
61
61
  /** Custom FK resolver (wins over `relatedIds`). */
62
62
  resolveRelation?: (relation: SpecRelation, value: unknown) => boolean;
63
+ /**
64
+ * The resource's state machine (`schema.rastack.json` `transitions`).
65
+ * Imported rows are creates, so the record-level `transition` gate rejects
66
+ * rows born mid-machine (a state other than the initial one).
67
+ */
68
+ transitions?: TransitionsModel;
63
69
  }
64
70
  /**
65
71
  * Run a whole tabular source through a resource's record machine. Each row is
@@ -47,7 +47,7 @@ function mapHeaders(headers, specs) {
47
47
  */
48
48
  function validateDataset(specs, data, options = {}) {
49
49
  const { columns, missingFields } = mapHeaders(data.headers, specs);
50
- const machine = (0, validate_1.compileRecordMachine)(specs);
50
+ const machine = (0, validate_1.compileRecordMachine)(specs, options.transitions);
51
51
  const ctx = (0, validate_1.createDatasetContext)(machine, options);
52
52
  const rows = [];
53
53
  const issues = [];
@@ -121,7 +121,10 @@ function main() {
121
121
  const data = loadFile(file);
122
122
  console.log(`\nrastack import ${path_1.default.basename(file)} → ${resourceName} ` +
123
123
  `[${data.headers.length} column(s), ${data.rows.length} row(s)]\n`);
124
- const report = (0, import_1.validateDataset)(specs, data, { relatedIds });
124
+ const report = (0, import_1.validateDataset)(specs, data, {
125
+ relatedIds,
126
+ transitions: resource.transitions,
127
+ });
125
128
  printReport(report);
126
129
  const outPath = getArg("--out");
127
130
  if (outPath) {
@@ -36,6 +36,8 @@ function specFromManifestField(field) {
36
36
  spec.maxLength = field.maxLength;
37
37
  if (field.unique)
38
38
  spec.unique = true;
39
+ if (field.options && field.options.length > 0)
40
+ spec.options = field.options;
39
41
  if (field.default !== undefined)
40
42
  spec.default = field.default;
41
43
  if (field.type === "fk" && field.relation)
@@ -1,2 +1,3 @@
1
1
  export * from "./machine";
2
2
  export * from "./adapters";
3
+ export * from "./transitions";
@@ -16,3 +16,4 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./machine"), exports);
18
18
  __exportStar(require("./adapters"), exports);
19
+ __exportStar(require("./transitions"), exports);
@@ -34,7 +34,15 @@
34
34
  * single keystroke — passes through them untouched, exactly like the server,
35
35
  * where uniqueness and FK existence are checked against the table, not the
36
36
  * payload.
37
+ *
38
+ * Record machines add one **record-level** gate on top of the per-field
39
+ * chains: `transition`. A resource that declares a `transitions` block gets
40
+ * its state-field writes guarded against the declared edges (with the
41
+ * persisted row supplied via `ctx.previous`), so an illegal state jump fails
42
+ * a form, an import row, and a server write with the same message — see
43
+ * `./transitions.ts`.
37
44
  */
45
+ import type { TransitionsModel } from "../compile/model";
38
46
  /** The `{ app, model }` a foreign-key field points at. */
39
47
  export interface SpecRelation {
40
48
  app: string;
@@ -107,6 +115,12 @@ export interface ValidationContext {
107
115
  seen?: Map<string, Set<string>>;
108
116
  /** Resolve whether a FK value exists on the target resource (the `resolved` gate). */
109
117
  resolveRelation?: (relation: SpecRelation, value: unknown) => boolean;
118
+ /**
119
+ * The persisted row this record updates (the record-level `transition`
120
+ * guard). Absent = create semantics: the state field must hold the initial
121
+ * state. Only consulted by record machines compiled with a transitions block.
122
+ */
123
+ previous?: Record<string, unknown>;
110
124
  }
111
125
  /** The result of running a value through a field machine. */
112
126
  export interface FieldRun {
@@ -144,11 +158,18 @@ export declare function runFieldMachine(machine: FieldMachine, rawValue: unknown
144
158
  /** A record machine is the composition of its fields' machines. */
145
159
  export interface RecordMachine {
146
160
  fields: FieldMachine[];
161
+ /**
162
+ * The resource's declarative state machine, when it declares one. Runs as a
163
+ * record-level gate after the per-field gates: a write that moves the state
164
+ * field without a declared edge fails at the `transition` gate.
165
+ */
166
+ transitions?: TransitionsModel;
147
167
  }
148
168
  /** One field's failure inside a record run. */
149
169
  export interface RecordError {
150
170
  field: string;
151
- gate: FieldState;
171
+ /** The gate that rejected the value — a field gate, or the record-level `transition` gate. */
172
+ gate: FieldState | "transition";
152
173
  constraint: string;
153
174
  message: string;
154
175
  }
@@ -161,7 +182,7 @@ export interface RecordRun {
161
182
  fields: Record<string, FieldRun>;
162
183
  errors: RecordError[];
163
184
  }
164
- export declare function compileRecordMachine(specs: FieldSpec[]): RecordMachine;
185
+ export declare function compileRecordMachine(specs: FieldSpec[], transitions?: TransitionsModel): RecordMachine;
165
186
  export declare function runRecordMachine(machine: RecordMachine, record: Record<string, unknown>, ctx?: ValidationContext): RecordRun;
166
187
  /**
167
188
  * Build the dataset-level context for a record machine: a seen-set per
@@ -35,6 +35,13 @@
35
35
  * single keystroke — passes through them untouched, exactly like the server,
36
36
  * where uniqueness and FK existence are checked against the table, not the
37
37
  * payload.
38
+ *
39
+ * Record machines add one **record-level** gate on top of the per-field
40
+ * chains: `transition`. A resource that declares a `transitions` block gets
41
+ * its state-field writes guarded against the declared edges (with the
42
+ * persisted row supplied via `ctx.previous`), so an illegal state jump fails
43
+ * a form, an import row, and a server write with the same message — see
44
+ * `./transitions.ts`.
38
45
  */
39
46
  Object.defineProperty(exports, "__esModule", { value: true });
40
47
  exports.GATE_ORDER = void 0;
@@ -44,6 +51,7 @@ exports.runFieldMachine = runFieldMachine;
44
51
  exports.compileRecordMachine = compileRecordMachine;
45
52
  exports.runRecordMachine = runRecordMachine;
46
53
  exports.createDatasetContext = createDatasetContext;
54
+ const transitions_1 = require("./transitions");
47
55
  /** The intermediate (gate) states, in canonical machine order. */
48
56
  exports.GATE_ORDER = [
49
57
  "present",
@@ -300,8 +308,11 @@ function runFieldMachine(machine, rawValue, ctx = EMPTY_CTX) {
300
308
  path.push("valid");
301
309
  return { state: "valid", path, value };
302
310
  }
303
- function compileRecordMachine(specs) {
304
- return { fields: specs.map(compileFieldMachine) };
311
+ function compileRecordMachine(specs, transitions) {
312
+ const machine = { fields: specs.map(compileFieldMachine) };
313
+ if (transitions)
314
+ machine.transitions = transitions;
315
+ return machine;
305
316
  }
306
317
  function runRecordMachine(machine, record, ctx = EMPTY_CTX) {
307
318
  const fields = {};
@@ -316,6 +327,28 @@ function runRecordMachine(machine, record, ctx = EMPTY_CTX) {
316
327
  errors.push({ field: name, ...run.failed });
317
328
  }
318
329
  }
330
+ // The record-level `transition` gate: with the per-field gates passed, a
331
+ // state-field change must follow a declared edge (against `ctx.previous`;
332
+ // no previous row = create semantics). A field-level failure on the state
333
+ // field already explains itself, so the gate only runs when that field is
334
+ // clean — one error per cause, like every other gate.
335
+ const transitions = machine.transitions;
336
+ if (transitions && !fields[transitions.field]?.failed) {
337
+ // Guard against what was actually submitted: an absent state field is not
338
+ // a transition, but the field machine defaults it in `values`.
339
+ const submitted = record[transitions.field] === undefined
340
+ ? {}
341
+ : { [transitions.field]: values[transitions.field] };
342
+ const guard = (0, transitions_1.transitionGuard)(transitions, ctx.previous, submitted);
343
+ if (!guard.ok) {
344
+ errors.push({
345
+ field: transitions.field,
346
+ gate: "transition",
347
+ constraint: `transitions: ${(0, transitions_1.describeTransitions)(transitions).join("; ")}`,
348
+ message: guard.message,
349
+ });
350
+ }
351
+ }
319
352
  return {
320
353
  state: errors.length === 0 ? "valid" : "invalid",
321
354
  values,
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Declarative state transitions — the record-level layer of the constraint
3
+ * state machine.
4
+ *
5
+ * A resource opts in with a `transitions` block (on `resource()` options, or a
6
+ * `static transitions` on an entity class):
7
+ *
8
+ * ```ts
9
+ * transitions: {
10
+ * field: "status",
11
+ * states: ["scheduled", "boarding", "departed", "cancelled"],
12
+ * initial: "scheduled",
13
+ * on: {
14
+ * board: { from: "scheduled", to: "boarding" },
15
+ * depart: { from: "boarding", to: "departed" },
16
+ * cancel: { from: ["scheduled", "boarding"], to: "cancelled",
17
+ * set: { gate: null } },
18
+ * },
19
+ * }
20
+ * ```
21
+ *
22
+ * The block compiles into the manifest verbatim (with `from` normalised to a
23
+ * list), and this module is the *shared executable form* of it: the same pure
24
+ * functions guard a form submit (`useForm(...).transition("board")`), the
25
+ * record machine (`runRecordMachine` with a `previous` row in context), and —
26
+ * re-implemented 1:1 in `rastack-api-core` — every server/WASM write. A write
27
+ * that jumps between states without a declared edge is rejected on every
28
+ * surface, which is what makes a transition "a backend function that is
29
+ * state-machine safe": the only way to move a record between states is a
30
+ * named edge, and the edge's `set` patches are applied by the engine.
31
+ */
32
+ import type { TransitionEdgeModel, TransitionsModel } from "../compile/model";
33
+ export type { TransitionEdgeModel, TransitionsModel } from "../compile/model";
34
+ /** A transition available from a given state, ready for a UI action list. */
35
+ export interface AvailableTransition {
36
+ name: string;
37
+ to: string;
38
+ set?: Record<string, unknown>;
39
+ }
40
+ /** The outcome of a transition guard or application. */
41
+ export type TransitionResult = {
42
+ ok: true;
43
+ patch: Record<string, unknown>;
44
+ } | {
45
+ ok: false;
46
+ message: string;
47
+ };
48
+ /**
49
+ * Normalise a raw (authored) transitions block into the canonical model shape:
50
+ * `from` becomes a string list, non-string states are dropped, and anything
51
+ * structurally broken yields `undefined` (the compiler then reports it).
52
+ */
53
+ export declare function normalizeTransitions(raw: unknown): TransitionsModel | undefined;
54
+ /**
55
+ * Sanity-check a transitions block against its resource's fields. Returns
56
+ * human-readable problems (empty = valid); the compiler surfaces each as an
57
+ * error diagnostic, mirroring the circular-dependency check.
58
+ */
59
+ export declare function transitionProblems(transitions: TransitionsModel, fields: Array<{
60
+ name: string;
61
+ }>): string[];
62
+ /** The state a record is in — its state-field value, else the initial state. */
63
+ export declare function currentState(transitions: TransitionsModel, record: Record<string, unknown> | undefined): string | undefined;
64
+ /** Every transition that may fire from `state` — the form's action list. */
65
+ export declare function allowedTransitions(transitions: TransitionsModel, state: unknown): AvailableTransition[];
66
+ /** The declared edge covering `from → to`, if any. */
67
+ export declare function findTransition(transitions: TransitionsModel, from: string, to: string): {
68
+ name: string;
69
+ edge: TransitionEdgeModel;
70
+ } | undefined;
71
+ /**
72
+ * Guard one write. `previous` is the persisted row (`undefined` = create).
73
+ * A create must start at the initial state; an update that changes the state
74
+ * field must follow a declared edge. Anything else passes untouched — the
75
+ * guard constrains only the state field.
76
+ */
77
+ export declare function transitionGuard(transitions: TransitionsModel, previous: Record<string, unknown> | undefined, next: Record<string, unknown>): TransitionResult;
78
+ /**
79
+ * Fire a *named* transition from a record's current state: validates the edge
80
+ * is available and returns the full patch to write — the state change plus the
81
+ * edge's `set` effects. This is the client half of the backend function; the
82
+ * server re-derives the same patch from the same manifest.
83
+ */
84
+ export declare function applyTransition(transitions: TransitionsModel, name: string, record: Record<string, unknown> | undefined): TransitionResult;
85
+ /** Human-readable edge list — `"board: scheduled → boarding"` — for UI hints. */
86
+ export declare function describeTransitions(transitions: TransitionsModel): string[];
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ /**
3
+ * Declarative state transitions — the record-level layer of the constraint
4
+ * state machine.
5
+ *
6
+ * A resource opts in with a `transitions` block (on `resource()` options, or a
7
+ * `static transitions` on an entity class):
8
+ *
9
+ * ```ts
10
+ * transitions: {
11
+ * field: "status",
12
+ * states: ["scheduled", "boarding", "departed", "cancelled"],
13
+ * initial: "scheduled",
14
+ * on: {
15
+ * board: { from: "scheduled", to: "boarding" },
16
+ * depart: { from: "boarding", to: "departed" },
17
+ * cancel: { from: ["scheduled", "boarding"], to: "cancelled",
18
+ * set: { gate: null } },
19
+ * },
20
+ * }
21
+ * ```
22
+ *
23
+ * The block compiles into the manifest verbatim (with `from` normalised to a
24
+ * list), and this module is the *shared executable form* of it: the same pure
25
+ * functions guard a form submit (`useForm(...).transition("board")`), the
26
+ * record machine (`runRecordMachine` with a `previous` row in context), and —
27
+ * re-implemented 1:1 in `rastack-api-core` — every server/WASM write. A write
28
+ * that jumps between states without a declared edge is rejected on every
29
+ * surface, which is what makes a transition "a backend function that is
30
+ * state-machine safe": the only way to move a record between states is a
31
+ * named edge, and the edge's `set` patches are applied by the engine.
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.normalizeTransitions = normalizeTransitions;
35
+ exports.transitionProblems = transitionProblems;
36
+ exports.currentState = currentState;
37
+ exports.allowedTransitions = allowedTransitions;
38
+ exports.findTransition = findTransition;
39
+ exports.transitionGuard = transitionGuard;
40
+ exports.applyTransition = applyTransition;
41
+ exports.describeTransitions = describeTransitions;
42
+ /**
43
+ * Normalise a raw (authored) transitions block into the canonical model shape:
44
+ * `from` becomes a string list, non-string states are dropped, and anything
45
+ * structurally broken yields `undefined` (the compiler then reports it).
46
+ */
47
+ function normalizeTransitions(raw) {
48
+ if (!raw || typeof raw !== "object")
49
+ return undefined;
50
+ const block = raw;
51
+ if (typeof block.field !== "string" || !block.field)
52
+ return undefined;
53
+ if (!Array.isArray(block.states))
54
+ return undefined;
55
+ const states = block.states.filter((s) => typeof s === "string");
56
+ if (!states.length)
57
+ return undefined;
58
+ if (!block.on || typeof block.on !== "object")
59
+ return undefined;
60
+ const on = {};
61
+ for (const [name, rawEdge] of Object.entries(block.on)) {
62
+ if (!rawEdge || typeof rawEdge !== "object")
63
+ continue;
64
+ const edge = rawEdge;
65
+ const from = (Array.isArray(edge.from) ? edge.from : [edge.from]).filter((s) => typeof s === "string");
66
+ if (!from.length || typeof edge.to !== "string")
67
+ continue;
68
+ const model = { from, to: edge.to };
69
+ if (edge.set && typeof edge.set === "object" && !Array.isArray(edge.set)) {
70
+ model.set = edge.set;
71
+ }
72
+ on[name] = model;
73
+ }
74
+ const model = { field: block.field, states, on };
75
+ if (typeof block.initial === "string")
76
+ model.initial = block.initial;
77
+ return model;
78
+ }
79
+ /**
80
+ * Sanity-check a transitions block against its resource's fields. Returns
81
+ * human-readable problems (empty = valid); the compiler surfaces each as an
82
+ * error diagnostic, mirroring the circular-dependency check.
83
+ */
84
+ function transitionProblems(transitions, fields) {
85
+ const problems = [];
86
+ const fieldNames = new Set(fields.map((f) => f.name));
87
+ const states = new Set(transitions.states);
88
+ if (!fieldNames.has(transitions.field)) {
89
+ problems.push(`transitions.field "${transitions.field}" is not a field`);
90
+ }
91
+ if (transitions.initial !== undefined && !states.has(transitions.initial)) {
92
+ problems.push(`initial state "${transitions.initial}" is not in states`);
93
+ }
94
+ for (const [name, edge] of Object.entries(transitions.on)) {
95
+ for (const from of edge.from) {
96
+ if (!states.has(from)) {
97
+ problems.push(`transition "${name}": from-state "${from}" is not in states`);
98
+ }
99
+ }
100
+ if (!states.has(edge.to)) {
101
+ problems.push(`transition "${name}": to-state "${edge.to}" is not in states`);
102
+ }
103
+ for (const key of Object.keys(edge.set ?? {})) {
104
+ if (key === transitions.field) {
105
+ problems.push(`transition "${name}": set must not patch the state field itself`);
106
+ }
107
+ else if (!fieldNames.has(key)) {
108
+ problems.push(`transition "${name}": set targets unknown field "${key}"`);
109
+ }
110
+ }
111
+ }
112
+ return problems;
113
+ }
114
+ /** The state a record is in — its state-field value, else the initial state. */
115
+ function currentState(transitions, record) {
116
+ const value = record?.[transitions.field];
117
+ if (value !== undefined && value !== null && value !== "")
118
+ return String(value);
119
+ return transitions.initial;
120
+ }
121
+ /** Every transition that may fire from `state` — the form's action list. */
122
+ function allowedTransitions(transitions, state) {
123
+ const from = state === undefined || state === null ? transitions.initial : String(state);
124
+ if (from === undefined)
125
+ return [];
126
+ return Object.entries(transitions.on)
127
+ .filter(([, edge]) => edge.from.includes(from))
128
+ .map(([name, edge]) => ({ name, to: edge.to, set: edge.set }));
129
+ }
130
+ /** The declared edge covering `from → to`, if any. */
131
+ function findTransition(transitions, from, to) {
132
+ for (const [name, edge] of Object.entries(transitions.on)) {
133
+ if (edge.to === to && edge.from.includes(from))
134
+ return { name, edge };
135
+ }
136
+ return undefined;
137
+ }
138
+ /**
139
+ * Guard one write. `previous` is the persisted row (`undefined` = create).
140
+ * A create must start at the initial state; an update that changes the state
141
+ * field must follow a declared edge. Anything else passes untouched — the
142
+ * guard constrains only the state field.
143
+ */
144
+ function transitionGuard(transitions, previous, next) {
145
+ const value = next[transitions.field];
146
+ if (previous === undefined) {
147
+ // Create: absent state falls back to the initial default; a supplied state
148
+ // must *be* the initial state — records cannot be born mid-machine.
149
+ if (value === undefined || value === null || value === "") {
150
+ return { ok: true, patch: {} };
151
+ }
152
+ if (transitions.initial !== undefined && String(value) !== transitions.initial) {
153
+ return {
154
+ ok: false,
155
+ message: `${transitions.field}: new records start at "${transitions.initial}", not "${String(value)}"`,
156
+ };
157
+ }
158
+ return { ok: true, patch: {} };
159
+ }
160
+ // Update: an untouched (or unchanged) state field is not a transition.
161
+ if (value === undefined)
162
+ return { ok: true, patch: {} };
163
+ const from = currentState(transitions, previous);
164
+ const to = String(value);
165
+ if (from === to)
166
+ return { ok: true, patch: {} };
167
+ const match = from !== undefined && findTransition(transitions, from, to);
168
+ if (!match) {
169
+ return {
170
+ ok: false,
171
+ message: `${transitions.field}: no transition from "${String(from)}" to "${to}"`,
172
+ };
173
+ }
174
+ return { ok: true, patch: { ...match.edge.set } };
175
+ }
176
+ /**
177
+ * Fire a *named* transition from a record's current state: validates the edge
178
+ * is available and returns the full patch to write — the state change plus the
179
+ * edge's `set` effects. This is the client half of the backend function; the
180
+ * server re-derives the same patch from the same manifest.
181
+ */
182
+ function applyTransition(transitions, name, record) {
183
+ const edge = transitions.on[name];
184
+ if (!edge) {
185
+ return { ok: false, message: `unknown transition "${name}"` };
186
+ }
187
+ const state = currentState(transitions, record);
188
+ if (state === undefined || !edge.from.includes(state)) {
189
+ return {
190
+ ok: false,
191
+ message: `"${name}" is not available from "${String(state)}" (needs ${edge.from.join(" | ")})`,
192
+ };
193
+ }
194
+ return { ok: true, patch: { [transitions.field]: edge.to, ...edge.set } };
195
+ }
196
+ /** Human-readable edge list — `"board: scheduled → boarding"` — for UI hints. */
197
+ function describeTransitions(transitions) {
198
+ return Object.entries(transitions.on).map(([name, edge]) => `${name}: ${edge.from.join(" | ")} → ${edge.to}`);
199
+ }
@@ -375,7 +375,7 @@ function __wbg_get_imports() {
375
375
  return ret;
376
376
  },
377
377
  __wbindgen_cast_0000000000000001: function(arg0, arg1) {
378
- // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 80, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
378
+ // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 85, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
379
379
  const ret = makeMutClosure(arg0, arg1, wasm_bindgen_1a7aa9db5392c486___convert__closures_____invoke___wasm_bindgen_1a7aa9db5392c486___JsValue__core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_1a7aa9db5392c486___JsError___true_);
380
380
  return ret;
381
381
  },
Binary file