interaqt 1.1.3 → 1.3.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.
Files changed (85) hide show
  1. package/README.md +36 -1
  2. package/agent/agentspace/knowledge/generator/api-reference.md +19 -21
  3. package/agent/agentspace/knowledge/generator/computation-implementation.md +6 -0
  4. package/agent/agentspace/knowledge/generator/integration-implementation-handler.md +2 -0
  5. package/agent/agentspace/knowledge/usage/03-entity-relations.md +37 -7
  6. package/agent/agentspace/knowledge/usage/04-reactive-computations.md +8 -0
  7. package/agent/agentspace/knowledge/usage/05-interactions.md +24 -0
  8. package/agent/agentspace/knowledge/usage/10-async-computations.md +13 -0
  9. package/agent/agentspace/knowledge/usage/13-testing.md +12 -2
  10. package/agent/agentspace/knowledge/usage/14-api-reference.md +10 -0
  11. package/agent/agentspace/knowledge/usage/18-api-exports-reference.md +33 -2
  12. package/agent/agentspace/knowledge/usage/20-postgresql-concurrency-migration.md +105 -0
  13. package/agent/agentspace/knowledge/usage/README.md +1 -0
  14. package/agent/skill/interaqt-reference.md +2 -1
  15. package/dist/builtins/interaction/activity/ActivityManager.d.ts.map +1 -1
  16. package/dist/core/Constraint.d.ts +95 -0
  17. package/dist/core/Constraint.d.ts.map +1 -0
  18. package/dist/core/Custom.d.ts +18 -0
  19. package/dist/core/Custom.d.ts.map +1 -1
  20. package/dist/core/Entity.d.ts +10 -0
  21. package/dist/core/Entity.d.ts.map +1 -1
  22. package/dist/core/EventSource.d.ts +33 -0
  23. package/dist/core/EventSource.d.ts.map +1 -1
  24. package/dist/core/Relation.d.ts +10 -0
  25. package/dist/core/Relation.d.ts.map +1 -1
  26. package/dist/core/index.d.ts +1 -0
  27. package/dist/core/index.d.ts.map +1 -1
  28. package/dist/core/init.d.ts.map +1 -1
  29. package/dist/drivers/Mysql.d.ts +11 -0
  30. package/dist/drivers/Mysql.d.ts.map +1 -1
  31. package/dist/drivers/PGLite.d.ts +13 -0
  32. package/dist/drivers/PGLite.d.ts.map +1 -1
  33. package/dist/drivers/PostgreSQL.d.ts +38 -5
  34. package/dist/drivers/PostgreSQL.d.ts.map +1 -1
  35. package/dist/drivers/SQLite.d.ts +13 -0
  36. package/dist/drivers/SQLite.d.ts.map +1 -1
  37. package/dist/index.js +4687 -3572
  38. package/dist/index.js.map +1 -1
  39. package/dist/runtime/ComputationSourceMap.d.ts.map +1 -1
  40. package/dist/runtime/Controller.d.ts +2 -0
  41. package/dist/runtime/Controller.d.ts.map +1 -1
  42. package/dist/runtime/MonoSystem.d.ts +2 -0
  43. package/dist/runtime/MonoSystem.d.ts.map +1 -1
  44. package/dist/runtime/Scheduler.d.ts +14 -1
  45. package/dist/runtime/Scheduler.d.ts.map +1 -1
  46. package/dist/runtime/System.d.ts +69 -6
  47. package/dist/runtime/System.d.ts.map +1 -1
  48. package/dist/runtime/computations/Any.d.ts.map +1 -1
  49. package/dist/runtime/computations/Average.d.ts +2 -2
  50. package/dist/runtime/computations/Average.d.ts.map +1 -1
  51. package/dist/runtime/computations/Computation.d.ts +17 -0
  52. package/dist/runtime/computations/Computation.d.ts.map +1 -1
  53. package/dist/runtime/computations/Count.d.ts +5 -1
  54. package/dist/runtime/computations/Count.d.ts.map +1 -1
  55. package/dist/runtime/computations/Every.d.ts +1 -2
  56. package/dist/runtime/computations/Every.d.ts.map +1 -1
  57. package/dist/runtime/computations/StateMachine.d.ts.map +1 -1
  58. package/dist/runtime/computations/Summation.d.ts +3 -1
  59. package/dist/runtime/computations/Summation.d.ts.map +1 -1
  60. package/dist/runtime/computations/Transform.d.ts.map +1 -1
  61. package/dist/runtime/computations/WeightedSummation.d.ts +3 -1
  62. package/dist/runtime/computations/WeightedSummation.d.ts.map +1 -1
  63. package/dist/runtime/errors/ConstraintErrors.d.ts +36 -0
  64. package/dist/runtime/errors/ConstraintErrors.d.ts.map +1 -0
  65. package/dist/runtime/errors/DatabaseErrors.d.ts +13 -0
  66. package/dist/runtime/errors/DatabaseErrors.d.ts.map +1 -0
  67. package/dist/runtime/errors/index.d.ts +2 -0
  68. package/dist/runtime/errors/index.d.ts.map +1 -1
  69. package/dist/runtime/index.d.ts +3 -0
  70. package/dist/runtime/index.d.ts.map +1 -1
  71. package/dist/runtime/transaction.d.ts +15 -0
  72. package/dist/runtime/transaction.d.ts.map +1 -0
  73. package/dist/storage/erstorage/EntityQueryHandle.d.ts +1 -0
  74. package/dist/storage/erstorage/EntityQueryHandle.d.ts.map +1 -1
  75. package/dist/storage/erstorage/QueryExecutor.d.ts +1 -1
  76. package/dist/storage/erstorage/QueryExecutor.d.ts.map +1 -1
  77. package/dist/storage/erstorage/RecordQueryAgent.d.ts +1 -0
  78. package/dist/storage/erstorage/RecordQueryAgent.d.ts.map +1 -1
  79. package/dist/storage/erstorage/SchemaDialect.d.ts +29 -0
  80. package/dist/storage/erstorage/SchemaDialect.d.ts.map +1 -0
  81. package/dist/storage/erstorage/Setup.d.ts +24 -1
  82. package/dist/storage/erstorage/Setup.d.ts.map +1 -1
  83. package/dist/storage/index.d.ts +1 -0
  84. package/dist/storage/index.d.ts.map +1 -1
  85. package/package.json +2 -1
package/README.md CHANGED
@@ -245,6 +245,40 @@ const system = new MonoSystem(new PostgreSQLDB({ /* connection config */ }))
245
245
 
246
246
  ---
247
247
 
248
+ ## Schema Constraints and Transaction Boundaries
249
+
250
+ interaqt supports schema-level uniqueness for framework-managed records. Declare uniqueness at the Entity or Relation level with `UniqueConstraint`; the storage setup installs database unique indexes and reports duplicate writes as structured framework errors.
251
+
252
+ ```typescript
253
+ import { Entity, Property, UniqueConstraint } from 'interaqt'
254
+
255
+ const User = Entity.create({
256
+ name: 'User',
257
+ properties: [
258
+ Property.create({ name: 'email', type: 'string' })
259
+ ],
260
+ constraints: [
261
+ UniqueConstraint.create({
262
+ name: 'User_email_unique',
263
+ properties: ['email'],
264
+ violationCode: 'USER_EMAIL_DUPLICATE'
265
+ })
266
+ ]
267
+ })
268
+ ```
269
+
270
+ `controller.dispatch()` runs event persistence and synchronous computation writes in one transaction. Unique conflicts roll back the whole dispatch attempt and can be handled with `ConstraintViolationError` or `findConstraintViolationError(error)`.
271
+
272
+ For diagnostics and migration planning, inspect the read-only schema metadata:
273
+
274
+ ```typescript
275
+ system.storage.schema.constraints
276
+ system.storage.schema.records
277
+ system.storage.schema.tables
278
+ ```
279
+
280
+ ---
281
+
248
282
  ## Installation
249
283
 
250
284
  ```bash
@@ -291,8 +325,9 @@ npm install @electric-sql/pglite
291
325
  - **Activities** — Compose multi-step business workflows from ordered Interactions
292
326
  - **Attributive Permissions** — Declarative, entity-aware access control
293
327
  - **Dictionary** — Global reactive key-value state
328
+ - **Schema Constraints** — Persistent unique constraints with structured duplicate errors
294
329
  - **Hard Deletion** — Built-in support for both soft and hard delete patterns
295
- - **Side Effects** — Hook into record mutations for external integrations (email, payments, file uploads)
330
+ - **Post-Commit Side Effects** — Use `postCommit` or record mutation side effects for external integrations after commit
296
331
 
297
332
  ---
298
333
 
@@ -553,7 +553,7 @@ EventSource.create<TArgs, TResult>(
553
553
  ```typescript
554
554
  async function(this: Controller, args: TArgs): Promise<TResult>
555
555
  ```
556
- - `config.afterDispatch` (function, optional): Hook called after dispatch completes. Can return additional context.
556
+ - `config.afterDispatch` (function, optional): Hook called inside the retryable transaction attempt. Can return additional context, but must be retry-safe and must not perform irreversible external IO.
557
557
  ```typescript
558
558
  async function(this: Controller, args: TArgs, result: { data?: TResult }): Promise<Record<string, unknown> | void>
559
559
  ```
@@ -664,7 +664,7 @@ const result = await controller.dispatch(webhookSource, {
664
664
  2. `guard` runs before event processing; throw to reject the event
665
665
  3. `mapEventData` converts dispatch args into the format stored in the entity
666
666
  4. `resolve` returns data to the caller (useful for query-type events)
667
- 5. `afterDispatch` runs after successful processing and can return additional context
667
+ 5. `afterDispatch` runs inside the retryable transaction attempt and can return additional context
668
668
  6. The Controller automatically registers the event source's entity if not already in the entities list
669
669
  7. Interaction is a built-in EventSource type that provides pre-built `guard`, `mapEventData`, and `resolve` implementations
670
670
 
@@ -1631,6 +1631,13 @@ Custom.create(config: CustomConfig): CustomInstance
1631
1631
  - For accessing dictionaries: use `type: 'global'` with `source: DictionaryInstance`
1632
1632
  - `config.useLastValue` (boolean, optional): Whether to use last computed value in incremental computation
1633
1633
  - `config.attributeQuery` (AttributeQueryData, optional): Attribute query configuration
1634
+ - `config.concurrency` (`'serializable' | 'atomic-safe'`, optional): Defaults to `'serializable'`. Serializable custom computations are retried in PostgreSQL `SERIALIZABLE` transactions. Use `'atomic-safe'` only when the callback is explicitly written with atomic state, idempotent patches, or other safe primitives.
1635
+
1636
+ **Retry safety**
1637
+
1638
+ Custom `compute`, `incrementalCompute`, `incrementalPatchCompute`, and `asyncReturn` callbacks may be replayed after transaction retry. Keep them deterministic and avoid irreversible external IO. Put post-commit external work in `recordMutationSideEffects`.
1639
+
1640
+ For async custom computations, `ComputationResult.async({ freshnessKey })` can override the default freshness stream. Property async freshness is scoped to the host record, global async freshness is scoped to the global result, and entity/relation async freshness defaults to the result target.
1634
1641
 
1635
1642
  **Examples**
1636
1643
 
@@ -3376,7 +3383,7 @@ type DispatchResponse = {
3376
3383
  2. Execute `guard` (if provided and `ignoreGuard` is false)
3377
3384
  3. Map event data via `mapEventData` and create event record
3378
3385
  4. Execute `resolve` (if provided) - returns data
3379
- 5. Execute `afterDispatch` (if provided) - returns context
3386
+ 5. Execute `afterDispatch` (if provided) inside the retryable transaction attempt - returns context
3380
3387
  6. Commit transaction
3381
3388
  7. Run RecordMutationSideEffects
3382
3389
 
@@ -3465,9 +3472,10 @@ interface Storage {
3465
3472
  map: any
3466
3473
 
3467
3474
  // Transaction operations
3468
- beginTransaction: (transactionName?: string) => Promise<any>
3469
- commitTransaction: (transactionName?: string) => Promise<any>
3470
- rollbackTransaction: (transactionName?: string) => Promise<any>
3475
+ runInTransaction: <T>(
3476
+ options: { name?: string; isolation?: 'READ COMMITTED' | 'SERIALIZABLE' },
3477
+ fn: () => Promise<T>
3478
+ ) => Promise<T>
3471
3479
 
3472
3480
  // Dictionary-specific API
3473
3481
  dict: {
@@ -3506,22 +3514,12 @@ interface Storage {
3506
3514
 
3507
3515
  #### Transaction Operations
3508
3516
 
3509
- **beginTransaction(transactionName?: string)**
3510
- Begin a database transaction.
3511
- ```typescript
3512
- await storage.beginTransaction('updateOrder')
3513
- ```
3514
-
3515
- **commitTransaction(transactionName?: string)**
3516
- Commit a database transaction.
3517
+ **runInTransaction(options, fn)**
3518
+ Run a callback inside a database transaction. The callback form is the only supported transaction boundary because it keeps the transaction connection bound to the full async chain.
3517
3519
  ```typescript
3518
- await storage.commitTransaction('updateOrder')
3519
- ```
3520
-
3521
- **rollbackTransaction(transactionName?: string)**
3522
- Rollback a database transaction.
3523
- ```typescript
3524
- await storage.rollbackTransaction('updateOrder')
3520
+ await storage.runInTransaction({ name: 'updateOrder' }, async () => {
3521
+ await storage.update('Order', match, data)
3522
+ })
3525
3523
  ```
3526
3524
 
3527
3525
  #### Entity/Relation Operations
@@ -5,6 +5,12 @@ Computations are the reactive core of interaqt, connecting interactions to entit
5
5
 
6
6
  ## Types of Computations
7
7
 
8
+ ## Retry Safety
9
+
10
+ Computation callbacks may be replayed under PostgreSQL transaction retry. Keep callbacks deterministic and database-focused. Do not perform irreversible external IO in `compute`, `incrementalCompute`, `incrementalPatchCompute`, Transform callbacks, StateMachine compute functions, or `asyncReturn`.
11
+
12
+ For custom computations, the default concurrency mode is `serializable`; use `concurrency: 'atomic-safe'` only when the callback is explicitly safe under concurrent `READ COMMITTED` execution.
13
+
8
14
  ### 1. Transform - Creates Entities/Relations
9
15
 
10
16
  **ONLY use in Entity/Relation computation, NEVER in Property!**
@@ -206,6 +206,8 @@ async setup(controller: Controller) {
206
206
 
207
207
  **Purpose**: Define side effects that synchronize reactive data changes to external systems.
208
208
 
209
+ `postCommit` is the recommended place for irreversible external IO tied to one interaction. `RecordMutationSideEffect` is the recommended place for irreversible external IO driven by record mutations. Interaction callbacks, custom computation callbacks, `afterDispatch`, and `asyncReturn` may be replayed by PostgreSQL transaction retry; post-commit hooks and side effects run after the final successful commit.
210
+
209
211
  **Use Cases**:
210
212
  - Push data mutations to external APIs
211
213
  - Publish messages to message queues
@@ -36,14 +36,20 @@ One-to-one relations represent unique correspondences between two entities, such
36
36
  ### Basic One-to-One Relation
37
37
 
38
38
  ```javascript
39
- import { Entity, Property, Relation } from 'interaqt';
39
+ import { Entity, Property, Relation, UniqueConstraint } from 'interaqt';
40
40
 
41
41
  // Define user entity
42
42
  const User = Entity.create({
43
43
  name: 'User',
44
44
  properties: [
45
- Property.create({ name: 'email', type: 'string', unique: true }),
45
+ Property.create({ name: 'email', type: 'string' }),
46
46
  Property.create({ name: 'name', type: 'string' })
47
+ ],
48
+ constraints: [
49
+ UniqueConstraint.create({
50
+ name: 'User_email_unique',
51
+ properties: ['email']
52
+ })
47
53
  ]
48
54
  });
49
55
 
@@ -116,8 +122,14 @@ One-to-many relations represent that one entity can be associated with multiple
116
122
  const User = Entity.create({
117
123
  name: 'User',
118
124
  properties: [
119
- Property.create({ name: 'email', type: 'string', unique: true }),
125
+ Property.create({ name: 'email', type: 'string' }),
120
126
  Property.create({ name: 'name', type: 'string' })
127
+ ],
128
+ constraints: [
129
+ UniqueConstraint.create({
130
+ name: 'User_email_unique',
131
+ properties: ['email']
132
+ })
121
133
  ]
122
134
  });
123
135
 
@@ -195,8 +207,14 @@ Many-to-many relations represent multiple associations between two entities, suc
195
207
  const Tag = Entity.create({
196
208
  name: 'Tag',
197
209
  properties: [
198
- Property.create({ name: 'name', type: 'string', unique: true }),
210
+ Property.create({ name: 'name', type: 'string' }),
199
211
  Property.create({ name: 'color', type: 'string', defaultValue: '#666666' })
212
+ ],
213
+ constraints: [
214
+ UniqueConstraint.create({
215
+ name: 'Tag_name_unique',
216
+ properties: ['name']
217
+ })
200
218
  ]
201
219
  });
202
220
 
@@ -499,15 +517,21 @@ const UserPosts = Relation.create({
499
517
  ## Complete Example: Blog System Relation Design
500
518
 
501
519
  ```javascript
502
- import { Entity, Property, Relation } from 'interaqt';
520
+ import { Entity, Property, Relation, UniqueConstraint } from 'interaqt';
503
521
 
504
522
  // Entity definitions
505
523
  const User = Entity.create({
506
524
  name: 'User',
507
525
  properties: [
508
- Property.create({ name: 'email', type: 'string', unique: true }),
526
+ Property.create({ name: 'email', type: 'string' }),
509
527
  Property.create({ name: 'name', type: 'string' }),
510
528
  Property.create({ name: 'avatar', type: 'string' })
529
+ ],
530
+ constraints: [
531
+ UniqueConstraint.create({
532
+ name: 'User_email_unique',
533
+ properties: ['email']
534
+ })
511
535
  ]
512
536
  });
513
537
 
@@ -532,8 +556,14 @@ const Comment = Entity.create({
532
556
  const Tag = Entity.create({
533
557
  name: 'Tag',
534
558
  properties: [
535
- Property.create({ name: 'name', type: 'string', unique: true }),
559
+ Property.create({ name: 'name', type: 'string' }),
536
560
  Property.create({ name: 'color', type: 'string', defaultValue: '#666666' })
561
+ ],
562
+ constraints: [
563
+ UniqueConstraint.create({
564
+ name: 'Tag_name_unique',
565
+ properties: ['name']
566
+ })
537
567
  ]
538
568
  });
539
569
 
@@ -81,6 +81,14 @@ Reactive computation is a **declarative way of defining data**:
81
81
  - **Incremental computation**: Framework uses efficient incremental algorithms to avoid unnecessary recomputation
82
82
  - **Persistent**: Computation results are stored in the database for fast queries
83
83
 
84
+ ### PostgreSQL Concurrency Guarantees
85
+
86
+ Built-in computations such as Count, Summation, Average, Every, Any, WeightedSummation, StateMachine, and data-based Transform use atomic state updates, row locks, or unique indexes on PostgreSQL. They are safe when multiple Node.js processes share one PostgreSQL database.
87
+
88
+ Custom computations are different because interaqt cannot inspect user callback logic. `Custom.create()` defaults to `concurrency: 'serializable'`, so the framework promotes the transaction to PostgreSQL `SERIALIZABLE` and retries on `40001` / `40P01`. Use `concurrency: 'atomic-safe'` only when the custom computation is explicitly written with atomic state, idempotent patches, or other safe primitives.
89
+
90
+ Because retry replays the transaction attempt, `guard`, `mapEventData`, `resolve`, computation callbacks, `afterDispatch`, and `asyncReturn` must be deterministic and retry-safe. Put irreversible external IO in `postCommit` for interaction-specific effects or `recordMutationSideEffects` for mutation-driven effects; both run after the final commit.
91
+
84
92
  ### Core Principle: Data Existence
85
93
 
86
94
  In interaqt, all data has its "reason for existence":
@@ -21,6 +21,30 @@ const result = await controller.callInteraction('CreatePost', {
21
21
  });
22
22
  ```
23
23
 
24
+ ## Retry-Safe Interaction Callbacks
25
+
26
+ `Controller.dispatch()` runs interaction processing inside a retryable transaction. PostgreSQL SERIALIZABLE promotion or retryable SQLSTATE errors (`40001`, `40P01`) may replay the transaction attempt.
27
+
28
+ These interaction callbacks must be deterministic and retry-safe:
29
+
30
+ - `guard`
31
+ - `mapEventData`
32
+ - `resolve`
33
+ - `afterDispatch`
34
+
35
+ `afterDispatch` runs before commit inside the retryable transaction attempt. It can return response context or perform retry-safe database work, but it must not perform irreversible external IO.
36
+
37
+ Use `postCommit` for interaction-specific external IO that should run only after the final successful commit:
38
+
39
+ ```javascript
40
+ CreateOrder.postCommit = async function(args, result) {
41
+ await sendOrderWebhook(result.data);
42
+ return { webhookSent: true };
43
+ };
44
+ ```
45
+
46
+ If `postCommit` fails, committed data is not rolled back; the error is reported in `result.sideEffects.__postCommit.error`. Use `recordMutationSideEffects` when the side effect should be driven by record mutation events rather than by a specific interaction.
47
+
24
48
  ## Basic Concepts of Interactions
25
49
 
26
50
  ### What is an Interaction
@@ -205,6 +205,19 @@ const currentWeather = await system.storage.get('state', 'currentWeather');
205
205
  console.log('Current weather:', currentWeather);
206
206
  ```
207
207
 
208
+ ### Retry and Freshness Semantics
209
+
210
+ `handleAsyncReturn()` runs inside the same retryable transaction model as `Controller.dispatch()`. If PostgreSQL reports a retryable transaction error, or if the async result applies a custom/full-replace path that requires `SERIALIZABLE`, interaqt may replay the async return attempt.
211
+
212
+ Keep `asyncReturn` deterministic and database-focused. Do not send emails, call payment APIs, publish messages, or perform other irreversible external IO from `asyncReturn`; those effects can be duplicated by a retry. Put irreversible post-commit work in `recordMutationSideEffects`.
213
+
214
+ Async tasks also use a freshness key so stale results do not overwrite newer work:
215
+
216
+ - Property async computations default to a key scoped by computation and host record.
217
+ - Global async computations default to a key scoped by computation and global result.
218
+ - Entity and relation async computations default to the result target, which means each target has one global freshness stream.
219
+ - Pass an explicit `freshnessKey` to `ComputationResult.async({ freshnessKey })` when multiple independent async streams should be allowed for the same entity or relation target.
220
+
208
221
  ## Implementing Entity Async Computations
209
222
 
210
223
  ### Use Cases
@@ -13,10 +13,10 @@ Many LLMs generate incorrect API usage. Here's the correct way to use interaqt t
13
13
  controller.run() // ❌ No such method
14
14
  storage.findByProperty('Entity', 'prop') // ❌ No such method
15
15
  controller.execute() // ❌ No such method
16
- controller.dispatch() // ❌ No such method
17
16
 
18
17
  // ✅ CORRECT: Use these APIs instead
19
- controller.callInteraction('InteractionName', args) // ✅ Call interactions
18
+ controller.callInteraction('InteractionName', args) // ✅ Call interaction by name
19
+ controller.dispatch(InteractionObject, args) // ✅ Dispatch an event source object
20
20
  storage.findOne('Entity', MatchExp) // ✅ Find single record
21
21
  storage.find('Entity', MatchExp) // ✅ Find multiple records
22
22
  storage.create('Entity', data) // ✅ Create record
@@ -139,6 +139,16 @@ describe('Feature Tests', () => {
139
139
  })
140
140
  ```
141
141
 
142
+ ### PostgreSQL Concurrency Tests
143
+
144
+ For changes that affect runtime transactions, custom computations, async computations, PostgreSQL id generation, or reactive computation state, run the real PostgreSQL suite:
145
+
146
+ ```bash
147
+ INTERAQT_POSTGRES_DATABASE=interaqt_test npm run test:postgres-concurrency
148
+ ```
149
+
150
+ This script fails when the database environment variable is missing. Plain `npm test` may skip PostgreSQL-specific tests in local lightweight environments.
151
+
142
152
  ### Key API Methods
143
153
 
144
154
  #### 1. Controller APIs
@@ -514,6 +514,16 @@ Transform.create(config: TransformConfig): KlassInstance<typeof Transform>
514
514
  - `config.callback` (function, required): Transformation function that converts source data to target data
515
515
  - `config.attributeQuery` (AttributeQueryData, required): Attribute query configuration
516
516
 
517
+ ### Custom.create()
518
+
519
+ Create a custom computation when built-in computations are not expressive enough.
520
+
521
+ **Concurrency parameter**
522
+ - `config.concurrency` (optional): `'serializable' | 'atomic-safe'`, defaults to `'serializable'`.
523
+ - `'serializable'`: interaqt runs the custom computation in a retryable PostgreSQL `SERIALIZABLE` transaction.
524
+ - `'atomic-safe'`: you explicitly promise the custom computation only uses atomic state, idempotent patches, or other concurrency-safe primitives. Full recompute and entity/relation full replace paths still require SERIALIZABLE even for `atomic-safe` custom computations.
525
+
526
+ Custom callbacks may be replayed after retryable transaction failures. Do not perform irreversible external IO inside custom `compute`, `incrementalCompute`, `incrementalPatchCompute`, or `asyncReturn`; use `recordMutationSideEffects` for post-commit external work.
517
527
 
518
528
  ### StateMachine.create()
519
529
 
@@ -9,6 +9,7 @@ import {
9
9
  // Entity-related
10
10
  Entity,
11
11
  Property,
12
+ UniqueConstraint,
12
13
 
13
14
  // Relation-related
14
15
  Relation,
@@ -53,6 +54,13 @@ import {
53
54
  // Storage and Query
54
55
  Controller,
55
56
  MonoSystem,
57
+ runWithTransactionRetry,
58
+ isRetryableTransactionError,
59
+ isRequireSerializableRetry,
60
+ ConstraintViolationError,
61
+ ConstraintSetupError,
62
+ findConstraintViolationError,
63
+ normalizeDatabaseError,
56
64
 
57
65
  // Dictionary (Global State)
58
66
  Dictionary,
@@ -92,17 +100,36 @@ import {
92
100
 
93
101
  ### Basic Entity Definition
94
102
  ```javascript
95
- import { Entity, Property } from 'interaqt';
103
+ import { Entity, Property, UniqueConstraint } from 'interaqt';
96
104
 
97
105
  const User = Entity.create({
98
106
  name: 'User',
99
107
  properties: [
100
108
  Property.create({ name: 'name', type: 'string' }),
101
109
  Property.create({ name: 'email', type: 'string' })
110
+ ],
111
+ constraints: [
112
+ UniqueConstraint.create({
113
+ name: 'User_email_unique',
114
+ properties: ['email']
115
+ })
102
116
  ]
103
117
  });
104
118
  ```
105
119
 
120
+ ### Data Constraints
121
+ ```javascript
122
+ import {
123
+ UniqueConstraint,
124
+ ConstraintViolationError,
125
+ ConstraintSetupError,
126
+ findConstraintViolationError,
127
+ normalizeDatabaseError
128
+ } from 'interaqt';
129
+ ```
130
+
131
+ `UniqueConstraint` declares persistent database uniqueness at Entity or Relation level. Runtime duplicate writes are reported with `ConstraintViolationError`; setup/index creation problems are reported with `ConstraintSetupError`. Use `findConstraintViolationError(error)` when the top-level error may be a wrapped computation error.
132
+
106
133
  ### Complete CRUD Setup
107
134
  ```javascript
108
135
  import {
@@ -173,4 +200,8 @@ const controller = new Controller({
173
200
 
174
201
  4. **Filtered Entities**: Created using `Entity.create()` with `baseEntity` and `filterCondition`, not a separate import.
175
202
 
176
- 5. **Database Drivers**: Choose one based on your needs - PGLiteDB for in-memory testing, PostgreSQLDB for production, etc.
203
+ 5. **Database Drivers**: Choose one based on your needs - PGLiteDB for in-memory testing, PostgreSQLDB for production, etc.
204
+
205
+ 6. **Transaction helpers**: `runWithTransactionRetry`, `isRetryableTransactionError`, and `isRequireSerializableRetry` are exported for advanced runtime integrations and tests. Most application code should use `Controller.dispatch()` or `system.storage.runInTransaction()` instead of calling retry helpers directly.
206
+
207
+ 7. **Constraint helpers**: `UniqueConstraint`, `ConstraintViolationError`, `ConstraintSetupError`, `findConstraintViolationError`, and `normalizeDatabaseError` are exported for schema-level uniqueness and stable duplicate handling.
@@ -0,0 +1,105 @@
1
+ # PostgreSQL Concurrency Migration
2
+
3
+ This note summarizes user-visible changes from the PostgreSQL reactive computation concurrency fix.
4
+
5
+ ## What Changed
6
+
7
+ interaqt now uses PostgreSQL as the concurrency coordinator for multi-process deployments:
8
+
9
+ - Built-in computations use atomic state updates, row locks, unique indexes, or SERIALIZABLE retry where needed.
10
+ - PostgreSQL id generation uses native sequences instead of writing `_IDS_`.
11
+ - Custom computations default to retryable `SERIALIZABLE` execution.
12
+ - Async return handling runs in a retryable transaction and applies freshness checks.
13
+
14
+ ## Callback Replay Rules
15
+
16
+ The following callbacks may be replayed after SERIALIZABLE promotion, `40001`, or `40P01`:
17
+
18
+ - `guard`
19
+ - `mapEventData`
20
+ - `resolve`
21
+ - computation callbacks
22
+ - `afterDispatch`
23
+ - `asyncReturn`
24
+
25
+ Keep these callbacks deterministic. Do not send emails, charge payments, call irreversible external APIs, publish messages, or write non-transactional resources from these callbacks.
26
+
27
+ Use `recordMutationSideEffects` for irreversible external IO. Side effects run after the final successful commit and are not replayed for failed attempts.
28
+
29
+ ## Custom Computation Concurrency
30
+
31
+ `Custom.create()` now defaults to:
32
+
33
+ ```typescript
34
+ Custom.create({
35
+ name: 'MyComputation',
36
+ concurrency: 'serializable',
37
+ // ...
38
+ })
39
+ ```
40
+
41
+ Use `concurrency: 'atomic-safe'` only when the custom computation is explicitly safe under concurrent PostgreSQL `READ COMMITTED` execution, for example because it only uses atomic state or idempotent patches.
42
+
43
+ Even with `atomic-safe`, full recompute and entity/relation full replace paths still require SERIALIZABLE.
44
+
45
+ ## Async Return Freshness
46
+
47
+ `handleAsyncReturn()` locks the task row and checks whether the task is still current.
48
+
49
+ Default freshness streams:
50
+
51
+ - Property async computation: scoped to the host record.
52
+ - Global async computation: scoped to the global result.
53
+ - Entity/relation async computation: scoped to the result target.
54
+
55
+ Pass an explicit `freshnessKey` when one entity or relation target needs multiple independent async streams:
56
+
57
+ ```typescript
58
+ return ComputationResult.async({
59
+ freshnessKey: `tenant:${tenantId}:job:${jobId}`,
60
+ // task args...
61
+ })
62
+ ```
63
+
64
+ Stale tasks are marked `skipped` and do not call `asyncReturn`.
65
+
66
+ ## PostgreSQL ID Sequences
67
+
68
+ PostgreSQL ids are allocated with native sequences.
69
+
70
+ Migration behavior:
71
+
72
+ - New databases start at id `1`.
73
+ - Existing table max id is used to initialize the sequence.
74
+ - Existing `_IDS_` rows are read as legacy migration input when the table exists.
75
+ - Databases without `_IDS_` are supported.
76
+ - Shared physical tables use one sequence per physical table/id field.
77
+
78
+ Do not write `_IDS_` to control ids. `_IDS_` is legacy input only.
79
+
80
+ PostgreSQL sequence gaps are normal after rollback or failed transactions. Do not depend on contiguous ids.
81
+
82
+ ## Transaction API
83
+
84
+ Use callback transactions:
85
+
86
+ ```typescript
87
+ await system.storage.runInTransaction(
88
+ { name: 'my-operation', isolation: 'SERIALIZABLE' },
89
+ async () => {
90
+ // transactional work
91
+ }
92
+ )
93
+ ```
94
+
95
+ Do not use old manual transaction APIs such as `beginTransaction`, `commitTransaction`, or `rollbackTransaction`.
96
+
97
+ ## Testing PostgreSQL Concurrency
98
+
99
+ Run the real PostgreSQL concurrency suite with:
100
+
101
+ ```bash
102
+ INTERAQT_POSTGRES_DATABASE=interaqt_test npm run test:postgres-concurrency
103
+ ```
104
+
105
+ This script intentionally fails when `INTERAQT_POSTGRES_DATABASE` is missing, so PostgreSQL concurrency coverage cannot silently skip in CI.
@@ -142,6 +142,7 @@ Remember: **Stop thinking "how to do", start thinking "what is"**!
142
142
  18. [Performance Optimization](./17-performance-optimization.md) - Performance tips
143
143
  19. [API Exports Reference](./18-api-exports-reference.md) - Complete list of available imports
144
144
  20. [Common Anti-Patterns](./19-common-anti-patterns.md) - Mistakes to avoid
145
+ 21. [PostgreSQL Concurrency Migration](./20-postgresql-concurrency-migration.md) - Retry, async, and sequence migration notes
145
146
 
146
147
  ## 📞 Need Help?
147
148
 
@@ -512,10 +512,11 @@ EventSource.create(args: {
512
512
  mapEventData?: (args: TArgs) => Record<string, any>
513
513
  resolve?: (this: Controller, args: TArgs) => Promise<TResult>
514
514
  afterDispatch?: (this: Controller, args: TArgs, result: { data?: TResult }) => Promise<Record<string, unknown> | void>
515
+ postCommit?: (this: Controller, args: TArgs, result: { data?: TResult, context?: Record<string, unknown> }) => Promise<Record<string, unknown> | void>
515
516
  }): EventSourceInstance
516
517
  ```
517
518
 
518
- Custom event source for scheduled tasks, webhooks, or any non-interaction trigger. Dispatch via `controller.dispatch(eventSource, args)`.
519
+ Custom event source for scheduled tasks, webhooks, or any non-interaction trigger. Dispatch via `controller.dispatch(eventSource, args)`. `afterDispatch` runs inside the retryable transaction; use `postCommit` for external IO that must run only after commit.
519
520
 
520
521
  ---
521
522
 
@@ -1 +1 @@
1
- {"version":3,"file":"ActivityManager.d.ts","sourceRoot":"","sources":["../../../../src/builtins/interaction/activity/ActivityManager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAqE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC1H,OAAO,EAA8B,mBAAmB,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAC;AAG1G,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEjD,OAAO,EAAE,kBAAkB,EAAE,CAAC;AAC9B,eAAO,MAAM,eAAe,eAAe,CAAA;AAE3C,eAAO,MAAM,mBAAmB,gBAwB9B,CAAA;AAEF,eAAO,MAAM,2BAA2B,kBAOtC,CAAA;AAEF,MAAM,WAAW,qBAAqB;IAClC,YAAY,EAAE,mBAAmB,CAAC,GAAG,EAAE,GAAG,CAAC,EAAE,CAAA;IAC7C,QAAQ,EAAE,cAAc,EAAE,CAAA;IAC1B,SAAS,EAAE,gBAAgB,EAAE,CAAA;CAChC;AAED,qBAAa,eAAe;IACjB,aAAa,4BAAkC;IAC/C,mBAAmB,4BAAkC;IAE5D,OAAO,CAAC,oBAAoB,CAAsC;IAClE,OAAO,CAAC,gBAAgB,CAAuB;IAC/C,OAAO,CAAC,iBAAiB,CAAyB;gBAG9C,UAAU,EAAE,gBAAgB,EAAE;IA8BlC,OAAO,CAAC,mCAAmC;IAmE3C,OAAO,CAAC,sBAAsB;IAU9B,SAAS,IAAI,qBAAqB;IAQlC,qCAAqC,CAAC,YAAY,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,GAAG,MAAM;IAI5F,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS;IAI7D,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS;CAGxE"}
1
+ {"version":3,"file":"ActivityManager.d.ts","sourceRoot":"","sources":["../../../../src/builtins/interaction/activity/ActivityManager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAqE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC1H,OAAO,EAA8B,mBAAmB,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAC;AAG1G,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEjD,OAAO,EAAE,kBAAkB,EAAE,CAAC;AAC9B,eAAO,MAAM,eAAe,eAAe,CAAA;AAE3C,eAAO,MAAM,mBAAmB,gBAwB9B,CAAA;AAEF,eAAO,MAAM,2BAA2B,kBAOtC,CAAA;AAEF,MAAM,WAAW,qBAAqB;IAClC,YAAY,EAAE,mBAAmB,CAAC,GAAG,EAAE,GAAG,CAAC,EAAE,CAAA;IAC7C,QAAQ,EAAE,cAAc,EAAE,CAAA;IAC1B,SAAS,EAAE,gBAAgB,EAAE,CAAA;CAChC;AAED,qBAAa,eAAe;IACjB,aAAa,4BAAkC;IAC/C,mBAAmB,4BAAkC;IAE5D,OAAO,CAAC,oBAAoB,CAAsC;IAClE,OAAO,CAAC,gBAAgB,CAAuB;IAC/C,OAAO,CAAC,iBAAiB,CAAyB;gBAG9C,UAAU,EAAE,gBAAgB,EAAE;IA8BlC,OAAO,CAAC,mCAAmC;IAoE3C,OAAO,CAAC,sBAAsB;IAU9B,SAAS,IAAI,qBAAqB;IAQlC,qCAAqC,CAAC,YAAY,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,GAAG,MAAM;IAI5F,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS;IAI7D,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS;CAGxE"}