@directive-run/knowledge 0.2.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.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/ai/ai-adapters.md +250 -0
- package/ai/ai-agents-streaming.md +269 -0
- package/ai/ai-budget-resilience.md +235 -0
- package/ai/ai-communication.md +281 -0
- package/ai/ai-debug-observability.md +243 -0
- package/ai/ai-guardrails-memory.md +332 -0
- package/ai/ai-mcp-rag.md +288 -0
- package/ai/ai-multi-agent.md +274 -0
- package/ai/ai-orchestrator.md +227 -0
- package/ai/ai-security.md +293 -0
- package/ai/ai-tasks.md +261 -0
- package/ai/ai-testing-evals.md +378 -0
- package/api-skeleton.md +5 -0
- package/core/anti-patterns.md +382 -0
- package/core/constraints.md +263 -0
- package/core/core-patterns.md +228 -0
- package/core/error-boundaries.md +322 -0
- package/core/multi-module.md +315 -0
- package/core/naming.md +283 -0
- package/core/plugins.md +344 -0
- package/core/react-adapter.md +262 -0
- package/core/resolvers.md +357 -0
- package/core/schema-types.md +262 -0
- package/core/system-api.md +271 -0
- package/core/testing.md +257 -0
- package/core/time-travel.md +238 -0
- package/dist/index.cjs +111 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +102 -0
- package/dist/index.js.map +1 -0
- package/examples/ab-testing.ts +385 -0
- package/examples/ai-checkpoint.ts +509 -0
- package/examples/ai-guardrails.ts +319 -0
- package/examples/ai-orchestrator.ts +589 -0
- package/examples/async-chains.ts +287 -0
- package/examples/auth-flow.ts +371 -0
- package/examples/batch-resolver.ts +341 -0
- package/examples/checkers.ts +589 -0
- package/examples/contact-form.ts +176 -0
- package/examples/counter.ts +393 -0
- package/examples/dashboard-loader.ts +512 -0
- package/examples/debounce-constraints.ts +105 -0
- package/examples/dynamic-modules.ts +293 -0
- package/examples/error-boundaries.ts +430 -0
- package/examples/feature-flags.ts +220 -0
- package/examples/form-wizard.ts +347 -0
- package/examples/fraud-analysis.ts +663 -0
- package/examples/goal-heist.ts +341 -0
- package/examples/multi-module.ts +57 -0
- package/examples/newsletter.ts +241 -0
- package/examples/notifications.ts +210 -0
- package/examples/optimistic-updates.ts +317 -0
- package/examples/pagination.ts +260 -0
- package/examples/permissions.ts +337 -0
- package/examples/provider-routing.ts +403 -0
- package/examples/server.ts +316 -0
- package/examples/shopping-cart.ts +422 -0
- package/examples/sudoku.ts +630 -0
- package/examples/theme-locale.ts +204 -0
- package/examples/time-machine.ts +225 -0
- package/examples/topic-guard.ts +306 -0
- package/examples/url-sync.ts +333 -0
- package/examples/websocket.ts +404 -0
- package/package.json +65 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// Example: goal-heist
|
|
2
|
+
// Source: examples/goal-heist/src/agents.ts
|
|
3
|
+
// Pure module file — no DOM wiring
|
|
4
|
+
|
|
5
|
+
import { createRunner } from "@directive-run/ai";
|
|
6
|
+
import type { GoalNode } from "@directive-run/ai";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// API key management (localStorage)
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
const STORAGE_KEY = "goal-heist-api-key";
|
|
13
|
+
|
|
14
|
+
export function getApiKey(): string | null {
|
|
15
|
+
return localStorage.getItem(STORAGE_KEY);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function setApiKey(key: string): void {
|
|
19
|
+
localStorage.setItem(STORAGE_KEY, key);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Agent metadata
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface HeistAgent {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
emoji: string;
|
|
30
|
+
title: string;
|
|
31
|
+
produces: string[];
|
|
32
|
+
requires: string[];
|
|
33
|
+
instruction: string;
|
|
34
|
+
mockResponse: Record<string, unknown>;
|
|
35
|
+
mockDelay: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const AGENTS: Record<string, HeistAgent> = {
|
|
39
|
+
gigi: {
|
|
40
|
+
id: "gigi",
|
|
41
|
+
name: "Gigi",
|
|
42
|
+
emoji: "\uD83D\uDC84",
|
|
43
|
+
title: "The Grifter",
|
|
44
|
+
produces: ["guard_schedule"],
|
|
45
|
+
requires: [],
|
|
46
|
+
instruction:
|
|
47
|
+
'You are Gigi "The Grifter", a master of social engineering. You sweet-talked the night guard and obtained their patrol schedule. Respond with JSON: { "guard_schedule": "<brief schedule description>" }',
|
|
48
|
+
mockResponse: {
|
|
49
|
+
guard_schedule:
|
|
50
|
+
"Guards rotate every 45min. East wing unpatrolled 2:15-3:00 AM. Shift change at 3 AM — 4min blind spot.",
|
|
51
|
+
},
|
|
52
|
+
mockDelay: 800,
|
|
53
|
+
},
|
|
54
|
+
felix: {
|
|
55
|
+
id: "felix",
|
|
56
|
+
name: "Felix",
|
|
57
|
+
emoji: "\uD83D\uDD8A\uFE0F",
|
|
58
|
+
title: "The Forger",
|
|
59
|
+
produces: ["blueprints"],
|
|
60
|
+
requires: [],
|
|
61
|
+
instruction:
|
|
62
|
+
'You are Felix "The Forger", an expert document forger. You acquired the museum floor plans from the city records archive. Respond with JSON: { "blueprints": "<brief blueprint description>" }',
|
|
63
|
+
mockResponse: {
|
|
64
|
+
blueprints:
|
|
65
|
+
"Floor plan secured. Vault in sub-basement B2, access via service elevator. Air ducts too narrow — main corridor only.",
|
|
66
|
+
},
|
|
67
|
+
mockDelay: 1000,
|
|
68
|
+
},
|
|
69
|
+
vince: {
|
|
70
|
+
id: "vince",
|
|
71
|
+
name: "Vince",
|
|
72
|
+
emoji: "\uD83D\uDE97",
|
|
73
|
+
title: "The Wheelman",
|
|
74
|
+
produces: ["escape_route"],
|
|
75
|
+
requires: [],
|
|
76
|
+
instruction:
|
|
77
|
+
'You are Vince "The Wheelman", the fastest driver in the city. You scouted three escape routes and picked the best one. Respond with JSON: { "escape_route": "<brief route description>" }',
|
|
78
|
+
mockResponse: {
|
|
79
|
+
escape_route:
|
|
80
|
+
"Primary: loading dock → alley → I-90 on-ramp. Backup: north exit → parking garage swap. ETA to safe house: 8 minutes.",
|
|
81
|
+
},
|
|
82
|
+
mockDelay: 600,
|
|
83
|
+
},
|
|
84
|
+
h4x: {
|
|
85
|
+
id: "h4x",
|
|
86
|
+
name: "H4X",
|
|
87
|
+
emoji: "\uD83D\uDCBB",
|
|
88
|
+
title: "The Hacker",
|
|
89
|
+
produces: ["cameras_disabled"],
|
|
90
|
+
requires: ["guard_schedule"],
|
|
91
|
+
instruction:
|
|
92
|
+
'You are H4X "The Hacker". Using the guard schedule, you found the perfect window to loop the security cameras. Respond with JSON: { "cameras_disabled": "<brief description>" }',
|
|
93
|
+
mockResponse: {
|
|
94
|
+
cameras_disabled:
|
|
95
|
+
"Cameras on loop from 2:15 AM. Feed shows empty corridors on repeat. Motion sensors in east wing bypassed.",
|
|
96
|
+
},
|
|
97
|
+
mockDelay: 1200,
|
|
98
|
+
},
|
|
99
|
+
luca: {
|
|
100
|
+
id: "luca",
|
|
101
|
+
name: "Luca",
|
|
102
|
+
emoji: "\uD83D\uDD13",
|
|
103
|
+
title: "The Locksmith",
|
|
104
|
+
produces: ["vault_cracked"],
|
|
105
|
+
requires: ["cameras_disabled", "blueprints"],
|
|
106
|
+
instruction:
|
|
107
|
+
'You are Luca "The Locksmith". With cameras down and blueprints in hand, you cracked the vault. Respond with JSON: { "vault_cracked": "<brief description>" }',
|
|
108
|
+
mockResponse: {
|
|
109
|
+
vault_cracked:
|
|
110
|
+
"Vault open. Biometric bypass took 90 seconds. Package secured. No alarms triggered.",
|
|
111
|
+
},
|
|
112
|
+
mockDelay: 1500,
|
|
113
|
+
},
|
|
114
|
+
ollie: {
|
|
115
|
+
id: "ollie",
|
|
116
|
+
name: "Ollie",
|
|
117
|
+
emoji: "\uD83D\uDC41\uFE0F",
|
|
118
|
+
title: "The Lookout",
|
|
119
|
+
produces: ["all_clear"],
|
|
120
|
+
requires: ["vault_cracked", "escape_route"],
|
|
121
|
+
instruction:
|
|
122
|
+
'You are Ollie "The Lookout". The vault is cracked and the escape route is ready. Confirm all clear for extraction. Respond with JSON: { "all_clear": "<brief confirmation>" }',
|
|
123
|
+
mockResponse: {
|
|
124
|
+
all_clear:
|
|
125
|
+
"All clear. No police activity within 2 miles. Team converging on loading dock. Go go go.",
|
|
126
|
+
},
|
|
127
|
+
mockDelay: 700,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Ordered list for rendering
|
|
132
|
+
export const AGENT_ORDER = ["gigi", "felix", "vince", "h4x", "luca", "ollie"];
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Satisfaction weights
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
export const WEIGHTS: Record<string, number> = {
|
|
139
|
+
guard_schedule: 0.1,
|
|
140
|
+
blueprints: 0.1,
|
|
141
|
+
escape_route: 0.05,
|
|
142
|
+
cameras_disabled: 0.2,
|
|
143
|
+
vault_cracked: 0.35,
|
|
144
|
+
all_clear: 0.2,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export function computeSatisfaction(facts: Record<string, unknown>): number {
|
|
148
|
+
let score = 0;
|
|
149
|
+
|
|
150
|
+
for (const [key, weight] of Object.entries(WEIGHTS)) {
|
|
151
|
+
if (facts[key] != null) {
|
|
152
|
+
score += weight;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return Math.min(score, 1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Goal nodes (used by runGoal)
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export function buildGoalNodes(): Record<string, GoalNode> {
|
|
164
|
+
const nodes: Record<string, GoalNode> = {};
|
|
165
|
+
|
|
166
|
+
for (const agent of Object.values(AGENTS)) {
|
|
167
|
+
nodes[agent.id] = {
|
|
168
|
+
agent: agent.id,
|
|
169
|
+
produces: agent.produces,
|
|
170
|
+
requires: agent.requires.length > 0 ? agent.requires : undefined,
|
|
171
|
+
buildInput: (facts) => {
|
|
172
|
+
const relevantFacts: Record<string, unknown> = {};
|
|
173
|
+
|
|
174
|
+
for (const key of agent.requires) {
|
|
175
|
+
if (facts[key] != null) {
|
|
176
|
+
relevantFacts[key] = facts[key];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return JSON.stringify(relevantFacts);
|
|
181
|
+
},
|
|
182
|
+
extractOutput: (result) => {
|
|
183
|
+
try {
|
|
184
|
+
const parsed =
|
|
185
|
+
typeof result.output === "string"
|
|
186
|
+
? JSON.parse(result.output)
|
|
187
|
+
: result.output;
|
|
188
|
+
const extracted: Record<string, unknown> = {};
|
|
189
|
+
|
|
190
|
+
for (const key of agent.produces) {
|
|
191
|
+
if (parsed[key] != null) {
|
|
192
|
+
extracted[key] = parsed[key];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return extracted;
|
|
197
|
+
} catch {
|
|
198
|
+
return {};
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return nodes;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Runner factory (real Claude or mock)
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
export function createHeistRunner(apiKey: string | null) {
|
|
212
|
+
if (apiKey) {
|
|
213
|
+
return createRunner({
|
|
214
|
+
buildRequest: (agent, input) => ({
|
|
215
|
+
url: "/api/claude",
|
|
216
|
+
init: {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: {
|
|
219
|
+
"Content-Type": "application/json",
|
|
220
|
+
"x-api-key": apiKey,
|
|
221
|
+
},
|
|
222
|
+
body: JSON.stringify({
|
|
223
|
+
model: "claude-haiku-4-5-20251001",
|
|
224
|
+
max_tokens: 256,
|
|
225
|
+
system: agent.instructions ?? "",
|
|
226
|
+
messages: [{ role: "user", content: input }],
|
|
227
|
+
}),
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
parseResponse: async (res) => {
|
|
231
|
+
const data = await res.json();
|
|
232
|
+
const text = data.content?.[0]?.text ?? "";
|
|
233
|
+
const inputTokens = data.usage?.input_tokens ?? 0;
|
|
234
|
+
const outputTokens = data.usage?.output_tokens ?? 0;
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
text,
|
|
238
|
+
totalTokens: inputTokens + outputTokens,
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
parseOutput: (text) => {
|
|
242
|
+
try {
|
|
243
|
+
return JSON.parse(text);
|
|
244
|
+
} catch {
|
|
245
|
+
return text;
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Mock runner — configurable delays, supports failure injection
|
|
252
|
+
return createMockRunner();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Mock runner with failure injection
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
let failHacker = false;
|
|
260
|
+
let failForger = false;
|
|
261
|
+
let hackerFailCount = 0;
|
|
262
|
+
|
|
263
|
+
export function setFailHacker(v: boolean): void {
|
|
264
|
+
failHacker = v;
|
|
265
|
+
hackerFailCount = 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function setFailForger(v: boolean): void {
|
|
269
|
+
failForger = v;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function createMockRunner() {
|
|
273
|
+
return createRunner({
|
|
274
|
+
buildRequest: (agent, input) => ({
|
|
275
|
+
url: "mock://local",
|
|
276
|
+
init: {
|
|
277
|
+
method: "POST",
|
|
278
|
+
headers: { "Content-Type": "application/json" },
|
|
279
|
+
body: JSON.stringify({ agent: agent.name, input }),
|
|
280
|
+
},
|
|
281
|
+
}),
|
|
282
|
+
parseResponse: async (res) => {
|
|
283
|
+
const data = await res.json();
|
|
284
|
+
const text = data.content?.[0]?.text ?? "";
|
|
285
|
+
const tokens = data.usage?.total_tokens ?? 0;
|
|
286
|
+
|
|
287
|
+
return { text, totalTokens: tokens };
|
|
288
|
+
},
|
|
289
|
+
parseOutput: (text) => {
|
|
290
|
+
try {
|
|
291
|
+
return JSON.parse(text);
|
|
292
|
+
} catch {
|
|
293
|
+
return text;
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
// Mock fetch — adds delay, failure injection, returns Anthropic-shaped response
|
|
297
|
+
fetch: async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
298
|
+
const body = JSON.parse((init?.body as string) ?? "{}");
|
|
299
|
+
const agentName = (body.agent as string)?.toLowerCase() ?? "";
|
|
300
|
+
|
|
301
|
+
const agentDef = Object.values(AGENTS).find(
|
|
302
|
+
(a) => a.name.toLowerCase() === agentName,
|
|
303
|
+
);
|
|
304
|
+
const delay = agentDef?.mockDelay ?? 800;
|
|
305
|
+
|
|
306
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
307
|
+
|
|
308
|
+
// Failure injection
|
|
309
|
+
if (agentName === "h4x" && failHacker) {
|
|
310
|
+
hackerFailCount++;
|
|
311
|
+
|
|
312
|
+
if (hackerFailCount <= 3) {
|
|
313
|
+
return new Response(
|
|
314
|
+
JSON.stringify({ error: "Firewall upgraded! Intrusion detected." }),
|
|
315
|
+
{ status: 500 },
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (agentName === "felix" && failForger) {
|
|
321
|
+
return new Response(
|
|
322
|
+
JSON.stringify({ error: "Felix arrested at the archive!" }),
|
|
323
|
+
{ status: 500 },
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const mockResp = agentDef?.mockResponse ?? {};
|
|
328
|
+
const tokens = Math.floor(Math.random() * 40) + 20;
|
|
329
|
+
|
|
330
|
+
const responseBody = {
|
|
331
|
+
content: [{ text: JSON.stringify(mockResp) }],
|
|
332
|
+
usage: { total_tokens: tokens },
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
return new Response(JSON.stringify(responseBody), {
|
|
336
|
+
status: 200,
|
|
337
|
+
headers: { "Content-Type": "application/json" },
|
|
338
|
+
});
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Example: multi-module
|
|
2
|
+
// Source: examples/multi-module/src/main.ts
|
|
3
|
+
// Extracted for AI rules — DOM wiring stripped
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Multi-Module Example - Main Entry Point
|
|
7
|
+
*
|
|
8
|
+
* Demonstrates the NEW namespaced module access:
|
|
9
|
+
* - `system.facts.auth.token` instead of `system.facts.auth_token`
|
|
10
|
+
* - `system.derive.data.userCount` instead of `system.derive.data_userCount`
|
|
11
|
+
* - `system.events.auth.login({ token })` instead of `dispatch({ type: "auth_login", token })`
|
|
12
|
+
*
|
|
13
|
+
* Cross-module constraints work automatically:
|
|
14
|
+
* - Data fetches when auth succeeds
|
|
15
|
+
* - No asCombined() helper needed
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getFacts, system } from "./system";
|
|
19
|
+
|
|
20
|
+
// DOM Elements
|
|
21
|
+
|
|
22
|
+
// Start the system
|
|
23
|
+
system.start();
|
|
24
|
+
|
|
25
|
+
// Update UI function
|
|
26
|
+
|
|
27
|
+
// Subscribe to derivation changes using namespaced keys
|
|
28
|
+
// Note: The internal keys are still prefixed (auth_status), so we use those for subscribe
|
|
29
|
+
system.subscribe(
|
|
30
|
+
[
|
|
31
|
+
"auth_status",
|
|
32
|
+
"auth_displayName",
|
|
33
|
+
"data_status",
|
|
34
|
+
"data_userCount",
|
|
35
|
+
"ui_hasNotifications",
|
|
36
|
+
],
|
|
37
|
+
() => {
|
|
38
|
+
updateUI();
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// Also update on fact changes via polling (simple approach for this demo)
|
|
43
|
+
|
|
44
|
+
// Event handlers using namespaced events accessor
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
// Initial render
|
|
48
|
+
updateUI();
|
|
49
|
+
|
|
50
|
+
// Log to console for debugging
|
|
51
|
+
console.log("Multi-Module Example Started (Namespaced Mode)");
|
|
52
|
+
console.log("Try clicking Login to see the cross-module constraint in action:");
|
|
53
|
+
console.log("1. Auth module validates token via facts.auth.*");
|
|
54
|
+
console.log(
|
|
55
|
+
"2. Data module automatically fetches users when facts.auth.isAuthenticated",
|
|
56
|
+
);
|
|
57
|
+
console.log("3. UI module effects react to facts.data.* changes");
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Example: newsletter
|
|
2
|
+
// Source: examples/newsletter/src/main.ts
|
|
3
|
+
// Extracted for AI rules — DOM wiring stripped
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Newsletter Signup - Vanilla Directive Example
|
|
7
|
+
*
|
|
8
|
+
* Demonstrates all six primitives with the simplest possible module:
|
|
9
|
+
* - Facts: email, touched, status, errorMessage, lastSubmittedAt
|
|
10
|
+
* - Derivations: emailError (touch-gated), isValid, canSubmit (rate-limited)
|
|
11
|
+
* - Events: updateEmail, touchEmail, submit
|
|
12
|
+
* - Constraints: subscribe (status === 'submitting'), resetAfterSuccess
|
|
13
|
+
* - Resolvers: simulated async subscribe, auto-reset after delay
|
|
14
|
+
* - Effects: logging status transitions
|
|
15
|
+
*
|
|
16
|
+
* Uses a simulated setTimeout instead of a real API so no account is needed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
type ModuleSchema,
|
|
21
|
+
createModule,
|
|
22
|
+
createSystem,
|
|
23
|
+
t,
|
|
24
|
+
} from "@directive-run/core";
|
|
25
|
+
import { devtoolsPlugin } from "@directive-run/core/plugins";
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// Constants
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
32
|
+
const RATE_LIMIT_MS = 10_000; // 10 seconds (shorter for demo)
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Schema
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
const schema = {
|
|
39
|
+
facts: {
|
|
40
|
+
email: t.string(),
|
|
41
|
+
touched: t.boolean(),
|
|
42
|
+
status: t.string<"idle" | "submitting" | "success" | "error">(),
|
|
43
|
+
errorMessage: t.string(),
|
|
44
|
+
lastSubmittedAt: t.number(),
|
|
45
|
+
},
|
|
46
|
+
derivations: {
|
|
47
|
+
emailError: t.string(),
|
|
48
|
+
isValid: t.boolean(),
|
|
49
|
+
canSubmit: t.boolean(),
|
|
50
|
+
},
|
|
51
|
+
events: {
|
|
52
|
+
updateEmail: { value: t.string() },
|
|
53
|
+
touchEmail: {},
|
|
54
|
+
submit: {},
|
|
55
|
+
},
|
|
56
|
+
requirements: {
|
|
57
|
+
SUBSCRIBE: {},
|
|
58
|
+
RESET_AFTER_DELAY: {},
|
|
59
|
+
},
|
|
60
|
+
} satisfies ModuleSchema;
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// Module
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
const newsletter = createModule("newsletter", {
|
|
67
|
+
schema,
|
|
68
|
+
|
|
69
|
+
init: (facts) => {
|
|
70
|
+
facts.email = "";
|
|
71
|
+
facts.touched = false;
|
|
72
|
+
facts.status = "idle";
|
|
73
|
+
facts.errorMessage = "";
|
|
74
|
+
facts.lastSubmittedAt = 0;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
derive: {
|
|
78
|
+
emailError: (facts) => {
|
|
79
|
+
if (!facts.touched) {
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
if (!facts.email.trim()) {
|
|
83
|
+
return "Email is required";
|
|
84
|
+
}
|
|
85
|
+
if (!EMAIL_REGEX.test(facts.email)) {
|
|
86
|
+
return "Enter a valid email address";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return "";
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
isValid: (facts) => EMAIL_REGEX.test(facts.email),
|
|
93
|
+
|
|
94
|
+
canSubmit: (facts, derive) => {
|
|
95
|
+
if (!derive.isValid) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
if (facts.status !== "idle") {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
if (
|
|
102
|
+
facts.lastSubmittedAt > 0 &&
|
|
103
|
+
Date.now() - facts.lastSubmittedAt < RATE_LIMIT_MS
|
|
104
|
+
) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return true;
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
events: {
|
|
113
|
+
updateEmail: (facts, { value }) => {
|
|
114
|
+
facts.email = value;
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
touchEmail: (facts) => {
|
|
118
|
+
facts.touched = true;
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
submit: (facts) => {
|
|
122
|
+
facts.touched = true;
|
|
123
|
+
facts.status = "submitting";
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
constraints: {
|
|
128
|
+
subscribe: {
|
|
129
|
+
when: (facts) => facts.status === "submitting",
|
|
130
|
+
require: { type: "SUBSCRIBE" },
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
resetAfterSuccess: {
|
|
134
|
+
when: (facts) => facts.status === "success",
|
|
135
|
+
require: { type: "RESET_AFTER_DELAY" },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
resolvers: {
|
|
140
|
+
// Simulated submission — no API account needed
|
|
141
|
+
subscribe: {
|
|
142
|
+
requirement: "SUBSCRIBE",
|
|
143
|
+
resolve: async (req, context) => {
|
|
144
|
+
log(`Subscribing: ${context.facts.email}`);
|
|
145
|
+
|
|
146
|
+
// Simulate network delay
|
|
147
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
148
|
+
|
|
149
|
+
// Simulate occasional failure (20% chance)
|
|
150
|
+
if (Math.random() < 0.2) {
|
|
151
|
+
context.facts.status = "error";
|
|
152
|
+
context.facts.errorMessage =
|
|
153
|
+
"Simulated error — try again (20% failure rate for demo).";
|
|
154
|
+
log("Subscription failed (simulated)");
|
|
155
|
+
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
context.facts.status = "success";
|
|
160
|
+
context.facts.lastSubmittedAt = Date.now();
|
|
161
|
+
log("Subscription succeeded");
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
resetAfterDelay: {
|
|
166
|
+
requirement: "RESET_AFTER_DELAY",
|
|
167
|
+
resolve: async (req, context) => {
|
|
168
|
+
log("Auto-resetting in 5 seconds...");
|
|
169
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
170
|
+
context.facts.email = "";
|
|
171
|
+
context.facts.touched = false;
|
|
172
|
+
context.facts.status = "idle";
|
|
173
|
+
context.facts.errorMessage = "";
|
|
174
|
+
log("Form reset");
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
effects: {
|
|
180
|
+
logSubscription: {
|
|
181
|
+
deps: ["status"],
|
|
182
|
+
run: (facts, prev) => {
|
|
183
|
+
if (!prev) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (facts.status !== prev.status) {
|
|
188
|
+
log(`Status: ${prev.status} → ${facts.status}`);
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ============================================================================
|
|
196
|
+
// System
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
const system = createSystem({
|
|
200
|
+
module: newsletter,
|
|
201
|
+
debug: { runHistory: true },
|
|
202
|
+
plugins: [devtoolsPlugin({ name: "newsletter" })],
|
|
203
|
+
});
|
|
204
|
+
system.start();
|
|
205
|
+
|
|
206
|
+
// ============================================================================
|
|
207
|
+
// Logging helper
|
|
208
|
+
// ============================================================================
|
|
209
|
+
|
|
210
|
+
function log(msg: string) {
|
|
211
|
+
console.log(`[newsletter] ${msg}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ============================================================================
|
|
215
|
+
// DOM Bindings
|
|
216
|
+
// ============================================================================
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
// ============================================================================
|
|
220
|
+
// Render
|
|
221
|
+
// ============================================================================
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
// Subscribe to all relevant facts and derivations
|
|
225
|
+
system.subscribe(
|
|
226
|
+
[
|
|
227
|
+
"email",
|
|
228
|
+
"touched",
|
|
229
|
+
"status",
|
|
230
|
+
"errorMessage",
|
|
231
|
+
"lastSubmittedAt",
|
|
232
|
+
"emailError",
|
|
233
|
+
"isValid",
|
|
234
|
+
"canSubmit",
|
|
235
|
+
],
|
|
236
|
+
render,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Initial render
|
|
240
|
+
render();
|
|
241
|
+
log("Newsletter signup ready. Enter an email and subscribe.");
|