@abloatai/humans 0.58.0 → 0.59.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.
@@ -189,7 +189,16 @@ export class Model {
189
189
  * Track property changes
190
190
  */
191
191
  propertyChanged(propertyName, oldValue, newValue) {
192
- if (oldValue === newValue)
192
+ // `createdAt` and `updatedAt` are server-managed bookkeeping, not
193
+ // user-authored model changes. In particular, every real field change
194
+ // advances `updatedAt` below. When a schema explicitly declares that
195
+ // timestamp, MobX observes the assignment and calls this method again;
196
+ // treating that callback as another edit recursively stamps `updatedAt`
197
+ // until the stack overflows. Ignore both timestamps at this boundary so
198
+ // they remain observable without entering the mutation payload.
199
+ if (oldValue === newValue ||
200
+ propertyName === 'createdAt' ||
201
+ propertyName === 'updatedAt')
193
202
  return;
194
203
  runInAction(() => {
195
204
  // Preserve the earliest captured `old` for this field until the entry
@@ -176,6 +176,7 @@ export function deriveConfigFromSchema(schema) {
176
176
  // the client compares only the models it declares, so an additive server
177
177
  // change stays silent and a real divergence names the exact models.
178
178
  expectedModelHashes: Object.fromEntries(Object.entries(toSchemaJSON(schema).models).map(([key, model]) => [key, modelHash(model)])),
179
+ expectedModelShapes: Object.fromEntries(Object.entries(toSchemaJSON(schema).models).map(([key, model]) => [key, Object.fromEntries(Object.entries(model.fields).map(([field, meta]) => [field, { type: meta.type, isOptional: meta.isOptional }]))])),
179
180
  // For a projection (`selectModels`/`omitModels`), also carry the full source
180
181
  // schema's hash. The drift check accepts a server match on either hash, so a
181
182
  // subset client stays quiet against a server running its full source schema.
@@ -289,6 +289,11 @@ export interface RuntimeConfig {
289
289
  * Advisory, like the hashes above.
290
290
  */
291
291
  expectedModelHashes?: Readonly<Record<string, string>>;
292
+ /** Field shapes paired with expectedModelHashes so drift can name direction, not just a model. */
293
+ expectedModelShapes?: Readonly<Record<string, Readonly<Record<string, {
294
+ readonly type: string;
295
+ readonly isOptional: boolean;
296
+ }>>>>;
292
297
  }
293
298
  /**
294
299
  * Extends the WebSocket event map with your own collaboration events, such as
@@ -161,14 +161,14 @@ export class BootstrapFetcher {
161
161
  // network hiccup). Fire-and-forget: never blocks or fails the bootstrap.
162
162
  const clientModels = this.runtime.config.expectedModelHashes;
163
163
  if (clientModels && Object.keys(clientModels).length > 0) {
164
- void this.resolveSemanticDrift(clientModels, clientHash, serverHash, where);
164
+ void this.resolveSemanticDrift(clientModels, clientHash, serverHash, where, this.runtime.config.expectedModelShapes);
165
165
  return;
166
166
  }
167
167
  this.warnWholeHashDrift(clientHash, serverHash, where);
168
168
  }
169
169
  /** Fetch the server's per-model schema surface and warn precisely — or stay
170
170
  * silent when every model this client declares matches (additive lead). */
171
- async resolveSemanticDrift(clientModels, clientHash, serverHash, where) {
171
+ async resolveSemanticDrift(clientModels, clientHash, serverHash, where, clientShapes) {
172
172
  try {
173
173
  const res = await fetch(`${this.options.baseUrl}/schema`, {
174
174
  method: 'GET',
@@ -181,11 +181,11 @@ export class BootstrapFetcher {
181
181
  ? body.models.flatMap((m) => {
182
182
  const entry = m;
183
183
  return typeof entry.key === 'string'
184
- ? [{ key: entry.key, ...(typeof entry.hash === 'string' ? { hash: entry.hash } : {}) }]
184
+ ? [{ key: entry.key, ...(typeof entry.hash === 'string' ? { hash: entry.hash } : {}), ...(entry.fields && typeof entry.fields === 'object' ? { fields: entry.fields } : {}) }]
185
185
  : [];
186
186
  })
187
187
  : [];
188
- const finding = classifySchemaDrift(clientModels, models);
188
+ const finding = classifySchemaDrift(clientModels, models, clientShapes);
189
189
  if (finding.kind === 'aligned')
190
190
  return; // additive server lead — not this client's concern
191
191
  if (finding.kind !== 'unknown') {
@@ -19,6 +19,16 @@ export interface ServerSchemaModel {
19
19
  readonly key: string;
20
20
  /** Per-model content hash; absent on servers older than this check. */
21
21
  readonly hash?: string;
22
+ readonly fields?: Readonly<Record<string, {
23
+ readonly type: string;
24
+ readonly isOptional: boolean;
25
+ }>>;
26
+ }
27
+ export interface SchemaFieldDrift {
28
+ readonly model: string;
29
+ readonly field: string;
30
+ readonly direction: 'client_only' | 'active_only' | 'changed';
31
+ readonly detail: string;
22
32
  }
23
33
  export type SchemaDriftFinding =
24
34
  /** Every model this client declares exists server-side with matching content
@@ -38,13 +48,17 @@ export type SchemaDriftFinding =
38
48
  readonly kind: 'changed';
39
49
  readonly models: readonly string[];
40
50
  readonly unpushed: readonly string[];
51
+ readonly fields?: readonly SchemaFieldDrift[];
41
52
  }
42
53
  /** The server surface carries no per-model hashes (older server) — the
43
54
  * caller falls back to the whole-hash comparison. */
44
55
  | {
45
56
  readonly kind: 'unknown';
46
57
  };
47
- export declare function classifySchemaDrift(clientModels: Readonly<Record<string, string>>, serverModels: readonly ServerSchemaModel[]): SchemaDriftFinding;
58
+ export declare function classifySchemaDrift(clientModels: Readonly<Record<string, string>>, serverModels: readonly ServerSchemaModel[], clientShapes?: Readonly<Record<string, Readonly<Record<string, {
59
+ readonly type: string;
60
+ readonly isOptional: boolean;
61
+ }>>>>): SchemaDriftFinding;
48
62
  /**
49
63
  * The warning for a real, named divergence. Calm and specific: which models,
50
64
  * what that means for this client, and the one next step. Never speaks about
@@ -14,22 +14,38 @@
14
14
  *
15
15
  * Pure and transport-free; the BootstrapFetcher owns fetching the surface.
16
16
  */
17
- export function classifySchemaDrift(clientModels, serverModels) {
17
+ import { reconcileClientToActive } from '@abloatai/transaction/schema';
18
+ export function classifySchemaDrift(clientModels, serverModels, clientShapes = {}) {
18
19
  if (serverModels.length > 0 && serverModels.every((m) => !m.hash)) {
19
20
  return { kind: 'unknown' };
20
21
  }
21
22
  const server = new Map(serverModels.map((m) => [m.key, m.hash]));
22
23
  const unpushed = [];
23
24
  const changed = [];
25
+ const fields = [];
24
26
  for (const [key, hash] of Object.entries(clientModels)) {
25
27
  const serverHash = server.get(key);
26
28
  if (serverHash === undefined)
27
29
  unpushed.push(key);
28
- else if (serverHash !== hash)
30
+ else if (serverHash !== hash) {
29
31
  changed.push(key);
32
+ const clientFields = clientShapes[key];
33
+ const activeFields = serverModels.find((model) => model.key === key)?.fields;
34
+ if (clientFields && activeFields)
35
+ for (const field of new Set([...Object.keys(clientFields), ...Object.keys(activeFields)])) {
36
+ const client = clientFields[field];
37
+ const active = activeFields[field];
38
+ if (!active)
39
+ fields.push({ model: key, field, direction: 'client_only', detail: 'present in this build but absent from the active schema' });
40
+ else if (!client)
41
+ fields.push({ model: key, field, direction: 'active_only', detail: 'present in the active schema but absent from this build' });
42
+ else if (client.type !== active.type || client.isOptional !== active.isOptional)
43
+ fields.push({ model: key, field, direction: 'changed', detail: `${client.type}${client.isOptional ? ' optional' : ' required'} in this build and ${active.type}${active.isOptional ? ' optional' : ' required'} in the active schema` });
44
+ }
45
+ }
30
46
  }
31
47
  if (changed.length > 0)
32
- return { kind: 'changed', models: changed, unpushed };
48
+ return { kind: 'changed', models: changed, unpushed, ...(fields.length ? { fields } : {}) };
33
49
  if (unpushed.length > 0)
34
50
  return { kind: 'unpushed', models: unpushed };
35
51
  return { kind: 'aligned' };
@@ -40,14 +56,15 @@ export function classifySchemaDrift(clientModels, serverModels) {
40
56
  * hashes — the point of the semantic check is that nobody has to compare hex.
41
57
  */
42
58
  export function describeSchemaDrift(finding, serverLabel) {
43
- if (finding.kind === 'unpushed') {
44
- return (`Ablo: This build declares models the server at ${serverLabel} doesn't have yet ` +
45
- `(${finding.models.join(', ')}). Writes to them will be declined until the schema is ` +
46
- `pushed run \`ablo push\` (and \`ablo status\` to confirm it targets this server).`);
47
- }
48
- const alsoUnpushed = finding.unpushed.length > 0 ? ` (${finding.unpushed.join(', ')} not pushed yet)` : '';
49
- return (`Ablo: These models differ between this build and the server at ${serverLabel}: ` +
50
- `${finding.models.join(', ')}${alsoUnpushed}. Reads and writes touching what changed may be ` +
51
- `declined \`ablo status\` shows the deployed shape; pushing your schema or deploying a ` +
52
- `current build aligns them.`);
59
+ const findings = finding.kind === 'unpushed'
60
+ ? reconcileClientToActive([], finding.models, serverLabel)
61
+ : reconcileClientToActive(finding.models, finding.unpushed, serverLabel, finding.fields ?? []);
62
+ const changed = findings.filter(({ code }) => code === 'model_changed').map(({ model }) => model).filter(Boolean);
63
+ const unpushed = findings.filter(({ code }) => code === 'model_unpushed').map(({ model }) => model).filter(Boolean);
64
+ const summary = [
65
+ ...findings.filter(({ field }) => field !== undefined).map(({ message }) => message),
66
+ ...(changed.length ? [`Models ${changed.join(', ')} differ between this build and the active schema at ${serverLabel}.`] : []),
67
+ ...(unpushed.length ? [`Models ${unpushed.join(', ')} are declared by this build but are not active at ${serverLabel}.`] : []),
68
+ ];
69
+ return `Ablo: ${summary.join(' ')} ${findings.map(({ action }) => action).filter((value, index, all) => all.indexOf(value) === index).join(' ')}`;
53
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "directory": "packages/humans"
85
85
  },
86
86
  "dependencies": {
87
- "@abloatai/transaction": "^0.58.0",
87
+ "@abloatai/transaction": "^0.59.0",
88
88
  "mobx": "^6.13.7",
89
89
  "uuid": "^11.1.0",
90
90
  "zod": "^4.4.3"
@@ -281,7 +281,18 @@ export abstract class Model {
281
281
  * Track property changes
282
282
  */
283
283
  propertyChanged(propertyName: string, oldValue: unknown, newValue: unknown): void {
284
- if (oldValue === newValue) return;
284
+ // `createdAt` and `updatedAt` are server-managed bookkeeping, not
285
+ // user-authored model changes. In particular, every real field change
286
+ // advances `updatedAt` below. When a schema explicitly declares that
287
+ // timestamp, MobX observes the assignment and calls this method again;
288
+ // treating that callback as another edit recursively stamps `updatedAt`
289
+ // until the stack overflows. Ignore both timestamps at this boundary so
290
+ // they remain observable without entering the mutation payload.
291
+ if (
292
+ oldValue === newValue ||
293
+ propertyName === 'createdAt' ||
294
+ propertyName === 'updatedAt'
295
+ ) return;
285
296
 
286
297
  runInAction(() => {
287
298
  // Preserve the earliest captured `old` for this field until the entry
@@ -185,6 +185,9 @@ export function deriveConfigFromSchema(schema: Schema): RuntimeConfig {
185
185
  expectedModelHashes: Object.fromEntries(
186
186
  Object.entries(toSchemaJSON(schema).models).map(([key, model]) => [key, modelHash(model)]),
187
187
  ),
188
+ expectedModelShapes: Object.fromEntries(
189
+ Object.entries(toSchemaJSON(schema).models).map(([key, model]) => [key, Object.fromEntries(Object.entries(model.fields).map(([field, meta]) => [field, { type: meta.type, isOptional: meta.isOptional }]))]),
190
+ ),
188
191
  // For a projection (`selectModels`/`omitModels`), also carry the full source
189
192
  // schema's hash. The drift check accepts a server match on either hash, so a
190
193
  // subset client stays quiet against a server running its full source schema.
@@ -436,6 +436,8 @@ export interface RuntimeConfig {
436
436
  * Advisory, like the hashes above.
437
437
  */
438
438
  expectedModelHashes?: Readonly<Record<string, string>>;
439
+ /** Field shapes paired with expectedModelHashes so drift can name direction, not just a model. */
440
+ expectedModelShapes?: Readonly<Record<string, Readonly<Record<string, { readonly type: string; readonly isOptional: boolean }>>>>;
439
441
  }
440
442
 
441
443
  // ─────────────────────────────────────────────
@@ -293,7 +293,7 @@ export class BootstrapFetcher {
293
293
  // network hiccup). Fire-and-forget: never blocks or fails the bootstrap.
294
294
  const clientModels = this.runtime.config.expectedModelHashes;
295
295
  if (clientModels && Object.keys(clientModels).length > 0) {
296
- void this.resolveSemanticDrift(clientModels, clientHash, serverHash, where);
296
+ void this.resolveSemanticDrift(clientModels, clientHash, serverHash, where, this.runtime.config.expectedModelShapes);
297
297
  return;
298
298
  }
299
299
  this.warnWholeHashDrift(clientHash, serverHash, where);
@@ -306,6 +306,7 @@ export class BootstrapFetcher {
306
306
  clientHash: string,
307
307
  serverHash: string,
308
308
  where: string,
309
+ clientShapes: NonNullable<typeof this.runtime.config.expectedModelShapes> | undefined,
309
310
  ): Promise<void> {
310
311
  try {
311
312
  const res = await fetch(`${this.options.baseUrl}/schema`, {
@@ -316,13 +317,13 @@ export class BootstrapFetcher {
316
317
  const body = (await res.json()) as { models?: unknown };
317
318
  const models = Array.isArray(body.models)
318
319
  ? body.models.flatMap((m): ServerSchemaModel[] => {
319
- const entry = m as { key?: unknown; hash?: unknown };
320
+ const entry = m as { key?: unknown; hash?: unknown; fields?: unknown };
320
321
  return typeof entry.key === 'string'
321
- ? [{ key: entry.key, ...(typeof entry.hash === 'string' ? { hash: entry.hash } : {}) }]
322
+ ? [{ key: entry.key, ...(typeof entry.hash === 'string' ? { hash: entry.hash } : {}), ...(entry.fields && typeof entry.fields === 'object' ? { fields: entry.fields as ServerSchemaModel['fields'] } : {}) }]
322
323
  : [];
323
324
  })
324
325
  : [];
325
- const finding = classifySchemaDrift(clientModels, models);
326
+ const finding = classifySchemaDrift(clientModels, models, clientShapes);
326
327
  if (finding.kind === 'aligned') return; // additive server lead — not this client's concern
327
328
  if (finding.kind !== 'unknown') {
328
329
  this.runtime.logger.warn(describeSchemaDrift(finding, where), {
@@ -15,13 +15,18 @@
15
15
  * Pure and transport-free; the BootstrapFetcher owns fetching the surface.
16
16
  */
17
17
 
18
+ import { reconcileClientToActive } from '@abloatai/transaction/schema';
19
+
18
20
  /** One model as the server's schema read-back reports it. */
19
21
  export interface ServerSchemaModel {
20
22
  readonly key: string;
21
23
  /** Per-model content hash; absent on servers older than this check. */
22
24
  readonly hash?: string;
25
+ readonly fields?: Readonly<Record<string, { readonly type: string; readonly isOptional: boolean }>>;
23
26
  }
24
27
 
28
+ export interface SchemaFieldDrift { readonly model: string; readonly field: string; readonly direction: 'client_only' | 'active_only' | 'changed'; readonly detail: string; }
29
+
25
30
  export type SchemaDriftFinding =
26
31
  /** Every model this client declares exists server-side with matching content
27
32
  * (the server may know more — that's an additive lead, not drift). */
@@ -35,6 +40,7 @@ export type SchemaDriftFinding =
35
40
  readonly kind: 'changed';
36
41
  readonly models: readonly string[];
37
42
  readonly unpushed: readonly string[];
43
+ readonly fields?: readonly SchemaFieldDrift[];
38
44
  }
39
45
  /** The server surface carries no per-model hashes (older server) — the
40
46
  * caller falls back to the whole-hash comparison. */
@@ -43,6 +49,7 @@ export type SchemaDriftFinding =
43
49
  export function classifySchemaDrift(
44
50
  clientModels: Readonly<Record<string, string>>,
45
51
  serverModels: readonly ServerSchemaModel[],
52
+ clientShapes: Readonly<Record<string, Readonly<Record<string, { readonly type: string; readonly isOptional: boolean }>>>> = {},
46
53
  ): SchemaDriftFinding {
47
54
  if (serverModels.length > 0 && serverModels.every((m) => !m.hash)) {
48
55
  return { kind: 'unknown' };
@@ -50,12 +57,24 @@ export function classifySchemaDrift(
50
57
  const server = new Map(serverModels.map((m) => [m.key, m.hash]));
51
58
  const unpushed: string[] = [];
52
59
  const changed: string[] = [];
60
+ const fields: SchemaFieldDrift[] = [];
53
61
  for (const [key, hash] of Object.entries(clientModels)) {
54
62
  const serverHash = server.get(key);
55
63
  if (serverHash === undefined) unpushed.push(key);
56
- else if (serverHash !== hash) changed.push(key);
64
+ else if (serverHash !== hash) {
65
+ changed.push(key);
66
+ const clientFields = clientShapes[key];
67
+ const activeFields = serverModels.find((model) => model.key === key)?.fields;
68
+ if (clientFields && activeFields) for (const field of new Set([...Object.keys(clientFields), ...Object.keys(activeFields)])) {
69
+ const client = clientFields[field];
70
+ const active = activeFields[field];
71
+ if (!active) fields.push({ model: key, field, direction: 'client_only', detail: 'present in this build but absent from the active schema' });
72
+ else if (!client) fields.push({ model: key, field, direction: 'active_only', detail: 'present in the active schema but absent from this build' });
73
+ else if (client.type !== active.type || client.isOptional !== active.isOptional) fields.push({ model: key, field, direction: 'changed', detail: `${client.type}${client.isOptional ? ' optional' : ' required'} in this build and ${active.type}${active.isOptional ? ' optional' : ' required'} in the active schema` });
74
+ }
75
+ }
57
76
  }
58
- if (changed.length > 0) return { kind: 'changed', models: changed, unpushed };
77
+ if (changed.length > 0) return { kind: 'changed', models: changed, unpushed, ...(fields.length ? { fields } : {}) };
59
78
  if (unpushed.length > 0) return { kind: 'unpushed', models: unpushed };
60
79
  return { kind: 'aligned' };
61
80
  }
@@ -69,18 +88,15 @@ export function describeSchemaDrift(
69
88
  finding: Extract<SchemaDriftFinding, { kind: 'unpushed' | 'changed' }>,
70
89
  serverLabel: string,
71
90
  ): string {
72
- if (finding.kind === 'unpushed') {
73
- return (
74
- `Ablo: This build declares models the server at ${serverLabel} doesn't have yet ` +
75
- `(${finding.models.join(', ')}). Writes to them will be declined until the schema is ` +
76
- `pushed run \`ablo push\` (and \`ablo status\` to confirm it targets this server).`
77
- );
78
- }
79
- const alsoUnpushed = finding.unpushed.length > 0 ? ` (${finding.unpushed.join(', ')} not pushed yet)` : '';
80
- return (
81
- `Ablo: These models differ between this build and the server at ${serverLabel}: ` +
82
- `${finding.models.join(', ')}${alsoUnpushed}. Reads and writes touching what changed may be ` +
83
- `declined — \`ablo status\` shows the deployed shape; pushing your schema or deploying a ` +
84
- `current build aligns them.`
85
- );
91
+ const findings = finding.kind === 'unpushed'
92
+ ? reconcileClientToActive([], finding.models, serverLabel)
93
+ : reconcileClientToActive(finding.models, finding.unpushed, serverLabel, finding.fields ?? []);
94
+ const changed = findings.filter(({ code }) => code === 'model_changed').map(({ model }) => model).filter(Boolean);
95
+ const unpushed = findings.filter(({ code }) => code === 'model_unpushed').map(({ model }) => model).filter(Boolean);
96
+ const summary = [
97
+ ...findings.filter(({ field }) => field !== undefined).map(({ message }) => message),
98
+ ...(changed.length ? [`Models ${changed.join(', ')} differ between this build and the active schema at ${serverLabel}.`] : []),
99
+ ...(unpushed.length ? [`Models ${unpushed.join(', ')} are declared by this build but are not active at ${serverLabel}.`] : []),
100
+ ];
101
+ return `Ablo: ${summary.join(' ')} ${findings.map(({ action }) => action).filter((value, index, all) => all.indexOf(value) === index).join(' ')}`;
86
102
  }