@spfn/core 0.2.0-beta.2 → 0.2.0-beta.21
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/README.md +262 -1092
- package/dist/{boss-D-fGtVgM.d.ts → boss-DI1r4kTS.d.ts} +68 -11
- package/dist/codegen/index.d.ts +55 -8
- package/dist/codegen/index.js +179 -5
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.d.ts +204 -6
- package/dist/config/index.js +44 -11
- package/dist/config/index.js.map +1 -1
- package/dist/db/index.d.ts +13 -0
- package/dist/db/index.js +92 -33
- package/dist/db/index.js.map +1 -1
- package/dist/env/index.d.ts +83 -3
- package/dist/env/index.js +83 -15
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.d.ts +95 -0
- package/dist/env/loader.js +78 -0
- package/dist/env/loader.js.map +1 -0
- package/dist/event/index.d.ts +29 -70
- package/dist/event/index.js +15 -1
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +157 -0
- package/dist/event/sse/client.js +169 -0
- package/dist/event/sse/client.js.map +1 -0
- package/dist/event/sse/index.d.ts +46 -0
- package/dist/event/sse/index.js +205 -0
- package/dist/event/sse/index.js.map +1 -0
- package/dist/job/index.d.ts +54 -8
- package/dist/job/index.js +61 -12
- package/dist/job/index.js.map +1 -1
- package/dist/middleware/index.d.ts +124 -11
- package/dist/middleware/index.js +41 -7
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/index.js +37 -5
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +45 -24
- package/dist/nextjs/server.js +87 -66
- package/dist/nextjs/server.js.map +1 -1
- package/dist/route/index.d.ts +207 -14
- package/dist/route/index.js +304 -31
- package/dist/route/index.js.map +1 -1
- package/dist/route/types.d.ts +2 -31
- package/dist/router-Di7ENoah.d.ts +151 -0
- package/dist/server/index.d.ts +321 -10
- package/dist/server/index.js +798 -189
- package/dist/server/index.js.map +1 -1
- package/dist/{types-DRG2XMTR.d.ts → types-7Mhoxnnt.d.ts} +97 -4
- package/dist/types-DHQMQlcb.d.ts +305 -0
- package/docs/cache.md +133 -0
- package/docs/codegen.md +74 -0
- package/docs/database.md +346 -0
- package/docs/entity.md +539 -0
- package/docs/env.md +499 -0
- package/docs/errors.md +319 -0
- package/docs/event.md +432 -0
- package/docs/file-upload.md +717 -0
- package/docs/job.md +131 -0
- package/docs/logger.md +108 -0
- package/docs/middleware.md +337 -0
- package/docs/nextjs.md +247 -0
- package/docs/repository.md +496 -0
- package/docs/route.md +497 -0
- package/docs/server.md +429 -0
- package/package.json +19 -3
package/docs/event.md
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
# Event
|
|
2
|
+
|
|
3
|
+
Decoupled pub/sub event system with SSE support for real-time frontend updates.
|
|
4
|
+
|
|
5
|
+
## Define Events
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// src/server/events/index.ts
|
|
9
|
+
import { defineEvent } from '@spfn/core/event';
|
|
10
|
+
import { Type } from '@sinclair/typebox';
|
|
11
|
+
|
|
12
|
+
// Event with typed payload
|
|
13
|
+
export const userCreated = defineEvent('user.created', Type.Object({
|
|
14
|
+
userId: Type.String(),
|
|
15
|
+
email: Type.String(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
export const orderPlaced = defineEvent('order.placed', Type.Object({
|
|
19
|
+
orderId: Type.String(),
|
|
20
|
+
amount: Type.Number(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Event without payload
|
|
24
|
+
export const serverStarted = defineEvent('server.started');
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Subscribe & Emit
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { userCreated, serverStarted } from './events';
|
|
31
|
+
|
|
32
|
+
// Subscribe to event
|
|
33
|
+
const unsubscribe = userCreated.subscribe((payload) => {
|
|
34
|
+
console.log('User created:', payload.userId);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Emit event
|
|
38
|
+
await userCreated.emit({ userId: '123', email: 'user@example.com' });
|
|
39
|
+
|
|
40
|
+
// Emit event without payload
|
|
41
|
+
await serverStarted.emit();
|
|
42
|
+
|
|
43
|
+
// Unsubscribe when done
|
|
44
|
+
unsubscribe();
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Multiple Subscribers
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
// Multiple independent handlers
|
|
51
|
+
userCreated.subscribe(async (payload) => {
|
|
52
|
+
await sendWelcomeEmail(payload.email);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
userCreated.subscribe(async (payload) => {
|
|
56
|
+
await createDefaultSettings(payload.userId);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
userCreated.subscribe(async (payload) => {
|
|
60
|
+
await notifyAdmins(payload.userId);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// All handlers execute when event is emitted
|
|
64
|
+
await userCreated.emit({ userId: '123', email: 'user@example.com' });
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Event Router for SSE
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
// src/server/events/router.ts
|
|
71
|
+
import { defineEventRouter } from '@spfn/core/event';
|
|
72
|
+
import { userCreated, orderPlaced } from './index';
|
|
73
|
+
|
|
74
|
+
export const eventRouter = defineEventRouter({
|
|
75
|
+
userCreated,
|
|
76
|
+
orderPlaced,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export type EventRouter = typeof eventRouter;
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Server Setup
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// server.config.ts
|
|
86
|
+
import { defineServerConfig } from '@spfn/core/server';
|
|
87
|
+
import { eventRouter } from './events/router';
|
|
88
|
+
|
|
89
|
+
export default defineServerConfig()
|
|
90
|
+
.routes(appRouter)
|
|
91
|
+
.jobs(jobRouter)
|
|
92
|
+
.events(eventRouter) // → GET /events/stream
|
|
93
|
+
.build();
|
|
94
|
+
|
|
95
|
+
// Custom path and options
|
|
96
|
+
.events(eventRouter, {
|
|
97
|
+
path: '/sse', // Custom endpoint path
|
|
98
|
+
pingInterval: 30000, // Keep-alive interval (default: 30s)
|
|
99
|
+
})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## SSE Authentication
|
|
103
|
+
|
|
104
|
+
Browser `EventSource` API does not support custom headers. SPFN uses a **Token Exchange** pattern:
|
|
105
|
+
|
|
106
|
+
1. Client sends `POST /events/token` with Bearer JWT
|
|
107
|
+
2. Server returns a one-time token (30s TTL)
|
|
108
|
+
3. Client connects to `GET /events/stream?token=...&events=...`
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
// server.config.ts
|
|
112
|
+
import { defineServerConfig } from '@spfn/core/server';
|
|
113
|
+
import { authenticate } from '@spfn/auth/server';
|
|
114
|
+
|
|
115
|
+
export default defineServerConfig()
|
|
116
|
+
.middlewares([authenticate])
|
|
117
|
+
.routes(appRouter)
|
|
118
|
+
.events(eventRouter, {
|
|
119
|
+
auth: { enabled: true }, // This is all you need
|
|
120
|
+
})
|
|
121
|
+
.build();
|
|
122
|
+
// → POST /events/token (protected by authenticate middleware)
|
|
123
|
+
// → GET /events/stream?token=...&events=... (token verified)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Authorization Hooks
|
|
127
|
+
|
|
128
|
+
#### `authorize` — Subscription Authorization (once on connect)
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
.events(eventRouter, {
|
|
132
|
+
auth: {
|
|
133
|
+
enabled: true,
|
|
134
|
+
authorize: async (subject, events) =>
|
|
135
|
+
{
|
|
136
|
+
// events: ('userCreated' | 'orderPlaced')[] — type inferred
|
|
137
|
+
const user = await usersRepository.findById(subject);
|
|
138
|
+
if (user.role === 'admin') return events;
|
|
139
|
+
return events.filter(e => !e.startsWith('admin.'));
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
#### `filter` — Payload Filtering (on every event emission)
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
.events(eventRouter, {
|
|
149
|
+
auth: {
|
|
150
|
+
enabled: true,
|
|
151
|
+
filter: {
|
|
152
|
+
// payload type inferred per-event — no casting needed
|
|
153
|
+
orderPlaced: (subject, payload) => payload.userId === subject,
|
|
154
|
+
// userCreated: no filter → sent to all authenticated users
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Browser Client
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { createSSEClient } from '@spfn/core/event/sse/client';
|
|
164
|
+
import type { EventRouter } from '@/server/events/router';
|
|
165
|
+
|
|
166
|
+
// Create client (uses defaults: NEXT_PUBLIC_SPFN_API_URL + /events/stream)
|
|
167
|
+
const client = createSSEClient<EventRouter>();
|
|
168
|
+
|
|
169
|
+
// Or with custom configuration
|
|
170
|
+
const client = createSSEClient<EventRouter>({
|
|
171
|
+
host: 'https://api.example.com',
|
|
172
|
+
pathname: '/sse',
|
|
173
|
+
reconnect: true,
|
|
174
|
+
reconnectDelay: 3000,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Subscribe to events
|
|
178
|
+
const unsubscribe = client.subscribe({
|
|
179
|
+
events: ['userCreated', 'orderPlaced'],
|
|
180
|
+
handlers: {
|
|
181
|
+
userCreated: (payload) => {
|
|
182
|
+
console.log('New user:', payload.userId);
|
|
183
|
+
},
|
|
184
|
+
orderPlaced: (payload) => {
|
|
185
|
+
console.log('New order:', payload.orderId);
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
onOpen: () => console.log('SSE connected'),
|
|
189
|
+
onError: (err) => console.error('SSE error:', err),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Cleanup
|
|
193
|
+
unsubscribe();
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### With Authentication
|
|
197
|
+
|
|
198
|
+
Use `createAuthSSEClient` which handles token acquisition automatically via the RPC proxy:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
import { createAuthSSEClient } from '@spfn/core/event/sse/client';
|
|
202
|
+
import type { EventRouter } from '@/server/events/router';
|
|
203
|
+
|
|
204
|
+
const client = createAuthSSEClient<EventRouter>();
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
This requires `eventRouteMap` to be merged into your RPC proxy (one-time setup):
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
// app/api/rpc/[routeName]/route.ts
|
|
211
|
+
import '@spfn/auth/nextjs/api';
|
|
212
|
+
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
213
|
+
import { eventRouteMap } from '@spfn/core/event';
|
|
214
|
+
import { routeMap } from '@/generated/route-map';
|
|
215
|
+
|
|
216
|
+
export const { GET, POST } = createRpcProxy({
|
|
217
|
+
routeMap: { ...routeMap, ...eventRouteMap },
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Tokens are acquired on every (re)connect — one-time tokens are handled automatically.
|
|
222
|
+
|
|
223
|
+
## Simple Subscribe Helper
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
import { subscribeToEvents } from '@spfn/core/event/sse/client';
|
|
227
|
+
import type { EventRouter } from '@/server/events/router';
|
|
228
|
+
|
|
229
|
+
// One-liner subscription
|
|
230
|
+
const unsubscribe = subscribeToEvents<EventRouter>(
|
|
231
|
+
['userCreated'],
|
|
232
|
+
{
|
|
233
|
+
userCreated: (payload) => console.log('User:', payload),
|
|
234
|
+
}
|
|
235
|
+
);
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Job Integration
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
import { defineEvent } from '@spfn/core/event';
|
|
242
|
+
import { job, defineJobRouter } from '@spfn/core/job';
|
|
243
|
+
|
|
244
|
+
// Define event
|
|
245
|
+
export const orderPlaced = defineEvent('order.placed', Type.Object({
|
|
246
|
+
orderId: Type.String(),
|
|
247
|
+
userId: Type.String(),
|
|
248
|
+
}));
|
|
249
|
+
|
|
250
|
+
// Jobs subscribe to event
|
|
251
|
+
export const sendOrderConfirmation = job('send-order-confirmation')
|
|
252
|
+
.on(orderPlaced)
|
|
253
|
+
.handler(async (payload) => {
|
|
254
|
+
await emailService.sendOrderConfirmation(payload.orderId);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
export const updateInventory = job('update-inventory')
|
|
258
|
+
.on(orderPlaced)
|
|
259
|
+
.handler(async (payload) => {
|
|
260
|
+
await inventoryService.reserve(payload.orderId);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Register jobs
|
|
264
|
+
export const jobRouter = defineJobRouter({
|
|
265
|
+
sendOrderConfirmation,
|
|
266
|
+
updateInventory,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Emit event - all subscribed jobs execute
|
|
270
|
+
await orderPlaced.emit({ orderId: 'ord-123', userId: 'user-456' });
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Multi-Instance Support
|
|
274
|
+
|
|
275
|
+
For applications running multiple instances, use cache-based pub/sub.
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
import { defineEvent } from '@spfn/core/event';
|
|
279
|
+
import { getCache } from '@spfn/core/cache';
|
|
280
|
+
|
|
281
|
+
const userCreated = defineEvent('user.created', Type.Object({
|
|
282
|
+
userId: Type.String(),
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
// Enable cache-based pub/sub
|
|
286
|
+
const cache = getCache();
|
|
287
|
+
if (cache)
|
|
288
|
+
{
|
|
289
|
+
await userCreated.useCache({
|
|
290
|
+
publish: async (channel, message) => {
|
|
291
|
+
await cache.publish(channel, JSON.stringify(message));
|
|
292
|
+
},
|
|
293
|
+
subscribe: async (channel, handler) => {
|
|
294
|
+
const subscriber = cache.duplicate();
|
|
295
|
+
await subscriber.subscribe(channel);
|
|
296
|
+
subscriber.on('message', (ch, msg) => {
|
|
297
|
+
if (ch === channel)
|
|
298
|
+
{
|
|
299
|
+
handler(JSON.parse(msg));
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Events now broadcast to all instances
|
|
307
|
+
await userCreated.emit({ userId: '123' });
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## API Reference
|
|
311
|
+
|
|
312
|
+
### defineEvent(name)
|
|
313
|
+
|
|
314
|
+
Define an event without payload.
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
export const serverStarted = defineEvent('server.started');
|
|
318
|
+
|
|
319
|
+
serverStarted.subscribe(() => { ... });
|
|
320
|
+
await serverStarted.emit();
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### defineEvent(name, schema)
|
|
324
|
+
|
|
325
|
+
Define an event with typed payload.
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
export const userCreated = defineEvent('user.created', Type.Object({
|
|
329
|
+
userId: Type.String(),
|
|
330
|
+
}));
|
|
331
|
+
|
|
332
|
+
userCreated.subscribe((payload) => { ... });
|
|
333
|
+
await userCreated.emit({ userId: '123' });
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### EventDef Methods
|
|
337
|
+
|
|
338
|
+
| Method | Description |
|
|
339
|
+
|--------|-------------|
|
|
340
|
+
| `subscribe(handler)` | Subscribe to event. Returns unsubscribe function |
|
|
341
|
+
| `unsubscribeAll()` | Remove all subscribers |
|
|
342
|
+
| `emit(payload?)` | Emit event to all subscribers |
|
|
343
|
+
| `useCache(cache)` | Enable cache-based pub/sub for multi-instance |
|
|
344
|
+
|
|
345
|
+
### SSE Client Options
|
|
346
|
+
|
|
347
|
+
| Option | Type | Default | Description |
|
|
348
|
+
|--------|------|---------|-------------|
|
|
349
|
+
| `host` | string | `NEXT_PUBLIC_SPFN_API_URL` | Backend API host URL |
|
|
350
|
+
| `pathname` | string | `/events/stream` | SSE endpoint pathname |
|
|
351
|
+
| `reconnect` | boolean | `true` | Auto reconnect on disconnect |
|
|
352
|
+
| `reconnectDelay` | number | `3000` | Reconnect delay (ms) |
|
|
353
|
+
| `maxReconnectAttempts` | number | `0` | Max attempts (0 = infinite) |
|
|
354
|
+
| `withCredentials` | boolean | `false` | Include cookies |
|
|
355
|
+
| `acquireToken` | () => Promise\<string\> | - | Acquire one-time SSE token before connecting |
|
|
356
|
+
|
|
357
|
+
### SSE Auth Options
|
|
358
|
+
|
|
359
|
+
| Option | Type | Default | Description |
|
|
360
|
+
|--------|------|---------|-------------|
|
|
361
|
+
| `enabled` | boolean | `false` | Enable token authentication |
|
|
362
|
+
| `tokenTtl` | number | `30000` | Token TTL in milliseconds |
|
|
363
|
+
| `store` | SSETokenStore | InMemory | Custom token store (e.g., Redis) |
|
|
364
|
+
| `getSubject` | (c) => string \| null | `c.get('auth')?.userId` | Extract subject from context |
|
|
365
|
+
| `authorize` | (subject, events) => events[] | - | Subscription authorization hook |
|
|
366
|
+
| `filter` | { [event]: (subject, payload) => boolean } | - | Per-event payload filter |
|
|
367
|
+
|
|
368
|
+
## Event Flow Architecture
|
|
369
|
+
|
|
370
|
+
```
|
|
371
|
+
userCreated.emit({ ... })
|
|
372
|
+
│
|
|
373
|
+
┌───────────────────┼───────────────────┐
|
|
374
|
+
▼ ▼ ▼
|
|
375
|
+
┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
376
|
+
│ Backend │ │ Job │ │ SSE │
|
|
377
|
+
│ Handler │ │ Queue │ │ Stream │
|
|
378
|
+
└──────────┘ └──────────┘ └──────────┘
|
|
379
|
+
.subscribe() .on(event) ↓
|
|
380
|
+
│ │ ┌──────────┐
|
|
381
|
+
▼ ▼ │ Browser │
|
|
382
|
+
[Logging, [Background │ Client │
|
|
383
|
+
Analytics] Processing] └──────────┘
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
## Best Practices
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
// 1. Use descriptive event names
|
|
390
|
+
defineEvent('user.created')
|
|
391
|
+
defineEvent('order.completed')
|
|
392
|
+
defineEvent('payment.failed')
|
|
393
|
+
|
|
394
|
+
// 2. Keep payloads minimal - just IDs, not full objects
|
|
395
|
+
defineEvent('user.deleted', Type.Object({
|
|
396
|
+
userId: Type.String(),
|
|
397
|
+
}));
|
|
398
|
+
|
|
399
|
+
// 3. Handler errors are isolated - one failing handler doesn't affect others
|
|
400
|
+
userCreated.subscribe(async (payload) => {
|
|
401
|
+
throw new Error('This fails');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
userCreated.subscribe(async (payload) => {
|
|
405
|
+
// This still executes
|
|
406
|
+
console.log('Handler 2 runs');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// 4. Use events for side effects, not core logic
|
|
410
|
+
// Core: await userRepo.create(data);
|
|
411
|
+
// Side effect: await userCreated.emit({ ... });
|
|
412
|
+
|
|
413
|
+
// 5. Await useCache() before emitting for multi-instance
|
|
414
|
+
await userCreated.useCache(cache);
|
|
415
|
+
await userCreated.emit({ userId: '123' });
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
## Event vs Direct Job
|
|
419
|
+
|
|
420
|
+
| Aspect | Event + Job | Direct Job |
|
|
421
|
+
|--------|-------------|------------|
|
|
422
|
+
| Coupling | Loose (producer doesn't know consumers) | Tight (producer calls specific job) |
|
|
423
|
+
| Multiple consumers | Easy (multiple jobs subscribe) | Manual (call each job) |
|
|
424
|
+
| Extensibility | Add consumers without modifying producer | Modify producer for each consumer |
|
|
425
|
+
|
|
426
|
+
**Use Event when:**
|
|
427
|
+
- Multiple systems need to react to the same occurrence
|
|
428
|
+
- You want to decouple producers from consumers
|
|
429
|
+
|
|
430
|
+
**Use Direct Job when:**
|
|
431
|
+
- Single, known consumer
|
|
432
|
+
- Simpler mental model preferred
|