@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 +42 -0
- package/dist/BaseSyncedStore.js +15 -11
- package/dist/Database.js +3 -3
- package/dist/ObjectPool.js +14 -4
- package/dist/SyncClient.js +18 -4
- package/dist/agent/Agent.js +5 -3
- package/dist/agent/session.js +9 -6
- package/dist/client/Ablo.d.ts +14 -2
- package/dist/client/Ablo.js +19 -9
- package/dist/client/ApiClient.js +28 -6
- package/dist/client/createModelProxy.js +6 -1
- package/dist/client/httpClient.d.ts +6 -1
- package/dist/client/identity.js +1 -1
- package/dist/core/DatabaseManager.js +1 -1
- package/dist/core/StoreManager.js +5 -2
- package/dist/mutators/UndoManager.js +7 -6
- package/dist/query/client.js +19 -5
- package/dist/react/useMutators.js +6 -2
- package/dist/sync/BootstrapHelper.js +2 -2
- package/dist/sync/ConnectionManager.js +7 -7
- package/dist/sync/NetworkProbe.js +2 -2
- package/dist/sync/SyncWebSocket.js +2 -2
- package/dist/transactions/TransactionQueue.js +37 -7
- package/package.json +1 -1
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
|
package/dist/BaseSyncedStore.js
CHANGED
|
@@ -215,7 +215,7 @@ export class BaseSyncedStore {
|
|
|
215
215
|
this.hydratedGroups.add(g);
|
|
216
216
|
}
|
|
217
217
|
catch (err) {
|
|
218
|
-
getContext().logger.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
package/dist/ObjectPool.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/SyncClient.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/agent/Agent.js
CHANGED
|
@@ -145,7 +145,9 @@ export class Agent {
|
|
|
145
145
|
await this.opts.announcer.announce(status, activity);
|
|
146
146
|
}
|
|
147
147
|
catch (err) {
|
|
148
|
-
|
|
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.
|
|
165
|
+
this.opts.logger.debug(`[perception] announce failed: ${res.status} ${res.statusText}`);
|
|
164
166
|
}
|
|
165
167
|
}
|
|
166
168
|
catch (err) {
|
|
167
|
-
this.opts.logger.
|
|
169
|
+
this.opts.logger.debug('[perception] announce error', {
|
|
168
170
|
error: err.message,
|
|
169
171
|
});
|
|
170
172
|
}
|
package/dist/agent/session.js
CHANGED
|
@@ -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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// `connectAgent.ts` so a
|
|
107
|
-
//
|
|
108
|
-
|
|
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,
|
package/dist/client/Ablo.d.ts
CHANGED
|
@@ -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
|
-
|
|
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<
|
|
590
|
+
}): Promise<T>;
|
|
579
591
|
update(params: ModelMutationOptions & {
|
|
580
592
|
readonly id: string;
|
|
581
593
|
readonly data: Record<string, unknown>;
|
package/dist/client/Ablo.js
CHANGED
|
@@ -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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
'
|
|
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
|
|
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
|
-
|
|
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();
|
package/dist/client/ApiClient.js
CHANGED
|
@@ -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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/client/identity.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
}
|
package/dist/query/client.js
CHANGED
|
@@ -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.
|
|
68
|
-
//
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1847
|
+
logger.warn(headline);
|
|
1848
|
+
logger.debug('write rejection — details', details);
|
|
1819
1849
|
}
|
|
1820
1850
|
}
|
|
1821
1851
|
catch { }
|