interaqt 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
  <a href="https://www.npmjs.com/package/interaqt"><img src="https://img.shields.io/npm/dm/interaqt.svg" alt="npm downloads"></a>
9
9
  <a href="https://github.com/InteraqtDev/interaqt/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/interaqt.svg" alt="license"></a>
10
10
  <a href="https://github.com/InteraqtDev/interaqt"><img src="https://img.shields.io/badge/TypeScript-strict-blue.svg" alt="TypeScript"></a>
11
+ <a href="#"><img src="https://img.shields.io/badge/coverage-92.41%25-brightgreen.svg" alt="coverage"></a>
11
12
  </p>
12
13
  </p>
13
14
 
@@ -0,0 +1,541 @@
1
+ # interaqt Patterns
2
+
3
+ > Read this file BEFORE writing any interaqt code. Every section is self-contained.
4
+
5
+ ---
6
+
7
+ ## When Defining Entities and Properties
8
+
9
+ ```typescript
10
+ import { Entity, Property } from 'interaqt'
11
+
12
+ const User = Entity.create({
13
+ name: 'User',
14
+ properties: [
15
+ Property.create({ name: 'name', type: 'string' }),
16
+ Property.create({ name: 'email', type: 'string' }),
17
+ Property.create({ name: 'age', type: 'number' }),
18
+ Property.create({ name: 'status', type: 'string', defaultValue: () => 'active' }),
19
+ Property.create({ name: 'createdAt', type: 'string', defaultValue: () => new Date().toISOString() }),
20
+ Property.create({
21
+ name: 'fullName',
22
+ type: 'string',
23
+ computed: (record) => `${record.firstName} ${record.lastName}`
24
+ })
25
+ ]
26
+ })
27
+ ```
28
+
29
+ Property types: `'string'`, `'number'`, `'boolean'`. Use `collection: true` for arrays. Use `type: 'object'` for JSON.
30
+
31
+ ### WRONG: Manual UUID assignment
32
+ ```typescript
33
+ // DON'T — the Klass pattern generates IDs internally
34
+ const user = Entity.create({
35
+ name: 'User',
36
+ uuid: 'my-custom-id-123',
37
+ properties: [...]
38
+ })
39
+ ```
40
+
41
+ ### CORRECT:
42
+ ```typescript
43
+ const user = Entity.create({
44
+ name: 'User',
45
+ properties: [...]
46
+ })
47
+ ```
48
+
49
+ ### WHY
50
+ The Klass pattern uses `generateUUID()` internally. Manual IDs risk collisions and break serialization.
51
+
52
+ ### Checklist
53
+ - [ ] Entity name is PascalCase and singular (`User` not `users`)
54
+ - [ ] No manual UUID assignment
55
+ - [ ] Computed properties that depend only on the same record use `computed`, NOT Transform
56
+ - [ ] Properties with reactive computations (Count, etc.) include `defaultValue`
57
+
58
+ ---
59
+
60
+ ## When Defining Relations
61
+
62
+ ```typescript
63
+ import { Entity, Property, Relation } from 'interaqt'
64
+
65
+ const User = Entity.create({ name: 'User', properties: [Property.create({ name: 'name', type: 'string' })] })
66
+ const Post = Entity.create({ name: 'Post', properties: [Property.create({ name: 'title', type: 'string' })] })
67
+
68
+ const UserPosts = Relation.create({
69
+ source: User,
70
+ sourceProperty: 'posts',
71
+ target: Post,
72
+ targetProperty: 'author',
73
+ type: '1:n'
74
+ })
75
+ ```
76
+
77
+ Relation names are auto-generated from source+target entity names (e.g., `UserPost`).
78
+
79
+ ### WRONG: Specifying relation name
80
+ ```typescript
81
+ // DON'T — name is auto-generated
82
+ const UserPosts = Relation.create({
83
+ name: 'UserPost',
84
+ source: User,
85
+ sourceProperty: 'posts',
86
+ target: Post,
87
+ targetProperty: 'author',
88
+ type: '1:n'
89
+ })
90
+ ```
91
+
92
+ ### CORRECT:
93
+ ```typescript
94
+ const UserPosts = Relation.create({
95
+ source: User,
96
+ sourceProperty: 'posts',
97
+ target: Post,
98
+ targetProperty: 'author',
99
+ type: '1:n'
100
+ })
101
+ ```
102
+
103
+ ### WHY
104
+ The framework generates relation names automatically from entity names. Specifying a name conflicts with this mechanism.
105
+
106
+ ### WRONG: Omitting relation type
107
+ ```typescript
108
+ // DON'T — type is mandatory
109
+ const UserPosts = Relation.create({
110
+ source: User,
111
+ sourceProperty: 'posts',
112
+ target: Post,
113
+ targetProperty: 'author'
114
+ })
115
+ ```
116
+
117
+ ### CORRECT:
118
+ ```typescript
119
+ const UserPosts = Relation.create({
120
+ source: User,
121
+ sourceProperty: 'posts',
122
+ target: Post,
123
+ targetProperty: 'author',
124
+ type: '1:n'
125
+ })
126
+ ```
127
+
128
+ ### WHY
129
+ Without `type`, the framework cannot determine cardinality. ALWAYS explicitly set `'1:1'`, `'1:n'`, `'n:1'`, or `'n:n'`.
130
+
131
+ ### Checklist
132
+ - [ ] NEVER specify `name` — it is auto-generated
133
+ - [ ] ALWAYS specify `type` explicitly
134
+ - [ ] Both `sourceProperty` and `targetProperty` are set with meaningful names
135
+ - [ ] Symmetric relations: `source === target` AND `sourceProperty === targetProperty`
136
+
137
+ ---
138
+
139
+ ## When Adding Reactive Computations
140
+
141
+ **Decision tree — which computation type to use:**
142
+
143
+ | Need | Use |
144
+ |------|-----|
145
+ | Count related records | `Count` |
146
+ | Weighted sum of related records | `WeightedSummation` |
147
+ | Check ALL related records match condition | `Every` |
148
+ | Check ANY related record matches condition | `Any` |
149
+ | Derive new entities from events or other entities | `Transform` (on Entity `computation`) |
150
+ | Update a property value based on state transitions | `StateMachine` (on Property `computation`) |
151
+ | Simple computation from same-record fields | `computed` (on Property) |
152
+
153
+ ```typescript
154
+ import { Entity, Property, Relation, Count, WeightedSummation, Transform, InteractionEventEntity } from 'interaqt'
155
+
156
+ const Post = Entity.create({
157
+ name: 'Post',
158
+ properties: [
159
+ Property.create({ name: 'title', type: 'string' }),
160
+ Property.create({
161
+ name: 'likeCount',
162
+ type: 'number',
163
+ defaultValue: () => 0,
164
+ computation: Count.create({ record: LikeRelation })
165
+ })
166
+ ]
167
+ })
168
+ ```
169
+
170
+ ### WRONG: Transform on a Property computation
171
+ ```typescript
172
+ // DON'T — Transform belongs on Entity.computation, not Property.computation
173
+ Property.create({
174
+ name: 'formattedPrice',
175
+ computation: Transform.create({
176
+ record: Product,
177
+ callback: (product) => `$${product.price}`
178
+ })
179
+ })
180
+ ```
181
+
182
+ ### CORRECT:
183
+ ```typescript
184
+ Property.create({
185
+ name: 'formattedPrice',
186
+ type: 'string',
187
+ computed: (record) => `$${record.price}`
188
+ })
189
+ ```
190
+
191
+ ### WHY
192
+ Transform creates new records in a computed entity collection. It CANNOT update a single property. Use `computed` for same-entity property computations.
193
+
194
+ ### WRONG: Transform for counting
195
+ ```typescript
196
+ // DON'T — use Count for counting
197
+ Property.create({
198
+ name: 'followerCount',
199
+ computation: Transform.create({
200
+ record: FollowRelation,
201
+ callback: (followers) => followers.length
202
+ })
203
+ })
204
+ ```
205
+
206
+ ### CORRECT:
207
+ ```typescript
208
+ Property.create({
209
+ name: 'followerCount',
210
+ type: 'number',
211
+ defaultValue: () => 0,
212
+ computation: Count.create({ record: FollowRelation })
213
+ })
214
+ ```
215
+
216
+ ### WHY
217
+ Count uses incremental algorithms. Transform loads all records into memory, which is inefficient for counting.
218
+
219
+ ### WRONG: Computations passed to Controller
220
+ ```typescript
221
+ // DON'T — Controller does NOT accept a computations parameter
222
+ const controller = new Controller({
223
+ system, entities, relations,
224
+ eventSources: [myComputation], // eventSources is for Interactions, not computations
225
+ })
226
+ ```
227
+
228
+ ### CORRECT:
229
+ ```typescript
230
+ // Computations MUST be placed inside Entity/Relation/Property definitions
231
+ Property.create({
232
+ name: 'postCount',
233
+ type: 'number',
234
+ defaultValue: () => 0,
235
+ computation: Count.create({ record: UserPostRelation })
236
+ })
237
+ ```
238
+
239
+ ### WHY
240
+ All computations are declared within the `computation` field of Entity, Relation, or Property. The `dict` parameter in Controller is for global Dictionary instances only.
241
+
242
+ ### Checklist
243
+ - [ ] Transform is on `Entity.computation` or `Relation.computation`, NEVER on `Property.computation`
244
+ - [ ] Count, WeightedSummation, Every, Any are on `Property.computation`
245
+ - [ ] StateMachine is on `Property.computation`
246
+ - [ ] `computed` is used for same-record-only property derivations
247
+ - [ ] Properties with computation ALWAYS have `defaultValue`
248
+ - [ ] NEVER pass computations to Controller constructor
249
+
250
+ ---
251
+
252
+ ## When Creating Interactions
253
+
254
+ ```typescript
255
+ import { Interaction, Action, Payload, PayloadItem, Entity, Property } from 'interaqt'
256
+
257
+ const Post = Entity.create({ name: 'Post', properties: [Property.create({ name: 'title', type: 'string' })] })
258
+
259
+ const CreatePost = Interaction.create({
260
+ name: 'CreatePost',
261
+ action: Action.create({ name: 'createPost' }),
262
+ payload: Payload.create({
263
+ items: [
264
+ PayloadItem.create({ name: 'title', type: 'string', required: true }),
265
+ PayloadItem.create({ name: 'content', type: 'string', required: true }),
266
+ PayloadItem.create({ name: 'postId', type: 'string', base: Post, isRef: true })
267
+ ]
268
+ })
269
+ })
270
+ ```
271
+
272
+ Action is ONLY an identifier — it contains no operational logic. All data changes happen through reactive computations (Transform, StateMachine, Count, etc.).
273
+
274
+ ### WRONG: Writing operational logic in Action
275
+ ```typescript
276
+ // DON'T — Action has no handler/execute method
277
+ const CreatePost = Action.create({
278
+ name: 'createPost',
279
+ execute: async (payload) => {
280
+ await db.create('Post', payload)
281
+ }
282
+ })
283
+ ```
284
+
285
+ ### CORRECT:
286
+ ```typescript
287
+ const CreatePost = Action.create({ name: 'createPost' })
288
+ ```
289
+
290
+ ### WHY
291
+ interaqt is declarative. Interactions declare "what users can do." Data changes are declared via computations (Transform, Count, StateMachine), not imperatively in handlers.
292
+
293
+ ### Checklist
294
+ - [ ] Action has ONLY a `name` — no handler, no execute, no callback
295
+ - [ ] PayloadItem uses `isRef: true` when referencing an existing entity by ID
296
+ - [ ] PayloadItem uses `isCollection: true` for array parameters
297
+ - [ ] `base` is set when the payload item corresponds to an Entity
298
+
299
+ ---
300
+
301
+ ## When Setting Up the Controller
302
+
303
+ ```typescript
304
+ import { Controller, MonoSystem, PGLiteDB, KlassByName } from 'interaqt'
305
+
306
+ const system = new MonoSystem(new PGLiteDB())
307
+ system.conceptClass = KlassByName
308
+
309
+ const controller = new Controller({
310
+ system,
311
+ entities: [User, Post],
312
+ relations: [UserPosts],
313
+ eventSources: [CreatePost],
314
+ dict: [],
315
+ recordMutationSideEffects: []
316
+ })
317
+
318
+ await controller.setup(true)
319
+ ```
320
+
321
+ ### WRONG: Calling dispatch before setup
322
+ ```typescript
323
+ // DON'T — setup MUST come first
324
+ const controller = new Controller({ system, entities, relations, eventSources: [CreatePost], dict: [] })
325
+ await controller.dispatch(CreatePost, { user: { id: '1' }, payload: { title: 'Hi' } })
326
+ ```
327
+
328
+ ### CORRECT:
329
+ ```typescript
330
+ const controller = new Controller({ system, entities, relations, eventSources: [CreatePost], dict: [] })
331
+ await controller.setup(true)
332
+ await controller.dispatch(CreatePost, { user: { id: '1' }, payload: { title: 'Hi' } })
333
+ ```
334
+
335
+ ### WHY
336
+ `setup(true)` installs database tables and initializes all computations. Without it, storage operations will fail.
337
+
338
+ ### Checklist
339
+ - [ ] `system.conceptClass = KlassByName` is set before creating Controller
340
+ - [ ] `controller.setup(true)` is called BEFORE any `dispatch`
341
+ - [ ] `dict` contains only Dictionary instances, not computations
342
+
343
+ ---
344
+
345
+ ## When Dispatching Interactions
346
+
347
+ ```typescript
348
+ const result = await controller.dispatch(CreatePost, {
349
+ user: { id: 'user-1', role: 'author' },
350
+ payload: {
351
+ title: 'My Post',
352
+ content: 'Hello world'
353
+ }
354
+ })
355
+
356
+ if (result.error) {
357
+ console.log('Error:', result.error.message)
358
+ }
359
+ ```
360
+
361
+ ### WRONG: Using try-catch for error handling
362
+ ```typescript
363
+ // DON'T — interaqt does NOT throw exceptions by default
364
+ try {
365
+ await controller.dispatch(CreatePost, { user: { id: '1' }, payload: {} })
366
+ } catch (e) {
367
+ // This code will NEVER execute (unless forceThrowDispatchError is true)
368
+ }
369
+ ```
370
+
371
+ ### CORRECT:
372
+ ```typescript
373
+ const result = await controller.dispatch(CreatePost, {
374
+ user: { id: '1' },
375
+ payload: {}
376
+ })
377
+ if (result.error) {
378
+ console.log('Error:', result.error.message)
379
+ }
380
+ ```
381
+
382
+ ### WHY
383
+ The framework catches all errors internally and returns them via `result.error`. Exceptions are never thrown to callers (unless `forceThrowDispatchError: true` is set on Controller).
384
+
385
+ ### WRONG: Passing a name string instead of instance
386
+ ```typescript
387
+ // DON'T — first argument must be the event source instance, not a string
388
+ controller.dispatch('CreatePost', payload)
389
+ ```
390
+
391
+ ### WRONG: Using non-existent API methods
392
+ ```typescript
393
+ // DON'T — these methods do NOT exist
394
+ controller.callInteraction('CreatePost', payload)
395
+ controller.run()
396
+ controller.execute()
397
+ ```
398
+
399
+ ### CORRECT:
400
+ ```typescript
401
+ // The ONLY method to trigger interactions — first arg is the instance reference
402
+ await controller.dispatch(CreatePost, {
403
+ user: { id: 'user-1' },
404
+ payload: { title: 'Hi' }
405
+ })
406
+ ```
407
+
408
+ ### Checklist
409
+ - [ ] ALWAYS pass a `user` object with at least `id`
410
+ - [ ] ALWAYS check `result.error` — NEVER use try-catch
411
+ - [ ] Use `controller.dispatch(eventSourceInstance, args)` — first arg is the instance, NOT a name string
412
+
413
+ ---
414
+
415
+ ## When Querying Data
416
+
417
+ ```typescript
418
+ import { MatchExp } from 'interaqt'
419
+
420
+ const user = await system.storage.findOne(
421
+ 'User',
422
+ MatchExp.atom({ key: 'email', value: ['=', 'alice@example.com'] }),
423
+ undefined,
424
+ ['id', 'name', 'email', 'status']
425
+ )
426
+
427
+ const activeUsers = await system.storage.find(
428
+ 'User',
429
+ MatchExp.atom({ key: 'status', value: ['=', 'active'] }),
430
+ { orderBy: { createdAt: 'DESC' }, limit: 10 },
431
+ ['id', 'name', 'email']
432
+ )
433
+ ```
434
+
435
+ ### WRONG: Omitting attributeQuery
436
+ ```typescript
437
+ // DON'T — without attributeQuery, only `id` is returned
438
+ const user = await system.storage.findOne(
439
+ 'User',
440
+ MatchExp.atom({ key: 'id', value: ['=', 1] })
441
+ )
442
+ // user.name → undefined!
443
+ ```
444
+
445
+ ### CORRECT:
446
+ ```typescript
447
+ const user = await system.storage.findOne(
448
+ 'User',
449
+ MatchExp.atom({ key: 'id', value: ['=', 1] }),
450
+ undefined,
451
+ ['id', 'name', 'email', 'status']
452
+ )
453
+ ```
454
+
455
+ ### WHY
456
+ Without `attributeQuery`, the framework returns only the `id` field. This is the most common cause of "undefined" bugs.
457
+
458
+ Nested attributeQuery for relations:
459
+ ```typescript
460
+ const usersWithPosts = await system.storage.find(
461
+ 'User',
462
+ undefined,
463
+ {},
464
+ [
465
+ 'id', 'name',
466
+ ['posts', { attributeQuery: ['id', 'title', 'status'] }]
467
+ ]
468
+ )
469
+ ```
470
+
471
+ Relation properties use `['&', { attributeQuery: ['role', 'joinedAt'] }]` syntax.
472
+
473
+ ### Checklist
474
+ - [ ] ALWAYS pass `attributeQuery` as the 4th argument to `find`/`findOne`
475
+ - [ ] Use `['*']` for all fields, or list specific field names
476
+ - [ ] Use nested arrays for relation data: `['relationName', { attributeQuery: [...] }]`
477
+
478
+ ---
479
+
480
+ ## When Writing Tests
481
+
482
+ ```typescript
483
+ import { describe, test, expect, beforeEach } from 'vitest'
484
+ import { Controller, MonoSystem, KlassByName, PGLiteDB, MatchExp } from 'interaqt'
485
+
486
+ describe('Feature', () => {
487
+ let system: MonoSystem
488
+ let controller: Controller
489
+
490
+ beforeEach(async () => {
491
+ system = new MonoSystem(new PGLiteDB())
492
+ system.conceptClass = KlassByName
493
+ controller = new Controller({
494
+ system, entities, relations, eventSources, dict: [], recordMutationSideEffects: []
495
+ })
496
+ await controller.setup(true)
497
+ })
498
+
499
+ test('creates a post via interaction', async () => {
500
+ const result = await controller.dispatch(CreatePost, {
501
+ user: { id: 'user-1' },
502
+ payload: { title: 'Test', content: 'Hello' }
503
+ })
504
+ expect(result.error).toBeUndefined()
505
+
506
+ const post = await system.storage.findOne(
507
+ 'Post',
508
+ MatchExp.atom({ key: 'title', value: ['=', 'Test'] }),
509
+ undefined,
510
+ ['id', 'title', 'content']
511
+ )
512
+ expect(post).toBeTruthy()
513
+ expect(post.title).toBe('Test')
514
+ })
515
+ })
516
+ ```
517
+
518
+ ### WRONG: Direct storage mutation in tests
519
+ ```typescript
520
+ // DON'T — bypasses all validation and business logic
521
+ const post = await system.storage.create('Post', { title: 'Test', content: 'Hello' })
522
+ ```
523
+
524
+ ### CORRECT:
525
+ ```typescript
526
+ // Use dispatch to test business logic
527
+ const result = await controller.dispatch(CreatePost, {
528
+ user: { id: 'user-1' },
529
+ payload: { title: 'Test', content: 'Hello' }
530
+ })
531
+ ```
532
+
533
+ ### WHY
534
+ `storage.create` bypasses ALL validation, permissions, and reactive computations. It is acceptable ONLY for test data setup (creating prerequisite records), NEVER for testing business logic.
535
+
536
+ ### Checklist
537
+ - [ ] Use `PGLiteDB` for test databases
538
+ - [ ] Call `controller.setup(true)` in `beforeEach`
539
+ - [ ] Test business logic through `controller.dispatch`, not direct storage
540
+ - [ ] Check `result.error` — NEVER use try-catch
541
+ - [ ] ALWAYS pass `attributeQuery` when asserting on query results