@abloatai/ablo 0.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 (278) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +12 -0
  4. package/README.md +230 -0
  5. package/dist/BaseSyncedStore.d.ts +709 -0
  6. package/dist/BaseSyncedStore.js +1843 -0
  7. package/dist/Database.d.ts +344 -0
  8. package/dist/Database.js +1259 -0
  9. package/dist/LazyReferenceCollection.d.ts +181 -0
  10. package/dist/LazyReferenceCollection.js +460 -0
  11. package/dist/Model.d.ts +339 -0
  12. package/dist/Model.js +715 -0
  13. package/dist/ModelRegistry.d.ts +200 -0
  14. package/dist/ModelRegistry.js +535 -0
  15. package/dist/NetworkMonitor.d.ts +27 -0
  16. package/dist/NetworkMonitor.js +73 -0
  17. package/dist/ObjectPool.d.ts +202 -0
  18. package/dist/ObjectPool.js +1106 -0
  19. package/dist/SyncClient.d.ts +489 -0
  20. package/dist/SyncClient.js +1555 -0
  21. package/dist/SyncEngineContext.d.ts +46 -0
  22. package/dist/SyncEngineContext.js +74 -0
  23. package/dist/adapters/alwaysOnline.d.ts +16 -0
  24. package/dist/adapters/alwaysOnline.js +19 -0
  25. package/dist/adapters/inMemoryStorage.d.ts +30 -0
  26. package/dist/adapters/inMemoryStorage.js +94 -0
  27. package/dist/agent/Agent.d.ts +358 -0
  28. package/dist/agent/Agent.js +500 -0
  29. package/dist/agent/index.d.ts +115 -0
  30. package/dist/agent/index.js +128 -0
  31. package/dist/agent/session.d.ts +90 -0
  32. package/dist/agent/session.js +156 -0
  33. package/dist/agent/types.d.ts +73 -0
  34. package/dist/agent/types.js +10 -0
  35. package/dist/ai-sdk/coordination-context.d.ts +51 -0
  36. package/dist/ai-sdk/coordination-context.js +107 -0
  37. package/dist/ai-sdk/index.d.ts +68 -0
  38. package/dist/ai-sdk/index.js +68 -0
  39. package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
  40. package/dist/ai-sdk/intent-broadcast.js +72 -0
  41. package/dist/ai-sdk/wrap.d.ts +67 -0
  42. package/dist/ai-sdk/wrap.js +45 -0
  43. package/dist/api/index.d.ts +10 -0
  44. package/dist/api/index.js +9 -0
  45. package/dist/auth/index.d.ts +137 -0
  46. package/dist/auth/index.js +246 -0
  47. package/dist/client/Ablo.d.ts +835 -0
  48. package/dist/client/Ablo.js +1440 -0
  49. package/dist/client/ApiClient.d.ts +200 -0
  50. package/dist/client/ApiClient.js +659 -0
  51. package/dist/client/auth.d.ts +79 -0
  52. package/dist/client/auth.js +81 -0
  53. package/dist/client/createInternalComponents.d.ts +44 -0
  54. package/dist/client/createInternalComponents.js +88 -0
  55. package/dist/client/createModelProxy.d.ts +152 -0
  56. package/dist/client/createModelProxy.js +199 -0
  57. package/dist/client/identity.d.ts +63 -0
  58. package/dist/client/identity.js +156 -0
  59. package/dist/client/index.d.ts +36 -0
  60. package/dist/client/index.js +33 -0
  61. package/dist/client/persistence.d.ts +7 -0
  62. package/dist/client/persistence.js +11 -0
  63. package/dist/client/validateAbloOptions.d.ts +42 -0
  64. package/dist/client/validateAbloOptions.js +43 -0
  65. package/dist/config/index.d.ts +10 -0
  66. package/dist/config/index.js +12 -0
  67. package/dist/context.d.ts +27 -0
  68. package/dist/context.js +58 -0
  69. package/dist/core/DatabaseManager.d.ts +108 -0
  70. package/dist/core/DatabaseManager.js +361 -0
  71. package/dist/core/QueryProcessor.d.ts +77 -0
  72. package/dist/core/QueryProcessor.js +262 -0
  73. package/dist/core/QueryView.d.ts +64 -0
  74. package/dist/core/QueryView.js +219 -0
  75. package/dist/core/StoreManager.d.ts +131 -0
  76. package/dist/core/StoreManager.js +334 -0
  77. package/dist/core/ViewRegistry.d.ts +20 -0
  78. package/dist/core/ViewRegistry.js +55 -0
  79. package/dist/core/index.d.ts +34 -0
  80. package/dist/core/index.js +59 -0
  81. package/dist/core/openIDBWithTimeout.d.ts +27 -0
  82. package/dist/core/openIDBWithTimeout.js +63 -0
  83. package/dist/core/query-utils.d.ts +37 -0
  84. package/dist/core/query-utils.js +60 -0
  85. package/dist/errors.d.ts +235 -0
  86. package/dist/errors.js +243 -0
  87. package/dist/index.d.ts +41 -0
  88. package/dist/index.js +82 -0
  89. package/dist/interfaces/headless.d.ts +95 -0
  90. package/dist/interfaces/headless.js +41 -0
  91. package/dist/interfaces/index.d.ts +321 -0
  92. package/dist/interfaces/index.js +8 -0
  93. package/dist/mutators/RecordingTransaction.d.ts +36 -0
  94. package/dist/mutators/RecordingTransaction.js +216 -0
  95. package/dist/mutators/Transaction.d.ts +48 -0
  96. package/dist/mutators/Transaction.js +64 -0
  97. package/dist/mutators/UndoManager.d.ts +114 -0
  98. package/dist/mutators/UndoManager.js +143 -0
  99. package/dist/mutators/defineMutators.d.ts +55 -0
  100. package/dist/mutators/defineMutators.js +28 -0
  101. package/dist/policy/index.d.ts +19 -0
  102. package/dist/policy/index.js +18 -0
  103. package/dist/policy/types.d.ts +74 -0
  104. package/dist/policy/types.js +17 -0
  105. package/dist/principal.d.ts +44 -0
  106. package/dist/principal.js +49 -0
  107. package/dist/query/client.d.ts +43 -0
  108. package/dist/query/client.js +84 -0
  109. package/dist/query/index.d.ts +6 -0
  110. package/dist/query/index.js +5 -0
  111. package/dist/query/types.d.ts +143 -0
  112. package/dist/query/types.js +36 -0
  113. package/dist/react/AbloProvider.d.ts +205 -0
  114. package/dist/react/AbloProvider.js +398 -0
  115. package/dist/react/ClientSideSuspense.d.ts +36 -0
  116. package/dist/react/ClientSideSuspense.js +17 -0
  117. package/dist/react/DefaultFallback.d.ts +24 -0
  118. package/dist/react/DefaultFallback.js +43 -0
  119. package/dist/react/SyncGroupProvider.d.ts +19 -0
  120. package/dist/react/SyncGroupProvider.js +44 -0
  121. package/dist/react/context.d.ts +161 -0
  122. package/dist/react/context.js +35 -0
  123. package/dist/react/index.d.ts +64 -0
  124. package/dist/react/index.js +73 -0
  125. package/dist/react/internalContext.d.ts +35 -0
  126. package/dist/react/internalContext.js +3 -0
  127. package/dist/react/useAblo.d.ts +72 -0
  128. package/dist/react/useAblo.js +63 -0
  129. package/dist/react/useCurrentUserId.d.ts +21 -0
  130. package/dist/react/useCurrentUserId.js +33 -0
  131. package/dist/react/useErrorListener.d.ts +20 -0
  132. package/dist/react/useErrorListener.js +39 -0
  133. package/dist/react/useIntent.d.ts +29 -0
  134. package/dist/react/useIntent.js +42 -0
  135. package/dist/react/useMutate.d.ts +83 -0
  136. package/dist/react/useMutate.js +122 -0
  137. package/dist/react/useMutationFailureListener.d.ts +26 -0
  138. package/dist/react/useMutationFailureListener.js +38 -0
  139. package/dist/react/useMutators.d.ts +56 -0
  140. package/dist/react/useMutators.js +66 -0
  141. package/dist/react/usePresence.d.ts +32 -0
  142. package/dist/react/usePresence.js +41 -0
  143. package/dist/react/useQuery.d.ts +123 -0
  144. package/dist/react/useQuery.js +145 -0
  145. package/dist/react/useReactive.d.ts +35 -0
  146. package/dist/react/useReactive.js +111 -0
  147. package/dist/react/useReader.d.ts +69 -0
  148. package/dist/react/useReader.js +73 -0
  149. package/dist/react/useSyncStatus.d.ts +61 -0
  150. package/dist/react/useSyncStatus.js +76 -0
  151. package/dist/react/useUndoScope.d.ts +36 -0
  152. package/dist/react/useUndoScope.js +73 -0
  153. package/dist/realtime/index.d.ts +10 -0
  154. package/dist/realtime/index.js +9 -0
  155. package/dist/schema/field.d.ts +134 -0
  156. package/dist/schema/field.js +264 -0
  157. package/dist/schema/index.d.ts +29 -0
  158. package/dist/schema/index.js +38 -0
  159. package/dist/schema/model.d.ts +326 -0
  160. package/dist/schema/model.js +89 -0
  161. package/dist/schema/queries.d.ts +203 -0
  162. package/dist/schema/queries.js +145 -0
  163. package/dist/schema/relation.d.ts +172 -0
  164. package/dist/schema/relation.js +104 -0
  165. package/dist/schema/schema.d.ts +259 -0
  166. package/dist/schema/schema.js +188 -0
  167. package/dist/schema/sugar.d.ts +129 -0
  168. package/dist/schema/sugar.js +94 -0
  169. package/dist/source/index.d.ts +423 -0
  170. package/dist/source/index.js +320 -0
  171. package/dist/source/pushQueue.d.ts +112 -0
  172. package/dist/source/pushQueue.js +249 -0
  173. package/dist/stores/ObjectStore.d.ts +103 -0
  174. package/dist/stores/ObjectStore.js +371 -0
  175. package/dist/stores/ObjectStoreContract.d.ts +39 -0
  176. package/dist/stores/ObjectStoreContract.js +1 -0
  177. package/dist/stores/SyncActionStore.d.ts +101 -0
  178. package/dist/stores/SyncActionStore.js +481 -0
  179. package/dist/sync/BootstrapHelper.d.ts +127 -0
  180. package/dist/sync/BootstrapHelper.js +434 -0
  181. package/dist/sync/ConnectionManager.d.ts +136 -0
  182. package/dist/sync/ConnectionManager.js +465 -0
  183. package/dist/sync/HydrationCoordinator.d.ts +137 -0
  184. package/dist/sync/HydrationCoordinator.js +468 -0
  185. package/dist/sync/NetworkProbe.d.ts +43 -0
  186. package/dist/sync/NetworkProbe.js +113 -0
  187. package/dist/sync/OfflineFlush.d.ts +9 -0
  188. package/dist/sync/OfflineFlush.js +22 -0
  189. package/dist/sync/OfflineTransactionStore.d.ts +37 -0
  190. package/dist/sync/OfflineTransactionStore.js +263 -0
  191. package/dist/sync/SyncWebSocket.d.ts +663 -0
  192. package/dist/sync/SyncWebSocket.js +1336 -0
  193. package/dist/sync/createIntentStream.d.ts +33 -0
  194. package/dist/sync/createIntentStream.js +243 -0
  195. package/dist/sync/createPresenceStream.d.ts +46 -0
  196. package/dist/sync/createPresenceStream.js +192 -0
  197. package/dist/sync/createSnapshot.d.ts +33 -0
  198. package/dist/sync/createSnapshot.js +124 -0
  199. package/dist/sync/participants.d.ts +114 -0
  200. package/dist/sync/participants.js +336 -0
  201. package/dist/sync/schemas.d.ts +79 -0
  202. package/dist/sync/schemas.js +78 -0
  203. package/dist/testing/fixtures/bootstrap.d.ts +45 -0
  204. package/dist/testing/fixtures/bootstrap.js +53 -0
  205. package/dist/testing/fixtures/deltas.d.ts +86 -0
  206. package/dist/testing/fixtures/deltas.js +139 -0
  207. package/dist/testing/fixtures/models.d.ts +82 -0
  208. package/dist/testing/fixtures/models.js +270 -0
  209. package/dist/testing/helpers/react-wrapper.d.ts +66 -0
  210. package/dist/testing/helpers/react-wrapper.js +64 -0
  211. package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
  212. package/dist/testing/helpers/sync-engine-harness.js +70 -0
  213. package/dist/testing/helpers/wait.d.ts +25 -0
  214. package/dist/testing/helpers/wait.js +44 -0
  215. package/dist/testing/index.d.ts +21 -0
  216. package/dist/testing/index.js +32 -0
  217. package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
  218. package/dist/testing/mocks/MockMutationExecutor.js +139 -0
  219. package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
  220. package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
  221. package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
  222. package/dist/testing/mocks/MockSyncContext.js +100 -0
  223. package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
  224. package/dist/testing/mocks/MockSyncStore.js +171 -0
  225. package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
  226. package/dist/testing/mocks/MockWebSocket.js +117 -0
  227. package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
  228. package/dist/transactions/OptimisticEchoTracker.js +104 -0
  229. package/dist/transactions/TransactionQueue.d.ts +499 -0
  230. package/dist/transactions/TransactionQueue.js +1895 -0
  231. package/dist/transactions/index.d.ts +16 -0
  232. package/dist/transactions/index.js +7 -0
  233. package/dist/transactions/mutation-error-handler.d.ts +5 -0
  234. package/dist/transactions/mutation-error-handler.js +39 -0
  235. package/dist/types/global.d.ts +107 -0
  236. package/dist/types/global.js +38 -0
  237. package/dist/types/index.d.ts +241 -0
  238. package/dist/types/index.js +70 -0
  239. package/dist/types/streams.d.ts +495 -0
  240. package/dist/types/streams.js +11 -0
  241. package/dist/utils/asyncIterator.d.ts +41 -0
  242. package/dist/utils/asyncIterator.js +142 -0
  243. package/dist/utils/duration.d.ts +28 -0
  244. package/dist/utils/duration.js +47 -0
  245. package/dist/utils/mobx-setup.d.ts +42 -0
  246. package/dist/utils/mobx-setup.js +381 -0
  247. package/docs/api-keys.md +24 -0
  248. package/docs/api.md +230 -0
  249. package/docs/audit.md +81 -0
  250. package/docs/capabilities.md +163 -0
  251. package/docs/client-behavior.md +202 -0
  252. package/docs/data-sources.md +214 -0
  253. package/docs/examples/agent-human.md +84 -0
  254. package/docs/examples/ai-sdk-tool.md +92 -0
  255. package/docs/examples/existing-python-backend.md +249 -0
  256. package/docs/examples/nextjs.md +88 -0
  257. package/docs/examples/server-agent.md +86 -0
  258. package/docs/guarantees.md +148 -0
  259. package/docs/index.md +97 -0
  260. package/docs/integration-guide.md +493 -0
  261. package/docs/interaction-model.md +140 -0
  262. package/docs/mcp/claude-code.md +43 -0
  263. package/docs/mcp/cursor.md +53 -0
  264. package/docs/mcp/windsurf.md +46 -0
  265. package/docs/mcp.md +59 -0
  266. package/docs/quickstart.md +152 -0
  267. package/docs/react.md +115 -0
  268. package/docs/roadmap.md +45 -0
  269. package/examples/README.md +54 -0
  270. package/examples/data-source/README.md +102 -0
  271. package/examples/data-source/ablo-driver.ts +89 -0
  272. package/examples/data-source/customer-server.ts +208 -0
  273. package/examples/data-source/run.ts +101 -0
  274. package/examples/data-source/schema.ts +25 -0
  275. package/examples/quickstart.ts +54 -0
  276. package/examples/tsconfig.json +16 -0
  277. package/llms.txt +143 -0
  278. package/package.json +147 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Schema Query Definitions
3
+ *
4
+ * A query is a zod input schema + a string reference to a schema model
5
+ * that the query returns. Types flow via `z.infer` for inputs and
6
+ * `InferModel` for results — the same inference path `model` and
7
+ * `relation` already use. No second type system.
8
+ *
9
+ * Usage:
10
+ * import { z } from 'zod';
11
+ * import {
12
+ * defineSchema, defineQueries, model, query, relation,
13
+ * } from '@ablo/sync-engine/schema';
14
+ *
15
+ * const schema = defineSchema({
16
+ * slideLayer: model(
17
+ * { slideId: z.string(), type: z.string() },
18
+ * { slide: relation.belongsTo('slides', 'slideId') },
19
+ * { load: 'lazy', typename: 'SlideLayer', persist: {} },
20
+ * ),
21
+ * });
22
+ *
23
+ * const queries = defineQueries(schema, {
24
+ * slideLayersByDeck: query({
25
+ * input: z.object({ deckId: z.string() }),
26
+ * returns: 'slideLayer', // ← type-checked against schema.models
27
+ * }),
28
+ * });
29
+ *
30
+ * type Input = InferQueryInput<typeof queries.queries.slideLayersByDeck>;
31
+ * // ^? { deckId: string }
32
+ * type Result = InferQueryResult<
33
+ * typeof schema,
34
+ * typeof queries.queries.slideLayersByDeck
35
+ * >;
36
+ * // ^? Array<SlideLayer>
37
+ *
38
+ * Design notes:
39
+ *
40
+ * - `query()` accepts any string for `returns`. The constraint that it
41
+ * must reference a real schema model is applied when the query is
42
+ * passed to `defineQueries(schema, ...)`. This mirrors how
43
+ * `relation.belongsTo('projects', 'projectId')` accepts a plain
44
+ * string at the factory and defers the cross-reference check to
45
+ * schema assembly time.
46
+ *
47
+ * - Queries do NOT carry a `name` until they pass through
48
+ * `defineQueries()`. The name is assigned from the record key —
49
+ * same pattern as `defineSchema({ tasks: model(...) })` where the
50
+ * model name is the record key, not a field on the model factory.
51
+ *
52
+ * - All queries return an array of a single model type. Multi-model
53
+ * fetches (e.g., files + folders) are expressed as multiple queries
54
+ * in a single batch at dispatch time, not as "bundle" shapes in the
55
+ * schema. This keeps each `QueryDef` pointed at exactly one model
56
+ * and lets the generic loader hydrate via a single
57
+ * `schema.models[queryDef.returns]` lookup.
58
+ */
59
+ import { AbloValidationError } from '../errors.js';
60
+ // ── query() factory ───────────────────────────────────────────────────────
61
+ /**
62
+ * Define a query.
63
+ *
64
+ * `TReturns` is a `const` generic so TypeScript preserves the literal
65
+ * type of the `returns` value at call time (e.g., `'slideLayer'`
66
+ * instead of widening to `string`). This is what lets `defineQueries`
67
+ * type-check `returns` against the schema's model keys without
68
+ * requiring the consumer to write `as const` manually.
69
+ *
70
+ * ```ts
71
+ * const slideLayersByDeck = query({
72
+ * input: z.object({ deckId: z.string() }),
73
+ * returns: 'slideLayer',
74
+ * });
75
+ * ```
76
+ */
77
+ export function query(spec) {
78
+ return {
79
+ input: spec.input,
80
+ returns: spec.returns,
81
+ };
82
+ }
83
+ // ── defineQueries() factory ───────────────────────────────────────────────
84
+ /**
85
+ * Define a typed query set against a schema.
86
+ *
87
+ * Each entry's `returns` field is constrained to the schema's model
88
+ * names at compile time via the `Q` generic bound to `QueryRecord<S>`.
89
+ * A query whose `returns` does not match a known model will fail at
90
+ * the `defineQueries` call site with a TypeScript error, not at
91
+ * runtime.
92
+ *
93
+ * The factory also performs a runtime validation of the same
94
+ * invariant. This catches the edge case where the schema and the
95
+ * queries live in separate modules that drift out of sync — for
96
+ * example, when a developer removes a model from the schema without
97
+ * updating the queries that referenced it. The runtime error points
98
+ * at the specific offending query and lists the available models,
99
+ * which is a nicer failure mode than a generic "property does not
100
+ * exist" error deep inside the loader.
101
+ *
102
+ * Each resolved query gets its `name` populated from the record key:
103
+ * `queries.slideLayersByDeck.name === 'slideLayersByDeck'`. Wire
104
+ * dispatch, the Go registry, and the loader orchestrator all read
105
+ * `queryDef.name` directly rather than re-deriving it.
106
+ *
107
+ * ```ts
108
+ * const schema = defineSchema({
109
+ * slideLayer: model(
110
+ * { slideId: z.string() },
111
+ * {},
112
+ * { load: 'lazy', typename: 'SlideLayer', persist: {} },
113
+ * ),
114
+ * });
115
+ *
116
+ * const queries = defineQueries(schema, {
117
+ * slideLayersByDeck: query({
118
+ * input: z.object({ deckId: z.string() }),
119
+ * returns: 'slideLayer', // ← type-checked
120
+ * }),
121
+ * });
122
+ * ```
123
+ */
124
+ export function defineQueries(schema, queries) {
125
+ // Rebuild the queries record with `name` populated per entry. Same
126
+ // shallow-spread strategy `defineSchema` uses for `typename` / `persist`
127
+ // defaults — preserves input references, avoids mutating `readonly`
128
+ // fields in place, and keeps each resolved `QueryDef` immutable from
129
+ // construction onward.
130
+ const resolvedQueries = {};
131
+ for (const [name, def] of Object.entries(queries)) {
132
+ if (!(def.returns in schema.models)) {
133
+ throw new AbloValidationError(`defineQueries: query "${name}" declares returns: "${def.returns}", ` +
134
+ `which is not a model in the schema. ` +
135
+ `Available models: ${Object.keys(schema.models).join(', ') || '(none)'}`, { code: 'query_returns_unknown_model' });
136
+ }
137
+ resolvedQueries[name] = { ...def, name };
138
+ }
139
+ return {
140
+ schema,
141
+ // Cast back to Q: the rebuild only added `name` (already optional on
142
+ // QueryDef) to each entry, so the shape is structurally unchanged.
143
+ queries: resolvedQueries,
144
+ };
145
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Schema Relation Definitions
3
+ *
4
+ * Declarative relations between models. Used for:
5
+ * - FK index registration (ObjectPool.registerForeignKey)
6
+ * - Model create priority derivation (parents before children)
7
+ * - Query include/join support
8
+ *
9
+ * Usage:
10
+ * import { relation } from '@ablo/sync-engine/schema';
11
+ *
12
+ * const taskRelations = {
13
+ * project: relation.belongsTo('projects', 'projectId'),
14
+ * assignee: relation.belongsTo('users', 'assigneeId'),
15
+ * comments: relation.hasMany('comments', 'taskId'),
16
+ * };
17
+ */
18
+ /**
19
+ * Options for `relation.belongsTo(...)`. All default to `false` — behavior
20
+ * is opt-in per relation.
21
+ *
22
+ * When `index: true`, the sync engine registers an O(1) foreign-key index
23
+ * on the child model's ObjectPool entry at construction time, so that
24
+ * `pool.getByForeignKey(childType, foreignKey, parentId)` becomes constant-
25
+ * time instead of a full scan. Use on hot paths like `SlideLayer.slideId`
26
+ * where you frequently want "all layers for this slide."
27
+ *
28
+ * When `enrich: true`, incoming deltas for the child model have their
29
+ * parent reference auto-populated from the ObjectPool before the model
30
+ * data lands. I.e., a delta for `Task { teamId: 't1' }` picks up the
31
+ * `teams:t1` entity from the pool and attaches it as `data.team`, so
32
+ * consumers can read `task.team` directly without a second lookup.
33
+ * If the parent isn't in the pool yet, enrichment silently no-ops
34
+ * (the child data is still applied) — enrichment is best-effort.
35
+ *
36
+ * When `defer: true`, the FK-create-priority computer (Tarjan SCC in
37
+ * `client/createSyncEngine.ts`) ignores this edge when building the
38
+ * dependency graph. Use on the *soft* side of a real cycle to break it
39
+ * deterministically — i.e. the side where you're willing to insert the
40
+ * child first with the FK left null and patch it in a follow-up
41
+ * UPDATE. The other side of the cycle then becomes a strict topological
42
+ * predecessor, so the child gets a higher priority than the parent
43
+ * instead of being tied with it.
44
+ *
45
+ * `defer` only affects priority computation, not what the engine sends
46
+ * on the wire — it does NOT auto-rewrite an INSERT to (insert-null +
47
+ * update-later). Pair with a Postgres `DEFERRABLE INITIALLY DEFERRED`
48
+ * constraint when you actually want the FK check to be relaxed at the
49
+ * database level. Example use case:
50
+ *
51
+ * ```ts
52
+ * layouts: model({ deckId: z.string().nullish() }, {
53
+ * // The deck-owns-layout link is nullable AND the consumer always
54
+ * // creates the layout first; mark it `defer` so SlideDeck can
55
+ * // commit ahead of Layout instead of being trapped in the same
56
+ * // SCC priority bucket.
57
+ * deck: relation.belongsTo('slideDecks', 'deckId', { defer: true }),
58
+ * }),
59
+ * ```
60
+ */
61
+ export interface BelongsToOptions {
62
+ readonly index?: boolean;
63
+ readonly enrich?: boolean;
64
+ readonly defer?: boolean;
65
+ }
66
+ declare const __relationType: unique symbol;
67
+ declare const __relationTarget: unique symbol;
68
+ declare const __relationField: unique symbol;
69
+ export type RelationType = 'belongsTo' | 'hasMany' | 'hasOne';
70
+ /**
71
+ * A relation definition with embedded type information.
72
+ *
73
+ * The 4th generic `Options` captures per-relation options at the type
74
+ * level (currently only `belongsTo` uses this — `hasMany`/`hasOne`
75
+ * default to empty). The `const Opts` modifier on the `belongsTo`
76
+ * factory preserves literal inference: `{ enrich: true }` narrows to
77
+ * `true`, not `boolean`, so future type-level features (like
78
+ * `InferModel` auto-adding enriched-parent properties) can read the
79
+ * literal value off the relation def at compile time.
80
+ *
81
+ * `options` is always present at runtime — the factory assigns an
82
+ * empty object when the caller omits it, which keeps
83
+ * `relation.options.index` / `relation.options.enrich` safe to read
84
+ * without a null guard downstream.
85
+ */
86
+ export interface RelationDef<Type extends RelationType = RelationType, Target extends string = string, Field extends string = string, Options extends BelongsToOptions = BelongsToOptions> {
87
+ readonly [__relationType]: Type;
88
+ readonly [__relationTarget]: Target;
89
+ readonly [__relationField]: Field;
90
+ /** Runtime metadata */
91
+ readonly type: Type;
92
+ readonly target: Target;
93
+ /**
94
+ * The child model's JS field that holds the parent's id. Always the
95
+ * camelCase schema field name — used by the client ObjectPool to read
96
+ * `model[foreignKey]`, by `LazyReferenceCollection` for IndexedDB
97
+ * index keys, and by `ModelRegistry` for cascade wiring. Never used
98
+ * verbatim in raw SQL.
99
+ */
100
+ readonly foreignKey: Field;
101
+ /**
102
+ * The same foreign key expressed as a database column identifier. Set
103
+ * by `defineSchema` when a `casing` option is configured (e.g.
104
+ * `'snake_case'` produces `message_id` from `messageId`). Used by
105
+ * server-side SQL compilers to interpolate the real column name into
106
+ * queries — `postgres.camel`-style data-layer transforms do NOT rewrite
107
+ * identifiers embedded in raw SQL, so the translation has to happen
108
+ * somewhere, and schema-build time is the one-place-once answer.
109
+ *
110
+ * Defaults to {@link foreignKey} when `casing` is unset (identity) —
111
+ * the SDK stays backward-compatible for consumers whose DB columns
112
+ * already match their JS field names.
113
+ */
114
+ readonly foreignKeyColumn: string;
115
+ readonly options: Options;
116
+ /**
117
+ * Optional sort field for `hasMany` relations. When set, the
118
+ * generated relation getter sorts results by this field. Populated
119
+ * by `relation.hasMany(target, fk, { orderBy: 'fieldName' })`.
120
+ */
121
+ readonly _orderBy?: string;
122
+ }
123
+ export declare const relation: {
124
+ /**
125
+ * This model belongs to another model via a foreign key.
126
+ * e.g., Task belongs to Project via projectId
127
+ *
128
+ * ```ts
129
+ * // Simple reference (no options)
130
+ * project: relation.belongsTo('projects', 'projectId'),
131
+ *
132
+ * // Register an FK index for O(1) child lookups
133
+ * slide: relation.belongsTo('slides', 'slideId', { index: true }),
134
+ *
135
+ * // Auto-populate the parent on delta arrival
136
+ * team: relation.belongsTo('teams', 'teamId', { enrich: true }),
137
+ *
138
+ * // Both
139
+ * parent: relation.belongsTo('threads', 'parentId', { index: true, enrich: true }),
140
+ *
141
+ * // Mark the soft side of a cycle so the priority computer breaks
142
+ * // the cycle deterministically instead of tying the two models.
143
+ * deck: relation.belongsTo('slideDecks', 'deckId', { defer: true }),
144
+ * ```
145
+ */
146
+ readonly belongsTo: <Target extends string, Field extends string, const Opts extends BelongsToOptions = Record<string, never>>(target: Target, foreignKey: Field, options?: Opts) => RelationDef<"belongsTo", Target, Field, Opts>;
147
+ /**
148
+ * This model has many of another model.
149
+ * e.g., Project has many Tasks (via Task.projectId)
150
+ *
151
+ * At runtime, generates a getter on the parent model that returns
152
+ * all child models matching the FK via ObjectPool.getByForeignKey.
153
+ * The FK index on the child model is auto-registered.
154
+ *
155
+ * ```ts
156
+ * slides: relation.hasMany('slideLayers', 'slideId'),
157
+ * // → deck.slides returns all SlideLayer[] where slideId === deck.id
158
+ *
159
+ * slides: relation.hasMany('slideLayers', 'slideId', { orderBy: 'zIndex' }),
160
+ * // → deck.slides returns SlideLayer[] sorted by zIndex ascending
161
+ * ```
162
+ */
163
+ readonly hasMany: <Target extends string, Field extends string>(target: Target, foreignKey: Field, options?: {
164
+ orderBy?: string;
165
+ }) => RelationDef<"hasMany", Target, Field>;
166
+ /**
167
+ * This model has one of another model.
168
+ * e.g., User has one Profile (via Profile.userId)
169
+ */
170
+ readonly hasOne: <Target extends string, Field extends string>(target: Target, foreignKey: Field) => RelationDef<"hasOne", Target, Field>;
171
+ };
172
+ export {};
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Schema Relation Definitions
3
+ *
4
+ * Declarative relations between models. Used for:
5
+ * - FK index registration (ObjectPool.registerForeignKey)
6
+ * - Model create priority derivation (parents before children)
7
+ * - Query include/join support
8
+ *
9
+ * Usage:
10
+ * import { relation } from '@ablo/sync-engine/schema';
11
+ *
12
+ * const taskRelations = {
13
+ * project: relation.belongsTo('projects', 'projectId'),
14
+ * assignee: relation.belongsTo('users', 'assigneeId'),
15
+ * comments: relation.hasMany('comments', 'taskId'),
16
+ * };
17
+ */
18
+ // ── Internal relation builder ─────────────────────────────────────────────
19
+ class RelationBuilder {
20
+ type;
21
+ target;
22
+ foreignKey;
23
+ /**
24
+ * Starts out identical to {@link foreignKey}. `defineSchema` overwrites
25
+ * this when a `casing` option is set — it's declared as a mutable
26
+ * (non-readonly on the implementation side) so the schema builder can
27
+ * resolve it once at build time without allocating a new object per
28
+ * relation. Consumers see it typed as `readonly` on {@link RelationDef}.
29
+ */
30
+ foreignKeyColumn;
31
+ options;
32
+ /**
33
+ * Stashed by `hasMany` when the caller provides `{ orderBy }`. Read
34
+ * back in `createSyncEngine` to install the sort comparator on the
35
+ * generated relation getter. Declared on the builder so both writer
36
+ * and reader stay type-safe — no `as unknown as Record<...>` smuggle.
37
+ */
38
+ _orderBy;
39
+ constructor(type, target, foreignKey, options) {
40
+ this.type = type;
41
+ this.target = target;
42
+ this.foreignKey = foreignKey;
43
+ this.foreignKeyColumn = foreignKey;
44
+ this.options = (options ?? {});
45
+ }
46
+ }
47
+ // ── Public relation factories ─────────────────────────────────────────────
48
+ export const relation = {
49
+ /**
50
+ * This model belongs to another model via a foreign key.
51
+ * e.g., Task belongs to Project via projectId
52
+ *
53
+ * ```ts
54
+ * // Simple reference (no options)
55
+ * project: relation.belongsTo('projects', 'projectId'),
56
+ *
57
+ * // Register an FK index for O(1) child lookups
58
+ * slide: relation.belongsTo('slides', 'slideId', { index: true }),
59
+ *
60
+ * // Auto-populate the parent on delta arrival
61
+ * team: relation.belongsTo('teams', 'teamId', { enrich: true }),
62
+ *
63
+ * // Both
64
+ * parent: relation.belongsTo('threads', 'parentId', { index: true, enrich: true }),
65
+ *
66
+ * // Mark the soft side of a cycle so the priority computer breaks
67
+ * // the cycle deterministically instead of tying the two models.
68
+ * deck: relation.belongsTo('slideDecks', 'deckId', { defer: true }),
69
+ * ```
70
+ */
71
+ belongsTo(target, foreignKey, options) {
72
+ return new RelationBuilder('belongsTo', target, foreignKey, options ?? {});
73
+ },
74
+ /**
75
+ * This model has many of another model.
76
+ * e.g., Project has many Tasks (via Task.projectId)
77
+ *
78
+ * At runtime, generates a getter on the parent model that returns
79
+ * all child models matching the FK via ObjectPool.getByForeignKey.
80
+ * The FK index on the child model is auto-registered.
81
+ *
82
+ * ```ts
83
+ * slides: relation.hasMany('slideLayers', 'slideId'),
84
+ * // → deck.slides returns all SlideLayer[] where slideId === deck.id
85
+ *
86
+ * slides: relation.hasMany('slideLayers', 'slideId', { orderBy: 'zIndex' }),
87
+ * // → deck.slides returns SlideLayer[] sorted by zIndex ascending
88
+ * ```
89
+ */
90
+ hasMany(target, foreignKey, options) {
91
+ const builder = new RelationBuilder('hasMany', target, foreignKey);
92
+ if (options?.orderBy) {
93
+ builder._orderBy = options.orderBy;
94
+ }
95
+ return builder;
96
+ },
97
+ /**
98
+ * This model has one of another model.
99
+ * e.g., User has one Profile (via Profile.userId)
100
+ */
101
+ hasOne(target, foreignKey) {
102
+ return new RelationBuilder('hasOne', target, foreignKey);
103
+ },
104
+ };
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Schema Definition + Type Inference
3
+ *
4
+ * defineSchema() wraps your models. Types are inferred via Zod — no custom type system.
5
+ *
6
+ * Usage:
7
+ * import { z } from 'zod';
8
+ * import { defineSchema, model, relation } from '@ablo/sync-engine/schema';
9
+ *
10
+ * const schema = defineSchema({
11
+ * tasks: model({
12
+ * title: z.string(),
13
+ * status: z.enum(['todo', 'doing', 'done']).default('todo'),
14
+ * projectId: z.string().optional(),
15
+ * }, {
16
+ * project: relation.belongsTo('projects', 'projectId'),
17
+ * }),
18
+ * });
19
+ *
20
+ * type Task = InferModel<typeof schema, 'tasks'>;
21
+ */
22
+ import { z } from 'zod';
23
+ import type { ModelDef, RelationRecord } from './model.js';
24
+ import type { RelationDef } from './relation.js';
25
+ /** The set of built-in casing conventions supported by `defineSchema`. */
26
+ export type CasingConvention = 'snake_case' | 'camelCase';
27
+ /** Plug point for custom conventions (e.g. mixed legacy databases). */
28
+ export type CasingFn = (jsField: string) => string;
29
+ /** `defineSchema`'s casing option. Identity when unset. */
30
+ export type Casing = CasingConvention | CasingFn;
31
+ /**
32
+ * The identity shape passed to {@link IdentityRole.extract}. Free-form
33
+ * `Record<string, unknown>` because each schema chooses what fields
34
+ * its roles read — Ablo's roles read `organizationId`/`userId`/`teamIds`;
35
+ * a tenant model with `regionId`/`customerId` is equally valid. The
36
+ * server hands its resolved identity (whatever shape its AuthProvider
37
+ * returns) straight to `extract` without translation.
38
+ */
39
+ export type IdentityContext = Record<string, unknown>;
40
+ /**
41
+ * Open registration of an identity-anchored sync-group. Each role
42
+ * declares (a) a free-form `kind` label (purely for diagnostics), (b)
43
+ * a `template` containing a single `{id}` placeholder, and (c) an
44
+ * `extract` function that pulls the relevant ids out of an arbitrary
45
+ * identity context.
46
+ *
47
+ * No closed enum. A consumer whose identity shape is
48
+ * `{ regionId, customerId }` registers:
49
+ *
50
+ * ```ts
51
+ * defineSchema({ ... }, {
52
+ * identityRoles: [
53
+ * { kind: 'region', template: 'region:{id}',
54
+ * extract: (i) => i.regionId ? [String(i.regionId)] : [] },
55
+ * { kind: 'customer', template: 'customer:{id}',
56
+ * extract: (i) => i.customerId ? [String(i.customerId)] : [] },
57
+ * ],
58
+ * });
59
+ * ```
60
+ *
61
+ * The server's `composeIdentitySyncGroups` walks every registered role
62
+ * and produces the union — no hardcoded `org:` / `user:` / `team:`
63
+ * anywhere in the engine.
64
+ */
65
+ export interface IdentityRole {
66
+ /** Free-form label for diagnostics/logging. Not parsed. */
67
+ readonly kind: string;
68
+ /**
69
+ * Sync-group template with a single `{id}` placeholder. Substituted
70
+ * once per id returned by `extract`. Example: `'org:{id}'` →
71
+ * `'org:abc-123'`.
72
+ */
73
+ readonly template: string;
74
+ /**
75
+ * Extract zero or more ids from the resolved identity context. Pure
76
+ * function — must not allocate persistent state, must be safe to
77
+ * call once per request. Return an empty array when the role
78
+ * doesn't apply to this identity.
79
+ */
80
+ readonly extract: (identity: IdentityContext) => readonly string[];
81
+ }
82
+ /** Options for `defineSchema`. */
83
+ export interface DefineSchemaOptions {
84
+ /**
85
+ * How to translate camelCase JS field names into database column
86
+ * identifiers. Applied once, at schema build, to every relation's
87
+ * `foreignKey` to produce `foreignKeyColumn`. Consumers whose DB
88
+ * columns already match their JS field names can omit this — the
89
+ * default is identity (no transform).
90
+ *
91
+ * Accepts a named convention or a custom function:
92
+ *
93
+ * ```ts
94
+ * defineSchema({ ... }, { casing: 'snake_case' })
95
+ * defineSchema({ ... }, { casing: (key) => key.toUpperCase() })
96
+ * ```
97
+ */
98
+ readonly casing?: Casing;
99
+ /**
100
+ * Identity-anchored sync-group roles. Server's
101
+ * `composeIdentitySyncGroups` iterates these to build the
102
+ * participant's allowed-set, replacing the prior hardcoded
103
+ * `org:/user:/team:` triad in `@ablo/schema`. See {@link IdentityRole}
104
+ * for the open-registration shape — no closed enum, consumer fully
105
+ * controls both the template string and the extractor function.
106
+ *
107
+ * Leave unset for schemas that don't need identity-derived scoping
108
+ * (e.g. fully public read models). When unset, `composeIdentitySyncGroups`
109
+ * returns `[]` and consumers fall back to whatever explicit
110
+ * `syncGroups` the AuthProvider attaches to the Identity.
111
+ */
112
+ readonly identityRoles?: readonly IdentityRole[];
113
+ }
114
+ /** A record of model names → model definitions */
115
+ export type SchemaRecord = Record<string, ModelDef>;
116
+ /** Base fields every synced model gets automatically */
117
+ declare const baseFieldsSchema: z.ZodObject<{
118
+ id: z.ZodString;
119
+ createdAt: z.ZodDate;
120
+ updatedAt: z.ZodDate;
121
+ organizationId: z.ZodOptional<z.ZodString>;
122
+ createdBy: z.ZodOptional<z.ZodString>;
123
+ }, z.core.$strip>;
124
+ /** The base fields type — pure data columns. */
125
+ export type BaseModelFields = z.infer<typeof baseFieldsSchema>;
126
+ /**
127
+ * Methods every dynamic model class inherits from `Model`. Intersected
128
+ * into `InferModel` (the read-side type) but NOT into `InferCreate` /
129
+ * `UpdatePatch` — methods aren't legal create/update inputs.
130
+ */
131
+ export interface BaseModelMethods {
132
+ /** Wire-format model name (e.g. `'Slide'`, `'Comment'`). */
133
+ getModelName(): string;
134
+ /** Plain-object serialization suitable for sending over the wire. */
135
+ toJSON(): Record<string, unknown>;
136
+ }
137
+ /** The schema object returned by defineSchema() */
138
+ export interface Schema<S extends SchemaRecord = SchemaRecord> {
139
+ /** The raw model definitions */
140
+ readonly models: S;
141
+ /** Zod schemas with base fields merged in */
142
+ readonly validators: {
143
+ readonly [K in keyof S]: S[K] extends ModelDef<infer Shape> ? z.ZodObject<Shape & typeof baseFieldsSchema.shape> : never;
144
+ };
145
+ /**
146
+ * Identity-anchored sync-group roles registered via
147
+ * `defineSchema({...}, { identityRoles })`. Empty array when unset.
148
+ * Server's `composeIdentitySyncGroups(identity, schema)` reads this
149
+ * to derive a participant's allowed sync-group set.
150
+ */
151
+ readonly identityRoles: readonly IdentityRole[];
152
+ }
153
+ /**
154
+ * Infer the full model type from a schema.
155
+ * Includes base fields (id, createdAt, updatedAt, etc.)
156
+ *
157
+ * ```ts
158
+ * type Task = InferModel<typeof schema, 'tasks'>;
159
+ * ```
160
+ */
161
+ export type InferModel<S extends Schema, ModelName extends keyof S['models']> = S['models'][ModelName] extends ModelDef<infer Shape, infer R, infer C> ? z.infer<z.ZodObject<Shape>> & BaseModelFields & BaseModelMethods & InferComputed<C> & InferRelations<S, R> : never;
162
+ /**
163
+ * Infer relation accessor types from a model's relations record.
164
+ *
165
+ * The dynamic class installs prototype getters for each declared relation in
166
+ * `createSyncEngine.ts` (`hasMany` → `store.getByForeignKey(...)`, `belongsTo`
167
+ * → pool lookup by FK). This type mirrors those installations so callers can
168
+ * write `slide.layers` and `slide.deck` without manual casts.
169
+ *
170
+ * - `hasMany` → `InferModel<S, Target>[]`
171
+ * - `belongsTo` / `hasOne` → `InferModel<S, Target> | undefined` (undefined
172
+ * when the FK is unset or the parent isn't in the pool yet)
173
+ *
174
+ * Kept as `readonly` because the accessors are prototype-level getters with
175
+ * no setter — writing to `slide.layers = [...]` would be a no-op at runtime.
176
+ */
177
+ export type InferRelations<S extends Schema, R extends RelationRecord> = string extends keyof R ? unknown : {
178
+ readonly [K in keyof R]: R[K] extends RelationDef<infer Type, infer Target, infer _F, infer _O> ? Target extends keyof S['models'] ? Type extends 'hasMany' ? InferModel<S, Target>[] : Type extends 'hasOne' | 'belongsTo' ? InferModel<S, Target> | undefined : never : never : never;
179
+ };
180
+ /**
181
+ * Infer the return types of computed getters.
182
+ * Maps each computed function's return type into a readonly property.
183
+ *
184
+ * ```ts
185
+ * // Given: computed: { displayTitle: (self) => self.title || 'Untitled' }
186
+ * // Infers: { readonly displayTitle: string }
187
+ * ```
188
+ */
189
+ export type InferComputed<C> = string extends keyof C ? unknown : {
190
+ readonly [K in keyof C]: C[K] extends (...args: any[]) => infer R ? R : never;
191
+ };
192
+ /**
193
+ * Infer the create input type. Only schema-defined fields are accepted —
194
+ * base fields (id, createdAt, updatedAt) are auto-generated by the SDK
195
+ * and cannot be passed by the consumer.
196
+ *
197
+ * The only exception is `id`: consumers can optionally provide one for
198
+ * client-generated IDs (useful for optimistic UI that needs to reference
199
+ * the entity before the server confirms).
200
+ *
201
+ * ```ts
202
+ * type CreateTask = InferCreate<typeof schema, 'tasks'>;
203
+ * // { title: string; status?: 'todo' | 'doing' | 'done'; id?: string }
204
+ * // createdAt, updatedAt are NOT accepted — they're auto-generated
205
+ * ```
206
+ */
207
+ export type InferCreate<S extends Schema, ModelName extends keyof S['models']> = S['models'][ModelName] extends ModelDef<infer Shape> ? z.input<z.ZodObject<Shape>> & Partial<BaseModelFields> : never;
208
+ /**
209
+ * Extract all model names from a schema.
210
+ */
211
+ export type InferModelNames<S extends Schema> = keyof S['models'] & string;
212
+ /**
213
+ * The value type for inserting a new row. Same shape as {@link InferCreate}:
214
+ * consumer-writable fields + optional `id` for client-generated IDs.
215
+ *
216
+ * Matches Zero's `InsertValue<TableSchema>` from `zql/src/mutate/crud.ts`.
217
+ */
218
+ export type InsertValue<S extends Schema, ModelName extends keyof S['models']> = InferCreate<S, ModelName>;
219
+ /**
220
+ * The value type for upserting (insert or overwrite). Same shape as
221
+ * {@link InsertValue} — a full row. If a row with the same `id` exists,
222
+ * it gets overwritten.
223
+ */
224
+ export type UpsertValue<S extends Schema, ModelName extends keyof S['models']> = InsertValue<S, ModelName>;
225
+ /**
226
+ * The value type for updating an existing row. `id` is required (identifies
227
+ * the row to update); all other fields are optional (only provided fields
228
+ * are changed).
229
+ *
230
+ * Matches Zero's `UpdateValue<TableSchema>` from `zql/src/mutate/crud.ts`.
231
+ */
232
+ export type UpdateValue<S extends Schema, ModelName extends keyof S['models']> = S['models'][ModelName] extends ModelDef<infer Shape> ? {
233
+ id: string;
234
+ } & Partial<z.input<z.ZodObject<Shape>>> : never;
235
+ /**
236
+ * The value type for deleting a row. Just the primary key.
237
+ *
238
+ * Matches Zero's `DeleteID<TableSchema>` from `zql/src/mutate/crud.ts`.
239
+ */
240
+ export type DeleteId<S extends Schema, ModelName extends keyof S['models']> = {
241
+ id: string;
242
+ };
243
+ export declare function defineSchema<const S extends SchemaRecord>(models: S, options?: DefineSchemaOptions): Schema<S>;
244
+ /**
245
+ * Compose the canonical sync-group set this identity is allowed to
246
+ * subscribe to, derived purely from the schema's registered
247
+ * {@link IdentityRole}s. Walks every role, calls its `extract` against
248
+ * the identity context, and substitutes each id into the role's
249
+ * `template`. Output is stable, deduped, and never includes a literal
250
+ * convention string from this function itself — the convention lives
251
+ * 100% in the consumer's schema declaration.
252
+ *
253
+ * Returns `[]` when the schema has no identity roles registered or
254
+ * when no role's extractor produces an id. Caller decides what to do
255
+ * with `[]`; the server's intersect-with-requested logic treats it as
256
+ * "no scope" rather than "match everything."
257
+ */
258
+ export declare function composeIdentitySyncGroups(identity: IdentityContext, schema: Pick<Schema, 'identityRoles'>): readonly string[];
259
+ export {};