@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,306 @@
|
|
|
1
|
+
// Example: topic-guard
|
|
2
|
+
// Source: examples/topic-guard/src/topic-guard.ts
|
|
3
|
+
// Pure module file — no DOM wiring
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Topic Guard — Directive Module
|
|
7
|
+
*
|
|
8
|
+
* Demonstrates input guardrails for AI agents. Messages are checked against
|
|
9
|
+
* configurable guardrails before reaching the mock agent. Blocked messages
|
|
10
|
+
* are rejected with an explanation; allowed messages get a mock response.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { type ModuleSchema, createModule, t } from "@directive-run/core";
|
|
14
|
+
import {
|
|
15
|
+
type GuardrailResult,
|
|
16
|
+
checkKeywordGuardrail,
|
|
17
|
+
checkTopicClassifier,
|
|
18
|
+
getMockAgentResponse,
|
|
19
|
+
} from "./mock-guardrails.js";
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Types
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
export interface ChatMessage {
|
|
26
|
+
role: "user" | "agent" | "system";
|
|
27
|
+
text: string;
|
|
28
|
+
blocked: boolean;
|
|
29
|
+
guardrail?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GuardrailLogEntry {
|
|
33
|
+
timestamp: number;
|
|
34
|
+
input: string;
|
|
35
|
+
result: GuardrailResult;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Schema
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
export const topicGuardSchema = {
|
|
43
|
+
facts: {
|
|
44
|
+
input: t.string(),
|
|
45
|
+
messages: t.object<ChatMessage[]>(),
|
|
46
|
+
isProcessing: t.boolean(),
|
|
47
|
+
lastGuardrailResult: t.object<GuardrailResult | null>(),
|
|
48
|
+
guardrailLog: t.object<GuardrailLogEntry[]>(),
|
|
49
|
+
allowedTopics: t.object<string[]>(),
|
|
50
|
+
},
|
|
51
|
+
derivations: {
|
|
52
|
+
messageCount: t.number(),
|
|
53
|
+
blockedCount: t.number(),
|
|
54
|
+
allowedCount: t.number(),
|
|
55
|
+
blockRate: t.string(),
|
|
56
|
+
canSend: t.boolean(),
|
|
57
|
+
lastMessageBlocked: t.boolean(),
|
|
58
|
+
},
|
|
59
|
+
events: {
|
|
60
|
+
send: {},
|
|
61
|
+
clear: {},
|
|
62
|
+
setInput: { value: t.string() },
|
|
63
|
+
toggleTopic: { topic: t.string() },
|
|
64
|
+
},
|
|
65
|
+
requirements: {
|
|
66
|
+
BLOCK_MESSAGE: {
|
|
67
|
+
reason: t.string(),
|
|
68
|
+
guardrailName: t.string(),
|
|
69
|
+
},
|
|
70
|
+
ALLOW_MESSAGE: {},
|
|
71
|
+
},
|
|
72
|
+
} satisfies ModuleSchema;
|
|
73
|
+
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// Module
|
|
76
|
+
// ============================================================================
|
|
77
|
+
|
|
78
|
+
export const topicGuardModule = createModule("topic-guard", {
|
|
79
|
+
schema: topicGuardSchema,
|
|
80
|
+
|
|
81
|
+
init: (facts) => {
|
|
82
|
+
facts.input = "";
|
|
83
|
+
facts.messages = [];
|
|
84
|
+
facts.isProcessing = false;
|
|
85
|
+
facts.lastGuardrailResult = null;
|
|
86
|
+
facts.guardrailLog = [];
|
|
87
|
+
facts.allowedTopics = ["product", "billing", "support", "technical"];
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// Derivations
|
|
92
|
+
// ============================================================================
|
|
93
|
+
|
|
94
|
+
derive: {
|
|
95
|
+
messageCount: (facts) => {
|
|
96
|
+
return (facts.messages as ChatMessage[]).filter((m) => m.role === "user")
|
|
97
|
+
.length;
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
blockedCount: (facts) => {
|
|
101
|
+
return (facts.messages as ChatMessage[]).filter(
|
|
102
|
+
(m) => m.role === "user" && m.blocked,
|
|
103
|
+
).length;
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
allowedCount: (facts) => {
|
|
107
|
+
return (facts.messages as ChatMessage[]).filter(
|
|
108
|
+
(m) => m.role === "user" && !m.blocked,
|
|
109
|
+
).length;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
blockRate: (facts, derive) => {
|
|
113
|
+
const total = derive.messageCount as number;
|
|
114
|
+
if (total === 0) {
|
|
115
|
+
return "0%";
|
|
116
|
+
}
|
|
117
|
+
const blocked = derive.blockedCount as number;
|
|
118
|
+
const rate = Math.round((blocked / total) * 100);
|
|
119
|
+
|
|
120
|
+
return `${rate}%`;
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
canSend: (facts) => {
|
|
124
|
+
return (
|
|
125
|
+
(facts.input as string).trim().length > 0 &&
|
|
126
|
+
!(facts.isProcessing as boolean)
|
|
127
|
+
);
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
lastMessageBlocked: (facts) => {
|
|
131
|
+
const msgs = facts.messages as ChatMessage[];
|
|
132
|
+
if (msgs.length === 0) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return msgs[msgs.length - 1].blocked;
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
// ============================================================================
|
|
141
|
+
// Events
|
|
142
|
+
// ============================================================================
|
|
143
|
+
|
|
144
|
+
events: {
|
|
145
|
+
send: (facts) => {
|
|
146
|
+
const text = (facts.input as string).trim();
|
|
147
|
+
if (text.length === 0 || facts.isProcessing) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Add user message
|
|
152
|
+
const messages = [...(facts.messages as ChatMessage[])];
|
|
153
|
+
messages.push({ role: "user", text, blocked: false });
|
|
154
|
+
facts.messages = messages;
|
|
155
|
+
|
|
156
|
+
// Run guardrails
|
|
157
|
+
const keywordResult = checkKeywordGuardrail(text);
|
|
158
|
+
if (keywordResult.blocked) {
|
|
159
|
+
facts.lastGuardrailResult = keywordResult;
|
|
160
|
+
facts.isProcessing = true;
|
|
161
|
+
facts.input = "";
|
|
162
|
+
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const classifierResult = checkTopicClassifier(
|
|
167
|
+
text,
|
|
168
|
+
facts.allowedTopics as string[],
|
|
169
|
+
);
|
|
170
|
+
facts.lastGuardrailResult = classifierResult;
|
|
171
|
+
facts.isProcessing = true;
|
|
172
|
+
facts.input = "";
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
clear: (facts) => {
|
|
176
|
+
facts.messages = [];
|
|
177
|
+
facts.guardrailLog = [];
|
|
178
|
+
facts.lastGuardrailResult = null;
|
|
179
|
+
facts.isProcessing = false;
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
setInput: (facts, { value }) => {
|
|
183
|
+
facts.input = value;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
toggleTopic: (facts, { topic }) => {
|
|
187
|
+
const topics = [...(facts.allowedTopics as string[])];
|
|
188
|
+
const idx = topics.indexOf(topic);
|
|
189
|
+
if (idx >= 0) {
|
|
190
|
+
topics.splice(idx, 1);
|
|
191
|
+
} else {
|
|
192
|
+
topics.push(topic);
|
|
193
|
+
}
|
|
194
|
+
facts.allowedTopics = topics;
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
// ============================================================================
|
|
199
|
+
// Constraints
|
|
200
|
+
// ============================================================================
|
|
201
|
+
|
|
202
|
+
constraints: {
|
|
203
|
+
offTopicDetected: {
|
|
204
|
+
priority: 100,
|
|
205
|
+
when: (facts) => {
|
|
206
|
+
const result = facts.lastGuardrailResult as GuardrailResult | null;
|
|
207
|
+
|
|
208
|
+
return result?.blocked === true && (facts.isProcessing as boolean);
|
|
209
|
+
},
|
|
210
|
+
require: (facts) => {
|
|
211
|
+
const result = facts.lastGuardrailResult as GuardrailResult;
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
type: "BLOCK_MESSAGE",
|
|
215
|
+
reason: result.reason,
|
|
216
|
+
guardrailName: result.guardrailName,
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
onTopicConfirmed: {
|
|
222
|
+
priority: 90,
|
|
223
|
+
when: (facts) => {
|
|
224
|
+
const result = facts.lastGuardrailResult as GuardrailResult | null;
|
|
225
|
+
|
|
226
|
+
return result?.blocked === false && (facts.isProcessing as boolean);
|
|
227
|
+
},
|
|
228
|
+
require: () => ({
|
|
229
|
+
type: "ALLOW_MESSAGE",
|
|
230
|
+
}),
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
// ============================================================================
|
|
235
|
+
// Resolvers
|
|
236
|
+
// ============================================================================
|
|
237
|
+
|
|
238
|
+
resolvers: {
|
|
239
|
+
blockMessage: {
|
|
240
|
+
requirement: "BLOCK_MESSAGE",
|
|
241
|
+
resolve: async (req, context) => {
|
|
242
|
+
const messages = [...(context.facts.messages as ChatMessage[])];
|
|
243
|
+
// Mark the last user message as blocked
|
|
244
|
+
const lastUserIdx = messages.length - 1;
|
|
245
|
+
if (lastUserIdx >= 0) {
|
|
246
|
+
messages[lastUserIdx] = {
|
|
247
|
+
...messages[lastUserIdx],
|
|
248
|
+
blocked: true,
|
|
249
|
+
guardrail: req.guardrailName,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
// Add system rejection message
|
|
253
|
+
messages.push({
|
|
254
|
+
role: "system",
|
|
255
|
+
text: "I can only help with product-related questions.",
|
|
256
|
+
blocked: true,
|
|
257
|
+
guardrail: req.guardrailName,
|
|
258
|
+
});
|
|
259
|
+
context.facts.messages = messages;
|
|
260
|
+
context.facts.isProcessing = false;
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
allowMessage: {
|
|
265
|
+
requirement: "ALLOW_MESSAGE",
|
|
266
|
+
resolve: async (_req, context) => {
|
|
267
|
+
const messages = [...(context.facts.messages as ChatMessage[])];
|
|
268
|
+
const lastUserMsg = messages.filter((m) => m.role === "user").pop();
|
|
269
|
+
const responseText = getMockAgentResponse(lastUserMsg?.text ?? "");
|
|
270
|
+
messages.push({
|
|
271
|
+
role: "agent",
|
|
272
|
+
text: responseText,
|
|
273
|
+
blocked: false,
|
|
274
|
+
});
|
|
275
|
+
context.facts.messages = messages;
|
|
276
|
+
context.facts.isProcessing = false;
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
// ============================================================================
|
|
282
|
+
// Effects
|
|
283
|
+
// ============================================================================
|
|
284
|
+
|
|
285
|
+
effects: {
|
|
286
|
+
logGuardrailResult: {
|
|
287
|
+
deps: ["lastGuardrailResult"],
|
|
288
|
+
run: (facts) => {
|
|
289
|
+
const result = facts.lastGuardrailResult as GuardrailResult | null;
|
|
290
|
+
if (!result) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const msgs = facts.messages as ChatMessage[];
|
|
295
|
+
const lastUserMsg = [...msgs].reverse().find((m) => m.role === "user");
|
|
296
|
+
const log = [...(facts.guardrailLog as GuardrailLogEntry[])];
|
|
297
|
+
log.push({
|
|
298
|
+
timestamp: Date.now(),
|
|
299
|
+
input: lastUserMsg?.text ?? "",
|
|
300
|
+
result,
|
|
301
|
+
});
|
|
302
|
+
facts.guardrailLog = log;
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
});
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Example: url-sync
|
|
2
|
+
// Source: examples/url-sync/src/url-sync.ts
|
|
3
|
+
// Pure module file — no DOM wiring
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* URL Sync — Directive Modules
|
|
7
|
+
*
|
|
8
|
+
* Two modules that synchronize URL query parameters with product filtering:
|
|
9
|
+
* - **url module**: Reads/writes URL params, dispatches filter changes
|
|
10
|
+
* - **products module**: Fetches filtered products via cross-module constraints
|
|
11
|
+
*
|
|
12
|
+
* Demonstrates bidirectional URL sync (popstate ↔ replaceState), cross-module
|
|
13
|
+
* constraints, and resolver-driven data fetching with mock delay.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type ModuleSchema,
|
|
18
|
+
createModule,
|
|
19
|
+
createSystem,
|
|
20
|
+
t,
|
|
21
|
+
} from "@directive-run/core";
|
|
22
|
+
import { devtoolsPlugin } from "@directive-run/core/plugins";
|
|
23
|
+
import { type Product, allProducts, filterProducts } from "./mock-products.js";
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// URL Module — Schema
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
export const urlSchema = {
|
|
30
|
+
facts: {
|
|
31
|
+
search: t.string(),
|
|
32
|
+
category: t.string(),
|
|
33
|
+
sortBy: t.string<"newest" | "price-asc" | "price-desc">(),
|
|
34
|
+
page: t.number(),
|
|
35
|
+
syncingFromUrl: t.boolean(),
|
|
36
|
+
},
|
|
37
|
+
derivations: {},
|
|
38
|
+
events: {
|
|
39
|
+
setSearch: { value: t.string() },
|
|
40
|
+
setCategory: { value: t.string() },
|
|
41
|
+
setSortBy: { value: t.string() },
|
|
42
|
+
setPage: { value: t.number() },
|
|
43
|
+
syncFromUrl: {
|
|
44
|
+
search: t.string(),
|
|
45
|
+
category: t.string(),
|
|
46
|
+
sortBy: t.string(),
|
|
47
|
+
page: t.number(),
|
|
48
|
+
},
|
|
49
|
+
syncComplete: {},
|
|
50
|
+
},
|
|
51
|
+
requirements: {},
|
|
52
|
+
} satisfies ModuleSchema;
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// URL Module — Helpers
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
function readUrlParams(): {
|
|
59
|
+
search: string;
|
|
60
|
+
category: string;
|
|
61
|
+
sortBy: string;
|
|
62
|
+
page: number;
|
|
63
|
+
} {
|
|
64
|
+
const params = new URLSearchParams(window.location.search);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
search: params.get("q") ?? "",
|
|
68
|
+
category: params.get("cat") ?? "",
|
|
69
|
+
sortBy: params.get("sort") ?? "newest",
|
|
70
|
+
page: Math.max(1, Number.parseInt(params.get("page") ?? "1", 10) || 1),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// URL Module
|
|
76
|
+
// ============================================================================
|
|
77
|
+
|
|
78
|
+
export const urlModule = createModule("url", {
|
|
79
|
+
schema: urlSchema,
|
|
80
|
+
|
|
81
|
+
init: (facts) => {
|
|
82
|
+
const params = readUrlParams();
|
|
83
|
+
facts.search = params.search;
|
|
84
|
+
facts.category = params.category;
|
|
85
|
+
facts.sortBy = params.sortBy;
|
|
86
|
+
facts.page = params.page;
|
|
87
|
+
facts.syncingFromUrl = false;
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// Events
|
|
92
|
+
// ============================================================================
|
|
93
|
+
|
|
94
|
+
events: {
|
|
95
|
+
setSearch: (facts, { value }) => {
|
|
96
|
+
facts.search = value;
|
|
97
|
+
facts.page = 1;
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
setCategory: (facts, { value }) => {
|
|
101
|
+
facts.category = value;
|
|
102
|
+
facts.page = 1;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
setSortBy: (facts, { value }) => {
|
|
106
|
+
facts.sortBy = value;
|
|
107
|
+
facts.page = 1;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
setPage: (facts, { value }) => {
|
|
111
|
+
facts.page = value;
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
syncFromUrl: (facts, { search, category, sortBy, page }) => {
|
|
115
|
+
facts.syncingFromUrl = true;
|
|
116
|
+
facts.search = search;
|
|
117
|
+
facts.category = category;
|
|
118
|
+
facts.sortBy = sortBy;
|
|
119
|
+
facts.page = page;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
syncComplete: (facts) => {
|
|
123
|
+
facts.syncingFromUrl = false;
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
// ============================================================================
|
|
128
|
+
// Effects
|
|
129
|
+
// ============================================================================
|
|
130
|
+
|
|
131
|
+
effects: {
|
|
132
|
+
urlToState: {
|
|
133
|
+
run: () => {
|
|
134
|
+
const handler = () => {
|
|
135
|
+
const params = readUrlParams();
|
|
136
|
+
system.events.url.syncFromUrl({
|
|
137
|
+
search: params.search,
|
|
138
|
+
category: params.category,
|
|
139
|
+
sortBy: params.sortBy,
|
|
140
|
+
page: params.page,
|
|
141
|
+
});
|
|
142
|
+
system.events.url.syncComplete();
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
window.addEventListener("popstate", handler);
|
|
146
|
+
|
|
147
|
+
return () => {
|
|
148
|
+
window.removeEventListener("popstate", handler);
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
stateToUrl: {
|
|
154
|
+
deps: ["search", "category", "sortBy", "page"],
|
|
155
|
+
run: (facts) => {
|
|
156
|
+
if (facts.syncingFromUrl) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const params = new URLSearchParams();
|
|
161
|
+
|
|
162
|
+
if (facts.search !== "") {
|
|
163
|
+
params.set("q", facts.search as string);
|
|
164
|
+
}
|
|
165
|
+
if (facts.category !== "" && facts.category !== "all") {
|
|
166
|
+
params.set("cat", facts.category as string);
|
|
167
|
+
}
|
|
168
|
+
if (facts.sortBy !== "newest") {
|
|
169
|
+
params.set("sort", facts.sortBy as string);
|
|
170
|
+
}
|
|
171
|
+
if ((facts.page as number) > 1) {
|
|
172
|
+
params.set("page", String(facts.page));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const search = params.toString();
|
|
176
|
+
const newUrl = search
|
|
177
|
+
? `${window.location.pathname}?${search}`
|
|
178
|
+
: window.location.pathname;
|
|
179
|
+
|
|
180
|
+
if (newUrl !== `${window.location.pathname}${window.location.search}`) {
|
|
181
|
+
history.replaceState(null, "", newUrl);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ============================================================================
|
|
189
|
+
// Products Module — Schema
|
|
190
|
+
// ============================================================================
|
|
191
|
+
|
|
192
|
+
export const productsSchema = {
|
|
193
|
+
facts: {
|
|
194
|
+
items: t.object<Product[]>(),
|
|
195
|
+
totalItems: t.number(),
|
|
196
|
+
isLoading: t.boolean(),
|
|
197
|
+
itemsPerPage: t.number(),
|
|
198
|
+
},
|
|
199
|
+
derivations: {
|
|
200
|
+
totalPages: t.number(),
|
|
201
|
+
currentPageDisplay: t.string(),
|
|
202
|
+
},
|
|
203
|
+
events: {
|
|
204
|
+
setItemsPerPage: { value: t.number() },
|
|
205
|
+
},
|
|
206
|
+
requirements: {
|
|
207
|
+
FETCH_PRODUCTS: {
|
|
208
|
+
search: t.string(),
|
|
209
|
+
category: t.string(),
|
|
210
|
+
sortBy: t.string(),
|
|
211
|
+
page: t.number(),
|
|
212
|
+
itemsPerPage: t.number(),
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
} satisfies ModuleSchema;
|
|
216
|
+
|
|
217
|
+
// ============================================================================
|
|
218
|
+
// Products Module
|
|
219
|
+
// ============================================================================
|
|
220
|
+
|
|
221
|
+
export const productsModule = createModule("products", {
|
|
222
|
+
schema: productsSchema,
|
|
223
|
+
|
|
224
|
+
crossModuleDeps: { url: urlSchema },
|
|
225
|
+
|
|
226
|
+
init: (facts) => {
|
|
227
|
+
facts.items = [];
|
|
228
|
+
facts.totalItems = 0;
|
|
229
|
+
facts.isLoading = false;
|
|
230
|
+
facts.itemsPerPage = 10;
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
// ============================================================================
|
|
234
|
+
// Derivations
|
|
235
|
+
// ============================================================================
|
|
236
|
+
|
|
237
|
+
derive: {
|
|
238
|
+
totalPages: (facts) => {
|
|
239
|
+
if (facts.self.totalItems === 0) {
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return Math.ceil(facts.self.totalItems / facts.self.itemsPerPage);
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
currentPageDisplay: (facts) => {
|
|
247
|
+
const total = facts.self.totalItems;
|
|
248
|
+
if (total === 0) {
|
|
249
|
+
return "No results";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const page = facts.url.page;
|
|
253
|
+
const perPage = facts.self.itemsPerPage;
|
|
254
|
+
const start = (page - 1) * perPage + 1;
|
|
255
|
+
const end = Math.min(page * perPage, total);
|
|
256
|
+
|
|
257
|
+
return `${start}\u2013${end} of ${total}`;
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
// ============================================================================
|
|
262
|
+
// Events
|
|
263
|
+
// ============================================================================
|
|
264
|
+
|
|
265
|
+
events: {
|
|
266
|
+
setItemsPerPage: (facts, { value }) => {
|
|
267
|
+
facts.itemsPerPage = value;
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
// ============================================================================
|
|
272
|
+
// Constraints
|
|
273
|
+
// ============================================================================
|
|
274
|
+
|
|
275
|
+
constraints: {
|
|
276
|
+
fetchProducts: {
|
|
277
|
+
priority: 100,
|
|
278
|
+
when: () => true,
|
|
279
|
+
require: (facts) => ({
|
|
280
|
+
type: "FETCH_PRODUCTS",
|
|
281
|
+
search: facts.url.search,
|
|
282
|
+
category: facts.url.category,
|
|
283
|
+
sortBy: facts.url.sortBy,
|
|
284
|
+
page: facts.url.page,
|
|
285
|
+
itemsPerPage: facts.self.itemsPerPage,
|
|
286
|
+
}),
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
// ============================================================================
|
|
291
|
+
// Resolvers
|
|
292
|
+
// ============================================================================
|
|
293
|
+
|
|
294
|
+
resolvers: {
|
|
295
|
+
fetchProducts: {
|
|
296
|
+
requirement: "FETCH_PRODUCTS",
|
|
297
|
+
key: (req) =>
|
|
298
|
+
`fetch-${req.search}-${req.category}-${req.sortBy}-${req.page}-${req.itemsPerPage}`,
|
|
299
|
+
timeout: 10000,
|
|
300
|
+
resolve: async (req, context) => {
|
|
301
|
+
context.facts.isLoading = true;
|
|
302
|
+
|
|
303
|
+
// Simulate network delay
|
|
304
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
305
|
+
|
|
306
|
+
const result = filterProducts(allProducts, {
|
|
307
|
+
search: req.search,
|
|
308
|
+
category: req.category,
|
|
309
|
+
sortBy: req.sortBy,
|
|
310
|
+
page: req.page,
|
|
311
|
+
itemsPerPage: req.itemsPerPage,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
context.facts.items = result.items;
|
|
315
|
+
context.facts.totalItems = result.totalItems;
|
|
316
|
+
context.facts.isLoading = false;
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ============================================================================
|
|
323
|
+
// System
|
|
324
|
+
// ============================================================================
|
|
325
|
+
|
|
326
|
+
export const system = createSystem({
|
|
327
|
+
modules: {
|
|
328
|
+
url: urlModule,
|
|
329
|
+
products: productsModule,
|
|
330
|
+
},
|
|
331
|
+
debug: { runHistory: true },
|
|
332
|
+
plugins: [devtoolsPlugin({ name: "url-sync" })],
|
|
333
|
+
});
|