@abloatai/ablo 0.22.1 → 0.24.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.
@@ -410,7 +410,13 @@ import type { ModelOperations, ClaimOptions, ClaimParams, ClaimReadApi, AwaitedC
410
410
  export type ModelOperationAction = 'create' | 'update' | 'delete' | 'archive' | 'unarchive';
411
411
  export type CommitWait = 'queued' | 'confirmed';
412
412
  export interface ModelRead<T = Record<string, unknown>> {
413
- readonly data: T;
413
+ /**
414
+ * The row, or `undefined` when no row matched the id (or it's outside the
415
+ * caller's scope). A miss is data-absence, not an error — `retrieve` never
416
+ * throws "not found", mirroring the WebSocket client's `T | undefined`.
417
+ * Branch on it: `const deal = (await ablo.deals.retrieve({ id })).data; if (!deal) …`.
418
+ */
419
+ readonly data: T | undefined;
414
420
  readonly stamp: number;
415
421
  readonly claims: readonly ModelClaim[];
416
422
  }
@@ -572,10 +578,16 @@ export interface ModelClient<T = Record<string, unknown>> {
572
578
  * `.model(name)` accessor omits it (use the typed `ablo.<model>.list` there).
573
579
  */
574
580
  list?(options?: ServerReadOptions<T>): Promise<T[]>;
581
+ /**
582
+ * Create a row and return it — the confirmed, authoritative server row (with
583
+ * framework defaults like `createdAt`/`createdBy`), mirroring the WebSocket
584
+ * client's `create`. A re-create of an existing caller-supplied id is
585
+ * idempotent and returns the EXISTING row, not the input.
586
+ */
575
587
  create(params: ModelMutationOptions & {
576
588
  readonly data: Record<string, unknown>;
577
589
  readonly id?: string | null;
578
- }): Promise<CommitReceipt>;
590
+ }): Promise<T>;
579
591
  update(params: ModelMutationOptions & {
580
592
  readonly id: string;
581
593
  readonly data: Record<string, unknown>;
@@ -20,7 +20,8 @@
20
20
  */
21
21
  import { z } from 'zod';
22
22
  import { baseFieldsSchema } from '../schema/schema.js';
23
- import { AbloError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, translateHttpError, toAbloError, claimedError } from '../errors.js';
23
+ import { schemaHash } from '../schema/serialize.js';
24
+ import { AbloError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, AbloNotFoundError, translateHttpError, toAbloError, claimedError } from '../errors.js';
24
25
  import { descriptionFromMeta } from '../coordination/schema.js';
25
26
  import { LoadStrategy, PropertyType } from '../types/index.js';
26
27
  import { initSyncEngine } from '../context.js';
@@ -222,6 +223,9 @@ function deriveConfigFromSchema(schema) {
222
223
  defaultNonCreatePriority: 50,
223
224
  essentialFields: {},
224
225
  classNameFallbackMap: {},
226
+ // Hash this client's schema once so bootstrap can detect drift against the
227
+ // server's active hash (same `schemaHash` the CLI push + server compute).
228
+ expectedSchemaHash: schemaHash(schema),
225
229
  };
226
230
  }
227
231
  // ── Auto model registration from schema ───────────────────────────────────
@@ -1009,11 +1013,13 @@ export function Ablo(options) {
1009
1013
  if (participantKind === 'user' &&
1010
1014
  (resolvedSyncGroups.length === 0 ||
1011
1015
  (resolvedSyncGroups.length === 1 && resolvedSyncGroups[0] === 'default'))) {
1012
- logger.warn('Ablo({kind:"user"}) initialized with degenerate syncGroups ' +
1013
- 'this client will receive zero deltas through the live WS path. ' +
1014
- 'Either pass `syncGroups` explicitly (typically ' +
1015
- '`["org:${orgId}", "user:${userId}"]`) or verify your auth ' +
1016
- 'provider populates them. See packages/sync-engine/src/client/identity.ts.', { participantKind, resolvedSyncGroups });
1016
+ // Actionable and NOT self-healing (no live updates until fixed):
1017
+ // kept at warn, consumer register engine jargon and the internal
1018
+ // file pointer stripped; forensic fields ride the debug companion.
1019
+ logger.warn('This client was started without sync groups, so it will not receive ' +
1020
+ 'live updates. Pass `syncGroups` (for example ' +
1021
+ '`["org:<id>", "user:<id>"]`) or check that your auth provider supplies them.');
1022
+ logger.debug('degenerate syncGroups — details', { participantKind, resolvedSyncGroups });
1017
1023
  }
1018
1024
  _resolvedOrganizationId = accountScope;
1019
1025
  if (resolved.refreshScheduler) {
@@ -1577,13 +1583,15 @@ export function Ablo(options) {
1577
1583
  async create(params) {
1578
1584
  const id = params.id ?? createModelId();
1579
1585
  await applyClaimedPolicy({ model: name, id }, params);
1580
- return commits.create({
1586
+ // Confirm, then return the authoritative row (with framework defaults;
1587
+ // the EXISTING row on an idempotent re-create) — mirrors the WS client.
1588
+ await commits.create({
1581
1589
  claimRef: params.claimRef,
1582
1590
  idempotencyKey: params.idempotencyKey,
1583
1591
  readAt: params.readAt,
1584
1592
  onStale: params.onStale,
1585
1593
  ...(isClaimHandleValue(params.claim) ? { claim: params.claim } : {}),
1586
- wait: params.wait,
1594
+ wait: params.wait ?? 'confirmed',
1587
1595
  operations: [
1588
1596
  {
1589
1597
  action: 'create',
@@ -1593,6 +1601,11 @@ export function Ablo(options) {
1593
1601
  },
1594
1602
  ],
1595
1603
  });
1604
+ const { data } = await retrieveModel(name, id, {});
1605
+ if (data === undefined) {
1606
+ throw new AbloNotFoundError(`create ${name}/${id} did not yield a readable row (the write did not confirm).`, [id]);
1607
+ }
1608
+ return data;
1596
1609
  },
1597
1610
  update: updateModel,
1598
1611
  async delete(params) {
@@ -1764,7 +1777,8 @@ export function Ablo(options) {
1764
1777
  await store.disconnect();
1765
1778
  }
1766
1779
  catch (err) {
1767
- logger.warn('Error during sync engine disposal', { error: err.message });
1780
+ // Best-effort teardown a disposal hiccup isn't consumer-actionable debug.
1781
+ logger.debug('Error during sync engine disposal', { error: err.message });
1768
1782
  }
1769
1783
  presenceStream.dispose();
1770
1784
  claimStream.dispose();
@@ -5,7 +5,7 @@
5
5
  * IndexedDB, no WebSocket. It maps the public Model / Claim / Commit
6
6
  * nouns directly to HTTP routes on sync-server.
7
7
  */
8
- import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, claimedError, translateHttpError, } from '../errors.js';
8
+ import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, AbloNotFoundError, claimedError, translateHttpError, } from '../errors.js';
9
9
  import { reconcileFunctionalUpdate, } from './functionalUpdate.js';
10
10
  import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfCliKeyMismatch, warnIfDatabaseUrlEnvIgnored, warnIfDatabaseUrlDeprecated, } from './auth.js';
11
11
  import { registerDataSource } from './registerDataSource.js';
@@ -502,10 +502,13 @@ export function createProtocolClient(options) {
502
502
  const query = await requestJson(`/v1/models/${encodeURIComponent(modelName)}/${encodeURIComponent(params.id)}`, {
503
503
  method: 'GET',
504
504
  });
505
- const data = query.data;
506
- if (!data) {
507
- throw new AbloValidationError(`Model row not found: ${modelName}/${params.id}`, { code: 'model_not_found' });
508
- }
505
+ // Miss = `data: undefined`, NOT a thrown error. The WebSocket client's
506
+ // `retrieve` returns `T | undefined` for a missing row; throwing only here
507
+ // made the obvious read ("does this row exist?") a hard edge that an agent
508
+ // had to wrap in try/catch. Both transports now agree: absent row → absent
509
+ // data. Callers branch on `.data` (already the documented `.data?.x` usage).
510
+ // Normalize a miss to `undefined` (the server may send `null` or omit it).
511
+ const data = (query.data ?? undefined);
509
512
  return {
510
513
  data,
511
514
  stamp: query.stamp ?? 0,
@@ -628,6 +631,12 @@ export function createProtocolClient(options) {
628
631
  reason: params.reason ?? 'editing',
629
632
  });
630
633
  const { data, stamp } = await retrieveModel(name, { id: params.id });
634
+ // A held claim hands back a snapshot; the typed `HeldClaim.data` is `T`.
635
+ // `retrieve` now reports a miss as `undefined` rather than throwing, but a
636
+ // claim on a row that doesn't exist has nothing to hold — surface it.
637
+ if (data === undefined) {
638
+ throw new AbloNotFoundError(`Cannot claim ${name}/${params.id}: it does not exist (or is outside this credential's scope).`, [params.id]);
639
+ }
631
640
  const release = () => releaseClaim(params);
632
641
  return {
633
642
  object: 'claim',
@@ -730,7 +739,20 @@ export function createProtocolClient(options) {
730
739
  const id = params.id ?? createModelId();
731
740
  return withMutationClaim(id, params, async (options) => {
732
741
  await applyClaimedPolicy({ model: name, id }, options);
733
- return mutateModel('create', name, id, params.data, options);
742
+ // Confirm the write, then return the row — the obvious expectation of
743
+ // "create" (the WebSocket client already returns the row). The read-
744
+ // back is the authoritative server row, so it carries the framework
745
+ // defaults (createdAt/createdBy/…) AND, for an idempotent re-create of
746
+ // an existing id, the EXISTING row rather than the caller's input.
747
+ await mutateModel('create', name, id, params.data, {
748
+ ...options,
749
+ wait: options?.wait ?? 'confirmed',
750
+ });
751
+ const { data } = await retrieveModel(name, { id });
752
+ if (data === undefined) {
753
+ throw new AbloNotFoundError(`create ${name}/${id} did not yield a readable row (the write did not confirm).`, [id]);
754
+ }
755
+ return data;
734
756
  });
735
757
  },
736
758
  update: updateModel,
@@ -539,8 +539,13 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
539
539
  }
540
540
  const { id } = params;
541
541
  const model = objectPool.get(id);
542
+ // Idempotent delete (AIP-135 for client-assigned ids): "ensure absent". A
543
+ // row that isn't in our replicated view is already gone from our
544
+ // perspective, so a delete is a no-op success — not an `entity_not_found`
545
+ // error. This matches the HTTP client (which never threw here) and makes
546
+ // delete safe to retry / race (two actors deleting the same row).
542
547
  if (!model)
543
- throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
548
+ return;
544
549
  const claimed = activeClaims.get(id);
545
550
  const opts = mutationOptions(params);
546
551
  const handle = isClaimHandle(params.claim) ? params.claim : undefined;
@@ -47,7 +47,12 @@ export interface AbloHttpClientOptions<S extends SchemaRecord> extends Omit<Ablo
47
47
  export interface HttpModelClient<T, C = T> {
48
48
  retrieve(params: ModelRetrieveParams & ModelReadOptions): Promise<ModelRead<T>>;
49
49
  list(options?: ServerReadOptions<T>): Promise<T[]>;
50
- create(params: ModelCreateParams<T, C>): Promise<CommitReceipt>;
50
+ /**
51
+ * Create a row and return it — the confirmed, authoritative server row (with
52
+ * framework defaults), mirroring the WebSocket client's `create`. A re-create
53
+ * of an existing caller-supplied id is idempotent and returns the EXISTING row.
54
+ */
55
+ create(params: ModelCreateParams<T, C>): Promise<T>;
51
56
  update(params: ModelUpdateParams<C>): Promise<CommitReceipt>;
52
57
  /**
53
58
  * Functional update under contention — `update(id, current => next)`, the
@@ -201,7 +201,7 @@ async function resolveHosted(input) {
201
201
  return { expiresAtMs: Date.parse(next.expiresAt) };
202
202
  },
203
203
  onError: (err) => {
204
- input.logger.warn('cap token refresh failed; will retry', {
204
+ input.logger.debug('cap token refresh failed; will retry', {
205
205
  error: err.message,
206
206
  });
207
207
  },
@@ -55,7 +55,7 @@ export class DatabaseManager {
55
55
  // safe to delete and re-create. Try exactly once: delete, then re-open.
56
56
  if (!(error instanceof IDBOpenTimeoutError))
57
57
  throw error;
58
- getContext().logger.warn('[sync-engine] meta DB open timed out — attempting self-heal (delete + retry)', { db: this.metaDbName, reason: error.reason });
58
+ getContext().logger.debug('[sync-engine] meta DB open timed out — attempting self-heal (delete + retry)', { db: this.metaDbName, reason: error.reason });
59
59
  getContext().observability.captureBootstrapFailure(error, {
60
60
  type: 'meta-db-open-timeout',
61
61
  });
@@ -33,7 +33,8 @@ export class StoreManager {
33
33
  async initializeStores(db) {
34
34
  this.db = db;
35
35
  if (this.isInitialized) {
36
- getContext().logger.warn('StoreManager already initialized');
36
+ // Idempotent re-entry — harmless, nothing for the consumer to act on → debug.
37
+ getContext().logger.debug('StoreManager already initialized');
37
38
  return;
38
39
  }
39
40
  getContext().logger.info('Initializing ObjectStore instances for all models');
@@ -95,7 +96,9 @@ export class StoreManager {
95
96
  getContext().logger.debug('Created index', { store: storeName, prop: propName });
96
97
  }
97
98
  catch (error) {
98
- getContext().logger.warn('Failed to create index', { store: storeName, prop: propName, error });
99
+ // Internal IndexedDB index setup a miss only affects local query
100
+ // speed and isn't consumer-actionable → debug.
101
+ getContext().logger.debug('Failed to create index', { store: storeName, prop: propName, error });
99
102
  }
100
103
  }
101
104
  // For partial load strategy models, we'll create additional partial index database later
@@ -380,6 +380,14 @@ export interface SyncEngineConfig {
380
380
  * e.g., { TaskModel: 'Task', ProjectModel: 'Project' }
381
381
  */
382
382
  classNameFallbackMap: Readonly<Record<string, string>>;
383
+ /**
384
+ * Content hash of the schema THIS client was built against (the same
385
+ * `schemaHash()` the CLI push + server compute). Used purely to detect
386
+ * schema drift: when the server reports a different active hash on bootstrap,
387
+ * the SDK warns the developer to run `ablo push` — otherwise drift only
388
+ * surfaces later as an opaque DB constraint error. Advisory, not enforced.
389
+ */
390
+ expectedSchemaHash?: string;
383
391
  }
384
392
  /**
385
393
  * Allows consumers to extend the WebSocket event map with
@@ -17,6 +17,7 @@
17
17
  * automatically invalidate the undo stack. Consumers should `clear()`
18
18
  * the scope on sync error if they want strict correctness.
19
19
  */
20
+ import { getContext } from '../context.js';
20
21
  import { createTransaction } from './Transaction.js';
21
22
  import { parseUndoEntry } from './inverseOp.js';
22
23
  import { resolveOps, DEFAULT_UNDO_CONFLICT_POLICY, } from './undoApply.js';
@@ -384,9 +385,9 @@ export class UndoScope {
384
385
  }
385
386
  catch (err) {
386
387
  // A faulty observer must never break the editor's recording path.
387
- if (typeof console !== 'undefined') {
388
- console.error('[UndoScope] onRecord listener threw', err);
389
- }
388
+ // Routed through the gated logger; the consumer's own onRecord callback
389
+ // is at fault, so this is actionable → warn (no engine tag on the line).
390
+ getContext().logger.warn('An undo/redo onRecord listener threw — your callback should not throw', err);
390
391
  }
391
392
  }
392
393
  }
@@ -408,9 +409,9 @@ export class UndoScope {
408
409
  listener();
409
410
  }
410
411
  catch (err) {
411
- if (typeof console !== 'undefined') {
412
- console.error('[UndoScope] onChange listener threw', err);
413
- }
412
+ // Consumer's own onChange callback is at fault → actionable warn,
413
+ // routed through the gated logger (no engine tag on the line).
414
+ getContext().logger.warn('An undo/redo onChange listener threw — your callback should not throw', err);
414
415
  }
415
416
  }
416
417
  }
@@ -14,6 +14,7 @@
14
14
  import { z } from 'zod';
15
15
  import { translateHttpError } from '../errors.js';
16
16
  import { withAuthHeaders } from '../auth/credentialSource.js';
17
+ import { getContext } from '../context.js';
17
18
  // ── Response validation ─────────────────────────────────────────────────
18
19
  //
19
20
  // Each result slot is an array of rows (or an object for bundled
@@ -64,8 +65,11 @@ export async function postQuery(options, batch) {
64
65
  // 401) instead of a bare status. We deliberately DON'T throw —
65
66
  // fire-and-forget callers would kill the Next.js router on an
66
67
  // unhandled rejection — and still return empty slots, but the failure
67
- // is now legible as an Ablo error. Direct console.error is
68
- // INTENTIONAL: operators alert on the `[postQuery.error]` prefix.
68
+ // is now legible as an Ablo error. Routed through the gated logger so it
69
+ // obeys ABLO_LOG_LEVEL like everything else: a consumer-register `warn`
70
+ // (their models + the typed message + a wire `code`) with the forensics on
71
+ // a `debug` companion. Actionable and not self-healing — the read returns
72
+ // empty until the underlying cause (auth, network) is resolved.
69
73
  let body = null;
70
74
  try {
71
75
  body = await response.clone().json();
@@ -74,14 +78,24 @@ export async function postQuery(options, batch) {
74
78
  // non-JSON error page — translateHttpError falls back to status text
75
79
  }
76
80
  const err = translateHttpError(response.status, body);
77
- console.error(`[postQuery.error] ${err.type} ${err.code ?? response.status} for ` +
78
- `${batch.queries.map((q) => q.model).join(',')}: ${err.message}`);
81
+ const models = batch.queries.map((q) => q.model).join(', ');
82
+ getContext().logger.warn(`Could not load ${models} — ${err.message} (code: ${err.code ?? response.status}). No results were returned.`);
83
+ getContext().logger.debug('[postQuery.error] query http failure', {
84
+ type: err.type,
85
+ code: err.code ?? response.status,
86
+ models,
87
+ message: err.message,
88
+ });
79
89
  return { results: batch.queries.map(() => []) };
80
90
  }
81
91
  const raw = await response.json();
82
92
  const parsed = QueryBatchResultSchema.safeParse(raw);
83
93
  if (!parsed.success) {
84
- console.error('[postQuery.error] malformed response:', parsed.error.issues);
94
+ // A malformed server response isn't something the consumer can act on
95
+ // (server/protocol issue) → debug, gated like everything else.
96
+ getContext().logger.debug('[postQuery.error] malformed response', {
97
+ issues: parsed.error.issues,
98
+ });
85
99
  return { results: batch.queries.map(() => []) };
86
100
  }
87
101
  return parsed.data;
@@ -51,7 +51,9 @@ export function useMutators(schemaOrMutators, mutatorsOrOptions, maybeOptions) {
51
51
  return result;
52
52
  }
53
53
  catch (err) {
54
- getContext().logger.error(`[useMutators] mutator "${label}" threw`, { error: err });
54
+ // The error is re-thrown to the caller's await (the real
55
+ // consumer surface), so this is a forensic follow-on → debug.
56
+ getContext().logger.debug(`[useMutators] mutator "${label}" threw`, { error: err });
55
57
  throw err;
56
58
  }
57
59
  });
@@ -62,7 +64,9 @@ export function useMutators(schemaOrMutators, mutatorsOrOptions, maybeOptions) {
62
64
  return await fn({ tx, args });
63
65
  }
64
66
  catch (err) {
65
- getContext().logger.error(`[useMutators] mutator "${label}" threw`, { error: err });
67
+ // Re-thrown to the caller's await (the real consumer surface)
68
+ // this duplicate is forensic, debug only.
69
+ getContext().logger.debug(`[useMutators] mutator "${label}" threw`, { error: err });
66
70
  throw err;
67
71
  }
68
72
  };
@@ -18,6 +18,12 @@ export interface BootstrapData {
18
18
  /** Model types whose server-side query failed (timeout, RLS error, etc.) */
19
19
  failedModels?: string[];
20
20
  timestamp: number;
21
+ /**
22
+ * The server's ACTIVE schema content hash for this tenant (same `schemaHash`
23
+ * the CLI push computes). Present once the tenant has pushed a schema; the
24
+ * client compares it to its own `config.expectedSchemaHash` to warn on drift.
25
+ */
26
+ schemaHash?: string;
21
27
  }
22
28
  export interface BootstrapFetchResult {
23
29
  notModified: boolean;
@@ -72,7 +78,17 @@ import { type ValidatedServerDelta } from './schemas.js';
72
78
  export declare class BootstrapHelper {
73
79
  private options;
74
80
  private abortController;
81
+ /** Warn about schema drift at most once per helper. */
82
+ private schemaDriftWarned;
75
83
  get baseUrl(): string;
84
+ /**
85
+ * Advisory schema-drift check: compare the server's active schema hash (on the
86
+ * bootstrap response) against the hash this client was built with. A mismatch
87
+ * means the app's schema and the deployed schema have diverged — reads/writes
88
+ * relying on undeployed changes will later fail with an opaque DB constraint
89
+ * error. Warn once, actionably; never throws or blocks the bootstrap.
90
+ */
91
+ private warnOnSchemaDrift;
76
92
  constructor(options: BootstrapOptions);
77
93
  /**
78
94
  * Update the offline-cache namespace once auth has resolved the server-side
@@ -10,9 +10,34 @@ import { parseBootstrapResponse } from './schemas.js';
10
10
  export class BootstrapHelper {
11
11
  options;
12
12
  abortController = null;
13
+ /** Warn about schema drift at most once per helper. */
14
+ schemaDriftWarned = false;
13
15
  get baseUrl() {
14
16
  return this.options.baseUrl;
15
17
  }
18
+ /**
19
+ * Advisory schema-drift check: compare the server's active schema hash (on the
20
+ * bootstrap response) against the hash this client was built with. A mismatch
21
+ * means the app's schema and the deployed schema have diverged — reads/writes
22
+ * relying on undeployed changes will later fail with an opaque DB constraint
23
+ * error. Warn once, actionably; never throws or blocks the bootstrap.
24
+ */
25
+ warnOnSchemaDrift(serverHash) {
26
+ if (this.schemaDriftWarned || !serverHash)
27
+ return;
28
+ const clientHash = getContext().config.expectedSchemaHash;
29
+ if (!clientHash || clientHash === serverHash)
30
+ return;
31
+ this.schemaDriftWarned = true;
32
+ // Self-brand the message ("Ablo:") rather than rely on the default logger's
33
+ // `[Ablo]` namespace — consumers wiring their own logger (pino, etc.) lose
34
+ // that prefix, and a drift warning that reads like the app's own log is
35
+ // worse than none. The brand tells them at a glance who is talking.
36
+ getContext().logger.warn(`Ablo: Schema drift detected — this app was built against schema ${clientHash}, ` +
37
+ `but the deployed schema is ${serverHash}. Operations that depend on schema ` +
38
+ `changes not yet deployed will fail later with an opaque database error. Run ` +
39
+ `\`ablo push\` to deploy your schema (or update this app to match the deployed one).`, { clientSchemaHash: clientHash, serverSchemaHash: serverHash });
40
+ }
16
41
  constructor(options) {
17
42
  // Defaults are spread first; the explicit `baseUrl` then takes precedence
18
43
  // and is computed from `options.baseUrl` (or the localhost fallback).
@@ -254,6 +279,7 @@ export class BootstrapHelper {
254
279
  }
255
280
  const rawJson = await res.json();
256
281
  const data = parseBootstrapResponse(rawJson);
282
+ this.warnOnSchemaDrift(data.schemaHash);
257
283
  // Persist payload for offline
258
284
  try {
259
285
  if (this.options.cacheScope) {
@@ -326,6 +352,7 @@ export class BootstrapHelper {
326
352
  }
327
353
  const rawJson = await response.json();
328
354
  const data = parseBootstrapResponse(rawJson);
355
+ this.warnOnSchemaDrift(data.schemaHash);
329
356
  // Save a copy for offline
330
357
  try {
331
358
  if (this.options.cacheScope) {
@@ -388,7 +415,7 @@ export class BootstrapHelper {
388
415
  });
389
416
  }
390
417
  catch (error) {
391
- getContext().logger.warn('Failed to clear cache', { error });
418
+ getContext().logger.debug('Failed to clear cache', { error });
392
419
  }
393
420
  }
394
421
  // Cache helpers for offline bootstrap
@@ -402,7 +429,7 @@ export class BootstrapHelper {
402
429
  localStorage.setItem(this.getBootstrapCacheKey(orgId), JSON.stringify(data));
403
430
  }
404
431
  catch (e) {
405
- getContext().logger.warn('Failed to cache bootstrap payload', {
432
+ getContext().logger.debug('Failed to cache bootstrap payload', {
406
433
  error: e instanceof Error ? e.message : String(e),
407
434
  });
408
435
  }
@@ -141,7 +141,7 @@ export class ConnectionManager {
141
141
  this.callbacks?.onStateChange?.(nextState, prevState);
142
142
  }
143
143
  catch (err) {
144
- getContext().logger.warn('[ConnectionManager] onStateChange threw', {
144
+ getContext().logger.debug('[ConnectionManager] onStateChange threw', {
145
145
  error: err instanceof Error ? err.message : String(err),
146
146
  });
147
147
  }
@@ -443,7 +443,7 @@ export class ConnectionManager {
443
443
  */
444
444
  async runRefreshCredential() {
445
445
  if (this.credentialRefreshAttempts >= MAX_CREDENTIAL_REFRESH_ATTEMPTS) {
446
- getContext().logger.warn('[ConnectionManager] Access key still stale after repeated re-mints — stopping', { attempts: this.credentialRefreshAttempts });
446
+ getContext().logger.debug('[ConnectionManager] Access key still stale after repeated re-mints — stopping', { attempts: this.credentialRefreshAttempts });
447
447
  runInAction(() => {
448
448
  this.credentialRefreshAttempts = 0;
449
449
  });
@@ -482,7 +482,7 @@ export class ConnectionManager {
482
482
  catch (error) {
483
483
  // A thrown refresher is transient by contract (offline / mint endpoint
484
484
  // unreachable) — back off and retry, never sign out.
485
- getContext().logger.warn('[ConnectionManager] Credential re-mint threw (transient)', { error });
485
+ getContext().logger.debug('[ConnectionManager] Credential re-mint threw (transient)', { error });
486
486
  this.send({ type: 'RECONNECT_FAILED' });
487
487
  }
488
488
  }
@@ -504,7 +504,7 @@ export class ConnectionManager {
504
504
  }
505
505
  }
506
506
  catch (error) {
507
- getContext().logger.error('[ConnectionManager] Reconnect threw', { error });
507
+ getContext().logger.debug('[ConnectionManager] Reconnect threw', { error });
508
508
  this.send({ type: 'RECONNECT_FAILED' });
509
509
  }
510
510
  }
@@ -517,14 +517,14 @@ export class ConnectionManager {
517
517
  // event (or watchdog) restart the cycle when the network comes
518
518
  // back.
519
519
  if (typeof navigator !== 'undefined' && navigator.onLine === false) {
520
- getContext().logger.warn('[ConnectionManager] Max retries while offline — parking until network restored');
520
+ getContext().logger.debug('[ConnectionManager] Max retries while offline — parking until network restored');
521
521
  getContext().observability.breadcrumb('Max retries — parking offline', 'sync.offline', 'warning');
522
522
  runInAction(() => {
523
523
  this.attempt = 0;
524
524
  });
525
525
  return;
526
526
  }
527
- getContext().logger.warn('[ConnectionManager] Max retries — hard reload');
527
+ getContext().logger.debug('[ConnectionManager] Max retries — hard reload');
528
528
  getContext().observability.breadcrumb('Max retries — hard reload', 'sync.offline', 'error');
529
529
  if (typeof window !== 'undefined') {
530
530
  window.location.reload();
@@ -611,7 +611,7 @@ export class ConnectionManager {
611
611
  // the user with a broken page.
612
612
  const browserOnline = typeof navigator === 'undefined' || navigator.onLine === true;
613
613
  if (this.stuckCycles >= MAX_STUCK_CYCLES_BEFORE_RELOAD && browserOnline) {
614
- getContext().logger.warn('[ConnectionManager] Watchdog: sustained stuck — hard reload');
614
+ getContext().logger.debug('[ConnectionManager] Watchdog: sustained stuck — hard reload');
615
615
  getContext().observability.breadcrumb('Watchdog hard reload', 'sync.offline', 'error');
616
616
  if (typeof window !== 'undefined') {
617
617
  window.location.reload();
@@ -141,7 +141,7 @@ export async function probeNetwork(input) {
141
141
  // or an auth-tagged code this SDK doesn't recognise. Re-auth re-mints
142
142
  // the same rejected credential and retrying won't help, so STOP
143
143
  // rather than reconnect-loop or sign the user out.
144
- getContext().logger.warn('[NetworkProbe] Reachable but auth-blocked (non-retryable, non-expiry)', {
144
+ getContext().logger.debug('[NetworkProbe] Reachable but auth-blocked (non-retryable, non-expiry)', {
145
145
  status: response.status,
146
146
  code: authFailure,
147
147
  recovery,
@@ -183,7 +183,7 @@ export async function probeNetwork(input) {
183
183
  // expected 204; log a warning so misconfigurations surface instead of
184
184
  // silently passing.
185
185
  if (response.status < 200 || response.status >= 300) {
186
- getContext().logger.warn('[NetworkProbe] Unexpected probe response', {
186
+ getContext().logger.debug('[NetworkProbe] Unexpected probe response', {
187
187
  status: response.status,
188
188
  url,
189
189
  latencyMs,
@@ -1363,7 +1363,7 @@ export class SyncWebSocket extends EventEmitter {
1363
1363
  if (!this.ws)
1364
1364
  return;
1365
1365
  this.lastForceCloseReason = reason;
1366
- getContext().logger.warn('[SyncWebSocket] forceClose', {
1366
+ getContext().logger.debug('[SyncWebSocket] forceClose', {
1367
1367
  reason,
1368
1368
  readyState: this.ws.readyState,
1369
1369
  msSinceOpen: this.lastOpenAt != null ? Date.now() - this.lastOpenAt : null,
@@ -1567,7 +1567,7 @@ export class SyncWebSocket extends EventEmitter {
1567
1567
  if (!hasDeltas && typeof payload.currentSyncId === 'number') {
1568
1568
  const serverHead = payload.currentSyncId;
1569
1569
  if (serverHead < this.lastSyncId) {
1570
- getContext().logger.warn('[SyncWebSocket] local cursor ahead of server head — resetting and resyncing', {
1570
+ getContext().logger.debug('[SyncWebSocket] local cursor ahead of server head — resetting and resyncing', {
1571
1571
  local: this.lastSyncId,
1572
1572
  server: serverHead,
1573
1573
  drift: this.lastSyncId - serverHead,
@@ -70,6 +70,7 @@ export declare const BootstrapResponseSchema: z.ZodObject<{
70
70
  deltaCount: z.ZodOptional<z.ZodNumber>;
71
71
  failedModels: z.ZodOptional<z.ZodArray<z.ZodString>>;
72
72
  timestamp: z.ZodDefault<z.ZodNumber>;
73
+ schemaHash: z.ZodOptional<z.ZodString>;
73
74
  }, z.core.$loose>;
74
75
  export type ValidatedBootstrapResponse = z.infer<typeof BootstrapResponseSchema>;
75
76
  /**
@@ -54,6 +54,9 @@ export const BootstrapResponseSchema = z
54
54
  deltaCount: z.number().optional(),
55
55
  failedModels: z.array(z.string()).optional(),
56
56
  timestamp: z.number().default(() => Date.now()),
57
+ // Server's active schema hash (drift detection). Optional: absent from
58
+ // older servers / tenants that have never pushed a schema.
59
+ schemaHash: z.string().optional(),
57
60
  })
58
61
  .passthrough();
59
62
  // ─── Parse Helpers ───────────────────────────────────────────────────────────