@abloatai/ablo 0.22.1 → 0.23.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,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2807efb: `create` now returns the created row, not a `CommitReceipt`.
8
+
9
+ The WebSocket client's `create` already returned the row (`T`); the HTTP client
10
+ and the `.model(name)` accessor returned a `CommitReceipt`, so "create returns
11
+ the thing I created" only held on one transport. Both now return the confirmed,
12
+ authoritative server row (framework defaults like `createdAt`/`createdBy`
13
+ included). For an idempotent re-create of an existing caller-supplied id, the
14
+ EXISTING row is returned (not the input).
15
+
16
+ BREAKING (HTTP / `.model()` callers only): `await ablo.<model>.create(...)` now
17
+ resolves to the row instead of `{ status, lastSyncId, ... }`. Code that ignored
18
+ the return value, or that read `.id` (the row carries `id` too), is unaffected;
19
+ code that read `lastSyncId` / `serverTxId` / `status` off a typed model create
20
+ should use the raw `commits.create(...)` resource, which still returns a
21
+ `CommitReceipt`. WebSocket-client callers are unaffected (already returned `T`).
22
+
23
+ - 2807efb: `delete` is idempotent — deleting an already-absent row is a no-op success, not
24
+ an error.
25
+
26
+ The WebSocket client's `delete` threw `entity_not_found` when the row wasn't in
27
+ the local pool, while the HTTP client returned without error — so "delete this"
28
+ was a hard edge on one transport. Both now agree: a row that isn't present is
29
+ already gone, so the delete succeeds with no effect. This is AIP-135's
30
+ recommended behavior for client-assigned-id / declarative APIs (Ablo is exactly
31
+ that), and it makes delete safe to retry and to race (two actors deleting the
32
+ same row). The deliberate "loud 0-row" assertion in `@ablo/slides-sdk` is
33
+ unchanged (it keeps its own `allowMissing` opt-out).
34
+
35
+ - 2807efb: `retrieve` reports a missing row as `data: undefined` instead of throwing.
36
+
37
+ The HTTP client previously threw `model_not_found` for a missing row while the
38
+ WebSocket client returned `T | undefined` — so the obvious read ("does this row
39
+ exist?") was a hard edge an agent had to wrap in `try/catch` on one transport
40
+ only. Both transports now agree: an absent row is data-absence, not an error.
41
+ `ModelRead.data` is now `T | undefined` (matching the documented `.data?.x`
42
+ usage). Taking a `claim` on a row that doesn't exist still throws
43
+ `AbloNotFoundError` — a claim has nothing to hold.
44
+
3
45
  ## 0.22.1
4
46
 
5
47
  ### Patch Changes
@@ -215,7 +215,7 @@ export class BaseSyncedStore {
215
215
  this.hydratedGroups.add(g);
216
216
  }
217
217
  catch (err) {
218
- getContext().logger.warn('[BaseSyncedStore] scoped hydrate failed', {
218
+ getContext().logger.debug('[BaseSyncedStore] scoped hydrate failed', {
219
219
  syncGroups: need,
220
220
  error: err instanceof Error ? err.message : String(err),
221
221
  });
@@ -684,7 +684,7 @@ export class BaseSyncedStore {
684
684
  }
685
685
  }
686
686
  catch (fallbackError) {
687
- getContext().logger.warn('[BaseSyncedStore] Local fallback failed', {
687
+ getContext().logger.debug('[BaseSyncedStore] Local fallback failed', {
688
688
  error: fallbackError.message,
689
689
  });
690
690
  }
@@ -752,7 +752,7 @@ export class BaseSyncedStore {
752
752
  "(e.g. new URL('/api/ablo-session', process.env.NEXT_PUBLIC_APP_URL)).", { error: message });
753
753
  }
754
754
  else {
755
- getContext().logger.warn('access-credential re-mint failed (transient)', { error: message });
755
+ getContext().logger.debug('access-credential re-mint failed (transient)', { error: message });
756
756
  }
757
757
  return 'network_error';
758
758
  }
@@ -951,7 +951,7 @@ export class BaseSyncedStore {
951
951
  const rawObj = (raw ?? {});
952
952
  const groupKey = typeof rawObj.group === 'string' ? rawObj.group : undefined;
953
953
  if (!groupKey) {
954
- getContext().logger.warn('[BaseSyncedStore] Group removed delta missing group key', {
954
+ getContext().logger.debug('[BaseSyncedStore] Group removed delta missing group key', {
955
955
  syncId: delta.id,
956
956
  });
957
957
  return;
@@ -1040,7 +1040,7 @@ export class BaseSyncedStore {
1040
1040
  });
1041
1041
  }
1042
1042
  catch (error) {
1043
- getContext().logger.warn('[BaseSyncedStore] Failed to check sync group shrinkage', {
1043
+ getContext().logger.debug('[BaseSyncedStore] Failed to check sync group shrinkage', {
1044
1044
  error: error instanceof Error ? error.message : String(error),
1045
1045
  });
1046
1046
  }
@@ -1102,7 +1102,7 @@ export class BaseSyncedStore {
1102
1102
  hasLocalData = this.objectPool.size > 0;
1103
1103
  }
1104
1104
  catch (hydrateError) {
1105
- getContext().logger.warn('[sync-engine] IDB hydration failed', { error: hydrateError });
1105
+ getContext().logger.debug('[sync-engine] IDB hydration failed', { error: hydrateError });
1106
1106
  getContext().observability.captureBootstrapFailure(hydrateError, { type: 'hydration-from-idb' });
1107
1107
  }
1108
1108
  // Get sync baseline for WebSocket
@@ -1221,7 +1221,7 @@ export class BaseSyncedStore {
1221
1221
  this.updateSyncStatus({ state: 'idle', progress: 100 });
1222
1222
  }
1223
1223
  catch (error) {
1224
- getContext().logger.warn('[sync-engine] Background bootstrap failed', {
1224
+ getContext().logger.debug('[sync-engine] Background bootstrap failed', {
1225
1225
  error: error instanceof Error ? error.message : String(error),
1226
1226
  cause: error,
1227
1227
  });
@@ -1418,7 +1418,7 @@ export class BaseSyncedStore {
1418
1418
  return;
1419
1419
  resolved = true;
1420
1420
  unsubscribe();
1421
- getContext().logger.warn(`[BaseSyncedStore] waitForWebSocketConnected timed out after ${timeoutMs}ms — initialize() will return but the next mutation may race the upgrade.`);
1421
+ getContext().logger.debug(`[BaseSyncedStore] waitForWebSocketConnected timed out after ${timeoutMs}ms — initialize() will return but the next mutation may race the upgrade.`);
1422
1422
  resolve(false);
1423
1423
  }, timeoutMs);
1424
1424
  });
@@ -1519,7 +1519,9 @@ export class BaseSyncedStore {
1519
1519
  // SECURITY: Clear IndexedDB data on session expiry.
1520
1520
  // When auth is revoked, locally cached data must not persist on disk.
1521
1521
  this.database.clear().catch((clearErr) => {
1522
- getContext().logger.error('[BaseSyncedStore] Failed to clear database on session error', clearErr);
1522
+ // consumer register: session ended, but cached data may remain on disk
1523
+ getContext().logger.error('Your session ended, but some locally cached data could not be cleared from this device.');
1524
+ getContext().logger.debug('[BaseSyncedStore] Failed to clear database on session error', clearErr);
1523
1525
  });
1524
1526
  this.objectPool.clear();
1525
1527
  });
@@ -1533,7 +1535,9 @@ export class BaseSyncedStore {
1533
1535
  this.updateSyncStatus({ state: 'offline', offlineSince: new Date() });
1534
1536
  });
1535
1537
  const onReconnectFailed = this.syncWebSocket.subscribe('reconnect_failed', ({ attempts }) => {
1536
- getContext().logger.warn('[BaseSyncedStore] WebSocket reconnection gave up', { attempts });
1538
+ // consumer register: reconnection exhausted the app is now offline
1539
+ getContext().logger.warn('Lost connection to the sync service and could not reconnect. Your app is now offline; changes will sync once the connection is restored.');
1540
+ getContext().logger.debug('[BaseSyncedStore] WebSocket reconnection gave up', { attempts });
1537
1541
  this.updateSyncStatus({ state: 'reconnecting' });
1538
1542
  });
1539
1543
  this.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onPresenceUpdate, onError, onSessionError, onHandshakeFailed, onReconnectFailed, () => { this.areaOfInterest?.dispose(); this.areaOfInterest = null; });
@@ -2126,7 +2130,7 @@ export class BaseSyncedStore {
2126
2130
  modelId: 'batch',
2127
2131
  error: error instanceof Error ? error : new Error(String(error)),
2128
2132
  });
2129
- getContext().logger.error('[BaseSyncedStore] Delta flush error', {
2133
+ getContext().logger.debug('[BaseSyncedStore] Delta flush error', {
2130
2134
  error: error instanceof Error ? error.message : String(error),
2131
2135
  });
2132
2136
  };
package/dist/Database.js CHANGED
@@ -436,7 +436,7 @@ export class Database {
436
436
  }
437
437
  const store = this.getStore(modelName, 'bootstrap');
438
438
  if (!store) {
439
- getContext().logger.warn(`[Bootstrap] NO IDB STORE for ${modelName} — ${modelData.length} items DROPPED`);
439
+ getContext().logger.debug(`[Bootstrap] NO IDB STORE for ${modelName} — ${modelData.length} items DROPPED`);
440
440
  continue;
441
441
  }
442
442
  let writeErrors = 0;
@@ -1023,7 +1023,7 @@ export class Database {
1023
1023
  // `DataError`, `AbortError`) so we can find what's wrong with
1024
1024
  // the `compacted` payload shape or store schema.
1025
1025
  const idbErr = err instanceof Error ? err : new Error(String(err));
1026
- getContext().logger.warn('[Database.processDeltaBatch] store tx FAILED', {
1026
+ getContext().logger.debug('[Database.processDeltaBatch] store tx FAILED', {
1027
1027
  modelName,
1028
1028
  storeDeltasCount: storeDeltas.length,
1029
1029
  errorName: idbErr.name,
@@ -1071,7 +1071,7 @@ export class Database {
1071
1071
  // persisted" signal loud when it actually happens. If this fires
1072
1072
  // repeatedly on the same sync IDs, a specific row is un-writable
1073
1073
  // (validation? compact issue?) and needs fixing at that layer.
1074
- getContext().logger.warn('[Database.processDeltaBatch] cursor withheld due to failed store tx', {
1074
+ getContext().logger.debug('[Database.processDeltaBatch] cursor withheld due to failed store tx', {
1075
1075
  seen: highestSyncId,
1076
1076
  persisted: highestPersistedSyncId,
1077
1077
  gap: highestSyncId - highestPersistedSyncId,
@@ -232,7 +232,9 @@ export class ObjectPool {
232
232
  // Warn about suspicious patterns
233
233
  if (deltaInfo.action === 'I' &&
234
234
  (history.lastAction === 'U' || history.lastAction === 'D')) {
235
- getContext().logger.warn(`ObjectPool.add() SUSPICIOUS: INSERT after ${history.lastAction}`, { modelType, id, syncId: deltaInfo.syncId });
235
+ // Internal delta-ordering anomaly that reconciles on the next
236
+ // catch-up — forensic, not consumer-actionable → debug.
237
+ getContext().logger.debug(`ObjectPool.add() SUSPICIOUS: INSERT after ${history.lastAction}`, { modelType, id, syncId: deltaInfo.syncId });
236
238
  }
237
239
  }
238
240
  // Update delta history
@@ -609,7 +611,9 @@ export class ObjectPool {
609
611
  const Constructor = ModelClass || this.registry.getModelByName(modelName);
610
612
  if (!Constructor) {
611
613
  if (!ModelClass && modelName === 'Unknown') {
612
- getContext().logger.warn('ObjectPool.createFromData: No model identifier found', { data });
614
+ // Malformed row with no type marker — dropped, but nothing the consumer
615
+ // can act on (the actionable schema-drift case is handled below) → debug.
616
+ getContext().logger.debug('ObjectPool.createFromData: No model identifier found', { data });
613
617
  getContext().modelDebugLogger?.logError('Unknown', 'CREATE', 'No model identifier found', data);
614
618
  return null;
615
619
  }
@@ -620,7 +624,11 @@ export class ObjectPool {
620
624
  `. The schema pushed to this org may differ from your local ` +
621
625
  `schema — run \`ablo status\` to compare.`, { code: 'model_not_registered' });
622
626
  }
623
- getContext().logger.warn(`ObjectPool.createFromData: No constructor found for model "${modelName}"`, { data });
627
+ // Genuinely actionable and NOT self-healing: a model the server is sending
628
+ // isn't in your schema, so these rows are silently skipped. Keep at warn,
629
+ // consumer register (their model name + the `ablo status` fix); forensics ride debug.
630
+ getContext().logger.warn(`Received data for "${modelName}", which isn't in your schema — these rows will be skipped. Run \`ablo status\` to compare your local schema with the server.`);
631
+ getContext().logger.debug(`ObjectPool.createFromData: No constructor found for model "${modelName}"`, { data });
624
632
  getContext().modelDebugLogger?.logError(modelName, 'CREATE', `No constructor found for model "${modelName}"`, data);
625
633
  return null;
626
634
  }
@@ -646,7 +654,9 @@ export class ObjectPool {
646
654
  }
647
655
  catch (error) {
648
656
  const errorMessage = error instanceof Error ? error.message : String(error);
649
- getContext().logger.warn(`[ObjectPool.createFromData] FAILED ${modelName}`, { errorMessage, stack: error instanceof Error ? error.stack : undefined });
657
+ // Internal construction failure captured via observability below and
658
+ // re-fetched on resync; the stack is forensic → debug.
659
+ getContext().logger.debug(`[ObjectPool.createFromData] FAILED ${modelName}`, { errorMessage, stack: error instanceof Error ? error.stack : undefined });
650
660
  getContext().observability.captureTransactionFailure({
651
661
  context: 'createFromData',
652
662
  modelName,
@@ -210,7 +210,13 @@ export class SyncClient extends EventEmitter {
210
210
  // cascade), don't re-add it — Object.assign cannot restore the private
211
211
  // isDisposed flag, so the model would be added in a broken state.
212
212
  if (model.disposed) {
213
- getContext().logger.warn('[SyncClient] Skipping rollback restore for disposed model', {
213
+ // Follow-on of an already-logged permanent error, not its own
214
+ // problem: the tx that failed has already surfaced the cause in
215
+ // TransactionQueue. Restoring a disposed model is a no-op by
216
+ // design (can't revive the private isDisposed flag), so keep this
217
+ // at `debug` instead of emitting a second `warn` that reads as a
218
+ // distinct failure in the console.
219
+ getContext().logger.debug('[SyncClient] Rollback skipped restore (model already disposed)', {
214
220
  modelId: transaction.modelId,
215
221
  modelName: transaction.modelName,
216
222
  reason,
@@ -1007,8 +1013,14 @@ export class SyncClient extends EventEmitter {
1007
1013
  // this class of misconfiguration surfaces in dev instead of
1008
1014
  // manifesting as "my drag doesn't save."
1009
1015
  if (!this.userId || !this.organizationId) {
1010
- getContext().logger.warn('[sync] mutations dropped SyncClient has no identity. ' +
1011
- 'Did the store call `syncClient.initialize(userId, orgId)`?', {
1016
+ // Internal invariant, not a consumer-actionable error: identity (user +
1017
+ // org) hasn't arrived yet. The mutations stay queued and retry once it
1018
+ // does, so this is `debug` — a transient startup race is normal. If it
1019
+ // never clears it means the host app finished sign-in without seeding
1020
+ // identity, which surfaces downstream as "writes never confirm"; we do
1021
+ // NOT name internal wiring (`SyncClient.initialize`) here because that
1022
+ // method isn't part of the @abloatai/ablo surface a reader could act on.
1023
+ getContext().logger.debug('[sync] writes waiting for identity (user/org not set yet) — queued, will retry', {
1012
1024
  pending: this.pendingMutations.length,
1013
1025
  userId: this.userId,
1014
1026
  organizationId: this.organizationId,
@@ -1601,7 +1613,9 @@ export class SyncClient extends EventEmitter {
1601
1613
  // re-fetch and re-apply. Logged here so silent IDB failures
1602
1614
  // are observable instead of disappearing into a default switch
1603
1615
  // fall-through.
1604
- getContext().logger.warn('[SyncClient.applyDeltaBatchToPool] skipping pool op for unpersisted delta', {
1616
+ // Self-healing: the next catch-up poll / reconnect re-fetches and
1617
+ // re-applies this delta, so it's forensic, not consumer-actionable → debug.
1618
+ getContext().logger.debug('[SyncClient.applyDeltaBatchToPool] skipping pool op for unpersisted delta', {
1605
1619
  modelName,
1606
1620
  modelId: modelId.slice(0, 12),
1607
1621
  });
@@ -145,7 +145,9 @@ export class Agent {
145
145
  await this.opts.announcer.announce(status, activity);
146
146
  }
147
147
  catch (err) {
148
- this.opts.logger.warn('[perception] announcer error', {
148
+ // Best-effort presence; failing only means peers don't see this
149
+ // agent's status. Not consumer-actionable → maintainer register.
150
+ this.opts.logger.debug('[perception] announcer error', {
149
151
  error: err.message,
150
152
  });
151
153
  }
@@ -160,11 +162,11 @@ export class Agent {
160
162
  syncGroups: this.opts.syncGroups,
161
163
  });
162
164
  if (!res.ok) {
163
- this.opts.logger.warn(`[perception] announce failed: ${res.status} ${res.statusText}`);
165
+ this.opts.logger.debug(`[perception] announce failed: ${res.status} ${res.statusText}`);
164
166
  }
165
167
  }
166
168
  catch (err) {
167
- this.opts.logger.warn('[perception] announce error', {
169
+ this.opts.logger.debug('[perception] announce error', {
168
170
  error: err.message,
169
171
  });
170
172
  }
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { Ablo } from '../client/Ablo.js';
23
23
  import { AbloConnectionError } from '../errors.js';
24
+ import { getContext } from '../context.js';
24
25
  /**
25
26
  * Returns a session whose `getAgent` method handles cache, mint,
26
27
  * sync_groups alignment, and lifecycle. Call `disposeAll()` from
@@ -100,12 +101,14 @@ export function createAgentSession(options) {
100
101
  await agent.dispose();
101
102
  }
102
103
  catch { /* ignore */ }
103
- // Use console.error directly (rather than the engine logger)
104
- // because this path may run before the per-agent logger is
105
- // attached. The structured fields match the cap-mint logger in
106
- // `connectAgent.ts` so a single search picks both up.
107
- // eslint-disable-next-line no-console
108
- console.error('[Agent.session] ws bootstrap failed', {
104
+ // Route through the gated logger so this obeys ABLO_LOG_LEVEL like every
105
+ // other line: a plain consumer-register `error` headline (the unreachable
106
+ // URL + code), with the structured fields on a `debug` companion. The
107
+ // companion's shape matches the cap-mint logger in `connectAgent.ts` so a
108
+ // single search picks both up.
109
+ const log = getContext().logger;
110
+ log.error(`Agent could not connect to the sync server at ${wsUrl}${code ? ` (${code})` : ''}.`);
111
+ log.debug('[Agent.session] ws bootstrap failed', {
109
112
  url: wsUrl,
110
113
  surfaceClass: identity.surfaceClass,
111
114
  orgId: identity.organizationId,
@@ -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,7 @@
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 { AbloError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, AbloNotFoundError, translateHttpError, toAbloError, claimedError } from '../errors.js';
24
24
  import { descriptionFromMeta } from '../coordination/schema.js';
25
25
  import { LoadStrategy, PropertyType } from '../types/index.js';
26
26
  import { initSyncEngine } from '../context.js';
@@ -1009,11 +1009,13 @@ export function Ablo(options) {
1009
1009
  if (participantKind === 'user' &&
1010
1010
  (resolvedSyncGroups.length === 0 ||
1011
1011
  (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 });
1012
+ // Actionable and NOT self-healing (no live updates until fixed):
1013
+ // kept at warn, consumer register engine jargon and the internal
1014
+ // file pointer stripped; forensic fields ride the debug companion.
1015
+ logger.warn('This client was started without sync groups, so it will not receive ' +
1016
+ 'live updates. Pass `syncGroups` (for example ' +
1017
+ '`["org:<id>", "user:<id>"]`) or check that your auth provider supplies them.');
1018
+ logger.debug('degenerate syncGroups — details', { participantKind, resolvedSyncGroups });
1017
1019
  }
1018
1020
  _resolvedOrganizationId = accountScope;
1019
1021
  if (resolved.refreshScheduler) {
@@ -1577,13 +1579,15 @@ export function Ablo(options) {
1577
1579
  async create(params) {
1578
1580
  const id = params.id ?? createModelId();
1579
1581
  await applyClaimedPolicy({ model: name, id }, params);
1580
- return commits.create({
1582
+ // Confirm, then return the authoritative row (with framework defaults;
1583
+ // the EXISTING row on an idempotent re-create) — mirrors the WS client.
1584
+ await commits.create({
1581
1585
  claimRef: params.claimRef,
1582
1586
  idempotencyKey: params.idempotencyKey,
1583
1587
  readAt: params.readAt,
1584
1588
  onStale: params.onStale,
1585
1589
  ...(isClaimHandleValue(params.claim) ? { claim: params.claim } : {}),
1586
- wait: params.wait,
1590
+ wait: params.wait ?? 'confirmed',
1587
1591
  operations: [
1588
1592
  {
1589
1593
  action: 'create',
@@ -1593,6 +1597,11 @@ export function Ablo(options) {
1593
1597
  },
1594
1598
  ],
1595
1599
  });
1600
+ const { data } = await retrieveModel(name, id, {});
1601
+ if (data === undefined) {
1602
+ throw new AbloNotFoundError(`create ${name}/${id} did not yield a readable row (the write did not confirm).`, [id]);
1603
+ }
1604
+ return data;
1596
1605
  },
1597
1606
  update: updateModel,
1598
1607
  async delete(params) {
@@ -1764,7 +1773,8 @@ export function Ablo(options) {
1764
1773
  await store.disconnect();
1765
1774
  }
1766
1775
  catch (err) {
1767
- logger.warn('Error during sync engine disposal', { error: err.message });
1776
+ // Best-effort teardown a disposal hiccup isn't consumer-actionable debug.
1777
+ logger.debug('Error during sync engine disposal', { error: err.message });
1768
1778
  }
1769
1779
  presenceStream.dispose();
1770
1780
  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
@@ -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
  };
@@ -388,7 +388,7 @@ export class BootstrapHelper {
388
388
  });
389
389
  }
390
390
  catch (error) {
391
- getContext().logger.warn('Failed to clear cache', { error });
391
+ getContext().logger.debug('Failed to clear cache', { error });
392
392
  }
393
393
  }
394
394
  // Cache helpers for offline bootstrap
@@ -402,7 +402,7 @@ export class BootstrapHelper {
402
402
  localStorage.setItem(this.getBootstrapCacheKey(orgId), JSON.stringify(data));
403
403
  }
404
404
  catch (e) {
405
- getContext().logger.warn('Failed to cache bootstrap payload', {
405
+ getContext().logger.debug('Failed to cache bootstrap payload', {
406
406
  error: e instanceof Error ? e.message : String(e),
407
407
  });
408
408
  }
@@ -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,
@@ -601,7 +601,9 @@ export class TransactionQueue extends EventEmitter {
601
601
  }
602
602
  if (inFlight.length === 0)
603
603
  return;
604
- getContext().logger.warn(`[TransactionQueue] WS disconnected > ${graceMs}ms; failing ${inFlight.length} in-flight commit(s) with AbloConnectionError`, { inFlightIds: inFlight.map((id) => id.slice(0, 8)) });
604
+ // Internal mechanic: each failed commit surfaces to the consumer via its own
605
+ // rejection path, so this aggregate is forensic — debug, not warn.
606
+ getContext().logger.debug(`[TransactionQueue] WS disconnected > ${graceMs}ms; failing ${inFlight.length} in-flight commit(s) with AbloConnectionError`, { inFlightIds: inFlight.map((id) => id.slice(0, 8)) });
605
607
  for (const id of inFlight) {
606
608
  const tx = this.commitStore.get(id);
607
609
  if (!tx)
@@ -1429,7 +1431,9 @@ export class TransactionQueue extends EventEmitter {
1429
1431
  }
1430
1432
  // If disconnected, re-schedule with same timeout (no backoff while offline)
1431
1433
  if (!this.isConnectedFn()) {
1432
- getContext().logger.warn('[TransactionQueue] Timeout fired while disconnected - re-scheduling', {
1434
+ // Self-healing: re-schedule the confirmation wait while offline, no
1435
+ // consumer action needed → debug.
1436
+ getContext().logger.debug('[TransactionQueue] Timeout fired while disconnected - re-scheduling', {
1433
1437
  txId: tx.id.slice(0, 8),
1434
1438
  model: tx.modelName,
1435
1439
  });
@@ -1471,7 +1475,9 @@ export class TransactionQueue extends EventEmitter {
1471
1475
  lastSeenSyncId: this.lastSeenSyncId,
1472
1476
  retryCount: retryCount + 1,
1473
1477
  });
1474
- getContext().logger.warn('[TransactionQueue] Re-scheduling with backoff', {
1478
+ // Self-healing retry with backoff — the server already committed; we're
1479
+ // just waiting on the delta. No consumer action → debug.
1480
+ getContext().logger.debug('[TransactionQueue] Re-scheduling with backoff', {
1475
1481
  txId: tx.id.slice(0, 8),
1476
1482
  model: tx.modelName,
1477
1483
  nextTimeoutMs: nextTimeout,
@@ -1636,7 +1642,9 @@ export class TransactionQueue extends EventEmitter {
1636
1642
  tx.status = 'failed';
1637
1643
  tx.error = error;
1638
1644
  this.commitLane.shift();
1639
- getContext().logger.warn('[TransactionQueue] commit lane permanent error', {
1645
+ // Internal bookkeeping the consumer-facing rejection is emitted via
1646
+ // 'transaction:failed' and surfaced by the permanent-error headline → debug.
1647
+ getContext().logger.debug('[TransactionQueue] commit lane permanent error', {
1640
1648
  txId: tx.id.slice(0, 12),
1641
1649
  attempts: tx.attempts,
1642
1650
  message: error.message,
@@ -1808,14 +1816,36 @@ export class TransactionQueue extends EventEmitter {
1808
1816
  const isRepeat = sig === this.lastPermanentErrorSig;
1809
1817
  this.lastPermanentErrorSig = sig;
1810
1818
  const logger = getContext().logger;
1819
+ // Two registers, one call site, split by log level (the default
1820
+ // consumer logger is gated at `warn`, so `debug` is invisible unless
1821
+ // someone sets ABLO_LOG_LEVEL=debug to debug the engine itself):
1822
+ // • the default-visible line speaks the APP DEVELOPER's language —
1823
+ // their verb (`update`), their model, the typed error's own human
1824
+ // message, and the wire `code` for grep — the way AI SDK / Next.js
1825
+ // surface errors. No engine nouns ("TransactionQueue", "permanent",
1826
+ // "rolling back") and no JSON dump on this line: those alarm and
1827
+ // don't help someone who just installed @abloatai/ablo.
1828
+ // • the forensic `details` ride a companion `debug` line for whoever
1829
+ // is debugging the engine internals.
1830
+ const revertNote = this.config.enableOptimistic
1831
+ ? ' The local change was reverted.'
1832
+ : '';
1833
+ const reason = abloErr?.message ? ` — ${abloErr.message}` : '';
1834
+ const code = abloErr?.code ? ` (code: ${abloErr.code})` : '';
1835
+ const headline = `Your ${transaction.type} to "${transaction.modelName}" was not saved${reason}${code}.${revertNote}`;
1811
1836
  if (isRepeat) {
1812
- logger.debug('[TransactionQueue] Permanent error - rolling back (repeat)', details);
1837
+ // Same write rejected for the same reason on each reconnect replay —
1838
+ // log the forensics once, stay quiet after.
1839
+ logger.debug('write rejected again (same reason)', details);
1813
1840
  }
1814
1841
  else if (isBenignIdempotent) {
1815
- logger.info('[TransactionQueue] Write skipped row already exists', details);
1842
+ // Already-exists on a `create` is expected on replay, not a problem.
1843
+ logger.info(`Your ${transaction.type} to "${transaction.modelName}" was skipped — this row already exists.`);
1844
+ logger.debug('idempotent skip — details', details);
1816
1845
  }
1817
1846
  else {
1818
- logger.warn('[TransactionQueue] Permanent error - rolling back', details);
1847
+ logger.warn(headline);
1848
+ logger.debug('write rejection — details', details);
1819
1849
  }
1820
1850
  }
1821
1851
  catch { }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",