@abloatai/ablo 0.18.0 → 0.20.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 (61) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/Model.d.ts +22 -0
  3. package/dist/Model.js +45 -0
  4. package/dist/ObjectPool.d.ts +14 -1
  5. package/dist/ObjectPool.js +8 -1
  6. package/dist/SyncClient.js +7 -1
  7. package/dist/SyncEngineContext.js +2 -0
  8. package/dist/ai-sdk/coordination-context.d.ts +2 -1
  9. package/dist/ai-sdk/coordination-context.js +3 -3
  10. package/dist/ai-sdk/index.d.ts +3 -4
  11. package/dist/ai-sdk/index.js +2 -3
  12. package/dist/ai-sdk/wrap.d.ts +13 -18
  13. package/dist/ai-sdk/wrap.js +2 -6
  14. package/dist/cli.cjs +253 -160
  15. package/dist/client/Ablo.d.ts +83 -22
  16. package/dist/client/Ablo.js +116 -32
  17. package/dist/client/ApiClient.d.ts +2 -2
  18. package/dist/client/ApiClient.js +27 -32
  19. package/dist/client/auth.d.ts +26 -0
  20. package/dist/client/auth.js +208 -0
  21. package/dist/client/createModelProxy.d.ts +16 -11
  22. package/dist/client/createModelProxy.js +15 -15
  23. package/dist/client/sessionMint.d.ts +10 -0
  24. package/dist/client/sessionMint.js +8 -1
  25. package/dist/coordination/schema.d.ts +10 -10
  26. package/dist/coordination/schema.js +7 -8
  27. package/dist/coordination/trace.d.ts +91 -0
  28. package/dist/coordination/trace.js +147 -0
  29. package/dist/errorCodes.d.ts +2 -0
  30. package/dist/errorCodes.js +2 -0
  31. package/dist/errors.d.ts +1 -2
  32. package/dist/errors.js +6 -9
  33. package/dist/index.d.ts +6 -1
  34. package/dist/index.js +13 -0
  35. package/dist/interfaces/index.d.ts +45 -2
  36. package/dist/mutators/undoApply.d.ts +7 -2
  37. package/dist/mutators/undoApply.js +7 -35
  38. package/dist/policy/types.d.ts +18 -9
  39. package/dist/policy/types.js +26 -16
  40. package/dist/react/AbloProvider.d.ts +2 -2
  41. package/dist/react/useAblo.js +12 -2
  42. package/dist/schema/sugar.js +10 -2
  43. package/dist/server/read-config.d.ts +7 -0
  44. package/dist/sync/HydrationCoordinator.js +7 -3
  45. package/dist/sync/SyncWebSocket.d.ts +11 -2
  46. package/dist/sync/SyncWebSocket.js +67 -3
  47. package/dist/sync/createClaimStream.d.ts +9 -2
  48. package/dist/sync/createClaimStream.js +9 -7
  49. package/dist/sync/participants.d.ts +12 -11
  50. package/dist/sync/participants.js +5 -5
  51. package/dist/transactions/TransactionQueue.d.ts +1 -0
  52. package/dist/transactions/TransactionQueue.js +40 -3
  53. package/dist/types/streams.d.ts +57 -135
  54. package/dist/utils/json.d.ts +39 -0
  55. package/dist/utils/json.js +88 -0
  56. package/docs/coordination.md +16 -0
  57. package/docs/debugging.md +194 -0
  58. package/docs/index.md +1 -0
  59. package/package.json +1 -1
  60. package/dist/ai-sdk/claim-broadcast.d.ts +0 -83
  61. package/dist/ai-sdk/claim-broadcast.js +0 -79
package/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Reactive reads now work out of the box. A read like `useAblo((a) => a.documents.get(id))` re-renders when a live delta updates the row — including in-place field updates (the common collaborative case), which previously fired no reaction and left the UI silently stale.
8
+
9
+ Two changes make this work:
10
+ - **Models are reactive by default.** Schema fields are now MobX-observable without opting in. `json` fields stay `observable.ref` (one atom for the whole blob, not a deep atom tree per node), so the default is cheap. Opt out per model with `lazyObservable: false` for very large read-only list models where the QueryView's entry-replaced reactivity is enough.
11
+ - **`useAblo` returns a plain row snapshot** (via the new `Model.toReactiveSnapshot()`) instead of the live model instance. Reading the fields inside the tracked function is what subscribes the reaction (MobX tracks property access, not values), and the fresh snapshot identity lets the hook detect the change. Consumers get plain row objects and never touch a MobX observable directly.
12
+
13
+ Also new: `deepEqual` and `stableStringify` exports for comparing `field.json()` values. A `jsonb`-backed json field round-trips with reordered object keys (Postgres `jsonb` does not preserve key order), so a naive `JSON.stringify(a) === JSON.stringify(b)` comparison is unreliable when reconciling against external state (e.g. a rich-text editor). These helpers compare key-order-insensitively.
14
+
15
+ ## 0.19.0
16
+
17
+ ### Minor Changes
18
+
19
+ - **Claim observability — a `ClaimLog` you can print or assert on.** A new
20
+ `observability` provider hook lets you tap every claim event and stale-write
21
+ collision the client sees. Hand `new ClaimLog()` to `Ablo({ observability })`
22
+ and it collects an ordered, readable log — `formatClaim` / `formatConflict`
23
+ render one line per event, and `collisions()` returns the conflicts for eval
24
+ assertions. New exports: `ClaimLog`, `formatClaim`, `formatConflict`,
25
+ `noopObservability`, and the types `ClaimLogEntry`, `ClaimEvent`,
26
+ `ConflictEvent`, `SyncObservabilityProvider`. Spread `noopObservability` to
27
+ override only the hooks you care about.
28
+
29
+ **AWS-shaped CLI credential store + `ablo config`.** Local CLI state is now split
30
+ into two files, matching `~/.aws/config` vs `~/.aws/credentials`: `config.json`
31
+ holds non-secret settings (active environment + active project) and is safe to
32
+ print or let an agent read; `credentials.json` holds the keys (0600, never
33
+ printed), keyed by project profile then environment. Per-project profiles follow
34
+ Stripe's model — `ablo projects use <slug>` selects the active profile, and a
35
+ key's project is fixed at mint so selecting a project never re-scopes an existing
36
+ key. `ablo status` now reports the resolved profile and environment.
37
+
38
+ **Schema JSON-column reconciliation.** `generateJsonColumnReconciliation` (new
39
+ export) emits the DDL to reconcile JSON-backed columns when adopting or evolving
40
+ an existing schema.
41
+
42
+ **Breaking (0.x):**
43
+ - The claim handle type `ClaimHandle` is renamed to **`Claim`**, and its
44
+ identifier field is `id` (was `claimId`). Update type imports and any code
45
+ reading `.claimId`.
46
+ - The ai-sdk `claimBroadcastMiddleware` (and `./ai-sdk/claim-broadcast`) is
47
+ removed — coordination broadcast is handled by `coordinationContextMiddleware`.
48
+ Import `ClaimTarget` from the package root or `@abloatai/ablo` ai-sdk's
49
+ `coordination-context` instead of `claim-broadcast`. The inline-claim option is
50
+ `reason` (not the pre-0.12 `action`); the ai-sdk docs are corrected to match.
51
+
3
52
  ## 0.18.0
4
53
 
5
54
  ### Minor Changes
package/dist/Model.d.ts CHANGED
@@ -359,6 +359,28 @@ export declare abstract class Model {
359
359
  * Capture snapshot for change detection
360
360
  */
361
361
  protected captureSnapshot(): ModelData;
362
+ /**
363
+ * A fresh, plain row snapshot that READS every declared field through its
364
+ * MobX observable getter. This is the reactive read surface for
365
+ * `useReactive`/`useAblo`, and it relies on two MobX facts:
366
+ *
367
+ * 1. MobX tracks property *access*, not values — so reading each field here,
368
+ * inside the caller's tracked function, SUBSCRIBES the reaction to that
369
+ * field. A bare `return model` (the previous `modelAsRow`) dereferences
370
+ * nothing, so an in-place delta update fires no reaction and the UI never
371
+ * re-renders. (https://mobx.js.org/understanding-reactivity.html)
372
+ * 2. The returned object is a NEW identity each call, so the hook's equality
373
+ * check detects the change — the same mutated instance would compare equal
374
+ * and suppress the re-render even after tracking fired.
375
+ *
376
+ * Unlike `toJSON()`, values keep their runtime types (a `Date` stays a `Date`,
377
+ * a json field stays its parsed object) and wire-noise keys
378
+ * (`__class`/`clientId`/`syncStatus`) are omitted — this is exactly the row
379
+ * the schema's `T` describes. Computed relations (`referenceModel`/
380
+ * `referenceCollection`) and ephemeral fields are skipped, matching `toJSON`'s
381
+ * row projection; they're lazy/recursive and not part of the row's data.
382
+ */
383
+ toReactiveSnapshot<T = ModelData>(): T;
362
384
  /**
363
385
  * Get field changes for activity tracking
364
386
  */
package/dist/Model.js CHANGED
@@ -731,6 +731,51 @@ export class Model {
731
731
  }
732
732
  return snapshot;
733
733
  }
734
+ /**
735
+ * A fresh, plain row snapshot that READS every declared field through its
736
+ * MobX observable getter. This is the reactive read surface for
737
+ * `useReactive`/`useAblo`, and it relies on two MobX facts:
738
+ *
739
+ * 1. MobX tracks property *access*, not values — so reading each field here,
740
+ * inside the caller's tracked function, SUBSCRIBES the reaction to that
741
+ * field. A bare `return model` (the previous `modelAsRow`) dereferences
742
+ * nothing, so an in-place delta update fires no reaction and the UI never
743
+ * re-renders. (https://mobx.js.org/understanding-reactivity.html)
744
+ * 2. The returned object is a NEW identity each call, so the hook's equality
745
+ * check detects the change — the same mutated instance would compare equal
746
+ * and suppress the re-render even after tracking fired.
747
+ *
748
+ * Unlike `toJSON()`, values keep their runtime types (a `Date` stays a `Date`,
749
+ * a json field stays its parsed object) and wire-noise keys
750
+ * (`__class`/`clientId`/`syncStatus`) are omitted — this is exactly the row
751
+ * the schema's `T` describes. Computed relations (`referenceModel`/
752
+ * `referenceCollection`) and ephemeral fields are skipped, matching `toJSON`'s
753
+ * row projection; they're lazy/recursive and not part of the row's data.
754
+ */
755
+ toReactiveSnapshot() {
756
+ const snapshot = {
757
+ id: this.id,
758
+ createdAt: this.createdAt,
759
+ updatedAt: this.updatedAt,
760
+ };
761
+ if (this.archivedAt !== undefined)
762
+ snapshot.archivedAt = this.archivedAt;
763
+ const properties = getActiveRegistry().getProperties(this.getModelName());
764
+ if (properties) {
765
+ const self = this;
766
+ for (const [propName, metadata] of properties) {
767
+ if (metadata.type === 'ephemeralProperty' ||
768
+ metadata.type === 'referenceModel' ||
769
+ metadata.type === 'referenceCollection') {
770
+ continue;
771
+ }
772
+ // Reading through the observable getter is the point: it subscribes the
773
+ // enclosing MobX reaction to this field.
774
+ snapshot[propName] = self[propName];
775
+ }
776
+ }
777
+ return snapshot;
778
+ }
734
779
  /**
735
780
  * Get field changes for activity tracking
736
781
  */
@@ -111,7 +111,20 @@ export declare class ObjectPool {
111
111
  __class?: string;
112
112
  modelName?: string;
113
113
  id?: string;
114
- }, ModelClass?: new (data: Record<string, unknown>) => Model): Model | null;
114
+ }, ModelClass?: new (data: Record<string, unknown>) => Model, opts?: {
115
+ /**
116
+ * Throw (instead of warn + return null) when the wire names a model
117
+ * this client never registered. Passed by the developer-await read
118
+ * path (`HydrationCoordinator`'s network leg) so a schema collision
119
+ * — the org's pushed schema names `Document` but this client only
120
+ * registered `documents` — surfaces AT the row that failed, with the
121
+ * known-model list and `ablo status` pointer, instead of bubbling up
122
+ * three layers later as a misleading `entity_not_found`. Left off
123
+ * (default) for the continuous delta loop and IDB-cache hydration,
124
+ * where a single unknown typename must not wedge the whole batch.
125
+ */
126
+ strict?: boolean;
127
+ }): Model | null;
115
128
  /**
116
129
  * Clear the object pool
117
130
  * @param options.preserveObserved - If true, keep models that are being observed by React
@@ -603,7 +603,7 @@ export class ObjectPool {
603
603
  create(typename, data) {
604
604
  return this.createFromData({ ...data, __typename: typename });
605
605
  }
606
- createFromData(data, ModelClass) {
606
+ createFromData(data, ModelClass, opts) {
607
607
  // Support multiple model identifier fields for backwards compatibility
608
608
  const modelName = data.__typename ?? data.__class ?? data.modelName ?? 'Unknown';
609
609
  const Constructor = ModelClass || this.registry.getModelByName(modelName);
@@ -613,6 +613,13 @@ export class ObjectPool {
613
613
  getContext().modelDebugLogger?.logError('Unknown', 'CREATE', 'No model identifier found', data);
614
614
  return null;
615
615
  }
616
+ if (opts?.strict) {
617
+ const known = this.registry.getRegisteredModelNames();
618
+ throw new AbloValidationError(`Model "${modelName}" is not registered on this client` +
619
+ (known.length ? ` (known: ${known.join(', ')})` : '') +
620
+ `. The schema pushed to this org may differ from your local ` +
621
+ `schema — run \`ablo status\` to compare.`, { code: 'model_not_registered' });
622
+ }
616
623
  getContext().logger.warn(`ObjectPool.createFromData: No constructor found for model "${modelName}"`, { data });
617
624
  getContext().modelDebugLogger?.logError(modelName, 'CREATE', `No constructor found for model "${modelName}"`, data);
618
625
  return null;
@@ -161,8 +161,14 @@ export class SyncClient extends EventEmitter {
161
161
  // (e.g. `AbloValidationError` with `code: 'schema_...'`,
162
162
  // `AbloServerError` with `httpStatus: 500`). Falling back to
163
163
  // generic message lets us still see unstructured errors.
164
+ // Mechanic-level breadcrumb only. The authoritative, user-facing
165
+ // reason is logged once at `warn` by `TransactionQueue.handleFailure`
166
+ // (`Permanent error - rolling back`). Logging the same typed cause
167
+ // again here at `warn` is what produced three identical dumps per
168
+ // rejected write — keep it at `debug` so the rollback mechanics are
169
+ // available when debugging but don't double the console noise.
164
170
  const abloErr = error instanceof AbloError ? error : undefined;
165
- getContext().logger.warn('[SyncClient.rollback]', {
171
+ getContext().logger.debug('[SyncClient.rollback]', {
166
172
  txType: transaction.type,
167
173
  modelName: transaction.modelName,
168
174
  modelId: transaction.modelId.slice(0, 12),
@@ -28,6 +28,8 @@ export const noopObservability = {
28
28
  captureWebSocketError() { },
29
29
  captureOfflineFlushFailure() { },
30
30
  captureSelfHealing() { },
31
+ captureClaim() { },
32
+ captureConflict() { },
31
33
  captureCommitZeroSyncId() { },
32
34
  startSpan(_name, _op, fn, _attributes) {
33
35
  return fn();
@@ -23,7 +23,8 @@
23
23
  import type { LanguageModelV3Middleware } from '@ai-sdk/provider';
24
24
  import type { Ablo } from '../client/Ablo.js';
25
25
  import type { SchemaRecord } from '../schema/schema.js';
26
- import type { ClaimTarget } from './claim-broadcast.js';
26
+ import type { ClaimTarget } from '../types/streams.js';
27
+ export type { ClaimTarget };
27
28
  export interface CoordinationContextMiddlewareOptions<R extends SchemaRecord = SchemaRecord> {
28
29
  readonly agent: Ablo<R> | null;
29
30
  readonly target: ClaimTarget | null;
@@ -38,8 +38,8 @@ export function coordinationContextMiddleware(options) {
38
38
  return params;
39
39
  // Read peer claims on the same target. Synchronous lookup
40
40
  // against the engine's reactive claims.others array — no I/O.
41
- const peerClaims = agent.claims.others.filter((claim) => claim.target.type === target.entityType &&
42
- claim.target.id === target.entityId &&
41
+ const peerClaims = agent.claims.others.filter((claim) => claim.target.type === target.type &&
42
+ claim.target.id === target.id &&
43
43
  targetsOverlap(claim.target, target) &&
44
44
  !excludeClaimIds.has(claim.id));
45
45
  if (peerClaims.length === 0)
@@ -77,7 +77,7 @@ function targetsOverlap(claimTarget, target) {
77
77
  * "AI knows," not "AI gets a wall of text."
78
78
  */
79
79
  function formatCoordinationNote(claims, target) {
80
- const entityLabel = target.entityType.toLowerCase();
80
+ const entityLabel = target.type.toLowerCase();
81
81
  if (claims.length === 1) {
82
82
  const c = claims[0];
83
83
  const details = c.description ? `Declared work: ${c.description}. ` : '';
@@ -27,7 +27,7 @@
27
27
  * wait: 'confirmed',
28
28
  * claim: {
29
29
  * field: 'title',
30
- * action: 'renaming',
30
+ * reason: 'renaming',
31
31
  * description,
32
32
  * },
33
33
  * });
@@ -51,7 +51,7 @@
51
51
  * ```ts
52
52
  * const claim = await ablo.tasks.claim({
53
53
  * id,
54
- * action: 'rewriting',
54
+ * reason: 'rewriting',
55
55
  * description: 'Rewriting the task brief before updating follow-up fields.',
56
56
  * ttl: '2m',
57
57
  * });
@@ -72,6 +72,5 @@
72
72
  * to one entity before any tool is chosen; tool implementations stay exactly
73
73
  * the same.
74
74
  */
75
- export { claimBroadcastMiddleware, type ClaimTarget, type ClaimBroadcastMiddlewareOptions, } from './claim-broadcast.js';
76
- export { coordinationContextMiddleware, type CoordinationContextMiddlewareOptions, } from './coordination-context.js';
75
+ export { coordinationContextMiddleware, type CoordinationContextMiddlewareOptions, type ClaimTarget, } from './coordination-context.js';
77
76
  export { wrapWithMultiplayer, type WrapWithMultiplayerOptions } from './wrap.js';
@@ -27,7 +27,7 @@
27
27
  * wait: 'confirmed',
28
28
  * claim: {
29
29
  * field: 'title',
30
- * action: 'renaming',
30
+ * reason: 'renaming',
31
31
  * description,
32
32
  * },
33
33
  * });
@@ -51,7 +51,7 @@
51
51
  * ```ts
52
52
  * const claim = await ablo.tasks.claim({
53
53
  * id,
54
- * action: 'rewriting',
54
+ * reason: 'rewriting',
55
55
  * description: 'Rewriting the task brief before updating follow-up fields.',
56
56
  * ttl: '2m',
57
57
  * });
@@ -72,6 +72,5 @@
72
72
  * to one entity before any tool is chosen; tool implementations stay exactly
73
73
  * the same.
74
74
  */
75
- export { claimBroadcastMiddleware, } from './claim-broadcast.js';
76
75
  export { coordinationContextMiddleware, } from './coordination-context.js';
77
76
  export { wrapWithMultiplayer } from './wrap.js';
@@ -14,8 +14,6 @@
14
14
  * model: anthropic('claude-opus-4-7'),
15
15
  * agent,
16
16
  * target: { entityType: 'SlideDeck', entityId: 'deck-abc' },
17
- * reason: 'renaming',
18
- * description: 'Renaming the deck title to match the project brief.',
19
17
  * });
20
18
  *
21
19
  * const result = streamText({
@@ -31,25 +29,22 @@ import { wrapLanguageModel } from 'ai';
31
29
  import type { LanguageModelV3, LanguageModelV3Middleware } from '@ai-sdk/provider';
32
30
  import type { Ablo } from '../client/Ablo.js';
33
31
  import type { SchemaRecord } from '../schema/schema.js';
34
- import { type ClaimTarget } from './claim-broadcast.js';
35
- export interface WrapWithMultiplayerOptions {
32
+ import { type ClaimTarget } from './coordination-context.js';
33
+ export interface WrapWithMultiplayerOptions<R extends SchemaRecord = SchemaRecord> {
36
34
  /** The base language model to wrap. Consumer brings their own. */
37
35
  readonly model: LanguageModelV3;
38
- /** Connected SyncAgent. Null = pass-through wrap (no broadcast, no read). */
39
- readonly agent: Ablo<SchemaRecord> | null;
40
- /** Target entity. Null = pass-through wrap. */
41
- readonly target: ClaimTarget | null;
42
- /**
43
- * Optional human-readable phase for the broadcast. Default `'edit'`.
44
- * Convention: `'edit'`, `'read'`, `'review'`, `'generate'`. The same
45
- * `reason` field used on every claim surface.
46
- */
47
- readonly reason?: string;
48
36
  /**
49
- * Peer-visible explanation of the specific work this model call is about to
50
- * perform. Other agents receive it in their coordination context.
37
+ * Connected Ablo. Null = pass-through wrap (no broadcast, no read).
38
+ *
39
+ * Generic over the schema record (like the two middlewares it composes) so a
40
+ * caller passing a typed `Ablo<typeof schema>` doesn't have to cast: `Ablo<S>`
41
+ * and `Ablo<SchemaRecord>` aren't structurally assignable (the widened version
42
+ * collapses model proxies to an index signature that clashes with the named
43
+ * methods `ready`/`dispose`/…).
51
44
  */
52
- readonly description?: string;
45
+ readonly agent: Ablo<R> | null;
46
+ /** Target entity. Null = pass-through wrap. */
47
+ readonly target: ClaimTarget | null;
53
48
  /**
54
49
  * Optional claimIds to exclude from the coordination-context
55
50
  * read — typically the caller's own claim if they're composing
@@ -68,4 +63,4 @@ export interface WrapWithMultiplayerOptions {
68
63
  */
69
64
  readonly extraMiddleware?: readonly LanguageModelV3Middleware[];
70
65
  }
71
- export declare function wrapWithMultiplayer(options: WrapWithMultiplayerOptions): ReturnType<typeof wrapLanguageModel>;
66
+ export declare function wrapWithMultiplayer<R extends SchemaRecord = SchemaRecord>(options: WrapWithMultiplayerOptions<R>): ReturnType<typeof wrapLanguageModel>;
@@ -14,8 +14,6 @@
14
14
  * model: anthropic('claude-opus-4-7'),
15
15
  * agent,
16
16
  * target: { entityType: 'SlideDeck', entityId: 'deck-abc' },
17
- * reason: 'renaming',
18
- * description: 'Renaming the deck title to match the project brief.',
19
17
  * });
20
18
  *
21
19
  * const result = streamText({
@@ -28,15 +26,13 @@
28
26
  * ```
29
27
  */
30
28
  import { wrapLanguageModel } from 'ai';
31
- import { claimBroadcastMiddleware, } from './claim-broadcast.js';
32
- import { coordinationContextMiddleware } from './coordination-context.js';
29
+ import { coordinationContextMiddleware, } from './coordination-context.js';
33
30
  export function wrapWithMultiplayer(options) {
34
- const { model, agent, target, reason, description, excludeClaimIds, extraMiddleware } = options;
31
+ const { model, agent, target, excludeClaimIds, extraMiddleware } = options;
35
32
  return wrapLanguageModel({
36
33
  model,
37
34
  middleware: [
38
35
  coordinationContextMiddleware({ agent, target, excludeClaimIds }),
39
- claimBroadcastMiddleware({ agent, target, reason, description }),
40
36
  ...(extraMiddleware ?? []),
41
37
  ],
42
38
  });