@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 +12 -0
- package/dist/Model.d.ts +22 -0
- package/dist/Model.js +45 -0
- package/dist/cli.cjs +1 -1
- package/dist/client/Ablo.js +13 -2
- package/dist/errorCodes.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -0
- package/dist/mutators/undoApply.d.ts +7 -2
- package/dist/mutators/undoApply.js +7 -35
- package/dist/react/useAblo.js +12 -2
- package/dist/schema/ddl.d.ts +0 -36
- package/dist/schema/ddl.js +0 -66
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/schema/sugar.js +10 -2
- package/dist/server/read-config.d.ts +7 -0
- package/dist/utils/json.d.ts +39 -0
- package/dist/utils/json.js +88 -0
- package/package.json +1 -1
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,
|
|
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."),
|
package/dist/client/Ablo.js
CHANGED
|
@@ -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
|
-
|
|
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`)
|
package/dist/errorCodes.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
37
|
-
|
|
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
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
package/dist/react/useAblo.js
CHANGED
|
@@ -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
|
|
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
|
|
31
|
+
return value.toReactiveSnapshot();
|
|
22
32
|
}
|
|
23
33
|
if (Array.isArray(value)) {
|
|
24
34
|
return value.map((item) => snapshotValue(item));
|
package/dist/schema/ddl.d.ts
CHANGED
|
@@ -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.
|
package/dist/schema/ddl.js
CHANGED
|
@@ -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
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -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,
|
|
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';
|
package/dist/schema/index.js
CHANGED
|
@@ -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,
|
|
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)
|
package/dist/schema/sugar.js
CHANGED
|
@@ -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) =>
|
|
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) =>
|
|
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
|
+
}
|