@abloatai/ablo 0.19.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
3
15
  ## 0.19.0
4
16
 
5
17
  ### 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
  */
package/dist/cli.cjs CHANGED
@@ -276847,7 +276847,7 @@ var ERROR_CODES = {
276847
276847
  unique_violation: wire("conflict", 409, false, "A value violates a uniqueness constraint."),
276848
276848
  check_violation: wire("validation", 400, false, "A value violates a database check constraint."),
276849
276849
  constraint_violation: wire("validation", 400, false, "A database integrity constraint was violated."),
276850
- column_type_mismatch: wire("validation", 400, false, 'A structured (JSON) value was written to a column whose database type is not jsonb. The column was not provisioned as jsonb (often a pre-existing column adopted by `ablo push`); storing the value would silently corrupt it to "[object Object]". Run `ablo migrate` to alter the column to jsonb.'),
276850
+ column_type_mismatch: wire("validation", 400, false, "A structured (JSON) value was written to a column whose database type cannot hold it. Ablo adapts a json field to either a jsonb column (native) or a text column (serialized) \u2014 but a scalar column (integer, boolean, uuid, timestamp, \u2026) cannot store a JSON object or array. Use a jsonb or text column for this field. Ablo adapts to your column; it does not alter your schema."),
276851
276851
  // ── tenant / unknown model (400) ───────────────────────────────────
276852
276852
  server_execute_unknown_model: wire("tenant", 400, false, "Wrote to a model the server does not know. The server keeps its own copy of the schema \u2014 run `ablo push` (or keep `ablo dev` running) to upload `ablo/schema.ts` before writing to new or changed models."),
276853
276853
  mutate_create_unknown_model: wire("tenant", 400, false, "Created a model the server does not know. Run `ablo push` (or keep `ablo dev` running) to upload `ablo/schema.ts` first \u2014 the server keeps its own copy of the schema."),
@@ -240,8 +240,19 @@ function registerModelsFromSchema(schema, registry) {
240
240
  jsonSubFields.push({ fieldName, subSchema: inner });
241
241
  }
242
242
  }
243
- // Create a dynamic Model subclass with JSON sub-property getters
244
- const isLazy = modelDef.lazyObservable === true;
243
+ // Create a dynamic Model subclass with JSON sub-property getters.
244
+ //
245
+ // Field-level MobX observability is ON BY DEFAULT. A reactive read like
246
+ // `useAblo((a) => a.documents.get(id))` must re-render when a remote delta
247
+ // mutates the row IN PLACE (the common collaborative case); without
248
+ // per-field observability that update fires no reaction and the UI silently
249
+ // goes stale. Models opt OUT with an explicit `lazyObservable: false` —
250
+ // appropriate only for very large read-only list models where per-field
251
+ // atoms cost more than the QueryView's entry-replaced reactivity already
252
+ // provides. json fields register as `observable.ref` (see
253
+ // `registerModelsFromSchema`), so the default is ~one atom per scalar field
254
+ // per loaded row — cheap — not a deep atom tree per blob.
255
+ const isLazy = modelDef.lazyObservable !== false;
245
256
  // Base provenance fields (`organizationId`, `createdBy`) live in
246
257
  // `baseFieldsSchema`, not the per-model `shape`. The server stamps + emits
247
258
  // them (camelCased on the wire), but hydration (`Model.assignFieldsFromData`)
@@ -202,7 +202,7 @@ export const ERROR_CODES = {
202
202
  unique_violation: wire('conflict', 409, false, 'A value violates a uniqueness constraint.'),
203
203
  check_violation: wire('validation', 400, false, 'A value violates a database check constraint.'),
204
204
  constraint_violation: wire('validation', 400, false, 'A database integrity constraint was violated.'),
205
- column_type_mismatch: wire('validation', 400, false, 'A structured (JSON) value was written to a column whose database type is not jsonb. The column was not provisioned as jsonb (often a pre-existing column adopted by `ablo push`); storing the value would silently corrupt it to "[object Object]". Run `ablo migrate` to alter the column to jsonb.'),
205
+ column_type_mismatch: wire('validation', 400, false, 'A structured (JSON) value was written to a column whose database type cannot hold it. Ablo adapts a json field to either a jsonb column (native) or a text column (serialized) but a scalar column (integer, boolean, uuid, timestamp, …) cannot store a JSON object or array. Use a jsonb or text column for this field. Ablo adapts to your column; it does not alter your schema.'),
206
206
  // ── tenant / unknown model (400) ───────────────────────────────────
207
207
  server_execute_unknown_model: wire('tenant', 400, false, 'Wrote to a model the server does not know. The server keeps its own copy of the schema — run `ablo push` (or keep `ablo dev` running) to upload `ablo/schema.ts` before writing to new or changed models.'),
208
208
  mutate_create_unknown_model: wire('tenant', 400, false, 'Created a model the server does not know. Run `ablo push` (or keep `ablo dev` running) to upload `ablo/schema.ts` first — the server keeps its own copy of the schema.'),
package/dist/index.d.ts CHANGED
@@ -82,3 +82,4 @@ export type { ModelVerb, ListOptionKey, AbloOptionKey } from './surface.js';
82
82
  export type { Register, DefaultSyncShape } from './types/global.js';
83
83
  export { defineMutators } from './mutators/defineMutators.js';
84
84
  export { createTransaction, type Transaction } from './mutators/Transaction.js';
85
+ export { deepEqual, stableStringify } from './utils/json.js';
package/dist/index.js CHANGED
@@ -134,3 +134,9 @@ export { defineMutators } from './mutators/defineMutators.js';
134
134
  export { createTransaction } from './mutators/Transaction.js';
135
135
  // Undo runtime is intentionally not part of the public root surface. App code
136
136
  // uses `useUndoScope` from `@abloatai/ablo/react`.
137
+ // JSON comparison helpers. A `field.json()` value backed by a Postgres `jsonb`
138
+ // column round-trips with reordered object keys (jsonb doesn't preserve key
139
+ // order), so a naive `JSON.stringify(a) === JSON.stringify(b)` guard misfires
140
+ // when an app reconciles an Ablo row against external editor state. Use these
141
+ // (key-order-insensitive) instead. See `utils/json.ts` for the full rationale.
142
+ export { deepEqual, stableStringify } from './utils/json.js';
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import type { SyncStoreContract } from '../react/context.js';
26
26
  import type { InverseOp } from './inverseOp.js';
27
+ import { deepEqual } from '../utils/json.js';
27
28
  /**
28
29
  * How undo/redo handles a field a collaborator changed after your op:
29
30
  * - `skip-stale` (default): leave it — your change is already superseded, so
@@ -33,8 +34,12 @@ import type { InverseOp } from './inverseOp.js';
33
34
  */
34
35
  export type UndoConflictPolicy = 'skip-stale' | 'last-writer-wins';
35
36
  export declare const DEFAULT_UNDO_CONFLICT_POLICY: UndoConflictPolicy;
36
- /** Structural equality for JSON-shaped values (scalars, arrays, plain objects). */
37
- export declare function deepEqual(a: unknown, b: unknown): boolean;
37
+ /**
38
+ * Structural equality for JSON-shaped values (scalars, arrays, plain objects).
39
+ * Re-exported from the shared `utils/json` helper (key order is ignored) so the
40
+ * undo path and app authors share one implementation.
41
+ */
42
+ export { deepEqual };
38
43
  /**
39
44
  * Filter the ops to apply so they don't clobber concurrent collaborator edits.
40
45
  * See the module docblock. `last-writer-wins` returns the ops unchanged.
@@ -22,42 +22,14 @@
22
22
  * With no collaborator, the live value always equals what you set, so nothing
23
23
  * is dropped — single-user undo is byte-for-byte unchanged.
24
24
  */
25
+ import { deepEqual } from '../utils/json.js';
25
26
  export const DEFAULT_UNDO_CONFLICT_POLICY = 'skip-stale';
26
- /** Structural equality for JSON-shaped values (scalars, arrays, plain objects). */
27
- export function deepEqual(a, b) {
28
- if (a === b)
29
- return true;
30
- if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
31
- return false;
32
- }
33
- const aArr = Array.isArray(a);
34
- if (aArr !== Array.isArray(b))
35
- return false;
36
- if (aArr) {
37
- const av = a;
38
- const bv = b;
39
- if (av.length !== bv.length)
40
- return false;
41
- for (let i = 0; i < av.length; i++) {
42
- if (!deepEqual(av[i], bv[i]))
43
- return false;
44
- }
45
- return true;
46
- }
47
- const ao = a;
48
- const bo = b;
49
- const ak = Object.keys(ao);
50
- const bk = Object.keys(bo);
51
- if (ak.length !== bk.length)
52
- return false;
53
- for (const k of ak) {
54
- if (!Object.prototype.hasOwnProperty.call(bo, k))
55
- return false;
56
- if (!deepEqual(ao[k], bo[k]))
57
- return false;
58
- }
59
- return true;
60
- }
27
+ /**
28
+ * Structural equality for JSON-shaped values (scalars, arrays, plain objects).
29
+ * Re-exported from the shared `utils/json` helper (key order is ignored) so the
30
+ * undo path and app authors share one implementation.
31
+ */
32
+ export { deepEqual };
61
33
  /**
62
34
  * Map `id → { field: establishedValue }` from the paired ops. Only update-family
63
35
  * ops carry per-field values worth comparing.
@@ -2,7 +2,7 @@
2
2
  import { useContext, useEffect, useState } from 'react';
3
3
  import { AbloInternalContext } from './internalContext.js';
4
4
  import { getModelClientMeta } from '../client/createModelProxy.js';
5
- import { Model, modelAsRow } from '../Model.js';
5
+ import { Model } from '../Model.js';
6
6
  import { useReactive } from './useReactive.js';
7
7
  const EMPTY_CLAIMS = Object.freeze([]);
8
8
  function readModelResult(engine, modelClient, id, initial) {
@@ -16,9 +16,19 @@ function readModelResult(engine, modelClient, id, initial) {
16
16
  : EMPTY_CLAIMS;
17
17
  return { data, claims, claimed: claims.length > 0 };
18
18
  }
19
+ /**
20
+ * Project a reactive read into the value `useReactive` caches and returns.
21
+ *
22
+ * For a `Model`, this MUST read the row's fields (via `toReactiveSnapshot`),
23
+ * not return the bare instance: MobX tracks property access, so reading the
24
+ * fields inside this tracked function is what subscribes the reaction to them —
25
+ * and the fresh object identity lets `useReactive`'s equality detect an
26
+ * in-place delta update. Returning `modelAsRow(value)` (the live instance, no
27
+ * field read) is why `useAblo(a => a.x.get(id))` used to ignore remote edits.
28
+ */
19
29
  function snapshotValue(value) {
20
30
  if (value instanceof Model) {
21
- return modelAsRow(value);
31
+ return value.toReactiveSnapshot();
22
32
  }
23
33
  if (Array.isArray(value)) {
24
34
  return value.map((item) => snapshotValue(item));
@@ -65,42 +65,6 @@ export declare function snakeToCamel(identifier: string): string;
65
65
  /** Quote an identifier (defense-in-depth; inputs are already slug/snake). */
66
66
  export declare function q(identifier: string): string;
67
67
  export declare function sqlType(fieldType: ModelJSON['fields'][string]['type']): string;
68
- /**
69
- * Detect and repair `field.json()` columns that exist in the live database as a
70
- * NON-jsonb type, and emit a salvaging in-place `ALTER … TYPE jsonb`.
71
- *
72
- * Why this is needed: provisioning uses `ADD COLUMN IF NOT EXISTS` and
73
- * `CREATE TABLE IF NOT EXISTS` (idempotent by design). When `ablo push` adopts
74
- * a table/column that PRE-EXISTS with a different type — e.g. a `content TEXT`
75
- * column from a legacy table — the additive DDL is a no-op and the column
76
- * silently stays `text`. The pure schema-to-schema differ
77
- * ({@link generateMigrationPlan}) can't see this: the schema says `json` on
78
- * both sides, so it emits no change. The drift is only visible by INTROSPECTING
79
- * the live database and comparing to the declared type — the drizzle-kit
80
- * `pull` discipline. A `json` value bound to a `text` column corrupts to the
81
- * literal `"[object Object]"`, so leaving the drift unrepaired is silent data
82
- * loss.
83
- *
84
- * The cast is SALVAGING, never aborting (Postgres won't auto-cast text→jsonb;
85
- * invalid rows would otherwise fail the whole ALTER): a row that `IS JSON`
86
- * parses to jsonb, anything else (including already-corrupted
87
- * `"[object Object]"`) is wrapped as a jsonb string via `to_jsonb`, and NULL
88
- * stays NULL. The `IS JSON` predicate requires Postgres 16+ (the fleet runs
89
- * 17); on an older engine the ALTER fails LOUD with a structured
90
- * `migration_failed` rather than silently — acceptable, since silent corruption
91
- * is the thing we're eliminating. See
92
- * https://echobind.com/post/safely-alter-postgres-columns-with-using.
93
- *
94
- * Pure + idempotent: emits a statement ONLY for a json field whose live column
95
- * type is present and not already `jsonb`/`json`. A correctly-provisioned schema
96
- * yields zero statements, so this is a no-op on every push after the first
97
- * repair. Columns absent from `liveColumnTypes` are left to the additive
98
- * provisioner (which adds them as jsonb).
99
- *
100
- * @param liveColumnTypes table name → (column name → information_schema
101
- * `data_type`), as introspected from the target schema.
102
- */
103
- export declare function generateJsonColumnReconciliation(schema: SchemaJSON, liveColumnTypes: ReadonlyMap<string, ReadonlyMap<string, string>>, targetSchema: string): string[];
104
68
  /**
105
69
  * Build the additive, idempotent provisioning plan for an app. Pure — no DB
106
70
  * access.
@@ -66,72 +66,6 @@ export function sqlType(fieldType) {
66
66
  }
67
67
  }
68
68
  const BASE_COLUMNS = new Set(['id', 'organization_id', 'created_by', 'created_at', 'updated_at']);
69
- // ── JSON column drift reconciliation ─────────────────────────────────────────
70
- /**
71
- * Detect and repair `field.json()` columns that exist in the live database as a
72
- * NON-jsonb type, and emit a salvaging in-place `ALTER … TYPE jsonb`.
73
- *
74
- * Why this is needed: provisioning uses `ADD COLUMN IF NOT EXISTS` and
75
- * `CREATE TABLE IF NOT EXISTS` (idempotent by design). When `ablo push` adopts
76
- * a table/column that PRE-EXISTS with a different type — e.g. a `content TEXT`
77
- * column from a legacy table — the additive DDL is a no-op and the column
78
- * silently stays `text`. The pure schema-to-schema differ
79
- * ({@link generateMigrationPlan}) can't see this: the schema says `json` on
80
- * both sides, so it emits no change. The drift is only visible by INTROSPECTING
81
- * the live database and comparing to the declared type — the drizzle-kit
82
- * `pull` discipline. A `json` value bound to a `text` column corrupts to the
83
- * literal `"[object Object]"`, so leaving the drift unrepaired is silent data
84
- * loss.
85
- *
86
- * The cast is SALVAGING, never aborting (Postgres won't auto-cast text→jsonb;
87
- * invalid rows would otherwise fail the whole ALTER): a row that `IS JSON`
88
- * parses to jsonb, anything else (including already-corrupted
89
- * `"[object Object]"`) is wrapped as a jsonb string via `to_jsonb`, and NULL
90
- * stays NULL. The `IS JSON` predicate requires Postgres 16+ (the fleet runs
91
- * 17); on an older engine the ALTER fails LOUD with a structured
92
- * `migration_failed` rather than silently — acceptable, since silent corruption
93
- * is the thing we're eliminating. See
94
- * https://echobind.com/post/safely-alter-postgres-columns-with-using.
95
- *
96
- * Pure + idempotent: emits a statement ONLY for a json field whose live column
97
- * type is present and not already `jsonb`/`json`. A correctly-provisioned schema
98
- * yields zero statements, so this is a no-op on every push after the first
99
- * repair. Columns absent from `liveColumnTypes` are left to the additive
100
- * provisioner (which adds them as jsonb).
101
- *
102
- * @param liveColumnTypes table name → (column name → information_schema
103
- * `data_type`), as introspected from the target schema.
104
- */
105
- export function generateJsonColumnReconciliation(schema, liveColumnTypes, targetSchema) {
106
- const qs = q(targetSchema);
107
- const statements = [];
108
- for (const [key, model] of Object.entries(schema.models)) {
109
- const table = model.tableName ?? key;
110
- const liveCols = liveColumnTypes.get(table);
111
- if (!liveCols)
112
- continue; // table not provisioned yet — nothing to reconcile
113
- const qt = `${qs}.${q(table)}`;
114
- for (const [fieldName, meta] of Object.entries(model.fields)) {
115
- if (meta.type !== 'json')
116
- continue;
117
- const col = meta.column ?? camelToSnake(fieldName);
118
- const liveType = liveCols.get(col);
119
- if (liveType === undefined)
120
- continue; // column absent — provisioner adds jsonb
121
- if (liveType === 'jsonb' || liveType === 'json')
122
- continue; // already correct
123
- const c = q(col);
124
- statements.push(`ALTER TABLE ${qt} ALTER COLUMN ${c} TYPE jsonb USING (\n` +
125
- ` CASE\n` +
126
- ` WHEN ${c} IS NULL THEN NULL\n` +
127
- ` WHEN ${c}::text IS JSON THEN ${c}::text::jsonb\n` +
128
- ` ELSE to_jsonb(${c}::text)\n` +
129
- ` END\n` +
130
- `);`);
131
- }
132
- }
133
- return statements;
134
- }
135
69
  // ── Foreign keys (relation-driven, sync-safe) ────────────────────────────────
136
70
  /**
137
71
  * A Postgres-identifier-safe constraint name ≤63 bytes. When the natural
@@ -33,7 +33,7 @@ export { mutable, readOnly, type SugarOptions } from './sugar.js';
33
33
  export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type Model, type InferModel, type InferCreate, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, composeEntitySyncGroups, type IdentityRole, type IdentityContext, type IdentityRoleSource, type EntityRole, type EntityContext, type EntityRoleSource, type RoleSource, type RoleContext, type SyncGroup, type SyncGroupInput, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, groupsInputSchema, type GroupsInput, } from './schema.js';
34
34
  export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, type SchemaJSON, type ModelJSON, type RelationJSON, } from './serialize.js';
35
35
  export { selectModels } from './select.js';
36
- export { generateProvisionPlan, generateMigrationPlan, generateJsonColumnReconciliation, appSchemaName, camelToSnake, snakeToCamel, q, sqlType, type ProvisionPlan, type MigrationPlan, } from './ddl.js';
36
+ export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, snakeToCamel, q, sqlType, type ProvisionPlan, type MigrationPlan, } from './ddl.js';
37
37
  export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, type BackfillValue, type MigrationStep, type FieldChanges, type FieldColumnChange, type FieldTypeChange, type NullabilityChange, type EnumValuesChange, type IndexChange, type CastSafety, type FieldType, type RenameHints, type MigrationSignal, type MigrationClassification, type WarningCode, type BlockerCode, } from './diff.js';
38
38
  export { generateTypes } from './generate.js';
39
39
  export { query, defineQueries, type QueryDef, type QueryRecord, type Queries, type InferQueryInput, type InferQueryResult, } from './queries.js';
@@ -55,7 +55,7 @@ export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash,
55
55
  // Schema projection — derive an app's subset from one canonical schema.
56
56
  export { selectModels } from './select.js';
57
57
  // Schema → Postgres DDL (pure; shared by the hosted server and the CLI)
58
- export { generateProvisionPlan, generateMigrationPlan, generateJsonColumnReconciliation, appSchemaName, camelToSnake, snakeToCamel, q, sqlType, } from './ddl.js';
58
+ export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, snakeToCamel, q, sqlType, } from './ddl.js';
59
59
  // Schema diff + migration planning (pure; SQL emission lowered by ddl.ts)
60
60
  export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, } from './diff.js';
61
61
  // Schema → TypeScript type emission (the `generate` half; pure)
@@ -66,7 +66,10 @@ function build(shape, opts, baseline) {
66
66
  * - `.manual` — never auto-loaded; explicit queries only
67
67
  */
68
68
  export const mutable = {
69
- instant: (shape, opts) => build(shape, opts, { mutable: true, load: 'instant', lazyObservable: false }),
69
+ instant: (shape, opts) =>
70
+ // Reactive by default (see readonly.instant note): opt out with
71
+ // `lazyObservable: false` only for very large read-only list models.
72
+ build(shape, opts, { mutable: true, load: 'instant', lazyObservable: true }),
70
73
  lazy: (shape, opts) => build(shape, opts, { mutable: true, load: 'lazy', lazyObservable: true }),
71
74
  manual: (shape, opts) => build(shape, opts, { mutable: true, load: 'manual', lazyObservable: true }),
72
75
  };
@@ -82,7 +85,12 @@ export const mutable = {
82
85
  * writes
83
86
  */
84
87
  export const readOnly = {
85
- instant: (shape, opts) => build(shape, opts, { mutable: false, load: 'instant', lazyObservable: false }),
88
+ instant: (shape, opts) =>
89
+ // Reactive by default (like every variant now): a remote delta that mutates
90
+ // a row in place must re-render reactive reads. Opt out per-model with
91
+ // `lazyObservable: false` for very large read-only lists where per-field
92
+ // atoms cost more than the QueryView's entry-replaced reactivity.
93
+ build(shape, opts, { mutable: false, load: 'instant', lazyObservable: true }),
86
94
  lazy: (shape, opts) => build(shape, opts, { mutable: false, load: 'lazy', lazyObservable: true }),
87
95
  /**
88
96
  * Internal-only: never auto-loaded, never written by clients. The
@@ -57,4 +57,11 @@ export interface BootstrapModel {
57
57
  fieldColumns?: Record<string, string>;
58
58
  /** Physical-column aliases needed after SELECT * for `.from(...)` fields. */
59
59
  columnOverrides?: readonly ColumnOverride[];
60
+ /**
61
+ * Physical columns the schema declares as `json` (`field.json()`). A json
62
+ * field stored in a TEXT column comes back from `row_to_json` as a serialized
63
+ * string; the bootstrap re-parses these so the wire is the canonical object
64
+ * regardless of physical column type (jsonb returns objects natively → no-op).
65
+ */
66
+ jsonColumns?: readonly string[];
60
67
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * json.ts — key-order-insensitive comparison helpers for JSON-shaped values.
3
+ *
4
+ * Why this exists as a first-class, exported util:
5
+ *
6
+ * A `field.json()` value may be backed by a Postgres `jsonb` column, and **jsonb
7
+ * does not preserve object key order** (it reorders keys by length, then
8
+ * bytewise, and drops insignificant whitespace — see
9
+ * https://www.postgresql.org/docs/current/datatype-json.html). So a document an
10
+ * app wrote as `{type,text}` streams back in a delta as `{text,type}`: the same
11
+ * value, a different serialization.
12
+ *
13
+ * That bites any app that reconciles an Ablo row against an *external* state
14
+ * container it doesn't control — a rich-text editor (Tiptap/ProseMirror/Slate),
15
+ * a `useState`, a form buffer. The natural guard, `JSON.stringify(remote) ===
16
+ * JSON.stringify(local)`, is silently wrong because the two sides serialize keys
17
+ * in different orders, so it never matches — and the app re-applies the remote
18
+ * value on every render, clobbering in-flight edits and fighting the cursor.
19
+ *
20
+ * The fix is to compare order-insensitively. `deepEqual` does structural
21
+ * equality directly; `stableStringify` produces a canonical string (recursively
22
+ * sorted keys) for when you need a stable cache key / dependency value. The SDK
23
+ * already uses `deepEqual` internally for store-level echo detection; this
24
+ * module makes the same guarantee available to app authors so they don't each
25
+ * reinvent it.
26
+ *
27
+ * (If you need byte-exact key order preserved end-to-end, store the field in a
28
+ * `text` column instead of `jsonb` — Ablo's adaptive codec serializes verbatim
29
+ * there, matching Postgres's `json` type behavior.)
30
+ */
31
+ /** Structural equality for JSON-shaped values (scalars, arrays, plain objects); key order is ignored. */
32
+ export declare function deepEqual(a: unknown, b: unknown): boolean;
33
+ /**
34
+ * Canonical JSON serialization: recursively sorts object keys so two values that
35
+ * differ only in key order (e.g. a jsonb round-trip) produce the same string.
36
+ * Use this when you need a comparable/cacheable string rather than a boolean —
37
+ * e.g. an echo guard or a `useEffect`/`useMemo` dependency.
38
+ */
39
+ export declare function stableStringify(value: unknown): string;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * json.ts — key-order-insensitive comparison helpers for JSON-shaped values.
3
+ *
4
+ * Why this exists as a first-class, exported util:
5
+ *
6
+ * A `field.json()` value may be backed by a Postgres `jsonb` column, and **jsonb
7
+ * does not preserve object key order** (it reorders keys by length, then
8
+ * bytewise, and drops insignificant whitespace — see
9
+ * https://www.postgresql.org/docs/current/datatype-json.html). So a document an
10
+ * app wrote as `{type,text}` streams back in a delta as `{text,type}`: the same
11
+ * value, a different serialization.
12
+ *
13
+ * That bites any app that reconciles an Ablo row against an *external* state
14
+ * container it doesn't control — a rich-text editor (Tiptap/ProseMirror/Slate),
15
+ * a `useState`, a form buffer. The natural guard, `JSON.stringify(remote) ===
16
+ * JSON.stringify(local)`, is silently wrong because the two sides serialize keys
17
+ * in different orders, so it never matches — and the app re-applies the remote
18
+ * value on every render, clobbering in-flight edits and fighting the cursor.
19
+ *
20
+ * The fix is to compare order-insensitively. `deepEqual` does structural
21
+ * equality directly; `stableStringify` produces a canonical string (recursively
22
+ * sorted keys) for when you need a stable cache key / dependency value. The SDK
23
+ * already uses `deepEqual` internally for store-level echo detection; this
24
+ * module makes the same guarantee available to app authors so they don't each
25
+ * reinvent it.
26
+ *
27
+ * (If you need byte-exact key order preserved end-to-end, store the field in a
28
+ * `text` column instead of `jsonb` — Ablo's adaptive codec serializes verbatim
29
+ * there, matching Postgres's `json` type behavior.)
30
+ */
31
+ /** Structural equality for JSON-shaped values (scalars, arrays, plain objects); key order is ignored. */
32
+ export function deepEqual(a, b) {
33
+ if (a === b)
34
+ return true;
35
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
36
+ return false;
37
+ }
38
+ const aArr = Array.isArray(a);
39
+ if (aArr !== Array.isArray(b))
40
+ return false;
41
+ if (aArr) {
42
+ const av = a;
43
+ const bv = b;
44
+ if (av.length !== bv.length)
45
+ return false;
46
+ for (let i = 0; i < av.length; i++) {
47
+ if (!deepEqual(av[i], bv[i]))
48
+ return false;
49
+ }
50
+ return true;
51
+ }
52
+ const ao = a;
53
+ const bo = b;
54
+ const ak = Object.keys(ao);
55
+ const bk = Object.keys(bo);
56
+ if (ak.length !== bk.length)
57
+ return false;
58
+ for (const k of ak) {
59
+ if (!Object.prototype.hasOwnProperty.call(bo, k))
60
+ return false;
61
+ if (!deepEqual(ao[k], bo[k]))
62
+ return false;
63
+ }
64
+ return true;
65
+ }
66
+ /**
67
+ * Canonical JSON serialization: recursively sorts object keys so two values that
68
+ * differ only in key order (e.g. a jsonb round-trip) produce the same string.
69
+ * Use this when you need a comparable/cacheable string rather than a boolean —
70
+ * e.g. an echo guard or a `useEffect`/`useMemo` dependency.
71
+ */
72
+ export function stableStringify(value) {
73
+ return JSON.stringify(sortKeysDeep(value));
74
+ }
75
+ function sortKeysDeep(value) {
76
+ if (Array.isArray(value))
77
+ return value.map(sortKeysDeep);
78
+ if (value && typeof value === 'object') {
79
+ const source = value;
80
+ return Object.keys(source)
81
+ .sort()
82
+ .reduce((acc, key) => {
83
+ acc[key] = sortKeysDeep(source[key]);
84
+ return acc;
85
+ }, {});
86
+ }
87
+ return value;
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",