interaqt 1.1.1 → 1.1.2

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,536 @@
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
+ getValue: (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 `getValue`, 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 | `getValue` (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
+ getValue: (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 `getValue` 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, activities, interactions,
224
+ dict: [myComputation], // dict is for Dictionaries, 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
+ - [ ] `getValue` 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', required: true }),
265
+ PayloadItem.create({ name: 'content', required: true }),
266
+ PayloadItem.create({ name: 'postId', 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
+ activities: [],
314
+ interactions: [CreatePost],
315
+ dict: [],
316
+ recordMutationSideEffects: []
317
+ })
318
+
319
+ await controller.setup(true)
320
+ ```
321
+
322
+ ### WRONG: Calling callInteraction before setup
323
+ ```typescript
324
+ // DON'T — setup MUST come first
325
+ const controller = new Controller({ system, entities, relations, activities, interactions, dict: [] })
326
+ await controller.callInteraction('CreatePost', { user: { id: '1' }, payload: { title: 'Hi' } })
327
+ ```
328
+
329
+ ### CORRECT:
330
+ ```typescript
331
+ const controller = new Controller({ system, entities, relations, activities, interactions, dict: [] })
332
+ await controller.setup(true)
333
+ await controller.callInteraction('CreatePost', { user: { id: '1' }, payload: { title: 'Hi' } })
334
+ ```
335
+
336
+ ### WHY
337
+ `setup(true)` installs database tables and initializes all computations. Without it, storage operations will fail.
338
+
339
+ ### Checklist
340
+ - [ ] `system.conceptClass = KlassByName` is set before creating Controller
341
+ - [ ] `controller.setup(true)` is called BEFORE any `callInteraction`
342
+ - [ ] `dict` contains only Dictionary instances, not computations
343
+
344
+ ---
345
+
346
+ ## When Calling Interactions
347
+
348
+ ```typescript
349
+ const result = await controller.callInteraction('CreatePost', {
350
+ user: { id: 'user-1', role: 'author' },
351
+ payload: {
352
+ title: 'My Post',
353
+ content: 'Hello world'
354
+ }
355
+ })
356
+
357
+ if (result.error) {
358
+ console.log('Error:', result.error.message)
359
+ }
360
+ ```
361
+
362
+ ### WRONG: Using try-catch for error handling
363
+ ```typescript
364
+ // DON'T — interaqt does NOT throw exceptions
365
+ try {
366
+ await controller.callInteraction('CreatePost', { user: { id: '1' }, payload: {} })
367
+ } catch (e) {
368
+ // This code will NEVER execute
369
+ }
370
+ ```
371
+
372
+ ### CORRECT:
373
+ ```typescript
374
+ const result = await controller.callInteraction('CreatePost', {
375
+ user: { id: '1' },
376
+ payload: {}
377
+ })
378
+ if (result.error) {
379
+ console.log('Error:', result.error.message)
380
+ }
381
+ ```
382
+
383
+ ### WHY
384
+ The framework catches all errors internally and returns them via `result.error`. Exceptions are never thrown to callers.
385
+
386
+ ### WRONG: Using non-existent API methods
387
+ ```typescript
388
+ // DON'T — these methods do NOT exist
389
+ controller.dispatch('CreatePost', payload)
390
+ controller.run()
391
+ controller.execute()
392
+ ```
393
+
394
+ ### CORRECT:
395
+ ```typescript
396
+ // The ONLY method to trigger interactions
397
+ await controller.callInteraction('CreatePost', {
398
+ user: { id: 'user-1' },
399
+ payload: { title: 'Hi' }
400
+ })
401
+ ```
402
+
403
+ ### Checklist
404
+ - [ ] ALWAYS pass a `user` object with at least `id`
405
+ - [ ] ALWAYS check `result.error` — NEVER use try-catch
406
+ - [ ] Use `controller.callInteraction(name, args)` — no other dispatch method exists
407
+
408
+ ---
409
+
410
+ ## When Querying Data
411
+
412
+ ```typescript
413
+ import { MatchExp } from 'interaqt'
414
+
415
+ const user = await system.storage.findOne(
416
+ 'User',
417
+ MatchExp.atom({ key: 'email', value: ['=', 'alice@example.com'] }),
418
+ undefined,
419
+ ['id', 'name', 'email', 'status']
420
+ )
421
+
422
+ const activeUsers = await system.storage.find(
423
+ 'User',
424
+ MatchExp.atom({ key: 'status', value: ['=', 'active'] }),
425
+ { orderBy: { createdAt: 'DESC' }, limit: 10 },
426
+ ['id', 'name', 'email']
427
+ )
428
+ ```
429
+
430
+ ### WRONG: Omitting attributeQuery
431
+ ```typescript
432
+ // DON'T — without attributeQuery, only `id` is returned
433
+ const user = await system.storage.findOne(
434
+ 'User',
435
+ MatchExp.atom({ key: 'id', value: ['=', 1] })
436
+ )
437
+ // user.name → undefined!
438
+ ```
439
+
440
+ ### CORRECT:
441
+ ```typescript
442
+ const user = await system.storage.findOne(
443
+ 'User',
444
+ MatchExp.atom({ key: 'id', value: ['=', 1] }),
445
+ undefined,
446
+ ['id', 'name', 'email', 'status']
447
+ )
448
+ ```
449
+
450
+ ### WHY
451
+ Without `attributeQuery`, the framework returns only the `id` field. This is the most common cause of "undefined" bugs.
452
+
453
+ Nested attributeQuery for relations:
454
+ ```typescript
455
+ const usersWithPosts = await system.storage.find(
456
+ 'User',
457
+ undefined,
458
+ {},
459
+ [
460
+ 'id', 'name',
461
+ ['posts', { attributeQuery: ['id', 'title', 'status'] }]
462
+ ]
463
+ )
464
+ ```
465
+
466
+ Relation properties use `['&', { attributeQuery: ['role', 'joinedAt'] }]` syntax.
467
+
468
+ ### Checklist
469
+ - [ ] ALWAYS pass `attributeQuery` as the 4th argument to `find`/`findOne`
470
+ - [ ] Use `['*']` for all fields, or list specific field names
471
+ - [ ] Use nested arrays for relation data: `['relationName', { attributeQuery: [...] }]`
472
+
473
+ ---
474
+
475
+ ## When Writing Tests
476
+
477
+ ```typescript
478
+ import { describe, test, expect, beforeEach } from 'vitest'
479
+ import { Controller, MonoSystem, KlassByName, PGLiteDB, MatchExp } from 'interaqt'
480
+
481
+ describe('Feature', () => {
482
+ let system: MonoSystem
483
+ let controller: Controller
484
+
485
+ beforeEach(async () => {
486
+ system = new MonoSystem(new PGLiteDB())
487
+ system.conceptClass = KlassByName
488
+ controller = new Controller({
489
+ system, entities, relations, activities, interactions, dict: [], recordMutationSideEffects: []
490
+ })
491
+ await controller.setup(true)
492
+ })
493
+
494
+ test('creates a post via interaction', async () => {
495
+ const result = await controller.callInteraction('CreatePost', {
496
+ user: { id: 'user-1' },
497
+ payload: { title: 'Test', content: 'Hello' }
498
+ })
499
+ expect(result.error).toBeUndefined()
500
+
501
+ const post = await system.storage.findOne(
502
+ 'Post',
503
+ MatchExp.atom({ key: 'title', value: ['=', 'Test'] }),
504
+ undefined,
505
+ ['id', 'title', 'content']
506
+ )
507
+ expect(post).toBeTruthy()
508
+ expect(post.title).toBe('Test')
509
+ })
510
+ })
511
+ ```
512
+
513
+ ### WRONG: Direct storage mutation in tests
514
+ ```typescript
515
+ // DON'T — bypasses all validation and business logic
516
+ const post = await system.storage.create('Post', { title: 'Test', content: 'Hello' })
517
+ ```
518
+
519
+ ### CORRECT:
520
+ ```typescript
521
+ // Use callInteraction to test business logic
522
+ const result = await controller.callInteraction('CreatePost', {
523
+ user: { id: 'user-1' },
524
+ payload: { title: 'Test', content: 'Hello' }
525
+ })
526
+ ```
527
+
528
+ ### WHY
529
+ `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.
530
+
531
+ ### Checklist
532
+ - [ ] Use `PGLiteDB` for test databases
533
+ - [ ] Call `controller.setup(true)` in `beforeEach`
534
+ - [ ] Test business logic through `callInteraction`, not direct storage
535
+ - [ ] Check `result.error` — NEVER use try-catch
536
+ - [ ] ALWAYS pass `attributeQuery` when asserting on query results