@mmapp/react 0.1.0-alpha.1 → 0.1.0-alpha.3

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 (94) hide show
  1. package/README.md +112 -0
  2. package/dist/index.d.mts +27 -2
  3. package/dist/index.d.ts +27 -2
  4. package/dist/index.js +70 -3
  5. package/dist/index.mjs +74 -12
  6. package/package.json +4 -3
  7. package/package.json.backup +0 -41
  8. package/src/Blueprint.ts +0 -216
  9. package/src/__tests__/Blueprint.test.ts +0 -106
  10. package/src/__tests__/action-context.test.ts +0 -166
  11. package/src/__tests__/actionCreators.test.ts +0 -179
  12. package/src/__tests__/builders.test.ts +0 -336
  13. package/src/__tests__/defineBlueprint-composition.test.ts +0 -106
  14. package/src/__tests__/factories.test.ts +0 -229
  15. package/src/__tests__/loader.test.ts +0 -159
  16. package/src/__tests__/logger.test.ts +0 -70
  17. package/src/__tests__/type-inference.test.ts +0 -160
  18. package/src/__tests__/typed-transitions.test.ts +0 -126
  19. package/src/__tests__/useModuleConfig.test.ts +0 -61
  20. package/src/actionCreators.ts +0 -132
  21. package/src/actions.ts +0 -547
  22. package/src/atoms/index.ts +0 -600
  23. package/src/authoring.ts +0 -92
  24. package/src/browser-player.ts +0 -783
  25. package/src/builders.ts +0 -1342
  26. package/src/components/ExperienceWorkflowBridge.tsx +0 -123
  27. package/src/components/PlayerProvider.tsx +0 -43
  28. package/src/components/atoms/index.tsx +0 -269
  29. package/src/components/index.ts +0 -36
  30. package/src/conditions.ts +0 -692
  31. package/src/config/defineBlueprint.ts +0 -329
  32. package/src/config/defineModel.ts +0 -753
  33. package/src/config/defineWorkspace.ts +0 -24
  34. package/src/core/WorkflowRuntime.ts +0 -153
  35. package/src/factories.ts +0 -425
  36. package/src/grammar/index.ts +0 -173
  37. package/src/hooks/index.ts +0 -106
  38. package/src/hooks/useAuth.ts +0 -288
  39. package/src/hooks/useChannel.ts +0 -304
  40. package/src/hooks/useComputed.ts +0 -154
  41. package/src/hooks/useDomainSubscription.ts +0 -110
  42. package/src/hooks/useDuringAction.ts +0 -99
  43. package/src/hooks/useExperienceState.ts +0 -59
  44. package/src/hooks/useExpressionLibrary.ts +0 -129
  45. package/src/hooks/useForm.ts +0 -352
  46. package/src/hooks/useGeolocation.ts +0 -207
  47. package/src/hooks/useMapView.ts +0 -259
  48. package/src/hooks/useMiddleware.ts +0 -291
  49. package/src/hooks/useModel.ts +0 -363
  50. package/src/hooks/useModule.ts +0 -59
  51. package/src/hooks/useModuleConfig.ts +0 -61
  52. package/src/hooks/useMutation.ts +0 -237
  53. package/src/hooks/useNotification.ts +0 -151
  54. package/src/hooks/useOnChange.ts +0 -30
  55. package/src/hooks/useOnEnter.ts +0 -59
  56. package/src/hooks/useOnEvent.ts +0 -37
  57. package/src/hooks/useOnExit.ts +0 -27
  58. package/src/hooks/useOnTransition.ts +0 -30
  59. package/src/hooks/usePackage.ts +0 -128
  60. package/src/hooks/useParams.ts +0 -33
  61. package/src/hooks/usePlayer.ts +0 -308
  62. package/src/hooks/useQuery.ts +0 -184
  63. package/src/hooks/useRealtimeQuery.ts +0 -222
  64. package/src/hooks/useRole.ts +0 -191
  65. package/src/hooks/useRouteParams.ts +0 -100
  66. package/src/hooks/useRouter.ts +0 -347
  67. package/src/hooks/useServerAction.ts +0 -178
  68. package/src/hooks/useServerState.ts +0 -284
  69. package/src/hooks/useToast.ts +0 -164
  70. package/src/hooks/useTransition.ts +0 -39
  71. package/src/hooks/useView.ts +0 -102
  72. package/src/hooks/useWhileIn.ts +0 -48
  73. package/src/hooks/useWorkflow.ts +0 -63
  74. package/src/index.ts +0 -465
  75. package/src/loader/experience-workflow-loader.ts +0 -192
  76. package/src/loader/index.ts +0 -6
  77. package/src/local/LocalEngine.ts +0 -388
  78. package/src/local/LocalEngineAdapter.ts +0 -175
  79. package/src/local/LocalEngineContext.ts +0 -30
  80. package/src/logger.ts +0 -37
  81. package/src/mixins.ts +0 -1160
  82. package/src/providers/RuntimeContext.ts +0 -20
  83. package/src/providers/WorkflowProvider.tsx +0 -28
  84. package/src/routing/instance-key.ts +0 -107
  85. package/src/server/transition-context.ts +0 -172
  86. package/src/testing/index.ts +0 -9
  87. package/src/testing/useBlueprintTestRunner.ts +0 -91
  88. package/src/testing/useGraphAnalysis.ts +0 -18
  89. package/src/testing/useTestRunner.ts +0 -77
  90. package/src/testing.ts +0 -995
  91. package/src/types/workflow-inference.ts +0 -158
  92. package/src/types.ts +0 -114
  93. package/tsconfig.json +0 -27
  94. package/vitest.config.ts +0 -8
package/src/mixins.ts DELETED
@@ -1,1160 +0,0 @@
1
- /**
2
- * Model Mixins — higher-order functions that enhance ModelDefinitions.
3
- *
4
- * Mixins compose reusable cross-cutting concerns (audit trails, soft delete,
5
- * versioning, RBAC, etc.) into workflow model definitions. Each mixin takes
6
- * a ModelDefinition and returns a new ModelDefinition with additional fields,
7
- * states, and/or transitions — without mutating the input.
8
- *
9
- * @example
10
- * ```typescript
11
- * import { defineModel } from '@mindmatrix/react';
12
- * import { pipe, withAuditTrail, withSoftDelete, withTags } from '@mindmatrix/react/mixins';
13
- *
14
- * export default defineModel(pipe(
15
- * {
16
- * slug: 'invoice',
17
- * fields: { amount: { type: 'currency', required: true } },
18
- * states: { draft: { type: 'initial' }, sent: {}, paid: { type: 'end' } },
19
- * transitions: {
20
- * send: { from: 'draft', to: 'sent' },
21
- * pay: { from: 'sent', to: 'paid' },
22
- * },
23
- * },
24
- * withAuditTrail(),
25
- * withSoftDelete({ retentionDays: 90 }),
26
- * withTags(),
27
- * ));
28
- * ```
29
- *
30
- * @module @mindmatrix/react/mixins
31
- */
32
-
33
- import type {
34
- ModelDefinition,
35
- WorkflowFieldDescriptor,
36
- StateDescriptor,
37
- TransitionDescriptor,
38
- ActionDefinition,
39
- RoleDefinition,
40
- } from './config/defineModel';
41
-
42
- // =============================================================================
43
- // Core Types
44
- // =============================================================================
45
-
46
- /**
47
- * A mixin function that takes a ModelDefinition and returns an enhanced one.
48
- *
49
- * Mixins MUST be pure — they never mutate the input definition. They return
50
- * a new object with merged fields, states, transitions, and roles.
51
- */
52
- export type ModelMixin = (def: ModelDefinition) => ModelDefinition;
53
-
54
- // =============================================================================
55
- // Deep Merge Helpers (internal)
56
- // =============================================================================
57
-
58
- /**
59
- * Deep-merge two records of fields, preserving existing entries.
60
- * New fields are added; existing fields are NOT overwritten.
61
- */
62
- function mergeFields(
63
- existing: Record<string, WorkflowFieldDescriptor>,
64
- incoming: Record<string, WorkflowFieldDescriptor>,
65
- ): Record<string, WorkflowFieldDescriptor> {
66
- const result = { ...existing };
67
- for (const [key, value] of Object.entries(incoming)) {
68
- if (!(key in result)) {
69
- result[key] = value;
70
- }
71
- }
72
- return result;
73
- }
74
-
75
- /**
76
- * Deep-merge two records of states. For existing states, onEnter/onExit
77
- * actions from `incoming` are appended (not replaced).
78
- */
79
- function mergeStates(
80
- existing: Record<string, StateDescriptor>,
81
- incoming: Record<string, StateDescriptor>,
82
- ): Record<string, StateDescriptor> {
83
- const result: Record<string, StateDescriptor> = {};
84
- // Copy all existing states
85
- for (const [key, value] of Object.entries(existing)) {
86
- result[key] = { ...value };
87
- }
88
- // Merge incoming states
89
- for (const [key, value] of Object.entries(incoming)) {
90
- if (key in result) {
91
- const merged: StateDescriptor = { ...result[key] };
92
- if (value.onEnter && value.onEnter.length > 0) {
93
- merged.onEnter = [...(merged.onEnter ?? []), ...value.onEnter];
94
- }
95
- if (value.onExit && value.onExit.length > 0) {
96
- merged.onExit = [...(merged.onExit ?? []), ...value.onExit];
97
- }
98
- result[key] = merged;
99
- } else {
100
- result[key] = { ...value };
101
- }
102
- }
103
- return result;
104
- }
105
-
106
- /**
107
- * Merge transitions — new transitions are added; existing ones are NOT overwritten.
108
- */
109
- function mergeTransitions(
110
- existing: Record<string, TransitionDescriptor>,
111
- incoming: Record<string, TransitionDescriptor>,
112
- ): Record<string, TransitionDescriptor> {
113
- const result = { ...existing };
114
- for (const [key, value] of Object.entries(incoming)) {
115
- if (!(key in result)) {
116
- result[key] = value;
117
- }
118
- }
119
- return result;
120
- }
121
-
122
- /**
123
- * Merge role definitions — new roles are added; existing ones are NOT overwritten.
124
- */
125
- function mergeRoles(
126
- existing: Record<string, RoleDefinition> | undefined,
127
- incoming: Record<string, RoleDefinition> | undefined,
128
- ): Record<string, RoleDefinition> | undefined {
129
- if (!incoming) return existing;
130
- if (!existing) return incoming;
131
- const result = { ...existing };
132
- for (const [key, value] of Object.entries(incoming)) {
133
- if (!(key in result)) {
134
- result[key] = value;
135
- }
136
- }
137
- return result;
138
- }
139
-
140
- /**
141
- * Collect all non-terminal state names from a definition.
142
- * Non-terminal = not type 'end' and not type 'cancelled'.
143
- */
144
- function getNonTerminalStates(def: ModelDefinition): string[] {
145
- return Object.entries(def.states)
146
- .filter(([, s]) => s.type !== 'end' && s.type !== 'cancelled')
147
- .map(([name]) => name);
148
- }
149
-
150
- // =============================================================================
151
- // Core Infrastructure
152
- // =============================================================================
153
-
154
- /**
155
- * Apply multiple mixins to a model definition, left to right.
156
- *
157
- * Each mixin receives the output of the previous mixin, allowing
158
- * composed enhancements to build on each other.
159
- *
160
- * @param def - The base model definition.
161
- * @param mixins - One or more mixin functions to apply.
162
- * @returns A new ModelDefinition with all mixins applied.
163
- *
164
- * @example
165
- * ```typescript
166
- * import { defineModel } from '@mindmatrix/react';
167
- * import { applyMixins, withTimestamps, withTags } from '@mindmatrix/react/mixins';
168
- *
169
- * const base = {
170
- * slug: 'article',
171
- * fields: { title: { type: 'string' } },
172
- * states: { draft: { type: 'initial' as const }, published: { type: 'end' as const } },
173
- * transitions: { publish: { from: 'draft', to: 'published' } },
174
- * };
175
- *
176
- * export default defineModel(applyMixins(base, withTimestamps(), withTags()));
177
- * ```
178
- */
179
- export function applyMixins(def: ModelDefinition, ...mixins: ModelMixin[]): ModelDefinition {
180
- return mixins.reduce<ModelDefinition>((acc, mixin) => mixin(acc), def);
181
- }
182
-
183
- /**
184
- * Functional composition helper — alias for `applyMixins`.
185
- *
186
- * Reads naturally as a pipeline: start with a definition, then pipe
187
- * it through a series of mixins.
188
- *
189
- * @param def - The base model definition.
190
- * @param mixins - One or more mixin functions to apply.
191
- * @returns A new ModelDefinition with all mixins applied.
192
- *
193
- * @example
194
- * ```typescript
195
- * import { defineModel } from '@mindmatrix/react';
196
- * import { pipe, withAuditTrail, withSoftDelete, withOwnership } from '@mindmatrix/react/mixins';
197
- *
198
- * export default defineModel(pipe(
199
- * {
200
- * slug: 'task',
201
- * fields: { title: { type: 'string', required: true } },
202
- * states: { open: { type: 'initial' }, done: { type: 'end' } },
203
- * transitions: { complete: { from: 'open', to: 'done' } },
204
- * },
205
- * withOwnership(),
206
- * withAuditTrail(),
207
- * withSoftDelete(),
208
- * ));
209
- * ```
210
- */
211
- export function pipe(def: ModelDefinition, ...mixins: ModelMixin[]): ModelDefinition {
212
- return applyMixins(def, ...mixins);
213
- }
214
-
215
- // =============================================================================
216
- // withAuditTrail
217
- // =============================================================================
218
-
219
- /**
220
- * Options for the {@link withAuditTrail} mixin.
221
- */
222
- export interface WithAuditTrailOptions {
223
- /**
224
- * If specified, adds a `changeLog` array field that records which
225
- * tracked fields changed on each transition.
226
- *
227
- * @example `{ trackFields: ['status', 'amount', 'assignee'] }`
228
- */
229
- trackFields?: string[];
230
- }
231
-
232
- /**
233
- * Adds audit trail fields and auto-set actions to a model definition.
234
- *
235
- * Injects:
236
- * - `createdAt` (datetime) — set on initial state entry
237
- * - `createdBy` (string) — set on initial state entry from `context.actor_id`
238
- * - `updatedAt` (datetime) — set on every transition
239
- * - `updatedBy` (string) — set on every transition from `context.actor_id`
240
- * - `changeLog` (array, optional) — records field-level changes if `trackFields` is set
241
- *
242
- * @param options - Optional configuration.
243
- * @returns A {@link ModelMixin} function.
244
- *
245
- * @example
246
- * ```typescript
247
- * import { defineModel } from '@mindmatrix/react';
248
- * import { pipe, withAuditTrail } from '@mindmatrix/react/mixins';
249
- *
250
- * export default defineModel(pipe(
251
- * {
252
- * slug: 'order',
253
- * fields: { total: { type: 'currency' } },
254
- * states: { draft: { type: 'initial' }, placed: { type: 'end' } },
255
- * transitions: { place: { from: 'draft', to: 'placed' } },
256
- * },
257
- * withAuditTrail({ trackFields: ['total'] }),
258
- * ));
259
- * ```
260
- */
261
- export function withAuditTrail(options: WithAuditTrailOptions = {}): ModelMixin {
262
- return (def: ModelDefinition): ModelDefinition => {
263
- const auditFields: Record<string, WorkflowFieldDescriptor> = {
264
- createdAt: { type: 'datetime', label: 'Created At' },
265
- createdBy: { type: 'string', label: 'Created By' },
266
- updatedAt: { type: 'datetime', label: 'Updated At' },
267
- updatedBy: { type: 'string', label: 'Updated By' },
268
- };
269
-
270
- if (options.trackFields && options.trackFields.length > 0) {
271
- auditFields.changeLog = {
272
- type: 'array',
273
- items: { type: 'object' },
274
- label: 'Change Log',
275
- description: `Tracks changes to: ${options.trackFields.join(', ')}`,
276
- };
277
- }
278
-
279
- // Build set_fields actions for transitions
280
- const updateAction: ActionDefinition = {
281
- id: '_audit_update',
282
- type: 'set_fields',
283
- mode: 'auto',
284
- config: {
285
- fields: {
286
- updatedAt: { expression: 'NOW()' },
287
- updatedBy: { expression: 'context.actor_id' },
288
- },
289
- },
290
- };
291
-
292
- // Inject updatedAt/updatedBy into all existing transitions
293
- const transitions: Record<string, TransitionDescriptor> = {};
294
- for (const [name, t] of Object.entries(def.transitions)) {
295
- transitions[name] = {
296
- ...t,
297
- actions: [...(t.actions ?? []), updateAction],
298
- };
299
- }
300
-
301
- // Inject createdAt/createdBy into onEnter of the initial state
302
- const states: Record<string, StateDescriptor> = {};
303
- for (const [name, s] of Object.entries(def.states)) {
304
- if (s.type === 'initial') {
305
- const creationAction: ActionDefinition = {
306
- id: '_audit_create',
307
- type: 'set_fields',
308
- mode: 'auto',
309
- config: {
310
- fields: {
311
- createdAt: { expression: 'NOW()' },
312
- createdBy: { expression: 'context.actor_id' },
313
- updatedAt: { expression: 'NOW()' },
314
- updatedBy: { expression: 'context.actor_id' },
315
- },
316
- },
317
- };
318
- states[name] = {
319
- ...s,
320
- onEnter: [...(s.onEnter ?? []), creationAction],
321
- };
322
- } else {
323
- states[name] = { ...s };
324
- }
325
- }
326
-
327
- return {
328
- ...def,
329
- fields: mergeFields(def.fields, auditFields),
330
- states: mergeStates(def.states, states),
331
- transitions,
332
- };
333
- };
334
- }
335
-
336
- // =============================================================================
337
- // withSoftDelete
338
- // =============================================================================
339
-
340
- /**
341
- * Options for the {@link withSoftDelete} mixin.
342
- */
343
- export interface WithSoftDeleteOptions {
344
- /**
345
- * Number of days to retain soft-deleted records before permanent deletion.
346
- * Stored as metadata; enforcement is handled by the engine or a scheduled job.
347
- */
348
- retentionDays?: number;
349
-
350
- /**
351
- * Whether restore (un-delete) is allowed. Default: `true`.
352
- */
353
- restoreAllowed?: boolean;
354
-
355
- /**
356
- * Roles allowed to perform delete/restore. If omitted, any role can.
357
- */
358
- roles?: string[];
359
- }
360
-
361
- /**
362
- * Adds soft-delete capability to a model definition.
363
- *
364
- * Injects:
365
- * - A `deleted` end state
366
- * - `deletedAt` (datetime) and `deletedBy` (string) fields
367
- * - A `delete` transition from all non-terminal states to `deleted`
368
- * - A `restore` transition from `deleted` back to the initial state (if `restoreAllowed`)
369
- *
370
- * @param options - Optional configuration.
371
- * @returns A {@link ModelMixin} function.
372
- *
373
- * @example
374
- * ```typescript
375
- * import { defineModel } from '@mindmatrix/react';
376
- * import { pipe, withSoftDelete } from '@mindmatrix/react/mixins';
377
- *
378
- * export default defineModel(pipe(
379
- * {
380
- * slug: 'document',
381
- * fields: { title: { type: 'string' } },
382
- * states: { active: { type: 'initial' }, archived: { type: 'end' } },
383
- * transitions: { archive: { from: 'active', to: 'archived' } },
384
- * },
385
- * withSoftDelete({ retentionDays: 30, roles: ['admin'] }),
386
- * ));
387
- * ```
388
- */
389
- export function withSoftDelete(options: WithSoftDeleteOptions = {}): ModelMixin {
390
- const { retentionDays, restoreAllowed = true, roles } = options;
391
-
392
- return (def: ModelDefinition): ModelDefinition => {
393
- const deleteFields: Record<string, WorkflowFieldDescriptor> = {
394
- deletedAt: { type: 'datetime', label: 'Deleted At' },
395
- deletedBy: { type: 'string', label: 'Deleted By' },
396
- };
397
-
398
- const deleteState: Record<string, StateDescriptor> = {
399
- deleted: { type: 'end', description: 'Soft-deleted record' },
400
- };
401
-
402
- const nonTerminalStates = getNonTerminalStates(def);
403
-
404
- const deleteTransition: TransitionDescriptor = {
405
- from: nonTerminalStates,
406
- to: 'deleted',
407
- description: 'Soft-delete this record',
408
- ...(roles ? { roles } : {}),
409
- actions: [
410
- {
411
- id: '_soft_delete_stamp',
412
- type: 'set_fields',
413
- mode: 'auto',
414
- config: {
415
- fields: {
416
- deletedAt: { expression: 'NOW()' },
417
- deletedBy: { expression: 'context.actor_id' },
418
- },
419
- },
420
- },
421
- ],
422
- };
423
-
424
- const newTransitions: Record<string, TransitionDescriptor> = {
425
- delete: deleteTransition,
426
- };
427
-
428
- // Find the initial state for restore target
429
- if (restoreAllowed) {
430
- const initialState = Object.entries(def.states).find(([, s]) => s.type === 'initial');
431
- if (initialState) {
432
- // Restore goes back to initial state; change type to regular (not end)
433
- deleteState.deleted = { description: 'Soft-deleted record' };
434
- newTransitions.restore = {
435
- from: 'deleted',
436
- to: initialState[0],
437
- description: 'Restore a soft-deleted record',
438
- ...(roles ? { roles } : {}),
439
- actions: [
440
- {
441
- id: '_soft_delete_clear',
442
- type: 'set_fields',
443
- mode: 'auto',
444
- config: {
445
- fields: {
446
- deletedAt: { expression: 'null' },
447
- deletedBy: { expression: 'null' },
448
- },
449
- },
450
- },
451
- ],
452
- };
453
- }
454
- }
455
-
456
- const result: ModelDefinition = {
457
- ...def,
458
- fields: mergeFields(def.fields, deleteFields),
459
- states: mergeStates(def.states, deleteState),
460
- transitions: mergeTransitions(def.transitions, newTransitions),
461
- };
462
-
463
- if (retentionDays !== undefined) {
464
- result.metadata = {
465
- ...def.metadata,
466
- softDelete: { retentionDays },
467
- };
468
- }
469
-
470
- return result;
471
- };
472
- }
473
-
474
- // =============================================================================
475
- // withVersioning
476
- // =============================================================================
477
-
478
- /**
479
- * Options for the {@link withVersioning} mixin.
480
- */
481
- export interface WithVersioningOptions {
482
- /**
483
- * Transition names that should automatically create a version snapshot
484
- * before executing. A `set_fields` action incrementing `version` is
485
- * prepended to these transitions.
486
- *
487
- * @example `{ autoVersionOnTransitions: ['publish', 'approve'] }`
488
- */
489
- autoVersionOnTransitions?: string[];
490
- }
491
-
492
- /**
493
- * Adds version tracking to a model definition.
494
- *
495
- * Injects:
496
- * - `version` (number, default 1) — incremented on version-creating transitions
497
- * - `previousVersionId` (string) — ID of the prior version snapshot
498
- * - A `create_version` self-transition on the initial state that spawns
499
- * a snapshot sub-workflow and increments the version counter
500
- *
501
- * @param options - Optional configuration.
502
- * @returns A {@link ModelMixin} function.
503
- *
504
- * @example
505
- * ```typescript
506
- * import { defineModel } from '@mindmatrix/react';
507
- * import { pipe, withVersioning } from '@mindmatrix/react/mixins';
508
- *
509
- * export default defineModel(pipe(
510
- * {
511
- * slug: 'policy',
512
- * fields: { content: { type: 'string' } },
513
- * states: { draft: { type: 'initial' }, published: {} },
514
- * transitions: { publish: { from: 'draft', to: 'published' } },
515
- * },
516
- * withVersioning({ autoVersionOnTransitions: ['publish'] }),
517
- * ));
518
- * ```
519
- */
520
- export function withVersioning(options: WithVersioningOptions = {}): ModelMixin {
521
- return (def: ModelDefinition): ModelDefinition => {
522
- const versionFields: Record<string, WorkflowFieldDescriptor> = {
523
- version: { type: 'number', default: 1, label: 'Version' },
524
- previousVersionId: { type: 'string', label: 'Previous Version ID' },
525
- };
526
-
527
- const nonTerminalStates = getNonTerminalStates(def);
528
-
529
- const newTransitions: Record<string, TransitionDescriptor> = {
530
- create_version: {
531
- from: nonTerminalStates,
532
- to: nonTerminalStates[0],
533
- description: 'Create a version snapshot',
534
- actions: [
535
- {
536
- id: '_version_snapshot',
537
- type: 'spawn_subworkflow',
538
- mode: 'auto',
539
- config: {
540
- definition_slug: def.slug,
541
- blocking: false,
542
- input_mapping: { _snapshot: 'true' },
543
- },
544
- },
545
- {
546
- id: '_version_increment',
547
- type: 'set_fields',
548
- mode: 'auto',
549
- config: {
550
- fields: {
551
- version: { expression: 'state_data.version + 1' },
552
- },
553
- },
554
- },
555
- ],
556
- },
557
- };
558
-
559
- // If autoVersionOnTransitions is specified, inject version increment
560
- // into those transitions
561
- const transitions: Record<string, TransitionDescriptor> = { ...def.transitions };
562
- if (options.autoVersionOnTransitions) {
563
- const versionAction: ActionDefinition = {
564
- id: '_auto_version',
565
- type: 'set_fields',
566
- mode: 'auto',
567
- config: {
568
- fields: {
569
- version: { expression: 'state_data.version + 1' },
570
- },
571
- },
572
- };
573
-
574
- for (const transName of options.autoVersionOnTransitions) {
575
- if (transName in transitions) {
576
- const existing = transitions[transName];
577
- transitions[transName] = {
578
- ...existing,
579
- actions: [versionAction, ...(existing.actions ?? [])],
580
- };
581
- }
582
- }
583
- }
584
-
585
- return {
586
- ...def,
587
- fields: mergeFields(def.fields, versionFields),
588
- transitions: mergeTransitions(transitions, newTransitions),
589
- };
590
- };
591
- }
592
-
593
- // =============================================================================
594
- // withRBAC
595
- // =============================================================================
596
-
597
- /**
598
- * Options for the {@link withRBAC} mixin.
599
- */
600
- export interface WithRBACOptions {
601
- /**
602
- * Role definitions to add to the model. Each role can have permissions
603
- * and can inherit from other roles.
604
- *
605
- * @example
606
- * ```typescript
607
- * roles: {
608
- * admin: { permissions: ['manage_users', 'delete'], inherits: ['editor'] },
609
- * editor: { permissions: ['read', 'write'] },
610
- * viewer: { permissions: ['read'] },
611
- * }
612
- * ```
613
- */
614
- roles: Record<string, { permissions?: string[]; inherits?: string[] }>;
615
-
616
- /**
617
- * Map of transition names to the roles allowed to trigger them.
618
- * Injects role guards into the specified transitions.
619
- *
620
- * @example `{ transitionRoles: { approve: ['admin', 'manager'], delete: ['admin'] } }`
621
- */
622
- transitionRoles?: Record<string, string[]>;
623
- }
624
-
625
- /**
626
- * Adds role-based access control (RBAC) to a model definition.
627
- *
628
- * Injects:
629
- * - Role definitions into `roles`
630
- * - Role guards (via `roles` property) into specified transitions
631
- *
632
- * @param options - RBAC configuration (required).
633
- * @returns A {@link ModelMixin} function.
634
- *
635
- * @example
636
- * ```typescript
637
- * import { defineModel } from '@mindmatrix/react';
638
- * import { pipe, withRBAC } from '@mindmatrix/react/mixins';
639
- *
640
- * export default defineModel(pipe(
641
- * {
642
- * slug: 'expense-report',
643
- * fields: { amount: { type: 'currency' } },
644
- * states: { draft: { type: 'initial' }, approved: { type: 'end' } },
645
- * transitions: { approve: { from: 'draft', to: 'approved' } },
646
- * },
647
- * withRBAC({
648
- * roles: {
649
- * admin: { permissions: ['approve', 'delete'] },
650
- * submitter: { permissions: ['create', 'read'] },
651
- * },
652
- * transitionRoles: { approve: ['admin'] },
653
- * }),
654
- * ));
655
- * ```
656
- */
657
- export function withRBAC(options: WithRBACOptions): ModelMixin {
658
- return (def: ModelDefinition): ModelDefinition => {
659
- const newRoles: Record<string, RoleDefinition> = {};
660
- for (const [name, role] of Object.entries(options.roles)) {
661
- newRoles[name] = {
662
- permissions: role.permissions,
663
- inherits: role.inherits,
664
- };
665
- }
666
-
667
- // Inject role guards into transitions
668
- const transitions: Record<string, TransitionDescriptor> = { ...def.transitions };
669
- if (options.transitionRoles) {
670
- for (const [transName, allowedRoles] of Object.entries(options.transitionRoles)) {
671
- if (transName in transitions) {
672
- transitions[transName] = {
673
- ...transitions[transName],
674
- roles: allowedRoles,
675
- };
676
- }
677
- }
678
- }
679
-
680
- return {
681
- ...def,
682
- roles: mergeRoles(def.roles, newRoles),
683
- transitions,
684
- };
685
- };
686
- }
687
-
688
- // =============================================================================
689
- // withTimestamps
690
- // =============================================================================
691
-
692
- /**
693
- * Adds simple timestamp fields (`createdAt`, `updatedAt`) with auto-set logic.
694
- *
695
- * This is a lighter alternative to {@link withAuditTrail} when you only need
696
- * timestamps without `createdBy`/`updatedBy` tracking.
697
- *
698
- * Injects:
699
- * - `createdAt` (datetime) — set on initial state entry
700
- * - `updatedAt` (datetime) — set on every transition
701
- *
702
- * @returns A {@link ModelMixin} function.
703
- *
704
- * @example
705
- * ```typescript
706
- * import { defineModel } from '@mindmatrix/react';
707
- * import { pipe, withTimestamps } from '@mindmatrix/react/mixins';
708
- *
709
- * export default defineModel(pipe(
710
- * {
711
- * slug: 'note',
712
- * fields: { body: { type: 'string' } },
713
- * states: { active: { type: 'initial' } },
714
- * transitions: {},
715
- * },
716
- * withTimestamps(),
717
- * ));
718
- * ```
719
- */
720
- export function withTimestamps(): ModelMixin {
721
- return (def: ModelDefinition): ModelDefinition => {
722
- const timestampFields: Record<string, WorkflowFieldDescriptor> = {
723
- createdAt: { type: 'datetime', label: 'Created At' },
724
- updatedAt: { type: 'datetime', label: 'Updated At' },
725
- };
726
-
727
- // Set createdAt on initial state entry
728
- const states: Record<string, StateDescriptor> = {};
729
- for (const [name, s] of Object.entries(def.states)) {
730
- if (s.type === 'initial') {
731
- states[name] = {
732
- ...s,
733
- onEnter: [
734
- ...(s.onEnter ?? []),
735
- {
736
- id: '_timestamps_create',
737
- type: 'set_fields',
738
- mode: 'auto' as const,
739
- config: {
740
- fields: {
741
- createdAt: { expression: 'NOW()' },
742
- updatedAt: { expression: 'NOW()' },
743
- },
744
- },
745
- },
746
- ],
747
- };
748
- } else {
749
- states[name] = { ...s };
750
- }
751
- }
752
-
753
- // Set updatedAt on every transition
754
- const transitions: Record<string, TransitionDescriptor> = {};
755
- const updateAction: ActionDefinition = {
756
- id: '_timestamps_update',
757
- type: 'set_fields',
758
- mode: 'auto',
759
- config: {
760
- fields: {
761
- updatedAt: { expression: 'NOW()' },
762
- },
763
- },
764
- };
765
- for (const [name, t] of Object.entries(def.transitions)) {
766
- transitions[name] = {
767
- ...t,
768
- actions: [...(t.actions ?? []), updateAction],
769
- };
770
- }
771
-
772
- return {
773
- ...def,
774
- fields: mergeFields(def.fields, timestampFields),
775
- states: mergeStates(def.states, states),
776
- transitions,
777
- };
778
- };
779
- }
780
-
781
- // =============================================================================
782
- // withSlug
783
- // =============================================================================
784
-
785
- /**
786
- * Options for the {@link withSlug} mixin.
787
- */
788
- export interface WithSlugOptions {
789
- /**
790
- * The field name to derive the slug from (e.g., `'title'`, `'name'`).
791
- */
792
- sourceField: string;
793
- }
794
-
795
- /**
796
- * Adds an auto-generated `slug` field derived from a source field.
797
- *
798
- * The slug is computed as a kebab-case, URL-safe version of the source field.
799
- * It is set via a computed expression.
800
- *
801
- * Injects:
802
- * - `slug` (string) — computed from the source field
803
- *
804
- * @param options - Configuration specifying the source field.
805
- * @returns A {@link ModelMixin} function.
806
- *
807
- * @example
808
- * ```typescript
809
- * import { defineModel } from '@mindmatrix/react';
810
- * import { pipe, withSlug } from '@mindmatrix/react/mixins';
811
- *
812
- * export default defineModel(pipe(
813
- * {
814
- * slug: 'article',
815
- * fields: { title: { type: 'string', required: true } },
816
- * states: { draft: { type: 'initial' }, published: { type: 'end' } },
817
- * transitions: { publish: { from: 'draft', to: 'published' } },
818
- * },
819
- * withSlug({ sourceField: 'title' }),
820
- * ));
821
- * ```
822
- */
823
- export function withSlug(options: WithSlugOptions): ModelMixin {
824
- return (def: ModelDefinition): ModelDefinition => {
825
- const slugField: Record<string, WorkflowFieldDescriptor> = {
826
- slug: {
827
- type: 'string',
828
- label: 'Slug',
829
- description: `Auto-generated from ${options.sourceField}`,
830
- computed: `SLUGIFY(state_data.${options.sourceField})`,
831
- computedDeps: [options.sourceField],
832
- },
833
- };
834
-
835
- return {
836
- ...def,
837
- fields: mergeFields(def.fields, slugField),
838
- };
839
- };
840
- }
841
-
842
- // =============================================================================
843
- // withOwnership
844
- // =============================================================================
845
-
846
- /**
847
- * Options for the {@link withOwnership} mixin.
848
- */
849
- export interface WithOwnershipOptions {
850
- /**
851
- * Field name for the owner ID. Default: `'ownerId'`.
852
- */
853
- ownerField?: string;
854
- }
855
-
856
- /**
857
- * Adds ownership fields auto-set from context on creation.
858
- *
859
- * Injects:
860
- * - `ownerId` (string, or custom name) — set to `context.actor_id` on initial state entry
861
- * - `ownerName` (string) — set to `context.actor_name` on initial state entry
862
- *
863
- * @param options - Optional configuration.
864
- * @returns A {@link ModelMixin} function.
865
- *
866
- * @example
867
- * ```typescript
868
- * import { defineModel } from '@mindmatrix/react';
869
- * import { pipe, withOwnership } from '@mindmatrix/react/mixins';
870
- *
871
- * export default defineModel(pipe(
872
- * {
873
- * slug: 'project',
874
- * fields: { name: { type: 'string' } },
875
- * states: { active: { type: 'initial' }, completed: { type: 'end' } },
876
- * transitions: { complete: { from: 'active', to: 'completed' } },
877
- * },
878
- * withOwnership(),
879
- * ));
880
- * ```
881
- */
882
- export function withOwnership(options: WithOwnershipOptions = {}): ModelMixin {
883
- const ownerField = options.ownerField ?? 'ownerId';
884
-
885
- return (def: ModelDefinition): ModelDefinition => {
886
- const ownerFields: Record<string, WorkflowFieldDescriptor> = {
887
- [ownerField]: { type: 'string', label: 'Owner ID' },
888
- ownerName: { type: 'string', label: 'Owner Name' },
889
- };
890
-
891
- // Set ownership fields on initial state entry
892
- const states: Record<string, StateDescriptor> = {};
893
- for (const [name, s] of Object.entries(def.states)) {
894
- if (s.type === 'initial') {
895
- states[name] = {
896
- ...s,
897
- onEnter: [
898
- ...(s.onEnter ?? []),
899
- {
900
- id: '_ownership_set',
901
- type: 'set_fields',
902
- mode: 'auto' as const,
903
- config: {
904
- fields: {
905
- [ownerField]: { expression: 'context.actor_id' },
906
- ownerName: { expression: 'context.actor_name' },
907
- },
908
- },
909
- },
910
- ],
911
- };
912
- } else {
913
- states[name] = { ...s };
914
- }
915
- }
916
-
917
- return {
918
- ...def,
919
- fields: mergeFields(def.fields, ownerFields),
920
- states: mergeStates(def.states, states),
921
- };
922
- };
923
- }
924
-
925
- // =============================================================================
926
- // withTags
927
- // =============================================================================
928
-
929
- /**
930
- * Adds a `tags` array field with `add_tag` and `remove_tag` self-transitions.
931
- *
932
- * The self-transitions are available from all non-terminal states, allowing
933
- * tags to be managed at any point in the workflow lifecycle.
934
- *
935
- * Injects:
936
- * - `tags` (array of strings) — default empty array
937
- * - `add_tag` transition — appends a tag (self-transition)
938
- * - `remove_tag` transition — removes a tag (self-transition)
939
- *
940
- * @returns A {@link ModelMixin} function.
941
- *
942
- * @example
943
- * ```typescript
944
- * import { defineModel } from '@mindmatrix/react';
945
- * import { pipe, withTags } from '@mindmatrix/react/mixins';
946
- *
947
- * export default defineModel(pipe(
948
- * {
949
- * slug: 'ticket',
950
- * fields: { title: { type: 'string' } },
951
- * states: { open: { type: 'initial' }, closed: { type: 'end' } },
952
- * transitions: { close: { from: 'open', to: 'closed' } },
953
- * },
954
- * withTags(),
955
- * ));
956
- * ```
957
- */
958
- export function withTags(): ModelMixin {
959
- return (def: ModelDefinition): ModelDefinition => {
960
- const tagFields: Record<string, WorkflowFieldDescriptor> = {
961
- tags: {
962
- type: 'array',
963
- items: { type: 'string' },
964
- default: [],
965
- label: 'Tags',
966
- },
967
- };
968
-
969
- const nonTerminalStates = getNonTerminalStates(def);
970
-
971
- const tagTransitions: Record<string, TransitionDescriptor> = {};
972
-
973
- if (nonTerminalStates.length > 0) {
974
- tagTransitions.add_tag = {
975
- from: nonTerminalStates,
976
- to: nonTerminalStates[0],
977
- description: 'Add a tag',
978
- actions: [
979
- {
980
- id: '_tag_add',
981
- type: 'set_field',
982
- mode: 'auto',
983
- config: {
984
- field: 'tags',
985
- expression: 'APPEND(state_data.tags, input.tag)',
986
- },
987
- },
988
- ],
989
- };
990
-
991
- tagTransitions.remove_tag = {
992
- from: nonTerminalStates,
993
- to: nonTerminalStates[0],
994
- description: 'Remove a tag',
995
- actions: [
996
- {
997
- id: '_tag_remove',
998
- type: 'set_field',
999
- mode: 'auto',
1000
- config: {
1001
- field: 'tags',
1002
- expression: 'FILTER(state_data.tags, item != input.tag)',
1003
- },
1004
- },
1005
- ],
1006
- };
1007
- }
1008
-
1009
- return {
1010
- ...def,
1011
- fields: mergeFields(def.fields, tagFields),
1012
- transitions: mergeTransitions(def.transitions, tagTransitions),
1013
- };
1014
- };
1015
- }
1016
-
1017
- // =============================================================================
1018
- // withSearch
1019
- // =============================================================================
1020
-
1021
- /**
1022
- * Options for the {@link withSearch} mixin.
1023
- */
1024
- export interface WithSearchOptions {
1025
- /**
1026
- * Field names to include in the full-text search index.
1027
- * These fields are concatenated into a single `searchText` computed field.
1028
- *
1029
- * @example `{ fields: ['title', 'description', 'content'] }`
1030
- */
1031
- fields: string[];
1032
- }
1033
-
1034
- /**
1035
- * Adds a `searchText` computed field that concatenates specified fields
1036
- * for full-text search indexing.
1037
- *
1038
- * Injects:
1039
- * - `searchText` (string, computed) — concatenation of the specified fields
1040
- *
1041
- * @param options - Configuration specifying which fields to index.
1042
- * @returns A {@link ModelMixin} function.
1043
- *
1044
- * @example
1045
- * ```typescript
1046
- * import { defineModel } from '@mindmatrix/react';
1047
- * import { pipe, withSearch } from '@mindmatrix/react/mixins';
1048
- *
1049
- * export default defineModel(pipe(
1050
- * {
1051
- * slug: 'article',
1052
- * fields: {
1053
- * title: { type: 'string' },
1054
- * body: { type: 'string' },
1055
- * author: { type: 'string' },
1056
- * },
1057
- * states: { draft: { type: 'initial' } },
1058
- * transitions: {},
1059
- * },
1060
- * withSearch({ fields: ['title', 'body', 'author'] }),
1061
- * ));
1062
- * ```
1063
- */
1064
- export function withSearch(options: WithSearchOptions): ModelMixin {
1065
- return (def: ModelDefinition): ModelDefinition => {
1066
- const concatExpr = options.fields
1067
- .map((f) => `COALESCE(state_data.${f}, "")`)
1068
- .join(' + " " + ');
1069
-
1070
- const searchFields: Record<string, WorkflowFieldDescriptor> = {
1071
- searchText: {
1072
- type: 'string',
1073
- label: 'Search Text',
1074
- description: `Full-text search index over: ${options.fields.join(', ')}`,
1075
- computed: concatExpr,
1076
- computedDeps: options.fields,
1077
- },
1078
- };
1079
-
1080
- return {
1081
- ...def,
1082
- fields: mergeFields(def.fields, searchFields),
1083
- };
1084
- };
1085
- }
1086
-
1087
- // =============================================================================
1088
- // withPagination
1089
- // =============================================================================
1090
-
1091
- /**
1092
- * Adds pagination and sorting fields for collection querying patterns.
1093
- *
1094
- * These fields are typically used by views and data grids to control
1095
- * how records are fetched and displayed. They live at the ephemeral scope
1096
- * (not persisted to the database).
1097
- *
1098
- * Injects:
1099
- * - `sortField` (string, default `'createdAt'`) — field name to sort by
1100
- * - `sortOrder` (string, default `'desc'`) — sort direction (`'asc'` or `'desc'`)
1101
- * - `page` (number, default 1) — current page number
1102
- * - `pageSize` (number, default 25) — records per page
1103
- *
1104
- * @returns A {@link ModelMixin} function.
1105
- *
1106
- * @example
1107
- * ```typescript
1108
- * import { defineModel } from '@mindmatrix/react';
1109
- * import { pipe, withPagination, withTimestamps } from '@mindmatrix/react/mixins';
1110
- *
1111
- * export default defineModel(pipe(
1112
- * {
1113
- * slug: 'product-catalog',
1114
- * fields: { name: { type: 'string' }, price: { type: 'currency' } },
1115
- * states: { active: { type: 'initial' } },
1116
- * transitions: {},
1117
- * },
1118
- * withTimestamps(),
1119
- * withPagination(),
1120
- * ));
1121
- * ```
1122
- */
1123
- export function withPagination(): ModelMixin {
1124
- return (def: ModelDefinition): ModelDefinition => {
1125
- const paginationFields: Record<string, WorkflowFieldDescriptor> = {
1126
- sortField: {
1127
- type: 'string',
1128
- default: 'createdAt',
1129
- label: 'Sort Field',
1130
- stateHome: { scope: 'ephemeral', persistence: 'none' },
1131
- },
1132
- sortOrder: {
1133
- type: 'string',
1134
- default: 'desc',
1135
- enum: ['asc', 'desc'],
1136
- label: 'Sort Order',
1137
- stateHome: { scope: 'ephemeral', persistence: 'none' },
1138
- },
1139
- page: {
1140
- type: 'number',
1141
- default: 1,
1142
- label: 'Page',
1143
- validation: { min: 1 },
1144
- stateHome: { scope: 'ephemeral', persistence: 'none' },
1145
- },
1146
- pageSize: {
1147
- type: 'number',
1148
- default: 25,
1149
- label: 'Page Size',
1150
- validation: { min: 1, max: 100 },
1151
- stateHome: { scope: 'ephemeral', persistence: 'none' },
1152
- },
1153
- };
1154
-
1155
- return {
1156
- ...def,
1157
- fields: mergeFields(def.fields, paginationFields),
1158
- };
1159
- };
1160
- }