@directive-run/claude-plugin 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/LICENSE +26 -0
  3. package/README.md +113 -0
  4. package/dist/index.cjs +2 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +66 -0
  7. package/dist/index.d.ts +66 -0
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -0
  10. package/package.json +64 -0
  11. package/skills/README.md +9 -0
  12. package/skills/building-ai-agents/SKILL.md +414 -0
  13. package/skills/building-ai-agents/ai-adapters.md +257 -0
  14. package/skills/building-ai-agents/ai-agents-streaming.md +307 -0
  15. package/skills/building-ai-agents/ai-communication.md +304 -0
  16. package/skills/building-ai-agents/api-skeleton.md +5 -0
  17. package/skills/building-ai-agents/examples.md +849 -0
  18. package/skills/building-ai-orchestrators/SKILL.md +401 -0
  19. package/skills/building-ai-orchestrators/ai-multi-agent.md +375 -0
  20. package/skills/building-ai-orchestrators/ai-orchestrator.md +466 -0
  21. package/skills/building-ai-orchestrators/ai-tasks.md +268 -0
  22. package/skills/building-ai-orchestrators/api-skeleton.md +5 -0
  23. package/skills/building-ai-orchestrators/examples.md +1609 -0
  24. package/skills/building-directive-systems/SKILL.md +499 -0
  25. package/skills/building-directive-systems/api-skeleton.md +5 -0
  26. package/skills/building-directive-systems/examples.md +4149 -0
  27. package/skills/building-directive-systems/multi-module.md +327 -0
  28. package/skills/building-directive-systems/plugins.md +361 -0
  29. package/skills/building-directive-systems/react-adapter.md +376 -0
  30. package/skills/building-directive-systems/system-api.md +463 -0
  31. package/skills/getting-started-with-directive/SKILL.md +198 -0
  32. package/skills/getting-started-with-directive/api-skeleton.md +5 -0
  33. package/skills/getting-started-with-directive/core-patterns.md +240 -0
  34. package/skills/getting-started-with-directive/examples.md +87 -0
  35. package/skills/getting-started-with-directive/sitemap.md +202 -0
  36. package/skills/hardening-ai-systems/SKILL.md +377 -0
  37. package/skills/hardening-ai-systems/ai-budget-resilience.md +408 -0
  38. package/skills/hardening-ai-systems/ai-guardrails-memory.md +370 -0
  39. package/skills/hardening-ai-systems/ai-security.md +400 -0
  40. package/skills/hardening-ai-systems/api-skeleton.md +5 -0
  41. package/skills/hardening-ai-systems/examples.md +628 -0
  42. package/skills/migrating-to-directive/SKILL.md +249 -0
  43. package/skills/migrating-to-directive/anti-patterns.md +382 -0
  44. package/skills/migrating-to-directive/api-skeleton.md +5 -0
  45. package/skills/migrating-to-directive/core-patterns.md +240 -0
  46. package/skills/migrating-to-directive/examples.md +511 -0
  47. package/skills/migrating-to-directive/schema-types.md +273 -0
  48. package/skills/reviewing-directive-code/SKILL.md +232 -0
  49. package/skills/reviewing-directive-code/anti-patterns.md +382 -0
  50. package/skills/reviewing-directive-code/api-skeleton.md +5 -0
  51. package/skills/reviewing-directive-code/core-patterns.md +240 -0
  52. package/skills/reviewing-directive-code/examples.md +463 -0
  53. package/skills/reviewing-directive-code/naming.md +321 -0
  54. package/skills/scaffolding-directive-modules/SKILL.md +218 -0
  55. package/skills/scaffolding-directive-modules/api-skeleton.md +5 -0
  56. package/skills/scaffolding-directive-modules/core-patterns.md +240 -0
  57. package/skills/scaffolding-directive-modules/examples.md +1386 -0
  58. package/skills/scaffolding-directive-modules/naming.md +321 -0
  59. package/skills/scaffolding-directive-modules/schema-types.md +273 -0
  60. package/skills/testing-ai-systems/SKILL.md +398 -0
  61. package/skills/testing-ai-systems/ai-debug-observability.md +329 -0
  62. package/skills/testing-ai-systems/ai-mcp-rag.md +293 -0
  63. package/skills/testing-ai-systems/ai-testing-evals.md +485 -0
  64. package/skills/testing-ai-systems/api-skeleton.md +5 -0
  65. package/skills/testing-ai-systems/examples.md +1261 -0
  66. package/skills/testing-directive-code/SKILL.md +479 -0
  67. package/skills/testing-directive-code/api-skeleton.md +5 -0
  68. package/skills/testing-directive-code/examples.md +156 -0
  69. package/skills/testing-directive-code/history.md +351 -0
  70. package/skills/testing-directive-code/testing.md +412 -0
  71. package/skills/writing-directive-constraints/SKILL.md +486 -0
  72. package/skills/writing-directive-constraints/api-skeleton.md +5 -0
  73. package/skills/writing-directive-constraints/constraints.md +274 -0
  74. package/skills/writing-directive-constraints/error-boundaries.md +330 -0
  75. package/skills/writing-directive-constraints/examples.md +1361 -0
  76. package/skills/writing-directive-constraints/resolvers.md +367 -0
  77. package/skills/writing-directive-modules/SKILL.md +342 -0
  78. package/skills/writing-directive-modules/anti-patterns.md +382 -0
  79. package/skills/writing-directive-modules/api-skeleton.md +5 -0
  80. package/skills/writing-directive-modules/core-patterns.md +240 -0
  81. package/skills/writing-directive-modules/examples.md +1639 -0
  82. package/skills/writing-directive-modules/naming.md +321 -0
  83. package/skills/writing-directive-modules/schema-types.md +273 -0
@@ -0,0 +1,4149 @@
1
+ # Examples
2
+
3
+ > Auto-generated from extracted examples. Do not edit manually.
4
+
5
+ ## dynamic-modules
6
+
7
+ ```typescript
8
+ // Example: dynamic-modules
9
+ // Source: examples/dynamic-modules/src/modules.ts
10
+ // Pure module file — no DOM wiring
11
+
12
+ /**
13
+ * Dynamic Modules — Directive Module Definitions
14
+ *
15
+ * Dashboard module (always loaded) + 3 dynamic modules (Counter, Weather, Dice).
16
+ * Demonstrates runtime module registration, namespaced fact access,
17
+ * constraints, resolvers, and derivations across independent modules.
18
+ */
19
+
20
+ import { type ModuleSchema, createModule, t } from "@directive-run/core";
21
+ import { mockFetchWeather } from "./mock-weather.js";
22
+
23
+ // ============================================================================
24
+ // Types
25
+ // ============================================================================
26
+
27
+ export interface EventLogEntry {
28
+ timestamp: number;
29
+ event: string;
30
+ detail: string;
31
+ }
32
+
33
+ // ============================================================================
34
+ // Helpers
35
+ // ============================================================================
36
+
37
+ function addLogEntry(facts: any, event: string, detail: string): void {
38
+ const log = [...facts.eventLog];
39
+ log.push({ timestamp: Date.now(), event, detail });
40
+ if (log.length > 50) {
41
+ log.splice(0, log.length - 50);
42
+ }
43
+ facts.eventLog = log;
44
+ }
45
+
46
+ // ============================================================================
47
+ // Dashboard Module (core, always loaded)
48
+ // ============================================================================
49
+
50
+ export const dashboardSchema = {
51
+ facts: {
52
+ loadedModules: t.array<string>(),
53
+ eventLog: t.array<EventLogEntry>(),
54
+ },
55
+ derivations: {
56
+ loadedCount: t.number(),
57
+ },
58
+ events: {
59
+ moduleLoaded: { name: t.string() },
60
+ },
61
+ requirements: {},
62
+ } satisfies ModuleSchema;
63
+
64
+ export const dashboardModule = createModule("dashboard", {
65
+ schema: dashboardSchema,
66
+
67
+ init: (facts) => {
68
+ facts.loadedModules = [];
69
+ facts.eventLog = [];
70
+ },
71
+
72
+ derive: {
73
+ loadedCount: (facts) => facts.loadedModules.length,
74
+ },
75
+
76
+ events: {
77
+ moduleLoaded: (facts, { name }) => {
78
+ facts.loadedModules = [...facts.loadedModules, name];
79
+ addLogEntry(facts, "loaded", `Loaded "${name}" module`);
80
+ },
81
+ },
82
+ });
83
+
84
+ // ============================================================================
85
+ // Counter Module (dynamic)
86
+ // ============================================================================
87
+
88
+ export const counterSchema = {
89
+ facts: {
90
+ count: t.number(),
91
+ step: t.number(),
92
+ },
93
+ derivations: {
94
+ isNearMax: t.boolean(),
95
+ },
96
+ events: {
97
+ increment: {},
98
+ decrement: {},
99
+ setStep: { value: t.number() },
100
+ },
101
+ requirements: {
102
+ COUNTER_RESET: {},
103
+ },
104
+ } satisfies ModuleSchema;
105
+
106
+ export const counterModule = createModule("counter", {
107
+ schema: counterSchema,
108
+
109
+ init: (facts) => {
110
+ facts.count = 0;
111
+ facts.step = 1;
112
+ },
113
+
114
+ derive: {
115
+ isNearMax: (facts) => facts.count >= 90,
116
+ },
117
+
118
+ events: {
119
+ increment: (facts) => {
120
+ facts.count = facts.count + facts.step;
121
+ },
122
+ decrement: (facts) => {
123
+ facts.count = Math.max(0, facts.count - facts.step);
124
+ },
125
+ setStep: (facts, { value }) => {
126
+ facts.step = value;
127
+ },
128
+ },
129
+
130
+ constraints: {
131
+ overflow: {
132
+ priority: 100,
133
+ when: (facts) => facts.count >= 100,
134
+ require: () => ({ type: "COUNTER_RESET" }),
135
+ },
136
+ },
137
+
138
+ resolvers: {
139
+ counterReset: {
140
+ requirement: "COUNTER_RESET",
141
+ resolve: async (_req, context) => {
142
+ context.facts.count = 0;
143
+ },
144
+ },
145
+ },
146
+ });
147
+
148
+ // ============================================================================
149
+ // Weather Module (dynamic)
150
+ // ============================================================================
151
+
152
+ export const weatherSchema = {
153
+ facts: {
154
+ city: t.string(),
155
+ temperature: t.number(),
156
+ condition: t.string(),
157
+ humidity: t.number(),
158
+ isLoading: t.boolean(),
159
+ lastFetchedCity: t.string(),
160
+ },
161
+ derivations: {
162
+ summary: t.string(),
163
+ hasFetched: t.boolean(),
164
+ },
165
+ events: {
166
+ setCity: { value: t.string() },
167
+ refresh: {},
168
+ },
169
+ requirements: {
170
+ FETCH_WEATHER: {
171
+ city: t.string(),
172
+ },
173
+ },
174
+ } satisfies ModuleSchema;
175
+
176
+ export const weatherModule = createModule("weather", {
177
+ schema: weatherSchema,
178
+
179
+ init: (facts) => {
180
+ facts.city = "";
181
+ facts.temperature = 0;
182
+ facts.condition = "";
183
+ facts.humidity = 0;
184
+ facts.isLoading = false;
185
+ facts.lastFetchedCity = "";
186
+ },
187
+
188
+ derive: {
189
+ summary: (facts) => {
190
+ if (facts.city === "") {
191
+ return "";
192
+ }
193
+
194
+ return `${facts.temperature}\u00B0F, ${facts.condition}`;
195
+ },
196
+ hasFetched: (facts) => facts.lastFetchedCity !== "",
197
+ },
198
+
199
+ events: {
200
+ setCity: (facts, { value }) => {
201
+ facts.city = value;
202
+ },
203
+ refresh: (facts) => {
204
+ facts.lastFetchedCity = "";
205
+ },
206
+ },
207
+
208
+ constraints: {
209
+ needsFetch: {
210
+ priority: 100,
211
+ when: (facts) =>
212
+ facts.city.length >= 2 &&
213
+ facts.city !== facts.lastFetchedCity &&
214
+ !facts.isLoading,
215
+ require: (facts) => ({
216
+ type: "FETCH_WEATHER",
217
+ city: facts.city,
218
+ }),
219
+ },
220
+ },
221
+
222
+ resolvers: {
223
+ fetchWeather: {
224
+ requirement: "FETCH_WEATHER",
225
+ key: (req) => `weather-${req.city}`,
226
+ timeout: 10000,
227
+ resolve: async (req, context) => {
228
+ context.facts.isLoading = true;
229
+
230
+ const data = await mockFetchWeather(req.city, 800);
231
+
232
+ // Stale check: only apply if city still matches
233
+ if (context.facts.city === req.city) {
234
+ context.facts.temperature = data.temperature;
235
+ context.facts.condition = data.condition;
236
+ context.facts.humidity = data.humidity;
237
+ context.facts.lastFetchedCity = req.city;
238
+ }
239
+
240
+ context.facts.isLoading = false;
241
+ },
242
+ },
243
+ },
244
+ });
245
+
246
+ // ============================================================================
247
+ // Dice Module (dynamic)
248
+ // ============================================================================
249
+
250
+ export const diceSchema = {
251
+ facts: {
252
+ die1: t.number(),
253
+ die2: t.number(),
254
+ rollCount: t.number(),
255
+ },
256
+ derivations: {
257
+ total: t.number(),
258
+ isDoubles: t.boolean(),
259
+ },
260
+ events: {
261
+ roll: {},
262
+ },
263
+ requirements: {},
264
+ } satisfies ModuleSchema;
265
+
266
+ export const diceModule = createModule("dice", {
267
+ schema: diceSchema,
268
+
269
+ init: (facts) => {
270
+ facts.die1 = 1;
271
+ facts.die2 = 1;
272
+ facts.rollCount = 0;
273
+ },
274
+
275
+ derive: {
276
+ total: (facts) => facts.die1 + facts.die2,
277
+ isDoubles: (facts) => facts.die1 === facts.die2,
278
+ },
279
+
280
+ events: {
281
+ roll: (facts) => {
282
+ facts.die1 = Math.floor(Math.random() * 6) + 1;
283
+ facts.die2 = Math.floor(Math.random() * 6) + 1;
284
+ facts.rollCount = facts.rollCount + 1;
285
+ },
286
+ },
287
+ });
288
+
289
+ // ============================================================================
290
+ // Module Registry
291
+ // ============================================================================
292
+
293
+ export const moduleRegistry: Record<string, { module: any; label: string }> = {
294
+ counter: { module: counterModule, label: "Counter" },
295
+ weather: { module: weatherModule, label: "Weather" },
296
+ dice: { module: diceModule, label: "Dice" },
297
+ };
298
+ ```
299
+
300
+ ## theme-locale
301
+
302
+ ```typescript
303
+ // Example: theme-locale
304
+ // Source: examples/theme-locale/src/theme-locale.ts
305
+ // Pure module file — no DOM wiring
306
+
307
+ /**
308
+ * Theme & Locale — Directive Modules
309
+ *
310
+ * Two modules:
311
+ * - `preferences` — theme, locale, sidebar, translations, system dark preference
312
+ * - `layout` — responsive breakpoint tracking
313
+ *
314
+ * Demonstrates multi-module composition, auto-tracked derivations,
315
+ * effects for DOM side-effects, and persistence plugin for user prefs.
316
+ */
317
+
318
+ import { type ModuleSchema, createModule, t } from "@directive-run/core";
319
+
320
+ // ============================================================================
321
+ // Types
322
+ // ============================================================================
323
+
324
+ export interface Translations {
325
+ greeting: string;
326
+ settings: string;
327
+ theme: string;
328
+ language: string;
329
+ sidebar: string;
330
+ }
331
+
332
+ export type ThemeChoice = "light" | "dark" | "system";
333
+ export type Breakpoint = "mobile" | "tablet" | "desktop";
334
+
335
+ // ============================================================================
336
+ // Translation data
337
+ // ============================================================================
338
+
339
+ const TRANSLATIONS: Record<string, Translations> = {
340
+ en: {
341
+ greeting: "Hello",
342
+ settings: "Settings",
343
+ theme: "Theme",
344
+ language: "Language",
345
+ sidebar: "Sidebar",
346
+ },
347
+ es: {
348
+ greeting: "Hola",
349
+ settings: "Configuraci\u00f3n",
350
+ theme: "Tema",
351
+ language: "Idioma",
352
+ sidebar: "Barra lateral",
353
+ },
354
+ fr: {
355
+ greeting: "Bonjour",
356
+ settings: "Param\u00e8tres",
357
+ theme: "Th\u00e8me",
358
+ language: "Langue",
359
+ sidebar: "Barre lat\u00e9rale",
360
+ },
361
+ };
362
+
363
+ function getTranslations(locale: string): Translations {
364
+ return TRANSLATIONS[locale] ?? TRANSLATIONS.en;
365
+ }
366
+
367
+ // ============================================================================
368
+ // Preferences Schema
369
+ // ============================================================================
370
+
371
+ export const preferencesSchema = {
372
+ facts: {
373
+ theme: t.string<ThemeChoice>(),
374
+ locale: t.string(),
375
+ sidebarOpen: t.boolean(),
376
+ systemPrefersDark: t.boolean(),
377
+ loadedLocale: t.string(),
378
+ translations: t.object<Translations>(),
379
+ },
380
+ derivations: {
381
+ effectiveTheme: t.string(),
382
+ isRTL: t.boolean(),
383
+ },
384
+ events: {
385
+ setTheme: { value: t.string() },
386
+ setLocale: { value: t.string() },
387
+ toggleSidebar: {},
388
+ setSystemPreference: { value: t.boolean() },
389
+ },
390
+ requirements: {},
391
+ } satisfies ModuleSchema;
392
+
393
+ // ============================================================================
394
+ // Layout Schema
395
+ // ============================================================================
396
+
397
+ export const layoutSchema = {
398
+ facts: {
399
+ breakpoint: t.string<Breakpoint>(),
400
+ },
401
+ derivations: {},
402
+ events: {
403
+ setBreakpoint: { value: t.string() },
404
+ },
405
+ requirements: {},
406
+ } satisfies ModuleSchema;
407
+
408
+ // ============================================================================
409
+ // Preferences Module
410
+ // ============================================================================
411
+
412
+ export const preferencesModule = createModule("preferences", {
413
+ schema: preferencesSchema,
414
+
415
+ init: (facts) => {
416
+ facts.theme = "system";
417
+ facts.locale = "en";
418
+ facts.sidebarOpen = true;
419
+ facts.systemPrefersDark = false;
420
+ facts.loadedLocale = "en";
421
+ facts.translations = getTranslations("en");
422
+ },
423
+
424
+ // ============================================================================
425
+ // Derivations
426
+ // ============================================================================
427
+
428
+ derive: {
429
+ effectiveTheme: (facts) => {
430
+ if (facts.theme === "system") {
431
+ return facts.systemPrefersDark ? "dark" : "light";
432
+ }
433
+
434
+ return facts.theme;
435
+ },
436
+
437
+ isRTL: (facts) => {
438
+ const rtlLocales = ["ar", "he", "fa", "ur"];
439
+
440
+ return rtlLocales.includes(facts.locale);
441
+ },
442
+ },
443
+
444
+ // ============================================================================
445
+ // Events
446
+ // ============================================================================
447
+
448
+ events: {
449
+ setTheme: (facts, { value }) => {
450
+ facts.theme = value as ThemeChoice;
451
+ },
452
+
453
+ setLocale: (facts, { value }) => {
454
+ facts.locale = value;
455
+ facts.loadedLocale = value;
456
+ facts.translations = getTranslations(value);
457
+ },
458
+
459
+ toggleSidebar: (facts) => {
460
+ facts.sidebarOpen = !facts.sidebarOpen;
461
+ },
462
+
463
+ setSystemPreference: (facts, { value }) => {
464
+ facts.systemPrefersDark = value;
465
+ },
466
+ },
467
+
468
+ // ============================================================================
469
+ // Effects
470
+ // ============================================================================
471
+
472
+ effects: {
473
+ applyTheme: {
474
+ run: (facts) => {
475
+ const effective =
476
+ facts.theme === "system"
477
+ ? facts.systemPrefersDark
478
+ ? "dark"
479
+ : "light"
480
+ : facts.theme;
481
+ document.documentElement.setAttribute("data-theme", effective);
482
+ },
483
+ },
484
+ },
485
+ });
486
+
487
+ // ============================================================================
488
+ // Layout Module
489
+ // ============================================================================
490
+
491
+ export const layoutModule = createModule("layout", {
492
+ schema: layoutSchema,
493
+
494
+ init: (facts) => {
495
+ facts.breakpoint = "desktop";
496
+ },
497
+
498
+ events: {
499
+ setBreakpoint: (facts, { value }) => {
500
+ facts.breakpoint = value as Breakpoint;
501
+ },
502
+ },
503
+ });
504
+ ```
505
+
506
+ ## permissions
507
+
508
+ ```typescript
509
+ // Example: permissions
510
+ // Source: examples/permissions/src/permissions.ts
511
+ // Pure module file — no DOM wiring
512
+
513
+ /**
514
+ * Role-Based Permissions — Directive Modules
515
+ *
516
+ * Three modules demonstrate cross-module constraint resolution:
517
+ * - auth: manages login state (role, userName, token)
518
+ * - permissions: loads permissions based on auth role, derives capability flags
519
+ * - content: manages articles with permission-gated actions
520
+ *
521
+ * The system uses `crossModuleDeps` so constraints in one module
522
+ * can react to derivations/facts from another module.
523
+ */
524
+
525
+ import {
526
+ type ModuleSchema,
527
+ createModule,
528
+ createSystem,
529
+ t,
530
+ } from "@directive-run/core";
531
+ import { devtoolsPlugin } from "@directive-run/core/plugins";
532
+ import {
533
+ type Article,
534
+ deleteArticle as apiDeleteArticle,
535
+ fetchArticles as apiFetchArticles,
536
+ fetchPermissions as apiFetchPermissions,
537
+ publishArticle as apiPublishArticle,
538
+ } from "./mock-api.js";
539
+
540
+ // ============================================================================
541
+ // Preset Users
542
+ // ============================================================================
543
+
544
+ const presetUsers: Record<
545
+ string,
546
+ { userName: string; role: string; token: string }
547
+ > = {
548
+ alice: { userName: "Alice", role: "admin", token: "tok-alice-admin" },
549
+ bob: { userName: "Bob", role: "editor", token: "tok-bob-editor" },
550
+ carol: { userName: "Carol", role: "viewer", token: "tok-carol-viewer" },
551
+ };
552
+
553
+ // ============================================================================
554
+ // Auth Module
555
+ // ============================================================================
556
+
557
+ export const authSchema = {
558
+ facts: {
559
+ role: t.string(),
560
+ userName: t.string(),
561
+ token: t.string(),
562
+ },
563
+ derivations: {
564
+ isAuthenticated: t.boolean(),
565
+ },
566
+ events: {
567
+ login: { userId: t.string() },
568
+ logout: {},
569
+ },
570
+ requirements: {},
571
+ } satisfies ModuleSchema;
572
+
573
+ export const authModule = createModule("auth", {
574
+ schema: authSchema,
575
+
576
+ init: (facts) => {
577
+ facts.role = "";
578
+ facts.userName = "";
579
+ facts.token = "";
580
+ },
581
+
582
+ derive: {
583
+ isAuthenticated: (facts) => facts.token !== "",
584
+ },
585
+
586
+ events: {
587
+ login: (facts, { userId }) => {
588
+ const preset = presetUsers[userId];
589
+ if (!preset) {
590
+ return;
591
+ }
592
+
593
+ facts.token = preset.token;
594
+ facts.userName = preset.userName;
595
+ facts.role = preset.role;
596
+ },
597
+
598
+ logout: (facts) => {
599
+ facts.token = "";
600
+ facts.userName = "";
601
+ facts.role = "";
602
+ },
603
+ },
604
+ });
605
+
606
+ // ============================================================================
607
+ // Permissions Module
608
+ // ============================================================================
609
+
610
+ export const permissionsSchema = {
611
+ facts: {
612
+ permissions: t.array<string>(),
613
+ loaded: t.boolean(),
614
+ },
615
+ derivations: {
616
+ canEdit: t.boolean(),
617
+ canPublish: t.boolean(),
618
+ canDelete: t.boolean(),
619
+ canManageUsers: t.boolean(),
620
+ canViewAnalytics: t.boolean(),
621
+ isAdmin: t.boolean(),
622
+ permissionCount: t.number(),
623
+ },
624
+ events: {
625
+ reset: {},
626
+ },
627
+ requirements: {
628
+ FETCH_PERMISSIONS: { role: t.string() },
629
+ },
630
+ } satisfies ModuleSchema;
631
+
632
+ export const permissionsModule = createModule("permissions", {
633
+ schema: permissionsSchema,
634
+
635
+ crossModuleDeps: { auth: authSchema },
636
+
637
+ init: (facts) => {
638
+ facts.permissions = [];
639
+ facts.loaded = false;
640
+ },
641
+
642
+ derive: {
643
+ canEdit: (facts) => facts.self.permissions.includes("content.edit"),
644
+ canPublish: (facts) => facts.self.permissions.includes("content.publish"),
645
+ canDelete: (facts) => facts.self.permissions.includes("content.delete"),
646
+ canManageUsers: (facts) => facts.self.permissions.includes("users.manage"),
647
+ canViewAnalytics: (facts) =>
648
+ facts.self.permissions.includes("analytics.view"),
649
+ isAdmin: (_facts, derived) => derived.canManageUsers,
650
+ permissionCount: (facts) => facts.self.permissions.length,
651
+ },
652
+
653
+ events: {
654
+ reset: (facts) => {
655
+ facts.permissions = [];
656
+ facts.loaded = false;
657
+ },
658
+ },
659
+
660
+ constraints: {
661
+ loadPermissions: {
662
+ when: (facts) => {
663
+ return facts.auth.token !== "" && !facts.self.loaded;
664
+ },
665
+ require: (facts) => ({
666
+ type: "FETCH_PERMISSIONS",
667
+ role: facts.auth.role,
668
+ }),
669
+ },
670
+ },
671
+
672
+ resolvers: {
673
+ fetchPermissions: {
674
+ requirement: "FETCH_PERMISSIONS",
675
+ timeout: 5000,
676
+ resolve: async (req, context) => {
677
+ const perms = await apiFetchPermissions(req.role);
678
+ context.facts.permissions = perms;
679
+ context.facts.loaded = true;
680
+ },
681
+ },
682
+ },
683
+ });
684
+
685
+ // ============================================================================
686
+ // Content Module
687
+ // ============================================================================
688
+
689
+ export const contentSchema = {
690
+ facts: {
691
+ articles: t.array<Article>(),
692
+ loaded: t.boolean(),
693
+ publishRequested: t.string(),
694
+ deleteRequested: t.string(),
695
+ actionStatus: t.string(),
696
+ },
697
+ derivations: {},
698
+ events: {
699
+ requestPublish: { articleId: t.string() },
700
+ requestDelete: { articleId: t.string() },
701
+ clearAction: {},
702
+ },
703
+ requirements: {
704
+ LOAD_CONTENT: {},
705
+ PUBLISH_ARTICLE: { articleId: t.string() },
706
+ DELETE_ARTICLE: { articleId: t.string() },
707
+ },
708
+ } satisfies ModuleSchema;
709
+
710
+ export const contentModule = createModule("content", {
711
+ schema: contentSchema,
712
+
713
+ crossModuleDeps: { auth: authSchema, permissions: permissionsSchema },
714
+
715
+ init: (facts) => {
716
+ facts.articles = [];
717
+ facts.loaded = false;
718
+ facts.publishRequested = "";
719
+ facts.deleteRequested = "";
720
+ facts.actionStatus = "idle";
721
+ },
722
+
723
+ constraints: {
724
+ loadContent: {
725
+ when: (facts) => {
726
+ return facts.auth.token !== "" && !facts.self.loaded;
727
+ },
728
+ require: { type: "LOAD_CONTENT" },
729
+ },
730
+
731
+ publishArticle: {
732
+ when: (facts) => {
733
+ return (
734
+ facts.self.publishRequested !== "" &&
735
+ facts.permissions.permissions.includes("content.publish")
736
+ );
737
+ },
738
+ require: (facts) => ({
739
+ type: "PUBLISH_ARTICLE",
740
+ articleId: facts.self.publishRequested,
741
+ }),
742
+ },
743
+
744
+ deleteArticle: {
745
+ when: (facts) => {
746
+ return (
747
+ facts.self.deleteRequested !== "" &&
748
+ facts.permissions.permissions.includes("content.delete")
749
+ );
750
+ },
751
+ require: (facts) => ({
752
+ type: "DELETE_ARTICLE",
753
+ articleId: facts.self.deleteRequested,
754
+ }),
755
+ },
756
+ },
757
+
758
+ resolvers: {
759
+ loadContent: {
760
+ requirement: "LOAD_CONTENT",
761
+ timeout: 5000,
762
+ resolve: async (_req, context) => {
763
+ const articles = await apiFetchArticles();
764
+ context.facts.articles = articles;
765
+ context.facts.loaded = true;
766
+ },
767
+ },
768
+
769
+ publishArticle: {
770
+ requirement: "PUBLISH_ARTICLE",
771
+ timeout: 5000,
772
+ resolve: async (req, context) => {
773
+ context.facts.actionStatus = "publishing";
774
+ await apiPublishArticle(req.articleId);
775
+
776
+ context.facts.articles = context.facts.articles.map((a) => {
777
+ if (a.id === req.articleId) {
778
+ return { ...a, status: "published" as const };
779
+ }
780
+
781
+ return a;
782
+ });
783
+ context.facts.publishRequested = "";
784
+ context.facts.actionStatus = "done";
785
+ },
786
+ },
787
+
788
+ deleteArticle: {
789
+ requirement: "DELETE_ARTICLE",
790
+ timeout: 5000,
791
+ resolve: async (req, context) => {
792
+ context.facts.actionStatus = "deleting";
793
+ await apiDeleteArticle(req.articleId);
794
+
795
+ context.facts.articles = context.facts.articles.filter(
796
+ (a) => a.id !== req.articleId,
797
+ );
798
+ context.facts.deleteRequested = "";
799
+ context.facts.actionStatus = "done";
800
+ },
801
+ },
802
+ },
803
+
804
+ events: {
805
+ requestPublish: (facts, { articleId }) => {
806
+ facts.publishRequested = articleId;
807
+ facts.actionStatus = "idle";
808
+ },
809
+
810
+ requestDelete: (facts, { articleId }) => {
811
+ facts.deleteRequested = articleId;
812
+ facts.actionStatus = "idle";
813
+ },
814
+
815
+ clearAction: (facts) => {
816
+ facts.publishRequested = "";
817
+ facts.deleteRequested = "";
818
+ facts.actionStatus = "idle";
819
+ },
820
+ },
821
+ });
822
+
823
+ // ============================================================================
824
+ // System
825
+ // ============================================================================
826
+
827
+ export const system = createSystem({
828
+ modules: {
829
+ auth: authModule,
830
+ permissions: permissionsModule,
831
+ content: contentModule,
832
+ },
833
+ trace: true,
834
+ plugins: [devtoolsPlugin({ name: "permissions" })],
835
+ });
836
+ ```
837
+
838
+ ## notifications
839
+
840
+ ```typescript
841
+ // Example: notifications
842
+ // Source: examples/notifications/src/notifications.ts
843
+ // Pure module file — no DOM wiring
844
+
845
+ /**
846
+ * Notifications & Toasts — Directive Modules
847
+ *
848
+ * Two modules:
849
+ * - notifications: queue management, auto-dismiss via constraints, overflow protection
850
+ * - app: action log that triggers cross-module notifications via effects
851
+ */
852
+
853
+ import { type ModuleSchema, createModule, t } from "@directive-run/core";
854
+
855
+ // ============================================================================
856
+ // Types
857
+ // ============================================================================
858
+
859
+ export interface Notification {
860
+ id: string;
861
+ message: string;
862
+ level: "info" | "success" | "warning" | "error";
863
+ createdAt: number;
864
+ ttl: number;
865
+ }
866
+
867
+ // ============================================================================
868
+ // Notifications Module
869
+ // ============================================================================
870
+
871
+ export const notificationsSchema = {
872
+ facts: {
873
+ queue: t.array<Notification>(),
874
+ maxVisible: t.number(),
875
+ now: t.number(),
876
+ idCounter: t.number(),
877
+ },
878
+ derivations: {
879
+ visibleNotifications: t.array<Notification>(),
880
+ hasNotifications: t.boolean(),
881
+ oldestExpired: t.object<Notification | null>(),
882
+ },
883
+ events: {
884
+ addNotification: {
885
+ message: t.string(),
886
+ level: t.string(),
887
+ ttl: t.number().optional(),
888
+ },
889
+ dismissNotification: { id: t.string() },
890
+ tick: {},
891
+ setMaxVisible: { value: t.number() },
892
+ },
893
+ requirements: {
894
+ DISMISS_NOTIFICATION: { id: t.string() },
895
+ },
896
+ } satisfies ModuleSchema;
897
+
898
+ export const notificationsModule = createModule("notifications", {
899
+ schema: notificationsSchema,
900
+
901
+ init: (facts) => {
902
+ facts.queue = [];
903
+ facts.maxVisible = 5;
904
+ facts.now = Date.now();
905
+ facts.idCounter = 0;
906
+ },
907
+
908
+ // ============================================================================
909
+ // Derivations
910
+ // ============================================================================
911
+
912
+ derive: {
913
+ visibleNotifications: (facts) => {
914
+ return facts.queue.slice(0, facts.maxVisible);
915
+ },
916
+
917
+ hasNotifications: (facts) => {
918
+ return facts.queue.length > 0;
919
+ },
920
+
921
+ oldestExpired: (facts) => {
922
+ const oldest = facts.queue[0];
923
+ if (!oldest) {
924
+ return null;
925
+ }
926
+
927
+ if (facts.now > oldest.createdAt + oldest.ttl) {
928
+ return oldest;
929
+ }
930
+
931
+ return null;
932
+ },
933
+ },
934
+
935
+ // ============================================================================
936
+ // Constraints
937
+ // ============================================================================
938
+
939
+ constraints: {
940
+ autoDismiss: {
941
+ priority: 50,
942
+ when: (facts) => {
943
+ const oldest = facts.queue[0];
944
+ if (!oldest) {
945
+ return false;
946
+ }
947
+
948
+ return facts.now > oldest.createdAt + oldest.ttl;
949
+ },
950
+ require: (facts) => ({
951
+ type: "DISMISS_NOTIFICATION" as const,
952
+ id: facts.queue[0].id,
953
+ }),
954
+ },
955
+
956
+ overflow: {
957
+ priority: 60,
958
+ when: (facts) => {
959
+ return facts.queue.length > facts.maxVisible + 5;
960
+ },
961
+ require: (facts) => ({
962
+ type: "DISMISS_NOTIFICATION" as const,
963
+ id: facts.queue[0].id,
964
+ }),
965
+ },
966
+ },
967
+
968
+ // ============================================================================
969
+ // Resolvers
970
+ // ============================================================================
971
+
972
+ resolvers: {
973
+ dismiss: {
974
+ requirement: "DISMISS_NOTIFICATION",
975
+ resolve: async (req, context) => {
976
+ context.facts.queue = context.facts.queue.filter(
977
+ (n) => n.id !== req.id,
978
+ );
979
+ },
980
+ },
981
+ },
982
+
983
+ // ============================================================================
984
+ // Events
985
+ // ============================================================================
986
+
987
+ events: {
988
+ addNotification: (
989
+ facts,
990
+ payload: { message: string; level: string; ttl?: number },
991
+ ) => {
992
+ const ttlMap: Record<string, number> = {
993
+ info: 4000,
994
+ success: 3000,
995
+ warning: 6000,
996
+ error: 10000,
997
+ };
998
+ const counter = facts.idCounter + 1;
999
+ facts.idCounter = counter;
1000
+
1001
+ const notification: Notification = {
1002
+ id: `notif-${counter}`,
1003
+ message: payload.message,
1004
+ level: payload.level as Notification["level"],
1005
+ createdAt: Date.now(),
1006
+ ttl: payload.ttl ?? ttlMap[payload.level] ?? 4000,
1007
+ };
1008
+
1009
+ facts.queue = [...facts.queue, notification];
1010
+ },
1011
+
1012
+ dismissNotification: (facts, { id }: { id: string }) => {
1013
+ facts.queue = facts.queue.filter((n) => n.id !== id);
1014
+ },
1015
+
1016
+ tick: (facts) => {
1017
+ facts.now = Date.now();
1018
+ },
1019
+
1020
+ setMaxVisible: (facts, { value }: { value: number }) => {
1021
+ facts.maxVisible = value;
1022
+ },
1023
+ },
1024
+ });
1025
+
1026
+ // ============================================================================
1027
+ // App Module
1028
+ // ============================================================================
1029
+
1030
+ export const appSchema = {
1031
+ facts: {
1032
+ actionLog: t.array<string>(),
1033
+ },
1034
+ events: {
1035
+ simulateAction: { message: t.string(), level: t.string() },
1036
+ },
1037
+ } satisfies ModuleSchema;
1038
+
1039
+ export const appModule = createModule("app", {
1040
+ schema: appSchema,
1041
+
1042
+ init: (facts) => {
1043
+ facts.actionLog = [];
1044
+ },
1045
+
1046
+ events: {
1047
+ simulateAction: (facts, { message }: { message: string }) => {
1048
+ facts.actionLog = [...facts.actionLog, message];
1049
+ },
1050
+ },
1051
+ });
1052
+ ```
1053
+
1054
+ ## dashboard-loader
1055
+
1056
+ ```typescript
1057
+ // Example: dashboard-loader
1058
+ // Source: examples/dashboard-loader/src/dashboard-loader.ts
1059
+ // Pure module file — no DOM wiring
1060
+
1061
+ /**
1062
+ * Dashboard Loader — Directive Module
1063
+ *
1064
+ * Demonstrates loading & error states with concurrent resource fetching,
1065
+ * configurable delays/failure rates, retry with exponential backoff,
1066
+ * and combined status derivations.
1067
+ */
1068
+
1069
+ import { type ModuleSchema, createModule, t } from "@directive-run/core";
1070
+ import {
1071
+ type Permissions,
1072
+ type Preferences,
1073
+ type Profile,
1074
+ fetchMockPermissions,
1075
+ fetchMockPreferences,
1076
+ fetchMockProfile,
1077
+ } from "./mock-api.js";
1078
+
1079
+ // ============================================================================
1080
+ // Types
1081
+ // ============================================================================
1082
+
1083
+ export type ResourceStatus = "idle" | "loading" | "success" | "error";
1084
+
1085
+ export interface ResourceState<T> {
1086
+ data: T | null;
1087
+ status: ResourceStatus;
1088
+ error: string | null;
1089
+ attempts: number;
1090
+ startedAt: number | null;
1091
+ completedAt: number | null;
1092
+ }
1093
+
1094
+ export interface EventLogEntry {
1095
+ timestamp: number;
1096
+ event: string;
1097
+ resource: string;
1098
+ detail: string;
1099
+ }
1100
+
1101
+ function makeIdleResource<T>(): ResourceState<T> {
1102
+ return {
1103
+ data: null,
1104
+ status: "idle",
1105
+ error: null,
1106
+ attempts: 0,
1107
+ startedAt: null,
1108
+ completedAt: null,
1109
+ };
1110
+ }
1111
+
1112
+ // ============================================================================
1113
+ // Schema
1114
+ // ============================================================================
1115
+
1116
+ export const dashboardLoaderSchema = {
1117
+ facts: {
1118
+ userId: t.string(),
1119
+ profile: t.object<ResourceState<Profile>>(),
1120
+ preferences: t.object<ResourceState<Preferences>>(),
1121
+ permissions: t.object<ResourceState<Permissions>>(),
1122
+ profileDelay: t.number(),
1123
+ preferencesDelay: t.number(),
1124
+ permissionsDelay: t.number(),
1125
+ profileFailRate: t.number(),
1126
+ preferencesFailRate: t.number(),
1127
+ permissionsFailRate: t.number(),
1128
+ loadRequested: t.boolean(),
1129
+ eventLog: t.array<EventLogEntry>(),
1130
+ },
1131
+ derivations: {
1132
+ loadedCount: t.number(),
1133
+ totalResources: t.number(),
1134
+ allLoaded: t.boolean(),
1135
+ anyError: t.boolean(),
1136
+ anyLoading: t.boolean(),
1137
+ combinedStatus: t.string(),
1138
+ canStart: t.boolean(),
1139
+ },
1140
+ events: {
1141
+ setUserId: { value: t.string() },
1142
+ start: {},
1143
+ retryResource: { resource: t.string() },
1144
+ reloadAll: {},
1145
+ setDelay: { resource: t.string(), value: t.number() },
1146
+ setFailRate: { resource: t.string(), value: t.number() },
1147
+ },
1148
+ requirements: {
1149
+ FETCH_PROFILE: { userId: t.string() },
1150
+ FETCH_PREFERENCES: { userId: t.string() },
1151
+ FETCH_PERMISSIONS: { userId: t.string() },
1152
+ },
1153
+ } satisfies ModuleSchema;
1154
+
1155
+ // ============================================================================
1156
+ // Helpers
1157
+ // ============================================================================
1158
+
1159
+ function addLogEntry(
1160
+ facts: any,
1161
+ event: string,
1162
+ resource: string,
1163
+ detail: string,
1164
+ ): void {
1165
+ const log = [...facts.eventLog];
1166
+ log.push({ timestamp: Date.now(), event, resource, detail });
1167
+ facts.eventLog = log;
1168
+ }
1169
+
1170
+ // ============================================================================
1171
+ // Module
1172
+ // ============================================================================
1173
+
1174
+ export const dashboardLoaderModule = createModule("dashboard-loader", {
1175
+ schema: dashboardLoaderSchema,
1176
+
1177
+ init: (facts) => {
1178
+ facts.userId = "";
1179
+ facts.profile = makeIdleResource<Profile>();
1180
+ facts.preferences = makeIdleResource<Preferences>();
1181
+ facts.permissions = makeIdleResource<Permissions>();
1182
+ facts.profileDelay = 1000;
1183
+ facts.preferencesDelay = 1500;
1184
+ facts.permissionsDelay = 2000;
1185
+ facts.profileFailRate = 0;
1186
+ facts.preferencesFailRate = 0;
1187
+ facts.permissionsFailRate = 0;
1188
+ facts.loadRequested = false;
1189
+ facts.eventLog = [];
1190
+ },
1191
+
1192
+ // ============================================================================
1193
+ // Derivations
1194
+ // ============================================================================
1195
+
1196
+ derive: {
1197
+ loadedCount: (facts) => {
1198
+ const resources = [
1199
+ facts.profile,
1200
+ facts.preferences,
1201
+ facts.permissions,
1202
+ ] as ResourceState<unknown>[];
1203
+
1204
+ return resources.filter((r) => r.status === "success").length;
1205
+ },
1206
+
1207
+ totalResources: () => 3,
1208
+
1209
+ allLoaded: (facts) => {
1210
+ const resources = [
1211
+ facts.profile,
1212
+ facts.preferences,
1213
+ facts.permissions,
1214
+ ] as ResourceState<unknown>[];
1215
+
1216
+ return resources.every((r) => r.status === "success");
1217
+ },
1218
+
1219
+ anyError: (facts) => {
1220
+ const resources = [
1221
+ facts.profile,
1222
+ facts.preferences,
1223
+ facts.permissions,
1224
+ ] as ResourceState<unknown>[];
1225
+
1226
+ return resources.some((r) => r.status === "error");
1227
+ },
1228
+
1229
+ anyLoading: (facts) => {
1230
+ const resources = [
1231
+ facts.profile,
1232
+ facts.preferences,
1233
+ facts.permissions,
1234
+ ] as ResourceState<unknown>[];
1235
+
1236
+ return resources.some((r) => r.status === "loading");
1237
+ },
1238
+
1239
+ combinedStatus: (facts, derived) => {
1240
+ const loaded = derived.loadedCount;
1241
+ const anyErr = derived.anyError;
1242
+ const anyLoad = derived.anyLoading;
1243
+ const allIdle = [
1244
+ facts.profile,
1245
+ facts.preferences,
1246
+ facts.permissions,
1247
+ ].every((r: any) => r.status === "idle");
1248
+
1249
+ if (allIdle) {
1250
+ return "Not started";
1251
+ }
1252
+
1253
+ const errCount = [
1254
+ facts.profile,
1255
+ facts.preferences,
1256
+ facts.permissions,
1257
+ ].filter((r: any) => r.status === "error").length;
1258
+
1259
+ if (anyLoad) {
1260
+ return `Loading ${loaded} of 3...`;
1261
+ }
1262
+
1263
+ if (anyErr && loaded > 0) {
1264
+ return `${errCount} failed, ${loaded} loaded`;
1265
+ }
1266
+
1267
+ if (anyErr) {
1268
+ return `${errCount} failed`;
1269
+ }
1270
+
1271
+ return "All loaded";
1272
+ },
1273
+
1274
+ canStart: (facts) => {
1275
+ const id = facts.userId.trim();
1276
+ const allIdle = [
1277
+ facts.profile,
1278
+ facts.preferences,
1279
+ facts.permissions,
1280
+ ].every((r: any) => r.status === "idle");
1281
+
1282
+ return id.length > 0 && allIdle;
1283
+ },
1284
+ },
1285
+
1286
+ // ============================================================================
1287
+ // Events
1288
+ // ============================================================================
1289
+
1290
+ events: {
1291
+ setUserId: (facts, { value }) => {
1292
+ facts.userId = value;
1293
+ },
1294
+
1295
+ start: (facts) => {
1296
+ const id = facts.userId.trim();
1297
+ if (id.length === 0) {
1298
+ return;
1299
+ }
1300
+
1301
+ // Reset all resources to idle so constraints re-fire
1302
+ facts.profile = makeIdleResource<Profile>();
1303
+ facts.preferences = makeIdleResource<Preferences>();
1304
+ facts.permissions = makeIdleResource<Permissions>();
1305
+ facts.loadRequested = true;
1306
+ facts.eventLog = [];
1307
+ },
1308
+
1309
+ retryResource: (facts, { resource }) => {
1310
+ const res = (facts as any)[resource] as ResourceState<unknown>;
1311
+ if (!res || res.status !== "error") {
1312
+ return;
1313
+ }
1314
+
1315
+ (facts as any)[resource] = {
1316
+ ...res,
1317
+ status: "idle",
1318
+ error: null,
1319
+ };
1320
+ },
1321
+
1322
+ reloadAll: (facts) => {
1323
+ facts.profile = makeIdleResource<Profile>();
1324
+ facts.preferences = makeIdleResource<Preferences>();
1325
+ facts.permissions = makeIdleResource<Permissions>();
1326
+ facts.eventLog = [];
1327
+ },
1328
+
1329
+ setDelay: (facts, { resource, value }) => {
1330
+ const key = `${resource}Delay` as keyof typeof facts;
1331
+ if (key in facts) {
1332
+ (facts as any)[key] = value;
1333
+ }
1334
+ },
1335
+
1336
+ setFailRate: (facts, { resource, value }) => {
1337
+ const key = `${resource}FailRate` as keyof typeof facts;
1338
+ if (key in facts) {
1339
+ (facts as any)[key] = value;
1340
+ }
1341
+ },
1342
+ },
1343
+
1344
+ // ============================================================================
1345
+ // Constraints
1346
+ // ============================================================================
1347
+
1348
+ constraints: {
1349
+ needsProfile: {
1350
+ priority: 100,
1351
+ when: (facts) => {
1352
+ const id = facts.userId.trim();
1353
+ const profile = facts.profile;
1354
+
1355
+ return facts.loadRequested && id !== "" && profile.status === "idle";
1356
+ },
1357
+ require: (facts) => ({
1358
+ type: "FETCH_PROFILE",
1359
+ userId: facts.userId.trim(),
1360
+ }),
1361
+ },
1362
+
1363
+ needsPreferences: {
1364
+ priority: 90,
1365
+ when: (facts) => {
1366
+ const id = facts.userId.trim();
1367
+ const prefs = facts.preferences;
1368
+
1369
+ return facts.loadRequested && id !== "" && prefs.status === "idle";
1370
+ },
1371
+ require: (facts) => ({
1372
+ type: "FETCH_PREFERENCES",
1373
+ userId: facts.userId.trim(),
1374
+ }),
1375
+ },
1376
+
1377
+ needsPermissions: {
1378
+ priority: 80,
1379
+ when: (facts) => {
1380
+ const id = facts.userId.trim();
1381
+ const perms = facts.permissions;
1382
+
1383
+ return facts.loadRequested && id !== "" && perms.status === "idle";
1384
+ },
1385
+ require: (facts) => ({
1386
+ type: "FETCH_PERMISSIONS",
1387
+ userId: facts.userId.trim(),
1388
+ }),
1389
+ },
1390
+ },
1391
+
1392
+ // ============================================================================
1393
+ // Resolvers
1394
+ // ============================================================================
1395
+
1396
+ resolvers: {
1397
+ fetchProfile: {
1398
+ requirement: "FETCH_PROFILE",
1399
+ retry: { attempts: 3, backoff: "exponential" },
1400
+ timeout: 10000,
1401
+ resolve: async (req, context) => {
1402
+ const prev = context.facts.profile;
1403
+ context.facts.profile = {
1404
+ ...prev,
1405
+ status: "loading",
1406
+ attempts: prev.attempts + 1,
1407
+ startedAt: prev.startedAt ?? Date.now(),
1408
+ };
1409
+ addLogEntry(
1410
+ context.facts,
1411
+ "loading",
1412
+ "profile",
1413
+ `Attempt ${prev.attempts + 1}`,
1414
+ );
1415
+
1416
+ try {
1417
+ const data = await fetchMockProfile(
1418
+ req.userId,
1419
+ context.facts.profileDelay,
1420
+ context.facts.profileFailRate,
1421
+ );
1422
+ context.facts.profile = {
1423
+ data,
1424
+ status: "success",
1425
+ error: null,
1426
+ attempts: context.facts.profile.attempts,
1427
+ startedAt: context.facts.profile.startedAt,
1428
+ completedAt: Date.now(),
1429
+ };
1430
+ addLogEntry(context.facts, "success", "profile", data.name);
1431
+ } catch (err) {
1432
+ const msg = err instanceof Error ? err.message : "Unknown error";
1433
+ context.facts.profile = {
1434
+ ...context.facts.profile,
1435
+ status: "error",
1436
+ error: msg,
1437
+ completedAt: Date.now(),
1438
+ };
1439
+ addLogEntry(context.facts, "error", "profile", msg);
1440
+ throw err;
1441
+ }
1442
+ },
1443
+ },
1444
+
1445
+ fetchPreferences: {
1446
+ requirement: "FETCH_PREFERENCES",
1447
+ retry: { attempts: 2, backoff: "exponential" },
1448
+ resolve: async (req, context) => {
1449
+ const prev = context.facts.preferences;
1450
+ context.facts.preferences = {
1451
+ ...prev,
1452
+ status: "loading",
1453
+ attempts: prev.attempts + 1,
1454
+ startedAt: prev.startedAt ?? Date.now(),
1455
+ };
1456
+ addLogEntry(
1457
+ context.facts,
1458
+ "loading",
1459
+ "preferences",
1460
+ `Attempt ${prev.attempts + 1}`,
1461
+ );
1462
+
1463
+ try {
1464
+ const data = await fetchMockPreferences(
1465
+ req.userId,
1466
+ context.facts.preferencesDelay,
1467
+ context.facts.preferencesFailRate,
1468
+ );
1469
+ context.facts.preferences = {
1470
+ data,
1471
+ status: "success",
1472
+ error: null,
1473
+ attempts: context.facts.preferences.attempts,
1474
+ startedAt: context.facts.preferences.startedAt,
1475
+ completedAt: Date.now(),
1476
+ };
1477
+ addLogEntry(
1478
+ context.facts,
1479
+ "success",
1480
+ "preferences",
1481
+ `${data.theme} / ${data.locale}`,
1482
+ );
1483
+ } catch (err) {
1484
+ const msg = err instanceof Error ? err.message : "Unknown error";
1485
+ context.facts.preferences = {
1486
+ ...context.facts.preferences,
1487
+ status: "error",
1488
+ error: msg,
1489
+ completedAt: Date.now(),
1490
+ };
1491
+ addLogEntry(context.facts, "error", "preferences", msg);
1492
+ throw err;
1493
+ }
1494
+ },
1495
+ },
1496
+
1497
+ fetchPermissions: {
1498
+ requirement: "FETCH_PERMISSIONS",
1499
+ retry: { attempts: 3, backoff: "exponential" },
1500
+ timeout: 15000,
1501
+ resolve: async (req, context) => {
1502
+ const prev = context.facts.permissions;
1503
+ context.facts.permissions = {
1504
+ ...prev,
1505
+ status: "loading",
1506
+ attempts: prev.attempts + 1,
1507
+ startedAt: prev.startedAt ?? Date.now(),
1508
+ };
1509
+ addLogEntry(
1510
+ context.facts,
1511
+ "loading",
1512
+ "permissions",
1513
+ `Attempt ${prev.attempts + 1}`,
1514
+ );
1515
+
1516
+ try {
1517
+ const data = await fetchMockPermissions(
1518
+ req.userId,
1519
+ context.facts.permissionsDelay,
1520
+ context.facts.permissionsFailRate,
1521
+ );
1522
+ context.facts.permissions = {
1523
+ data,
1524
+ status: "success",
1525
+ error: null,
1526
+ attempts: context.facts.permissions.attempts,
1527
+ startedAt: context.facts.permissions.startedAt,
1528
+ completedAt: Date.now(),
1529
+ };
1530
+ addLogEntry(
1531
+ context.facts,
1532
+ "success",
1533
+ "permissions",
1534
+ `${data.role} (${data.features.join(", ")})`,
1535
+ );
1536
+ } catch (err) {
1537
+ const msg = err instanceof Error ? err.message : "Unknown error";
1538
+ context.facts.permissions = {
1539
+ ...context.facts.permissions,
1540
+ status: "error",
1541
+ error: msg,
1542
+ completedAt: Date.now(),
1543
+ };
1544
+ addLogEntry(context.facts, "error", "permissions", msg);
1545
+ throw err;
1546
+ }
1547
+ },
1548
+ },
1549
+ },
1550
+ });
1551
+ ```
1552
+
1553
+ ## pagination
1554
+
1555
+ ```typescript
1556
+ // Example: pagination
1557
+ // Source: examples/pagination/src/pagination.ts
1558
+ // Pure module file — no DOM wiring
1559
+
1560
+ /**
1561
+ * Pagination & Infinite Scroll — Directive Modules
1562
+ *
1563
+ * Two modules: `filters` owns search/sort/category,
1564
+ * `list` owns items and pagination state with crossModuleDeps.
1565
+ *
1566
+ * Constraints:
1567
+ * - loadMore: appends next page when scrollNearBottom
1568
+ * - filterChanged: resets and re-fetches when filters change
1569
+ *
1570
+ * Effects:
1571
+ * - observeScroll: IntersectionObserver on sentinel element
1572
+ */
1573
+
1574
+ import {
1575
+ type ModuleSchema,
1576
+ createModule,
1577
+ createSystem,
1578
+ t,
1579
+ } from "@directive-run/core";
1580
+ import { devtoolsPlugin, loggingPlugin } from "@directive-run/core/plugins";
1581
+ import { type ListItem, fetchPage } from "./mock-api.js";
1582
+
1583
+ // ============================================================================
1584
+ // Filters Module
1585
+ // ============================================================================
1586
+
1587
+ export const filtersSchema = {
1588
+ facts: {
1589
+ search: t.string(),
1590
+ sortBy: t.string<"newest" | "oldest" | "title">(),
1591
+ category: t.string(),
1592
+ },
1593
+ events: {
1594
+ setSearch: { value: t.string() },
1595
+ setSortBy: { value: t.string() },
1596
+ setCategory: { value: t.string() },
1597
+ },
1598
+ } satisfies ModuleSchema;
1599
+
1600
+ export const filtersModule = createModule("filters", {
1601
+ schema: filtersSchema,
1602
+
1603
+ init: (facts) => {
1604
+ facts.search = "";
1605
+ facts.sortBy = "newest";
1606
+ facts.category = "all";
1607
+ },
1608
+
1609
+ events: {
1610
+ setSearch: (facts, { value }) => {
1611
+ facts.search = value;
1612
+ },
1613
+ setSortBy: (facts, { value }) => {
1614
+ facts.sortBy = value as "newest" | "oldest" | "title";
1615
+ },
1616
+ setCategory: (facts, { value }) => {
1617
+ facts.category = value;
1618
+ },
1619
+ },
1620
+ });
1621
+
1622
+ // ============================================================================
1623
+ // List Module
1624
+ // ============================================================================
1625
+
1626
+ export const listSchema = {
1627
+ facts: {
1628
+ items: t.array<ListItem>(),
1629
+ cursor: t.string(),
1630
+ hasMore: t.boolean(),
1631
+ isLoadingMore: t.boolean(),
1632
+ scrollNearBottom: t.boolean(),
1633
+ lastFilterHash: t.string(),
1634
+ },
1635
+ derivations: {
1636
+ totalLoaded: t.number(),
1637
+ isEmpty: t.boolean(),
1638
+ },
1639
+ events: {
1640
+ setScrollNearBottom: { value: t.boolean() },
1641
+ },
1642
+ requirements: {
1643
+ LOAD_PAGE: {
1644
+ cursor: t.string(),
1645
+ search: t.string(),
1646
+ sortBy: t.string(),
1647
+ category: t.string(),
1648
+ },
1649
+ RESET_AND_LOAD: {
1650
+ search: t.string(),
1651
+ sortBy: t.string(),
1652
+ category: t.string(),
1653
+ },
1654
+ },
1655
+ } satisfies ModuleSchema;
1656
+
1657
+ export const listModule = createModule("list", {
1658
+ schema: listSchema,
1659
+
1660
+ crossModuleDeps: { filters: filtersSchema },
1661
+
1662
+ init: (facts) => {
1663
+ facts.items = [];
1664
+ facts.cursor = "";
1665
+ facts.hasMore = true;
1666
+ facts.isLoadingMore = false;
1667
+ facts.scrollNearBottom = false;
1668
+ facts.lastFilterHash = "";
1669
+ },
1670
+
1671
+ // ============================================================================
1672
+ // Derivations
1673
+ // ============================================================================
1674
+
1675
+ derive: {
1676
+ totalLoaded: (facts) => facts.self.items.length,
1677
+ isEmpty: (facts) => facts.self.items.length === 0 && !facts.self.hasMore,
1678
+ },
1679
+
1680
+ // ============================================================================
1681
+ // Events
1682
+ // ============================================================================
1683
+
1684
+ events: {
1685
+ setScrollNearBottom: (facts, { value }) => {
1686
+ facts.scrollNearBottom = value;
1687
+ },
1688
+ },
1689
+
1690
+ // ============================================================================
1691
+ // Constraints
1692
+ // ============================================================================
1693
+
1694
+ constraints: {
1695
+ loadMore: {
1696
+ when: (facts) => {
1697
+ return (
1698
+ facts.self.hasMore &&
1699
+ !facts.self.isLoadingMore &&
1700
+ facts.self.scrollNearBottom
1701
+ );
1702
+ },
1703
+ require: (facts) => ({
1704
+ type: "LOAD_PAGE",
1705
+ cursor: facts.self.cursor,
1706
+ search: facts.filters.search,
1707
+ sortBy: facts.filters.sortBy,
1708
+ category: facts.filters.category,
1709
+ }),
1710
+ },
1711
+
1712
+ filterChanged: {
1713
+ when: (facts) => {
1714
+ const hash = `${facts.filters.search}|${facts.filters.sortBy}|${facts.filters.category}`;
1715
+
1716
+ return hash !== facts.self.lastFilterHash;
1717
+ },
1718
+ require: (facts) => ({
1719
+ type: "RESET_AND_LOAD",
1720
+ search: facts.filters.search,
1721
+ sortBy: facts.filters.sortBy,
1722
+ category: facts.filters.category,
1723
+ }),
1724
+ },
1725
+ },
1726
+
1727
+ // ============================================================================
1728
+ // Resolvers
1729
+ // ============================================================================
1730
+
1731
+ resolvers: {
1732
+ loadPage: {
1733
+ requirement: "LOAD_PAGE",
1734
+ resolve: async (req, context) => {
1735
+ context.facts.isLoadingMore = true;
1736
+
1737
+ try {
1738
+ const data = await fetchPage(req.cursor, 20, {
1739
+ search: req.search,
1740
+ sortBy: req.sortBy,
1741
+ category: req.category,
1742
+ });
1743
+
1744
+ context.facts.items = [...context.facts.items, ...data.items];
1745
+ context.facts.cursor = data.nextCursor;
1746
+ context.facts.hasMore = data.hasMore;
1747
+ } finally {
1748
+ context.facts.isLoadingMore = false;
1749
+ }
1750
+ },
1751
+ },
1752
+
1753
+ resetAndLoad: {
1754
+ requirement: "RESET_AND_LOAD",
1755
+ resolve: async (req, context) => {
1756
+ const hash = `${req.search}|${req.sortBy}|${req.category}`;
1757
+
1758
+ context.facts.items = [];
1759
+ context.facts.cursor = "";
1760
+ context.facts.hasMore = true;
1761
+ context.facts.isLoadingMore = true;
1762
+ context.facts.lastFilterHash = hash;
1763
+
1764
+ try {
1765
+ const data = await fetchPage("", 20, {
1766
+ search: req.search,
1767
+ sortBy: req.sortBy,
1768
+ category: req.category,
1769
+ });
1770
+
1771
+ context.facts.items = data.items;
1772
+ context.facts.cursor = data.nextCursor;
1773
+ context.facts.hasMore = data.hasMore;
1774
+ } finally {
1775
+ context.facts.isLoadingMore = false;
1776
+ }
1777
+ },
1778
+ },
1779
+ },
1780
+
1781
+ // ============================================================================
1782
+ // Effects
1783
+ // ============================================================================
1784
+
1785
+ effects: {
1786
+ observeScroll: {
1787
+ run: (facts) => {
1788
+ const sentinel = document.getElementById("pg-scroll-sentinel");
1789
+ if (!sentinel) {
1790
+ return;
1791
+ }
1792
+
1793
+ const observer = new IntersectionObserver(
1794
+ ([entry]) => {
1795
+ facts.self.scrollNearBottom = entry.isIntersecting;
1796
+ },
1797
+ { rootMargin: "200px" },
1798
+ );
1799
+ observer.observe(sentinel);
1800
+
1801
+ return () => observer.disconnect();
1802
+ },
1803
+ },
1804
+ },
1805
+ });
1806
+
1807
+ // ============================================================================
1808
+ // System
1809
+ // ============================================================================
1810
+
1811
+ export const system = createSystem({
1812
+ modules: { filters: filtersModule, list: listModule },
1813
+ trace: true,
1814
+ plugins: [loggingPlugin(), devtoolsPlugin({ name: "pagination" })],
1815
+ });
1816
+ ```
1817
+
1818
+ ## url-sync
1819
+
1820
+ ```typescript
1821
+ // Example: url-sync
1822
+ // Source: examples/url-sync/src/url-sync.ts
1823
+ // Pure module file — no DOM wiring
1824
+
1825
+ /**
1826
+ * URL Sync — Directive Modules
1827
+ *
1828
+ * Two modules that synchronize URL query parameters with product filtering:
1829
+ * - **url module**: Reads/writes URL params, dispatches filter changes
1830
+ * - **products module**: Fetches filtered products via cross-module constraints
1831
+ *
1832
+ * Demonstrates bidirectional URL sync (popstate ↔ replaceState), cross-module
1833
+ * constraints, and resolver-driven data fetching with mock delay.
1834
+ */
1835
+
1836
+ import {
1837
+ type ModuleSchema,
1838
+ createModule,
1839
+ createSystem,
1840
+ t,
1841
+ } from "@directive-run/core";
1842
+ import { devtoolsPlugin } from "@directive-run/core/plugins";
1843
+ import { type Product, allProducts, filterProducts } from "./mock-products.js";
1844
+
1845
+ // ============================================================================
1846
+ // URL Module — Schema
1847
+ // ============================================================================
1848
+
1849
+ export const urlSchema = {
1850
+ facts: {
1851
+ search: t.string(),
1852
+ category: t.string(),
1853
+ sortBy: t.string<"newest" | "price-asc" | "price-desc">(),
1854
+ page: t.number(),
1855
+ syncingFromUrl: t.boolean(),
1856
+ },
1857
+ derivations: {},
1858
+ events: {
1859
+ setSearch: { value: t.string() },
1860
+ setCategory: { value: t.string() },
1861
+ setSortBy: { value: t.string() },
1862
+ setPage: { value: t.number() },
1863
+ syncFromUrl: {
1864
+ search: t.string(),
1865
+ category: t.string(),
1866
+ sortBy: t.string(),
1867
+ page: t.number(),
1868
+ },
1869
+ syncComplete: {},
1870
+ },
1871
+ requirements: {},
1872
+ } satisfies ModuleSchema;
1873
+
1874
+ // ============================================================================
1875
+ // URL Module — Helpers
1876
+ // ============================================================================
1877
+
1878
+ function readUrlParams(): {
1879
+ search: string;
1880
+ category: string;
1881
+ sortBy: string;
1882
+ page: number;
1883
+ } {
1884
+ const params = new URLSearchParams(window.location.search);
1885
+
1886
+ return {
1887
+ search: params.get("q") ?? "",
1888
+ category: params.get("cat") ?? "",
1889
+ sortBy: params.get("sort") ?? "newest",
1890
+ page: Math.max(1, Number.parseInt(params.get("page") ?? "1", 10) || 1),
1891
+ };
1892
+ }
1893
+
1894
+ // ============================================================================
1895
+ // URL Module
1896
+ // ============================================================================
1897
+
1898
+ export const urlModule = createModule("url", {
1899
+ schema: urlSchema,
1900
+
1901
+ init: (facts) => {
1902
+ const params = readUrlParams();
1903
+ facts.search = params.search;
1904
+ facts.category = params.category;
1905
+ facts.sortBy = params.sortBy as "newest" | "price-asc" | "price-desc";
1906
+ facts.page = params.page;
1907
+ facts.syncingFromUrl = false;
1908
+ },
1909
+
1910
+ // ============================================================================
1911
+ // Events
1912
+ // ============================================================================
1913
+
1914
+ events: {
1915
+ setSearch: (facts, { value }) => {
1916
+ facts.search = value;
1917
+ facts.page = 1;
1918
+ },
1919
+
1920
+ setCategory: (facts, { value }) => {
1921
+ facts.category = value;
1922
+ facts.page = 1;
1923
+ },
1924
+
1925
+ setSortBy: (facts, { value }) => {
1926
+ facts.sortBy = value as "newest" | "price-asc" | "price-desc";
1927
+ facts.page = 1;
1928
+ },
1929
+
1930
+ setPage: (facts, { value }) => {
1931
+ facts.page = value;
1932
+ },
1933
+
1934
+ syncFromUrl: (facts, { search, category, sortBy, page }) => {
1935
+ facts.syncingFromUrl = true;
1936
+ facts.search = search;
1937
+ facts.category = category;
1938
+ facts.sortBy = sortBy as "newest" | "price-asc" | "price-desc";
1939
+ facts.page = page;
1940
+ },
1941
+
1942
+ syncComplete: (facts) => {
1943
+ facts.syncingFromUrl = false;
1944
+ },
1945
+ },
1946
+
1947
+ // ============================================================================
1948
+ // Effects
1949
+ // ============================================================================
1950
+
1951
+ effects: {
1952
+ urlToState: {
1953
+ run: () => {
1954
+ const handler = () => {
1955
+ const params = readUrlParams();
1956
+ system.events.url.syncFromUrl({
1957
+ search: params.search,
1958
+ category: params.category,
1959
+ sortBy: params.sortBy,
1960
+ page: params.page,
1961
+ });
1962
+ system.events.url.syncComplete();
1963
+ };
1964
+
1965
+ window.addEventListener("popstate", handler);
1966
+
1967
+ return () => {
1968
+ window.removeEventListener("popstate", handler);
1969
+ };
1970
+ },
1971
+ },
1972
+
1973
+ stateToUrl: {
1974
+ deps: ["search", "category", "sortBy", "page"],
1975
+ run: (facts) => {
1976
+ if (facts.syncingFromUrl) {
1977
+ return;
1978
+ }
1979
+
1980
+ const params = new URLSearchParams();
1981
+
1982
+ if (facts.search !== "") {
1983
+ params.set("q", facts.search);
1984
+ }
1985
+ if (facts.category !== "" && facts.category !== "all") {
1986
+ params.set("cat", facts.category);
1987
+ }
1988
+ if (facts.sortBy !== "newest") {
1989
+ params.set("sort", facts.sortBy);
1990
+ }
1991
+ if (facts.page > 1) {
1992
+ params.set("page", String(facts.page));
1993
+ }
1994
+
1995
+ const search = params.toString();
1996
+ const newUrl = search
1997
+ ? `${window.location.pathname}?${search}`
1998
+ : window.location.pathname;
1999
+
2000
+ if (newUrl !== `${window.location.pathname}${window.location.search}`) {
2001
+ history.replaceState(null, "", newUrl);
2002
+ }
2003
+ },
2004
+ },
2005
+ },
2006
+ });
2007
+
2008
+ // ============================================================================
2009
+ // Products Module — Schema
2010
+ // ============================================================================
2011
+
2012
+ export const productsSchema = {
2013
+ facts: {
2014
+ items: t.array<Product>(),
2015
+ totalItems: t.number(),
2016
+ isLoading: t.boolean(),
2017
+ itemsPerPage: t.number(),
2018
+ },
2019
+ derivations: {
2020
+ totalPages: t.number(),
2021
+ currentPageDisplay: t.string(),
2022
+ },
2023
+ events: {
2024
+ setItemsPerPage: { value: t.number() },
2025
+ },
2026
+ requirements: {
2027
+ FETCH_PRODUCTS: {
2028
+ search: t.string(),
2029
+ category: t.string(),
2030
+ sortBy: t.string(),
2031
+ page: t.number(),
2032
+ itemsPerPage: t.number(),
2033
+ },
2034
+ },
2035
+ } satisfies ModuleSchema;
2036
+
2037
+ // ============================================================================
2038
+ // Products Module
2039
+ // ============================================================================
2040
+
2041
+ export const productsModule = createModule("products", {
2042
+ schema: productsSchema,
2043
+
2044
+ crossModuleDeps: { url: urlSchema },
2045
+
2046
+ init: (facts) => {
2047
+ facts.items = [];
2048
+ facts.totalItems = 0;
2049
+ facts.isLoading = false;
2050
+ facts.itemsPerPage = 10;
2051
+ },
2052
+
2053
+ // ============================================================================
2054
+ // Derivations
2055
+ // ============================================================================
2056
+
2057
+ derive: {
2058
+ totalPages: (facts) => {
2059
+ if (facts.self.totalItems === 0) {
2060
+ return 0;
2061
+ }
2062
+
2063
+ return Math.ceil(facts.self.totalItems / facts.self.itemsPerPage);
2064
+ },
2065
+
2066
+ currentPageDisplay: (facts) => {
2067
+ const total = facts.self.totalItems;
2068
+ if (total === 0) {
2069
+ return "No results";
2070
+ }
2071
+
2072
+ const page = facts.url.page;
2073
+ const perPage = facts.self.itemsPerPage;
2074
+ const start = (page - 1) * perPage + 1;
2075
+ const end = Math.min(page * perPage, total);
2076
+
2077
+ return `${start}\u2013${end} of ${total}`;
2078
+ },
2079
+ },
2080
+
2081
+ // ============================================================================
2082
+ // Events
2083
+ // ============================================================================
2084
+
2085
+ events: {
2086
+ setItemsPerPage: (facts, { value }) => {
2087
+ facts.itemsPerPage = value;
2088
+ },
2089
+ },
2090
+
2091
+ // ============================================================================
2092
+ // Constraints
2093
+ // ============================================================================
2094
+
2095
+ constraints: {
2096
+ fetchProducts: {
2097
+ priority: 100,
2098
+ when: () => true,
2099
+ require: (facts) => ({
2100
+ type: "FETCH_PRODUCTS",
2101
+ search: facts.url.search,
2102
+ category: facts.url.category,
2103
+ sortBy: facts.url.sortBy,
2104
+ page: facts.url.page,
2105
+ itemsPerPage: facts.self.itemsPerPage,
2106
+ }),
2107
+ },
2108
+ },
2109
+
2110
+ // ============================================================================
2111
+ // Resolvers
2112
+ // ============================================================================
2113
+
2114
+ resolvers: {
2115
+ fetchProducts: {
2116
+ requirement: "FETCH_PRODUCTS",
2117
+ key: (req) =>
2118
+ `fetch-${req.search}-${req.category}-${req.sortBy}-${req.page}-${req.itemsPerPage}`,
2119
+ timeout: 10000,
2120
+ resolve: async (req, context) => {
2121
+ context.facts.isLoading = true;
2122
+
2123
+ // Simulate network delay
2124
+ await new Promise((resolve) => setTimeout(resolve, 300));
2125
+
2126
+ const result = filterProducts(allProducts, {
2127
+ search: req.search,
2128
+ category: req.category,
2129
+ sortBy: req.sortBy,
2130
+ page: req.page,
2131
+ itemsPerPage: req.itemsPerPage,
2132
+ });
2133
+
2134
+ context.facts.items = result.items;
2135
+ context.facts.totalItems = result.totalItems;
2136
+ context.facts.isLoading = false;
2137
+ },
2138
+ },
2139
+ },
2140
+ });
2141
+
2142
+ // ============================================================================
2143
+ // System
2144
+ // ============================================================================
2145
+
2146
+ export const system = createSystem({
2147
+ modules: {
2148
+ url: urlModule,
2149
+ products: productsModule,
2150
+ },
2151
+ trace: true,
2152
+ plugins: [devtoolsPlugin({ name: "url-sync" })],
2153
+ });
2154
+ ```
2155
+
2156
+ ## websocket
2157
+
2158
+ ```typescript
2159
+ // Example: websocket
2160
+ // Source: examples/websocket/src/websocket.ts
2161
+ // Pure module file — no DOM wiring
2162
+
2163
+ /**
2164
+ * WebSocket Connections — Directive Module
2165
+ *
2166
+ * Demonstrates resolver-driven connection lifecycle, automatic reconnection
2167
+ * via constraints with exponential backoff, live message streaming,
2168
+ * reconnect countdown via time-based reactivity, and cleanup functions.
2169
+ */
2170
+
2171
+ import { type ModuleSchema, createModule, t } from "@directive-run/core";
2172
+ import { MockWebSocket, type WsMessage } from "./mock-ws.js";
2173
+
2174
+ // ============================================================================
2175
+ // Types
2176
+ // ============================================================================
2177
+
2178
+ export type WsStatus =
2179
+ | "disconnected"
2180
+ | "connecting"
2181
+ | "connected"
2182
+ | "reconnecting"
2183
+ | "error";
2184
+
2185
+ export interface EventLogEntry {
2186
+ timestamp: number;
2187
+ event: string;
2188
+ detail: string;
2189
+ }
2190
+
2191
+ // ============================================================================
2192
+ // Module-level socket reference
2193
+ // ============================================================================
2194
+
2195
+ let activeSocket: MockWebSocket | null = null;
2196
+
2197
+ export function getActiveSocket(): MockWebSocket | null {
2198
+ return activeSocket;
2199
+ }
2200
+
2201
+ // ============================================================================
2202
+ // Schema
2203
+ // ============================================================================
2204
+
2205
+ export const websocketSchema = {
2206
+ facts: {
2207
+ url: t.string(),
2208
+ status: t.string<WsStatus>(),
2209
+ connectRequested: t.boolean(),
2210
+ messages: t.array<WsMessage>(),
2211
+ retryCount: t.number(),
2212
+ maxRetries: t.number(),
2213
+ messageToSend: t.string(),
2214
+ now: t.number(),
2215
+ reconnectTargetTime: t.number(),
2216
+ messageRate: t.number(),
2217
+ connectFailRate: t.number(),
2218
+ reconnectFailRate: t.number(),
2219
+ eventLog: t.array<EventLogEntry>(),
2220
+ },
2221
+ derivations: {
2222
+ isConnected: t.boolean(),
2223
+ shouldReconnect: t.boolean(),
2224
+ reconnectDelay: t.number(),
2225
+ reconnectCountdown: t.number(),
2226
+ canSend: t.boolean(),
2227
+ messageCount: t.number(),
2228
+ },
2229
+ events: {
2230
+ requestConnect: {},
2231
+ disconnect: {},
2232
+ setMessageToSend: { value: t.string() },
2233
+ messageSent: {},
2234
+ setUrl: { value: t.string() },
2235
+ setMessageRate: { value: t.number() },
2236
+ setConnectFailRate: { value: t.number() },
2237
+ setReconnectFailRate: { value: t.number() },
2238
+ setMaxRetries: { value: t.number() },
2239
+ tick: {},
2240
+ clearMessages: {},
2241
+ forceError: {},
2242
+ },
2243
+ requirements: {
2244
+ CONNECT: {
2245
+ url: t.string(),
2246
+ messageRate: t.number(),
2247
+ connectFailRate: t.number(),
2248
+ },
2249
+ RECONNECT: {
2250
+ delay: t.number(),
2251
+ reconnectFailRate: t.number(),
2252
+ },
2253
+ },
2254
+ } satisfies ModuleSchema;
2255
+
2256
+ // ============================================================================
2257
+ // Helpers
2258
+ // ============================================================================
2259
+
2260
+ function addLogEntry(facts: any, event: string, detail: string): void {
2261
+ const log = [...facts.eventLog];
2262
+ log.push({ timestamp: Date.now(), event, detail });
2263
+ // Cap at 100
2264
+ if (log.length > 100) {
2265
+ log.splice(0, log.length - 100);
2266
+ }
2267
+ facts.eventLog = log;
2268
+ }
2269
+
2270
+ // ============================================================================
2271
+ // Module
2272
+ // ============================================================================
2273
+
2274
+ export const websocketModule = createModule("websocket", {
2275
+ schema: websocketSchema,
2276
+
2277
+ init: (facts) => {
2278
+ facts.url = "wss://demo.directive.run/chat";
2279
+ facts.status = "disconnected";
2280
+ facts.connectRequested = false;
2281
+ facts.messages = [];
2282
+ facts.retryCount = 0;
2283
+ facts.maxRetries = 5;
2284
+ facts.messageToSend = "";
2285
+ facts.now = Date.now();
2286
+ facts.reconnectTargetTime = 0;
2287
+ facts.messageRate = 3;
2288
+ facts.connectFailRate = 0;
2289
+ facts.reconnectFailRate = 0;
2290
+ facts.eventLog = [];
2291
+ },
2292
+
2293
+ // ============================================================================
2294
+ // Derivations
2295
+ // ============================================================================
2296
+
2297
+ derive: {
2298
+ isConnected: (facts) => facts.status === "connected",
2299
+
2300
+ shouldReconnect: (facts) => {
2301
+ return (
2302
+ facts.status === "error" &&
2303
+ facts.retryCount < facts.maxRetries &&
2304
+ facts.connectRequested
2305
+ );
2306
+ },
2307
+
2308
+ reconnectDelay: (facts) => {
2309
+ return Math.min(1000 * 2 ** facts.retryCount, 30000);
2310
+ },
2311
+
2312
+ reconnectCountdown: (facts) => {
2313
+ if (facts.reconnectTargetTime <= 0) {
2314
+ return 0;
2315
+ }
2316
+
2317
+ return Math.max(
2318
+ 0,
2319
+ Math.ceil((facts.reconnectTargetTime - facts.now) / 1000),
2320
+ );
2321
+ },
2322
+
2323
+ canSend: (facts) => {
2324
+ return facts.status === "connected" && facts.messageToSend.trim() !== "";
2325
+ },
2326
+
2327
+ messageCount: (facts) => facts.messages.length,
2328
+ },
2329
+
2330
+ // ============================================================================
2331
+ // Events
2332
+ // ============================================================================
2333
+
2334
+ events: {
2335
+ requestConnect: (facts) => {
2336
+ facts.connectRequested = true;
2337
+ facts.status = "connecting";
2338
+ facts.retryCount = 0;
2339
+ facts.reconnectTargetTime = 0;
2340
+ facts.messages = [];
2341
+ facts.eventLog = [];
2342
+ },
2343
+
2344
+ disconnect: (facts) => {
2345
+ facts.connectRequested = false;
2346
+ facts.status = "disconnected";
2347
+ facts.reconnectTargetTime = 0;
2348
+
2349
+ // Null out before close() so the onclose handler's stale-socket guard works
2350
+ const socket = activeSocket;
2351
+ activeSocket = null;
2352
+ if (socket) {
2353
+ socket.close();
2354
+ }
2355
+ },
2356
+
2357
+ setMessageToSend: (facts, { value }) => {
2358
+ facts.messageToSend = value;
2359
+ },
2360
+
2361
+ messageSent: (facts) => {
2362
+ facts.messageToSend = "";
2363
+ },
2364
+
2365
+ setUrl: (facts, { value }) => {
2366
+ facts.url = value;
2367
+ },
2368
+
2369
+ setMessageRate: (facts, { value }) => {
2370
+ facts.messageRate = value;
2371
+ },
2372
+
2373
+ setConnectFailRate: (facts, { value }) => {
2374
+ facts.connectFailRate = value;
2375
+ },
2376
+
2377
+ setReconnectFailRate: (facts, { value }) => {
2378
+ facts.reconnectFailRate = value;
2379
+ },
2380
+
2381
+ setMaxRetries: (facts, { value }) => {
2382
+ facts.maxRetries = value;
2383
+ },
2384
+
2385
+ tick: (facts) => {
2386
+ facts.now = Date.now();
2387
+ },
2388
+
2389
+ clearMessages: (facts) => {
2390
+ facts.messages = [];
2391
+ },
2392
+
2393
+ forceError: (facts) => {
2394
+ facts.status = "error";
2395
+
2396
+ // Null out before close() so the onclose handler's stale-socket guard works
2397
+ const socket = activeSocket;
2398
+ activeSocket = null;
2399
+ if (socket) {
2400
+ socket.close();
2401
+ }
2402
+ },
2403
+ },
2404
+
2405
+ // ============================================================================
2406
+ // Constraints
2407
+ // ============================================================================
2408
+
2409
+ constraints: {
2410
+ needsConnection: {
2411
+ priority: 100,
2412
+ when: (facts) => {
2413
+ return facts.connectRequested && facts.status === "connecting";
2414
+ },
2415
+ require: (facts) => ({
2416
+ type: "CONNECT",
2417
+ url: facts.url,
2418
+ messageRate: facts.messageRate,
2419
+ connectFailRate: facts.connectFailRate,
2420
+ }),
2421
+ },
2422
+
2423
+ needsReconnect: {
2424
+ priority: 90,
2425
+ when: (facts) => {
2426
+ return (
2427
+ facts.status === "error" &&
2428
+ facts.retryCount < facts.maxRetries &&
2429
+ facts.connectRequested
2430
+ );
2431
+ },
2432
+ require: (facts) => ({
2433
+ type: "RECONNECT",
2434
+ delay: Math.min(1000 * 2 ** facts.retryCount, 30000),
2435
+ reconnectFailRate: facts.reconnectFailRate,
2436
+ }),
2437
+ },
2438
+ },
2439
+
2440
+ // ============================================================================
2441
+ // Resolvers
2442
+ // ============================================================================
2443
+
2444
+ resolvers: {
2445
+ connect: {
2446
+ requirement: "CONNECT",
2447
+ timeout: 10000,
2448
+ resolve: async (req, context) => {
2449
+ addLogEntry(context.facts, "connect", `Connecting to ${req.url}...`);
2450
+
2451
+ // Close any existing socket
2452
+ if (activeSocket) {
2453
+ activeSocket.close();
2454
+ activeSocket = null;
2455
+ }
2456
+
2457
+ try {
2458
+ const socket = new MockWebSocket(
2459
+ req.url,
2460
+ req.connectFailRate,
2461
+ req.messageRate * 1000,
2462
+ );
2463
+
2464
+ // Track this socket so we can detect stale callbacks
2465
+ activeSocket = socket;
2466
+ const currentSocket = socket;
2467
+
2468
+ socket.onmessage = (msg) => {
2469
+ if (activeSocket !== currentSocket) {
2470
+ return;
2471
+ }
2472
+
2473
+ const messages = [...context.facts.messages];
2474
+ messages.push(msg);
2475
+ // Cap at 50
2476
+ if (messages.length > 50) {
2477
+ messages.splice(0, messages.length - 50);
2478
+ }
2479
+ context.facts.messages = messages;
2480
+ };
2481
+
2482
+ socket.onclose = () => {
2483
+ if (activeSocket !== currentSocket) {
2484
+ return;
2485
+ }
2486
+
2487
+ context.facts.status = "disconnected";
2488
+ activeSocket = null;
2489
+ addLogEntry(context.facts, "close", "Connection closed");
2490
+ };
2491
+
2492
+ socket.onerror = (error) => {
2493
+ if (activeSocket !== currentSocket) {
2494
+ return;
2495
+ }
2496
+
2497
+ context.facts.status = "error";
2498
+ activeSocket = null;
2499
+ addLogEntry(context.facts, "error", error.message);
2500
+ };
2501
+
2502
+ // Wait for connection to open
2503
+ await new Promise<void>((resolve, reject) => {
2504
+ socket.onopen = () => resolve();
2505
+ const prevError = socket.onerror;
2506
+ socket.onerror = (error) => {
2507
+ prevError?.(error);
2508
+ reject(error);
2509
+ };
2510
+ });
2511
+
2512
+ context.facts.status = "connected";
2513
+ context.facts.retryCount = 0;
2514
+ context.facts.reconnectTargetTime = 0;
2515
+ addLogEntry(context.facts, "connected", "Connection established");
2516
+ } catch (err) {
2517
+ const msg = err instanceof Error ? err.message : "Unknown error";
2518
+ context.facts.status = "error";
2519
+ activeSocket = null;
2520
+ addLogEntry(context.facts, "connect-error", msg);
2521
+ throw err;
2522
+ }
2523
+ },
2524
+ },
2525
+
2526
+ reconnect: {
2527
+ requirement: "RECONNECT",
2528
+ timeout: 60000,
2529
+ resolve: async (req, context) => {
2530
+ const retryCount = context.facts.retryCount;
2531
+ context.facts.status = "reconnecting";
2532
+ context.facts.reconnectTargetTime = Date.now() + req.delay;
2533
+ addLogEntry(
2534
+ context.facts,
2535
+ "reconnect",
2536
+ `Waiting ${(req.delay / 1000).toFixed(1)}s (attempt ${retryCount + 1})...`,
2537
+ );
2538
+
2539
+ await new Promise((resolve) => setTimeout(resolve, req.delay));
2540
+
2541
+ context.facts.retryCount = retryCount + 1;
2542
+ context.facts.reconnectTargetTime = 0;
2543
+ context.facts.status = "connecting";
2544
+ },
2545
+ },
2546
+ },
2547
+
2548
+ // ============================================================================
2549
+ // Effects
2550
+ // ============================================================================
2551
+
2552
+ effects: {
2553
+ logStatusChange: {
2554
+ deps: ["status"],
2555
+ run: (facts, prev) => {
2556
+ if (prev && prev.status !== facts.status) {
2557
+ addLogEntry(facts, "status", `${prev.status} \u2192 ${facts.status}`);
2558
+ }
2559
+ },
2560
+ },
2561
+ },
2562
+ });
2563
+ ```
2564
+
2565
+ ## server
2566
+
2567
+ ```typescript
2568
+ // Example: server
2569
+ // Source: examples/server/src/server.ts
2570
+ // Pure module file — no DOM wiring
2571
+
2572
+ /**
2573
+ * Directive Server Example
2574
+ *
2575
+ * An Express API demonstrating Directive's server-side features:
2576
+ * - Distributable snapshots with TTL
2577
+ * - Signed snapshot verification (HMAC-SHA256)
2578
+ * - Cryptographic audit trail
2579
+ * - GDPR/CCPA compliance tooling
2580
+ *
2581
+ * Run: npx tsx --watch src/server.ts
2582
+ */
2583
+
2584
+ import {
2585
+ createAuditTrail,
2586
+ createCompliance,
2587
+ createInMemoryComplianceStorage,
2588
+ } from "@directive-run/ai";
2589
+ import {
2590
+ createSystem,
2591
+ isSnapshotExpired,
2592
+ signSnapshot,
2593
+ verifySnapshotSignature,
2594
+ } from "@directive-run/core";
2595
+ import express from "express";
2596
+ import { userProfile } from "./module.js";
2597
+
2598
+ const app = express();
2599
+ app.use(express.json());
2600
+
2601
+ // ============================================================================
2602
+ // Shared Infrastructure
2603
+ // ============================================================================
2604
+
2605
+ const SIGNING_SECRET =
2606
+ process.env.SIGNING_SECRET ?? "dev-secret-change-in-production";
2607
+
2608
+ // Audit trail – shared across requests, acts as a Directive plugin
2609
+ const audit = createAuditTrail({
2610
+ maxEntries: 10_000,
2611
+ retentionMs: 7 * 24 * 60 * 60 * 1000, // 7 days
2612
+ piiMasking: {
2613
+ enabled: true,
2614
+ types: ["email", "name"],
2615
+ redactionStyle: "masked",
2616
+ },
2617
+ });
2618
+
2619
+ // Compliance – in-memory storage for this example (use a DB adapter in production)
2620
+ const compliance = createCompliance({
2621
+ storage: createInMemoryComplianceStorage(),
2622
+ consentPurposes: ["analytics", "marketing", "personalization"],
2623
+ });
2624
+
2625
+ // In-memory snapshot cache (use Redis in production)
2626
+ const snapshotCache = new Map<
2627
+ string,
2628
+ { snapshot: unknown; cachedAt: number }
2629
+ >();
2630
+ const CACHE_TTL_MS = 60_000; // 1 minute
2631
+
2632
+ // ============================================================================
2633
+ // Helper: Per-Request System Factory
2634
+ // ============================================================================
2635
+
2636
+ function createUserSystem(userId: string) {
2637
+ const system = createSystem({
2638
+ module: userProfile,
2639
+ plugins: [audit.createPlugin()],
2640
+ });
2641
+
2642
+ system.start();
2643
+ system.events.loadUser({ userId });
2644
+
2645
+ return system;
2646
+ }
2647
+
2648
+ // ============================================================================
2649
+ // GET /snapshot/:userId
2650
+ // Distributable Snapshots with TTL
2651
+ // ============================================================================
2652
+
2653
+ app.get("/snapshot/:userId", async (req, res) => {
2654
+ const { userId } = req.params;
2655
+
2656
+ // Check cache first
2657
+ const cached = snapshotCache.get(userId);
2658
+ if (cached && Date.now() - cached.cachedAt < CACHE_TTL_MS) {
2659
+ res.json({ source: "cache", snapshot: cached.snapshot });
2660
+
2661
+ return;
2662
+ }
2663
+
2664
+ // Create a per-request system, settle it, then export a distributable snapshot
2665
+ const system = createUserSystem(userId);
2666
+
2667
+ try {
2668
+ await system.settle(5000);
2669
+
2670
+ const snapshot = system.getDistributableSnapshot({
2671
+ includeDerivations: ["effectivePlan", "canUseFeature", "isReady"],
2672
+ ttlSeconds: 3600,
2673
+ });
2674
+
2675
+ // Cache it
2676
+ snapshotCache.set(userId, { snapshot, cachedAt: Date.now() });
2677
+
2678
+ res.json({ source: "fresh", snapshot });
2679
+ } catch (_error) {
2680
+ res.status(500).json({ error: "Failed to settle system" });
2681
+ } finally {
2682
+ system.destroy();
2683
+ }
2684
+ });
2685
+
2686
+ // ============================================================================
2687
+ // POST /snapshot/:userId/verify
2688
+ // Signed Snapshot Verification
2689
+ // ============================================================================
2690
+
2691
+ app.post("/snapshot/:userId/verify", async (req, res) => {
2692
+ const { snapshot } = req.body;
2693
+
2694
+ if (!snapshot) {
2695
+ res.status(400).json({ error: "Missing snapshot in request body" });
2696
+
2697
+ return;
2698
+ }
2699
+
2700
+ // Sign a fresh snapshot for this user
2701
+ const system = createUserSystem(req.params.userId);
2702
+
2703
+ try {
2704
+ await system.settle(5000);
2705
+
2706
+ const freshSnapshot = system.getDistributableSnapshot({
2707
+ includeDerivations: ["effectivePlan", "canUseFeature", "isReady"],
2708
+ ttlSeconds: 3600,
2709
+ });
2710
+
2711
+ // Sign the snapshot with HMAC-SHA256
2712
+ const signed = await signSnapshot(freshSnapshot, SIGNING_SECRET);
2713
+
2714
+ // Verify the provided snapshot's signature
2715
+ if (snapshot.signature) {
2716
+ const isValid = await verifySnapshotSignature(snapshot, SIGNING_SECRET);
2717
+ const isExpired = isSnapshotExpired(snapshot);
2718
+
2719
+ res.json({
2720
+ signatureValid: isValid,
2721
+ expired: isExpired,
2722
+ signedSnapshot: signed,
2723
+ });
2724
+ } else {
2725
+ // No signature on the incoming snapshot – just return a signed version
2726
+ res.json({
2727
+ signatureValid: null,
2728
+ expired: false,
2729
+ signedSnapshot: signed,
2730
+ });
2731
+ }
2732
+ } catch (_error) {
2733
+ res.status(500).json({ error: "Verification failed" });
2734
+ } finally {
2735
+ system.destroy();
2736
+ }
2737
+ });
2738
+
2739
+ // ============================================================================
2740
+ // GET /audit
2741
+ // Query Audit Entries
2742
+ // ============================================================================
2743
+
2744
+ app.get("/audit", (req, res) => {
2745
+ const { eventType, since, actorId, limit } = req.query;
2746
+
2747
+ // biome-ignore lint/suspicious/noExplicitAny: eventType comes from query string
2748
+ const entries = audit.getEntries({
2749
+ eventTypes: eventType ? [eventType as any] : undefined,
2750
+ since: since ? Number(since) : undefined,
2751
+ actorId: actorId as string | undefined,
2752
+ limit: limit ? Number(limit) : 50,
2753
+ });
2754
+
2755
+ res.json({
2756
+ count: entries.length,
2757
+ entries,
2758
+ });
2759
+ });
2760
+
2761
+ // ============================================================================
2762
+ // GET /audit/verify
2763
+ // Verify Audit Hash Chain Integrity
2764
+ // ============================================================================
2765
+
2766
+ app.get("/audit/verify", async (_req, res) => {
2767
+ const result = await audit.verifyChain();
2768
+
2769
+ res.json({
2770
+ chainValid: result.valid,
2771
+ entriesVerified: result.entriesVerified,
2772
+ brokenAt: result.brokenAt ?? null,
2773
+ verifiedAt: new Date(result.verifiedAt).toISOString(),
2774
+ });
2775
+ });
2776
+
2777
+ // ============================================================================
2778
+ // POST /compliance/:subjectId/export
2779
+ // GDPR Article 20 – Data Export
2780
+ // ============================================================================
2781
+
2782
+ app.post("/compliance/:subjectId/export", async (req, res) => {
2783
+ const { subjectId } = req.params;
2784
+
2785
+ // Record consent for analytics before exporting
2786
+ await compliance.consent.grant(subjectId, "analytics", {
2787
+ source: "api-request",
2788
+ });
2789
+
2790
+ const result = await compliance.exportData({
2791
+ subjectId,
2792
+ format: "json",
2793
+ includeAudit: true,
2794
+ });
2795
+
2796
+ if (result.success) {
2797
+ res.json({
2798
+ subjectId,
2799
+ exportedAt: new Date(result.exportedAt).toISOString(),
2800
+ expiresAt: result.expiresAt
2801
+ ? new Date(result.expiresAt).toISOString()
2802
+ : null,
2803
+ recordCount: result.recordCount,
2804
+ checksum: result.checksum,
2805
+ data: JSON.parse(result.data),
2806
+ });
2807
+ } else {
2808
+ res.status(500).json({ error: "Export failed" });
2809
+ }
2810
+ });
2811
+
2812
+ // ============================================================================
2813
+ // POST /compliance/:subjectId/delete
2814
+ // GDPR Article 17 – Right to Erasure
2815
+ // ============================================================================
2816
+
2817
+ app.post("/compliance/:subjectId/delete", async (req, res) => {
2818
+ const { subjectId } = req.params;
2819
+ const { reason } = req.body;
2820
+
2821
+ const result = await compliance.deleteData({
2822
+ subjectId,
2823
+ scope: "all",
2824
+ reason: reason ?? "GDPR Article 17 request",
2825
+ });
2826
+
2827
+ if (result.success) {
2828
+ res.json({
2829
+ subjectId,
2830
+ deletedAt: new Date(result.deletedAt).toISOString(),
2831
+ recordsAffected: result.recordsAffected,
2832
+ certificate: result.certificate,
2833
+ });
2834
+ } else {
2835
+ res.status(500).json({ error: "Deletion failed" });
2836
+ }
2837
+ });
2838
+
2839
+ // ============================================================================
2840
+ // GET /health
2841
+ // Health Check
2842
+ // ============================================================================
2843
+
2844
+ app.get("/health", (_req, res) => {
2845
+ const auditStats = audit.getStats();
2846
+
2847
+ res.json({
2848
+ status: "ok",
2849
+ audit: {
2850
+ totalEntries: auditStats.totalEntries,
2851
+ oldestEntry: auditStats.oldestEntry
2852
+ ? new Date(auditStats.oldestEntry).toISOString()
2853
+ : null,
2854
+ newestEntry: auditStats.newestEntry
2855
+ ? new Date(auditStats.newestEntry).toISOString()
2856
+ : null,
2857
+ chainIntegrity: auditStats.chainIntegrity,
2858
+ },
2859
+ });
2860
+ });
2861
+
2862
+ // ============================================================================
2863
+ // Start
2864
+ // ============================================================================
2865
+
2866
+ const PORT = Number(process.env.PORT ?? 3000);
2867
+
2868
+ app.listen(PORT, () => {
2869
+ console.log(`Directive server example running on http://localhost:${PORT}`);
2870
+ console.log();
2871
+ console.log("Endpoints:");
2872
+ console.log(
2873
+ " GET /snapshot/:userId Distributable snapshot with TTL",
2874
+ );
2875
+ console.log(" POST /snapshot/:userId/verify Sign and verify snapshots");
2876
+ console.log(" GET /audit Query audit entries");
2877
+ console.log(" GET /audit/verify Verify hash chain integrity");
2878
+ console.log(" POST /compliance/:subjectId/export GDPR data export");
2879
+ console.log(" POST /compliance/:subjectId/delete GDPR right to erasure");
2880
+ console.log(" GET /health Health check");
2881
+ console.log();
2882
+ console.log("Try: curl http://localhost:3000/snapshot/user-1");
2883
+ });
2884
+ ```
2885
+
2886
+ ## optimistic-updates
2887
+
2888
+ ```typescript
2889
+ // Example: optimistic-updates
2890
+ // Source: examples/optimistic-updates/src/optimistic-updates.ts
2891
+ // Pure module file — no DOM wiring
2892
+
2893
+ /**
2894
+ * Optimistic Updates — Directive Module
2895
+ *
2896
+ * Demonstrates optimistic mutations via events (instant UI), server sync via
2897
+ * constraint-resolver pattern, per-operation rollback from a sync queue,
2898
+ * resolver key deduplication, toast notifications, and context.snapshot().
2899
+ */
2900
+
2901
+ import { type ModuleSchema, createModule, t } from "@directive-run/core";
2902
+ import { mockServerSync } from "./mock-server.js";
2903
+
2904
+ // ============================================================================
2905
+ // Types
2906
+ // ============================================================================
2907
+
2908
+ export interface TodoItem {
2909
+ id: string;
2910
+ text: string;
2911
+ done: boolean;
2912
+ }
2913
+
2914
+ export type OpType = "toggle" | "delete" | "add";
2915
+
2916
+ export interface SyncQueueEntry {
2917
+ opId: string;
2918
+ itemId: string;
2919
+ op: OpType;
2920
+ undoItems: TodoItem[];
2921
+ }
2922
+
2923
+ export interface EventLogEntry {
2924
+ timestamp: number;
2925
+ event: string;
2926
+ detail: string;
2927
+ }
2928
+
2929
+ // ============================================================================
2930
+ // ID Generation
2931
+ // ============================================================================
2932
+
2933
+ let nextId = 6; // items are pre-seeded 1-5
2934
+ let nextOpId = 1;
2935
+
2936
+ // ============================================================================
2937
+ // Schema
2938
+ // ============================================================================
2939
+
2940
+ export const optimisticUpdatesSchema = {
2941
+ facts: {
2942
+ items: t.array<TodoItem>(),
2943
+ syncQueue: t.array<SyncQueueEntry>(),
2944
+ syncingOpId: t.string(),
2945
+ newItemText: t.string(),
2946
+ serverDelay: t.number(),
2947
+ failRate: t.number(),
2948
+ toastMessage: t.string(),
2949
+ toastType: t.string(),
2950
+ eventLog: t.array<EventLogEntry>(),
2951
+ },
2952
+ derivations: {
2953
+ totalCount: t.number(),
2954
+ doneCount: t.number(),
2955
+ pendingCount: t.number(),
2956
+ canAdd: t.boolean(),
2957
+ isSyncing: t.boolean(),
2958
+ },
2959
+ events: {
2960
+ toggleItem: { id: t.string() },
2961
+ deleteItem: { id: t.string() },
2962
+ addItem: {},
2963
+ setNewItemText: { value: t.string() },
2964
+ setServerDelay: { value: t.number() },
2965
+ setFailRate: { value: t.number() },
2966
+ dismissToast: {},
2967
+ },
2968
+ requirements: {
2969
+ SYNC_TODO: {
2970
+ opId: t.string(),
2971
+ },
2972
+ },
2973
+ } satisfies ModuleSchema;
2974
+
2975
+ // ============================================================================
2976
+ // Helpers
2977
+ // ============================================================================
2978
+
2979
+ function addLogEntry(facts: any, event: string, detail: string): void {
2980
+ const log = [...(facts.eventLog as EventLogEntry[])];
2981
+ log.push({ timestamp: Date.now(), event, detail });
2982
+ if (log.length > 100) {
2983
+ log.splice(0, log.length - 100);
2984
+ }
2985
+ facts.eventLog = log;
2986
+ }
2987
+
2988
+ // ============================================================================
2989
+ // Module
2990
+ // ============================================================================
2991
+
2992
+ export const optimisticUpdatesModule = createModule("optimistic-updates", {
2993
+ schema: optimisticUpdatesSchema,
2994
+
2995
+ init: (facts) => {
2996
+ facts.items = [
2997
+ { id: "1", text: "Buy groceries", done: false },
2998
+ { id: "2", text: "Learn Directive", done: true },
2999
+ { id: "3", text: "Walk the dog", done: false },
3000
+ { id: "4", text: "Read a book", done: false },
3001
+ { id: "5", text: "Fix the bug", done: true },
3002
+ ];
3003
+ facts.syncQueue = [];
3004
+ facts.syncingOpId = "";
3005
+ facts.newItemText = "";
3006
+ facts.serverDelay = 800;
3007
+ facts.failRate = 30;
3008
+ facts.toastMessage = "";
3009
+ facts.toastType = "";
3010
+ facts.eventLog = [];
3011
+ },
3012
+
3013
+ // ============================================================================
3014
+ // Derivations
3015
+ // ============================================================================
3016
+
3017
+ derive: {
3018
+ totalCount: (facts) => facts.items.length,
3019
+
3020
+ doneCount: (facts) => facts.items.filter((i) => i.done).length,
3021
+
3022
+ pendingCount: (facts) => facts.syncQueue.length,
3023
+
3024
+ canAdd: (facts) => facts.newItemText.trim() !== "",
3025
+
3026
+ isSyncing: (facts) => facts.syncingOpId !== "",
3027
+ },
3028
+
3029
+ // ============================================================================
3030
+ // Events
3031
+ // ============================================================================
3032
+
3033
+ events: {
3034
+ toggleItem: (facts, { id }) => {
3035
+ const undoItems = facts.items.map((i) => ({ ...i }));
3036
+
3037
+ facts.items = facts.items.map((i) =>
3038
+ i.id === id ? { ...i, done: !i.done } : i,
3039
+ );
3040
+
3041
+ const opId = String(nextOpId++);
3042
+ const queue = [...facts.syncQueue];
3043
+ queue.push({ opId, itemId: id, op: "toggle", undoItems });
3044
+ facts.syncQueue = queue;
3045
+
3046
+ addLogEntry(facts, "optimistic", `Toggle item ${id}`);
3047
+ },
3048
+
3049
+ deleteItem: (facts, { id }) => {
3050
+ const undoItems = facts.items.map((i) => ({ ...i }));
3051
+
3052
+ facts.items = facts.items.filter((i) => i.id !== id);
3053
+
3054
+ const opId = String(nextOpId++);
3055
+ const queue = [...facts.syncQueue];
3056
+ queue.push({ opId, itemId: id, op: "delete", undoItems });
3057
+ facts.syncQueue = queue;
3058
+
3059
+ addLogEntry(facts, "optimistic", `Delete item ${id}`);
3060
+ },
3061
+
3062
+ addItem: (facts) => {
3063
+ const text = facts.newItemText.trim();
3064
+ if (!text) {
3065
+ return;
3066
+ }
3067
+
3068
+ const undoItems = facts.items.map((i) => ({ ...i }));
3069
+
3070
+ const itemId = String(nextId++);
3071
+ facts.items = [...facts.items, { id: itemId, text, done: false }];
3072
+ facts.newItemText = "";
3073
+
3074
+ const opId = String(nextOpId++);
3075
+ const queue = [...facts.syncQueue];
3076
+ queue.push({ opId, itemId, op: "add", undoItems });
3077
+ facts.syncQueue = queue;
3078
+
3079
+ addLogEntry(facts, "optimistic", `Add item "${text}"`);
3080
+ },
3081
+
3082
+ setNewItemText: (facts, { value }) => {
3083
+ facts.newItemText = value;
3084
+ },
3085
+
3086
+ setServerDelay: (facts, { value }) => {
3087
+ facts.serverDelay = value;
3088
+ },
3089
+
3090
+ setFailRate: (facts, { value }) => {
3091
+ facts.failRate = value;
3092
+ },
3093
+
3094
+ dismissToast: (facts) => {
3095
+ facts.toastMessage = "";
3096
+ facts.toastType = "";
3097
+ },
3098
+ },
3099
+
3100
+ // ============================================================================
3101
+ // Constraints
3102
+ // ============================================================================
3103
+
3104
+ constraints: {
3105
+ needsSync: {
3106
+ priority: 100,
3107
+ when: (facts) => {
3108
+ return facts.syncQueue.length > 0 && facts.syncingOpId === "";
3109
+ },
3110
+ require: (facts) => {
3111
+ return {
3112
+ type: "SYNC_TODO",
3113
+ opId: facts.syncQueue[0].opId,
3114
+ };
3115
+ },
3116
+ },
3117
+ },
3118
+
3119
+ // ============================================================================
3120
+ // Resolvers
3121
+ // ============================================================================
3122
+
3123
+ resolvers: {
3124
+ syncTodo: {
3125
+ requirement: "SYNC_TODO",
3126
+ key: (req) => `sync-${req.opId}`,
3127
+ timeout: 10000,
3128
+ resolve: async (req, context) => {
3129
+ const entry = context.facts.syncQueue.find((e) => e.opId === req.opId);
3130
+ if (!entry) {
3131
+ return;
3132
+ }
3133
+
3134
+ context.facts.syncingOpId = req.opId;
3135
+ addLogEntry(
3136
+ context.facts,
3137
+ "syncing",
3138
+ `Syncing ${entry.op} for item ${entry.itemId}...`,
3139
+ );
3140
+
3141
+ const serverDelay = context.facts.serverDelay;
3142
+ const failRate = context.facts.failRate;
3143
+
3144
+ try {
3145
+ await mockServerSync(entry.op, entry.itemId, serverDelay, failRate);
3146
+
3147
+ addLogEntry(
3148
+ context.facts,
3149
+ "success",
3150
+ `${entry.op} item ${entry.itemId} synced`,
3151
+ );
3152
+ context.facts.toastMessage = `${entry.op} synced successfully`;
3153
+ context.facts.toastType = "success";
3154
+ } catch {
3155
+ context.facts.items = entry.undoItems;
3156
+ addLogEntry(
3157
+ context.facts,
3158
+ "rollback",
3159
+ `Failed to ${entry.op} item ${entry.itemId} — rolled back`,
3160
+ );
3161
+ context.facts.toastMessage = `Failed to ${entry.op} — rolled back`;
3162
+ context.facts.toastType = "error";
3163
+ }
3164
+
3165
+ // Remove entry from queue
3166
+ context.facts.syncQueue = context.facts.syncQueue.filter(
3167
+ (e) => e.opId !== req.opId,
3168
+ );
3169
+ context.facts.syncingOpId = "";
3170
+ },
3171
+ },
3172
+ },
3173
+
3174
+ // ============================================================================
3175
+ // Effects
3176
+ // ============================================================================
3177
+
3178
+ effects: {
3179
+ logSyncChange: {
3180
+ deps: ["syncingOpId"],
3181
+ run: (facts, prev) => {
3182
+ if (prev) {
3183
+ if (prev.syncingOpId === "" && facts.syncingOpId !== "") {
3184
+ addLogEntry(
3185
+ facts,
3186
+ "status",
3187
+ `Sync started: op ${facts.syncingOpId}`,
3188
+ );
3189
+ } else if (prev.syncingOpId !== "" && facts.syncingOpId === "") {
3190
+ addLogEntry(
3191
+ facts,
3192
+ "status",
3193
+ `Sync completed: op ${prev.syncingOpId}`,
3194
+ );
3195
+ }
3196
+ }
3197
+ },
3198
+ },
3199
+ },
3200
+ });
3201
+ ```
3202
+
3203
+ ## ab-testing
3204
+
3205
+ ```typescript
3206
+ // Example: ab-testing
3207
+ // Source: examples/ab-testing/src/module.ts
3208
+ // Pure module file — no DOM wiring
3209
+
3210
+ /**
3211
+ * A/B Testing Engine — Directive Module
3212
+ *
3213
+ * Types, schema, helpers, module definition, timeline, and system creation
3214
+ * for a constraint-driven A/B testing engine with deterministic hashing.
3215
+ */
3216
+
3217
+ import {
3218
+ type ModuleSchema,
3219
+ createModule,
3220
+ createSystem,
3221
+ t,
3222
+ } from "@directive-run/core";
3223
+ import { devtoolsPlugin } from "@directive-run/core/plugins";
3224
+
3225
+ // ============================================================================
3226
+ // Types
3227
+ // ============================================================================
3228
+
3229
+ export interface Variant {
3230
+ id: string;
3231
+ weight: number;
3232
+ label: string;
3233
+ }
3234
+
3235
+ export interface Experiment {
3236
+ id: string;
3237
+ name: string;
3238
+ variants: Variant[];
3239
+ active: boolean;
3240
+ }
3241
+
3242
+ export interface TimelineEntry {
3243
+ time: number;
3244
+ event: string;
3245
+ detail: string;
3246
+ type: string;
3247
+ }
3248
+
3249
+ // ============================================================================
3250
+ // Deterministic Hash
3251
+ // ============================================================================
3252
+
3253
+ function hashCode(str: string): number {
3254
+ let hash = 0;
3255
+ for (let i = 0; i < str.length; i++) {
3256
+ const char = str.charCodeAt(i);
3257
+ hash = ((hash << 5) - hash + char) | 0;
3258
+ }
3259
+
3260
+ return Math.abs(hash);
3261
+ }
3262
+
3263
+ function pickVariant(
3264
+ userId: string,
3265
+ experimentId: string,
3266
+ variants: Variant[],
3267
+ ): string {
3268
+ const hash = hashCode(`${userId}:${experimentId}`);
3269
+ const totalWeight = variants.reduce((sum, v) => sum + v.weight, 0);
3270
+ let roll = hash % totalWeight;
3271
+
3272
+ for (const variant of variants) {
3273
+ roll -= variant.weight;
3274
+ if (roll < 0) {
3275
+ return variant.id;
3276
+ }
3277
+ }
3278
+
3279
+ return variants[variants.length - 1].id;
3280
+ }
3281
+
3282
+ // ============================================================================
3283
+ // Timeline
3284
+ // ============================================================================
3285
+
3286
+ export const timeline: TimelineEntry[] = [];
3287
+
3288
+ export function addLog(type: "event" | "constraint" | "resolver", msg: string) {
3289
+ console.log(`[AB] [${type}] ${msg}`);
3290
+
3291
+ // Classify for timeline
3292
+ let event = "";
3293
+ let detail = msg;
3294
+ let tlType = "register";
3295
+
3296
+ if (msg.startsWith("Registered")) {
3297
+ event = "registered";
3298
+ detail = msg.replace("Registered experiment: ", "");
3299
+ tlType = "register";
3300
+ } else if (msg.startsWith("Assigned") || msg.includes("→")) {
3301
+ event = "assigned";
3302
+ detail = msg;
3303
+ tlType = "assign";
3304
+ } else if (msg.includes("Exposure tracked")) {
3305
+ event = "exposure";
3306
+ detail = msg.replace("Exposure tracked: ", "");
3307
+ tlType = "exposure";
3308
+ } else if (msg.includes("Manual assignment")) {
3309
+ event = "manual";
3310
+ detail = msg.replace("Manual assignment: ", "");
3311
+ tlType = "assign";
3312
+ } else if (
3313
+ msg.includes("Paused") ||
3314
+ msg.includes("Resumed") ||
3315
+ msg.includes("Reset")
3316
+ ) {
3317
+ event = msg.toLowerCase().split(" ")[0];
3318
+ detail = msg;
3319
+ tlType = "control";
3320
+ } else {
3321
+ event = type;
3322
+ detail = msg;
3323
+ tlType = "register";
3324
+ }
3325
+
3326
+ timeline.unshift({ time: Date.now(), event, detail, type: tlType });
3327
+ }
3328
+
3329
+ // ============================================================================
3330
+ // Schema
3331
+ // ============================================================================
3332
+
3333
+ export const schema = {
3334
+ facts: {
3335
+ experiments: t.array<Experiment>(),
3336
+ assignments: t.object<Record<string, string>>(),
3337
+ exposures: t.object<Record<string, number>>(),
3338
+ userId: t.string(),
3339
+ paused: t.boolean(),
3340
+ },
3341
+ derivations: {
3342
+ activeExperiments: t.array<Experiment>(),
3343
+ assignedCount: t.number(),
3344
+ exposedCount: t.number(),
3345
+ },
3346
+ events: {
3347
+ registerExperiment: {
3348
+ id: t.string(),
3349
+ name: t.string(),
3350
+ variants: t.array<Variant>(),
3351
+ },
3352
+ assignVariant: { experimentId: t.string(), variantId: t.string() },
3353
+ recordExposure: { experimentId: t.string() },
3354
+ pauseAll: {},
3355
+ resumeAll: {},
3356
+ reset: {},
3357
+ },
3358
+ requirements: {
3359
+ ASSIGN_VARIANT: { experimentId: t.string() },
3360
+ TRACK_EXPOSURE: { experimentId: t.string(), variantId: t.string() },
3361
+ },
3362
+ } satisfies ModuleSchema;
3363
+
3364
+ // ============================================================================
3365
+ // Module
3366
+ // ============================================================================
3367
+
3368
+ const abTesting = createModule("ab-testing", {
3369
+ schema,
3370
+
3371
+ init: (facts) => {
3372
+ facts.experiments = [];
3373
+ facts.assignments = {};
3374
+ facts.exposures = {};
3375
+ facts.userId = `user-${hashCode(String(Date.now())).toString(36)}`;
3376
+ facts.paused = false;
3377
+ },
3378
+
3379
+ derive: {
3380
+ activeExperiments: (facts) =>
3381
+ facts.experiments.filter((e: Experiment) => e.active && !facts.paused),
3382
+ assignedCount: (facts) => Object.keys(facts.assignments).length,
3383
+ exposedCount: (facts) => Object.keys(facts.exposures).length,
3384
+ },
3385
+
3386
+ events: {
3387
+ registerExperiment: (facts, { id, name, variants }) => {
3388
+ const experiments = facts.experiments as Experiment[];
3389
+ if (!experiments.find((e: Experiment) => e.id === id)) {
3390
+ facts.experiments = [
3391
+ ...experiments,
3392
+ { id, name, variants, active: true },
3393
+ ];
3394
+ }
3395
+ },
3396
+ assignVariant: (facts, { experimentId, variantId }) => {
3397
+ facts.assignments = { ...facts.assignments, [experimentId]: variantId };
3398
+ },
3399
+ recordExposure: (facts, { experimentId }) => {
3400
+ facts.exposures = { ...facts.exposures, [experimentId]: Date.now() };
3401
+ },
3402
+ pauseAll: (facts) => {
3403
+ facts.paused = true;
3404
+ },
3405
+ resumeAll: (facts) => {
3406
+ facts.paused = false;
3407
+ },
3408
+ reset: (facts) => {
3409
+ facts.assignments = {};
3410
+ facts.exposures = {};
3411
+ facts.paused = false;
3412
+ },
3413
+ },
3414
+
3415
+ constraints: {
3416
+ needsAssignment: {
3417
+ priority: 100,
3418
+ when: (facts) => {
3419
+ if (facts.paused) {
3420
+ return false;
3421
+ }
3422
+ const experiments = facts.experiments as Experiment[];
3423
+ const assignments = facts.assignments as Record<string, string>;
3424
+
3425
+ return experiments.some(
3426
+ (e: Experiment) => e.active && !assignments[e.id],
3427
+ );
3428
+ },
3429
+ require: (facts) => {
3430
+ const experiments = facts.experiments as Experiment[];
3431
+ const assignments = facts.assignments as Record<string, string>;
3432
+ const unassigned = experiments.find(
3433
+ (e: Experiment) => e.active && !assignments[e.id],
3434
+ );
3435
+
3436
+ return { type: "ASSIGN_VARIANT", experimentId: unassigned!.id };
3437
+ },
3438
+ },
3439
+
3440
+ needsExposure: {
3441
+ priority: 50,
3442
+ when: (facts) => {
3443
+ if (facts.paused) {
3444
+ return false;
3445
+ }
3446
+ const assignments = facts.assignments as Record<string, string>;
3447
+ const exposures = facts.exposures as Record<string, number>;
3448
+
3449
+ return Object.keys(assignments).some((id) => !exposures[id]);
3450
+ },
3451
+ require: (facts) => {
3452
+ const assignments = facts.assignments as Record<string, string>;
3453
+ const exposures = facts.exposures as Record<string, number>;
3454
+ const experimentId = Object.keys(assignments).find(
3455
+ (id) => !exposures[id],
3456
+ )!;
3457
+
3458
+ return {
3459
+ type: "TRACK_EXPOSURE",
3460
+ experimentId,
3461
+ variantId: assignments[experimentId],
3462
+ };
3463
+ },
3464
+ },
3465
+ },
3466
+
3467
+ resolvers: {
3468
+ assignVariant: {
3469
+ requirement: "ASSIGN_VARIANT",
3470
+ resolve: async (req, context) => {
3471
+ const experiments = context.facts.experiments as Experiment[];
3472
+ const experiment = experiments.find(
3473
+ (e: Experiment) => e.id === req.experimentId,
3474
+ );
3475
+ if (!experiment) {
3476
+ return;
3477
+ }
3478
+
3479
+ const variantId = pickVariant(
3480
+ context.facts.userId,
3481
+ req.experimentId,
3482
+ experiment.variants,
3483
+ );
3484
+
3485
+ context.facts.assignments = {
3486
+ ...context.facts.assignments,
3487
+ [req.experimentId]: variantId,
3488
+ };
3489
+ addLog("resolver", `Assigned ${req.experimentId} → ${variantId}`);
3490
+ },
3491
+ },
3492
+
3493
+ trackExposure: {
3494
+ requirement: "TRACK_EXPOSURE",
3495
+ resolve: async (req, context) => {
3496
+ const now = Date.now();
3497
+ context.facts.exposures = {
3498
+ ...context.facts.exposures,
3499
+ [req.experimentId]: now,
3500
+ };
3501
+ addLog(
3502
+ "resolver",
3503
+ `Exposure tracked: ${req.experimentId} (variant: ${req.variantId}) at ${new Date(now).toLocaleTimeString()}`,
3504
+ );
3505
+ },
3506
+ },
3507
+ },
3508
+ });
3509
+
3510
+ // ============================================================================
3511
+ // System
3512
+ // ============================================================================
3513
+
3514
+ export const system = createSystem({
3515
+ module: abTesting,
3516
+ trace: true,
3517
+ plugins: [devtoolsPlugin({ name: "ab-testing" })],
3518
+ });
3519
+ ```
3520
+
3521
+ ## sudoku
3522
+
3523
+ ```typescript
3524
+ // Example: sudoku
3525
+ // Source: examples/sudoku/src/sudoku.ts
3526
+ // Pure module file — no DOM wiring
3527
+
3528
+ /**
3529
+ * Sudoku – Directive Module
3530
+ *
3531
+ * Constraint-driven Sudoku game. Sudoku IS a constraint satisfaction problem:
3532
+ * no duplicates in rows, columns, or 3x3 boxes. The game rules map directly
3533
+ * to Directive's constraint→resolver flow.
3534
+ *
3535
+ * Also demonstrates temporal constraints (countdown timer) and runtime
3536
+ * reconfiguration (difficulty modes) – patterns not shown in checkers.
3537
+ *
3538
+ * Pure Sudoku logic lives in rules.ts; puzzle generation in generator.ts.
3539
+ */
3540
+
3541
+ import { type ModuleSchema, createModule, t } from "@directive-run/core";
3542
+ import { generatePuzzle } from "./generator.js";
3543
+ import {
3544
+ type Conflict,
3545
+ type Difficulty,
3546
+ type Grid,
3547
+ MAX_HINTS,
3548
+ TIMER_CRITICAL_THRESHOLD,
3549
+ TIMER_DURATIONS,
3550
+ TIMER_EFFECT_CRITICAL,
3551
+ TIMER_EFFECT_WARNING,
3552
+ TIMER_WARNING_THRESHOLD,
3553
+ createEmptyNotes,
3554
+ findConflicts,
3555
+ getCandidates,
3556
+ getPeers,
3557
+ isBoardComplete,
3558
+ toRowCol,
3559
+ } from "./rules.js";
3560
+
3561
+ // ============================================================================
3562
+ // Schema
3563
+ // ============================================================================
3564
+
3565
+ export const sudokuSchema = {
3566
+ facts: {
3567
+ grid: t.object<Grid>(),
3568
+ solution: t.object<Grid>(),
3569
+ givens: t.object<Set<number>>(),
3570
+ selectedIndex: t.object<number | null>(),
3571
+ difficulty: t.object<Difficulty>(),
3572
+ timerRemaining: t.number(),
3573
+ timerRunning: t.boolean(),
3574
+ gameOver: t.boolean(),
3575
+ won: t.boolean(),
3576
+ message: t.string(),
3577
+ notesMode: t.boolean(),
3578
+ notes: t.array<Set<number>>(),
3579
+ hintsUsed: t.number(),
3580
+ errorsCount: t.number(),
3581
+ hintRequested: t.boolean(),
3582
+ },
3583
+ derivations: {
3584
+ conflicts: t.array<Conflict>(),
3585
+ conflictIndices: t.object<Set<number>>(),
3586
+ hasConflicts: t.boolean(),
3587
+ filledCount: t.number(),
3588
+ progress: t.number(),
3589
+ isComplete: t.boolean(),
3590
+ isSolved: t.boolean(),
3591
+ selectedPeers: t.array<number>(),
3592
+ highlightValue: t.number(),
3593
+ sameValueIndices: t.object<Set<number>>(),
3594
+ candidates: t.array<number>(),
3595
+ timerDisplay: t.string(),
3596
+ timerUrgency: t.object<"normal" | "warning" | "critical">(),
3597
+ },
3598
+ events: {
3599
+ newGame: { difficulty: t.object<Difficulty>() },
3600
+ selectCell: { index: t.number() },
3601
+ inputNumber: { value: t.number() },
3602
+ toggleNote: { value: t.number() },
3603
+ toggleNotesMode: {},
3604
+ requestHint: {},
3605
+ tick: {},
3606
+ },
3607
+ requirements: {
3608
+ SHOW_CONFLICT: {
3609
+ index: t.number(),
3610
+ value: t.number(),
3611
+ row: t.number(),
3612
+ col: t.number(),
3613
+ },
3614
+ GAME_WON: {
3615
+ timeLeft: t.number(),
3616
+ hintsUsed: t.number(),
3617
+ errors: t.number(),
3618
+ },
3619
+ GAME_OVER: {
3620
+ reason: t.string(),
3621
+ },
3622
+ REVEAL_HINT: {
3623
+ index: t.number(),
3624
+ value: t.number(),
3625
+ },
3626
+ },
3627
+ } satisfies ModuleSchema;
3628
+
3629
+ // ============================================================================
3630
+ // Module
3631
+ // ============================================================================
3632
+
3633
+ export const sudokuGame = createModule("sudoku", {
3634
+ schema: sudokuSchema,
3635
+ history: {
3636
+ snapshotEvents: ["inputNumber", "toggleNote", "requestHint", "newGame"],
3637
+ },
3638
+
3639
+ init: (facts) => {
3640
+ const { puzzle, solution } = generatePuzzle("easy");
3641
+ const givens = new Set<number>();
3642
+ for (let i = 0; i < 81; i++) {
3643
+ if (puzzle[i] !== 0) {
3644
+ givens.add(i);
3645
+ }
3646
+ }
3647
+
3648
+ facts.grid = puzzle;
3649
+ facts.solution = solution;
3650
+ facts.givens = givens;
3651
+ facts.selectedIndex = null;
3652
+ facts.difficulty = "easy";
3653
+ facts.timerRemaining = TIMER_DURATIONS.easy;
3654
+ facts.timerRunning = true;
3655
+ facts.gameOver = false;
3656
+ facts.won = false;
3657
+ facts.message =
3658
+ "Fill in the grid. No duplicates in rows, columns, or boxes.";
3659
+ facts.notesMode = false;
3660
+ facts.notes = createEmptyNotes();
3661
+ facts.hintsUsed = 0;
3662
+ facts.errorsCount = 0;
3663
+ facts.hintRequested = false;
3664
+ },
3665
+
3666
+ // ============================================================================
3667
+ // Derivations
3668
+ // ============================================================================
3669
+
3670
+ derive: {
3671
+ conflicts: (facts) => {
3672
+ return findConflicts(facts.grid);
3673
+ },
3674
+
3675
+ conflictIndices: (facts, derived) => {
3676
+ const indices = new Set<number>();
3677
+ const givens = facts.givens;
3678
+ for (const c of derived.conflicts) {
3679
+ // Only highlight player-placed cells, not givens
3680
+ if (!givens.has(c.index)) {
3681
+ indices.add(c.index);
3682
+ }
3683
+ }
3684
+
3685
+ return indices;
3686
+ },
3687
+
3688
+ hasConflicts: (_facts, derived) => {
3689
+ return derived.conflicts.length > 0;
3690
+ },
3691
+
3692
+ filledCount: (facts) => {
3693
+ let count = 0;
3694
+ const grid = facts.grid;
3695
+ for (let i = 0; i < 81; i++) {
3696
+ if (grid[i] !== 0) {
3697
+ count++;
3698
+ }
3699
+ }
3700
+
3701
+ return count;
3702
+ },
3703
+
3704
+ progress: (_facts, derived) => {
3705
+ return Math.round((derived.filledCount / 81) * 100);
3706
+ },
3707
+
3708
+ isComplete: (facts) => {
3709
+ return isBoardComplete(facts.grid);
3710
+ },
3711
+
3712
+ isSolved: (_facts, derived) => {
3713
+ return derived.isComplete && !derived.hasConflicts;
3714
+ },
3715
+
3716
+ selectedPeers: (facts) => {
3717
+ const sel = facts.selectedIndex;
3718
+ if (sel === null) {
3719
+ return [];
3720
+ }
3721
+
3722
+ return getPeers(sel);
3723
+ },
3724
+
3725
+ highlightValue: (facts) => {
3726
+ const sel = facts.selectedIndex;
3727
+ if (sel === null) {
3728
+ return 0;
3729
+ }
3730
+
3731
+ return facts.grid[sel];
3732
+ },
3733
+
3734
+ sameValueIndices: (facts, derived) => {
3735
+ const val = derived.highlightValue;
3736
+ if (val === 0) {
3737
+ return new Set<number>();
3738
+ }
3739
+
3740
+ const indices = new Set<number>();
3741
+ const grid = facts.grid;
3742
+ for (let i = 0; i < 81; i++) {
3743
+ if (grid[i] === val) {
3744
+ indices.add(i);
3745
+ }
3746
+ }
3747
+
3748
+ return indices;
3749
+ },
3750
+
3751
+ candidates: (facts) => {
3752
+ const sel = facts.selectedIndex;
3753
+ if (sel === null) {
3754
+ return [];
3755
+ }
3756
+
3757
+ return getCandidates(facts.grid, sel);
3758
+ },
3759
+
3760
+ timerDisplay: (facts) => {
3761
+ const remaining = facts.timerRemaining;
3762
+ const mins = Math.max(0, Math.floor(remaining / 60));
3763
+ const secs = Math.max(0, remaining % 60);
3764
+
3765
+ return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
3766
+ },
3767
+
3768
+ timerUrgency: (facts) => {
3769
+ const remaining = facts.timerRemaining;
3770
+ if (remaining <= TIMER_CRITICAL_THRESHOLD) {
3771
+ return "critical";
3772
+ }
3773
+ if (remaining <= TIMER_WARNING_THRESHOLD) {
3774
+ return "warning";
3775
+ }
3776
+
3777
+ return "normal";
3778
+ },
3779
+ },
3780
+
3781
+ // ============================================================================
3782
+ // Events
3783
+ // ============================================================================
3784
+
3785
+ events: {
3786
+ newGame: (facts, { difficulty }) => {
3787
+ const { puzzle, solution } = generatePuzzle(difficulty);
3788
+ const givens = new Set<number>();
3789
+ for (let i = 0; i < 81; i++) {
3790
+ if (puzzle[i] !== 0) {
3791
+ givens.add(i);
3792
+ }
3793
+ }
3794
+
3795
+ facts.grid = puzzle;
3796
+ facts.solution = solution;
3797
+ facts.givens = givens;
3798
+ facts.selectedIndex = null;
3799
+ facts.difficulty = difficulty;
3800
+ facts.timerRemaining = TIMER_DURATIONS[difficulty];
3801
+ facts.timerRunning = true;
3802
+ facts.gameOver = false;
3803
+ facts.won = false;
3804
+ facts.message =
3805
+ "Fill in the grid. No duplicates in rows, columns, or boxes.";
3806
+ facts.notesMode = false;
3807
+ facts.notes = createEmptyNotes();
3808
+ facts.hintsUsed = 0;
3809
+ facts.errorsCount = 0;
3810
+ facts.hintRequested = false;
3811
+ },
3812
+
3813
+ selectCell: (facts, { index }) => {
3814
+ if (facts.gameOver) {
3815
+ return;
3816
+ }
3817
+ facts.selectedIndex = index;
3818
+ },
3819
+
3820
+ inputNumber: (facts, { value }) => {
3821
+ if (facts.gameOver) {
3822
+ return;
3823
+ }
3824
+
3825
+ const sel = facts.selectedIndex;
3826
+ if (sel === null) {
3827
+ return;
3828
+ }
3829
+
3830
+ const givens = facts.givens;
3831
+ if (givens.has(sel)) {
3832
+ facts.message = "That cell is locked.";
3833
+
3834
+ return;
3835
+ }
3836
+
3837
+ if (facts.notesMode && value !== 0) {
3838
+ // In notes mode, toggle the pencil mark instead
3839
+ const notes = [...facts.notes];
3840
+ notes[sel] = new Set(notes[sel]);
3841
+ if (notes[sel].has(value)) {
3842
+ notes[sel].delete(value);
3843
+ } else {
3844
+ notes[sel].add(value);
3845
+ }
3846
+ facts.notes = notes;
3847
+ facts.message = "";
3848
+
3849
+ return;
3850
+ }
3851
+
3852
+ // Place or clear a number
3853
+ const grid = [...facts.grid];
3854
+ grid[sel] = value;
3855
+ facts.grid = grid;
3856
+
3857
+ // Clear notes for this cell when placing a number
3858
+ if (value !== 0) {
3859
+ const notes = [...facts.notes];
3860
+ notes[sel] = new Set();
3861
+ // Also clear this value from peer notes
3862
+ for (const peer of getPeers(sel)) {
3863
+ if (notes[peer].has(value)) {
3864
+ notes[peer] = new Set(notes[peer]);
3865
+ notes[peer].delete(value);
3866
+ }
3867
+ }
3868
+ facts.notes = notes;
3869
+ }
3870
+
3871
+ facts.message = "";
3872
+ },
3873
+
3874
+ toggleNote: (facts, { value }) => {
3875
+ if (facts.gameOver) {
3876
+ return;
3877
+ }
3878
+
3879
+ const sel = facts.selectedIndex;
3880
+ if (sel === null) {
3881
+ return;
3882
+ }
3883
+
3884
+ const givens = facts.givens;
3885
+ if (givens.has(sel)) {
3886
+ return;
3887
+ }
3888
+
3889
+ // Only allow notes on empty cells
3890
+ if (facts.grid[sel] !== 0) {
3891
+ return;
3892
+ }
3893
+
3894
+ const notes = [...facts.notes];
3895
+ notes[sel] = new Set(notes[sel]);
3896
+ if (notes[sel].has(value)) {
3897
+ notes[sel].delete(value);
3898
+ } else {
3899
+ notes[sel].add(value);
3900
+ }
3901
+ facts.notes = notes;
3902
+ },
3903
+
3904
+ toggleNotesMode: (facts) => {
3905
+ facts.notesMode = !facts.notesMode;
3906
+ },
3907
+
3908
+ requestHint: (facts) => {
3909
+ if (facts.gameOver) {
3910
+ return;
3911
+ }
3912
+ if (facts.hintsUsed >= MAX_HINTS) {
3913
+ facts.message = "No hints remaining.";
3914
+
3915
+ return;
3916
+ }
3917
+
3918
+ const sel = facts.selectedIndex;
3919
+ if (sel === null) {
3920
+ facts.message = "Select a cell first.";
3921
+
3922
+ return;
3923
+ }
3924
+
3925
+ const givens = facts.givens;
3926
+ if (givens.has(sel)) {
3927
+ facts.message = "That cell is already filled.";
3928
+
3929
+ return;
3930
+ }
3931
+
3932
+ if (facts.grid[sel] !== 0) {
3933
+ facts.message = "Clear the cell first, or select an empty cell.";
3934
+
3935
+ return;
3936
+ }
3937
+
3938
+ // Signal the hintAvailable constraint to fire
3939
+ facts.hintRequested = true;
3940
+ },
3941
+
3942
+ tick: (facts) => {
3943
+ if (!facts.timerRunning || facts.gameOver) {
3944
+ return;
3945
+ }
3946
+ facts.timerRemaining = Math.max(0, facts.timerRemaining - 1);
3947
+ },
3948
+ },
3949
+
3950
+ // ============================================================================
3951
+ // Constraints – The Showcase
3952
+ // ============================================================================
3953
+
3954
+ constraints: {
3955
+ // Highest priority: timer expiry ends the game immediately
3956
+ timerExpired: {
3957
+ priority: 200,
3958
+ when: (facts) => {
3959
+ if (facts.gameOver) {
3960
+ return false;
3961
+ }
3962
+
3963
+ return facts.timerRemaining <= 0;
3964
+ },
3965
+ require: () => ({
3966
+ type: "GAME_OVER",
3967
+ reason: "Time's up!",
3968
+ }),
3969
+ },
3970
+
3971
+ // Detect conflicts on player-placed cells
3972
+ detectConflict: {
3973
+ priority: 100,
3974
+ when: (facts) => {
3975
+ if (facts.gameOver) {
3976
+ return false;
3977
+ }
3978
+ const conflicts = findConflicts(facts.grid);
3979
+ const givens = facts.givens;
3980
+
3981
+ return conflicts.some((c) => !givens.has(c.index));
3982
+ },
3983
+ require: (facts) => {
3984
+ const conflicts = findConflicts(facts.grid);
3985
+ const givens = facts.givens;
3986
+ const playerConflict = conflicts.find((c) => !givens.has(c.index));
3987
+ const idx = playerConflict?.index ?? 0;
3988
+ const { row, col } = toRowCol(idx);
3989
+
3990
+ return {
3991
+ type: "SHOW_CONFLICT",
3992
+ index: idx,
3993
+ value: playerConflict?.value ?? 0,
3994
+ row: row + 1,
3995
+ col: col + 1,
3996
+ };
3997
+ },
3998
+ },
3999
+
4000
+ // Puzzle solved: all cells filled with no conflicts
4001
+ puzzleSolved: {
4002
+ priority: 90,
4003
+ when: (facts) => {
4004
+ if (facts.gameOver) {
4005
+ return false;
4006
+ }
4007
+
4008
+ return (
4009
+ isBoardComplete(facts.grid) && findConflicts(facts.grid).length === 0
4010
+ );
4011
+ },
4012
+ require: (facts) => ({
4013
+ type: "GAME_WON",
4014
+ timeLeft: facts.timerRemaining,
4015
+ hintsUsed: facts.hintsUsed,
4016
+ errors: facts.errorsCount,
4017
+ }),
4018
+ },
4019
+
4020
+ // Hint available: player requested a hint on an empty cell
4021
+ hintAvailable: {
4022
+ priority: 70,
4023
+ when: (facts) => {
4024
+ if (facts.gameOver) {
4025
+ return false;
4026
+ }
4027
+ if (!facts.hintRequested) {
4028
+ return false;
4029
+ }
4030
+
4031
+ const sel = facts.selectedIndex;
4032
+ if (sel === null) {
4033
+ return false;
4034
+ }
4035
+
4036
+ return facts.grid[sel] === 0;
4037
+ },
4038
+ require: (facts) => {
4039
+ const sel = facts.selectedIndex as number;
4040
+ const solution = facts.solution;
4041
+
4042
+ return {
4043
+ type: "REVEAL_HINT",
4044
+ index: sel,
4045
+ value: solution[sel],
4046
+ };
4047
+ },
4048
+ },
4049
+ },
4050
+
4051
+ // ============================================================================
4052
+ // Resolvers
4053
+ // ============================================================================
4054
+
4055
+ resolvers: {
4056
+ showConflict: {
4057
+ requirement: "SHOW_CONFLICT",
4058
+ resolve: async (req, context) => {
4059
+ context.facts.errorsCount = context.facts.errorsCount + 1;
4060
+ context.facts.message = `Conflict at row ${req.row}, column ${req.col} – duplicate ${req.value}.`;
4061
+ },
4062
+ },
4063
+
4064
+ gameWon: {
4065
+ requirement: "GAME_WON",
4066
+ resolve: async (req, context) => {
4067
+ context.facts.timerRunning = false;
4068
+ context.facts.gameOver = true;
4069
+ context.facts.won = true;
4070
+
4071
+ const mins = Math.floor(
4072
+ (TIMER_DURATIONS[context.facts.difficulty] - req.timeLeft) / 60,
4073
+ );
4074
+ const secs =
4075
+ (TIMER_DURATIONS[context.facts.difficulty] - req.timeLeft) % 60;
4076
+ context.facts.message = `Solved in ${mins}m ${secs}s! Hints: ${req.hintsUsed}, Errors: ${req.errors}`;
4077
+ },
4078
+ },
4079
+
4080
+ gameOver: {
4081
+ requirement: "GAME_OVER",
4082
+ resolve: async (req, context) => {
4083
+ context.facts.timerRunning = false;
4084
+ context.facts.gameOver = true;
4085
+ context.facts.won = false;
4086
+ context.facts.message = req.reason;
4087
+ },
4088
+ },
4089
+
4090
+ revealHint: {
4091
+ requirement: "REVEAL_HINT",
4092
+ resolve: async (req, context) => {
4093
+ const grid = [...context.facts.grid];
4094
+ grid[req.index] = req.value;
4095
+ context.facts.grid = grid;
4096
+
4097
+ // Clear notes for the hinted cell and remove value from peer notes
4098
+ const notes = [...context.facts.notes];
4099
+ notes[req.index] = new Set();
4100
+ for (const peer of getPeers(req.index)) {
4101
+ if (notes[peer].has(req.value)) {
4102
+ notes[peer] = new Set(notes[peer]);
4103
+ notes[peer].delete(req.value);
4104
+ }
4105
+ }
4106
+ context.facts.notes = notes;
4107
+
4108
+ context.facts.hintRequested = false;
4109
+ context.facts.hintsUsed = context.facts.hintsUsed + 1;
4110
+ context.facts.message = `Hint revealed! ${MAX_HINTS - context.facts.hintsUsed} remaining.`;
4111
+ },
4112
+ },
4113
+ },
4114
+
4115
+ // ============================================================================
4116
+ // Effects
4117
+ // ============================================================================
4118
+
4119
+ effects: {
4120
+ timerWarning: {
4121
+ deps: ["timerRemaining"],
4122
+ run: (facts) => {
4123
+ const remaining = facts.timerRemaining;
4124
+ if (remaining === TIMER_EFFECT_WARNING) {
4125
+ console.log("[Sudoku] 1 minute remaining!");
4126
+ }
4127
+ if (remaining === TIMER_EFFECT_CRITICAL) {
4128
+ console.log("[Sudoku] 30 seconds remaining!");
4129
+ }
4130
+ },
4131
+ },
4132
+
4133
+ gameResult: {
4134
+ deps: ["gameOver"],
4135
+ run: (facts) => {
4136
+ if (facts.gameOver) {
4137
+ if (facts.won) {
4138
+ console.log(
4139
+ `[Sudoku] Puzzle solved! Difficulty: ${facts.difficulty}, Hints: ${facts.hintsUsed}, Errors: ${facts.errorsCount}`,
4140
+ );
4141
+ } else {
4142
+ console.log(`[Sudoku] Game over: ${facts.message}`);
4143
+ }
4144
+ }
4145
+ },
4146
+ },
4147
+ },
4148
+ });
4149
+ ```