@abloatai/ablo 0.22.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -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/ai-sdk/coordinated-tool.d.ts +101 -0
- package/dist/ai-sdk/coordinated-tool.js +129 -0
- package/dist/ai-sdk/index.d.ts +42 -0
- package/dist/ai-sdk/index.js +42 -0
- package/dist/cli.cjs +687 -498
- package/dist/client/Ablo.d.ts +14 -2
- package/dist/client/Ablo.js +23 -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/interfaces/index.d.ts +8 -0
- 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.d.ts +16 -0
- package/dist/sync/BootstrapHelper.js +29 -2
- package/dist/sync/ConnectionManager.js +7 -7
- package/dist/sync/NetworkProbe.js +2 -2
- package/dist/sync/SyncWebSocket.js +2 -2
- package/dist/sync/schemas.d.ts +1 -0
- package/dist/sync/schemas.js +3 -0
- package/dist/transactions/TransactionQueue.js +37 -7
- package/package.json +1 -1
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,8 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { z } from 'zod';
|
|
22
22
|
import { baseFieldsSchema } from '../schema/schema.js';
|
|
23
|
-
import {
|
|
23
|
+
import { schemaHash } from '../schema/serialize.js';
|
|
24
|
+
import { AbloError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, AbloNotFoundError, translateHttpError, toAbloError, claimedError } from '../errors.js';
|
|
24
25
|
import { descriptionFromMeta } from '../coordination/schema.js';
|
|
25
26
|
import { LoadStrategy, PropertyType } from '../types/index.js';
|
|
26
27
|
import { initSyncEngine } from '../context.js';
|
|
@@ -222,6 +223,9 @@ function deriveConfigFromSchema(schema) {
|
|
|
222
223
|
defaultNonCreatePriority: 50,
|
|
223
224
|
essentialFields: {},
|
|
224
225
|
classNameFallbackMap: {},
|
|
226
|
+
// Hash this client's schema once so bootstrap can detect drift against the
|
|
227
|
+
// server's active hash (same `schemaHash` the CLI push + server compute).
|
|
228
|
+
expectedSchemaHash: schemaHash(schema),
|
|
225
229
|
};
|
|
226
230
|
}
|
|
227
231
|
// ── Auto model registration from schema ───────────────────────────────────
|
|
@@ -1009,11 +1013,13 @@ export function Ablo(options) {
|
|
|
1009
1013
|
if (participantKind === 'user' &&
|
|
1010
1014
|
(resolvedSyncGroups.length === 0 ||
|
|
1011
1015
|
(resolvedSyncGroups.length === 1 && resolvedSyncGroups[0] === 'default'))) {
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
'
|
|
1016
|
+
// Actionable and NOT self-healing (no live updates until fixed):
|
|
1017
|
+
// kept at warn, consumer register — engine jargon and the internal
|
|
1018
|
+
// file pointer stripped; forensic fields ride the debug companion.
|
|
1019
|
+
logger.warn('This client was started without sync groups, so it will not receive ' +
|
|
1020
|
+
'live updates. Pass `syncGroups` (for example ' +
|
|
1021
|
+
'`["org:<id>", "user:<id>"]`) or check that your auth provider supplies them.');
|
|
1022
|
+
logger.debug('degenerate syncGroups — details', { participantKind, resolvedSyncGroups });
|
|
1017
1023
|
}
|
|
1018
1024
|
_resolvedOrganizationId = accountScope;
|
|
1019
1025
|
if (resolved.refreshScheduler) {
|
|
@@ -1577,13 +1583,15 @@ export function Ablo(options) {
|
|
|
1577
1583
|
async create(params) {
|
|
1578
1584
|
const id = params.id ?? createModelId();
|
|
1579
1585
|
await applyClaimedPolicy({ model: name, id }, params);
|
|
1580
|
-
return
|
|
1586
|
+
// Confirm, then return the authoritative row (with framework defaults;
|
|
1587
|
+
// the EXISTING row on an idempotent re-create) — mirrors the WS client.
|
|
1588
|
+
await commits.create({
|
|
1581
1589
|
claimRef: params.claimRef,
|
|
1582
1590
|
idempotencyKey: params.idempotencyKey,
|
|
1583
1591
|
readAt: params.readAt,
|
|
1584
1592
|
onStale: params.onStale,
|
|
1585
1593
|
...(isClaimHandleValue(params.claim) ? { claim: params.claim } : {}),
|
|
1586
|
-
wait: params.wait,
|
|
1594
|
+
wait: params.wait ?? 'confirmed',
|
|
1587
1595
|
operations: [
|
|
1588
1596
|
{
|
|
1589
1597
|
action: 'create',
|
|
@@ -1593,6 +1601,11 @@ export function Ablo(options) {
|
|
|
1593
1601
|
},
|
|
1594
1602
|
],
|
|
1595
1603
|
});
|
|
1604
|
+
const { data } = await retrieveModel(name, id, {});
|
|
1605
|
+
if (data === undefined) {
|
|
1606
|
+
throw new AbloNotFoundError(`create ${name}/${id} did not yield a readable row (the write did not confirm).`, [id]);
|
|
1607
|
+
}
|
|
1608
|
+
return data;
|
|
1596
1609
|
},
|
|
1597
1610
|
update: updateModel,
|
|
1598
1611
|
async delete(params) {
|
|
@@ -1764,7 +1777,8 @@ export function Ablo(options) {
|
|
|
1764
1777
|
await store.disconnect();
|
|
1765
1778
|
}
|
|
1766
1779
|
catch (err) {
|
|
1767
|
-
|
|
1780
|
+
// Best-effort teardown — a disposal hiccup isn't consumer-actionable → debug.
|
|
1781
|
+
logger.debug('Error during sync engine disposal', { error: err.message });
|
|
1768
1782
|
}
|
|
1769
1783
|
presenceStream.dispose();
|
|
1770
1784
|
claimStream.dispose();
|
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
|
|
@@ -380,6 +380,14 @@ export interface SyncEngineConfig {
|
|
|
380
380
|
* e.g., { TaskModel: 'Task', ProjectModel: 'Project' }
|
|
381
381
|
*/
|
|
382
382
|
classNameFallbackMap: Readonly<Record<string, string>>;
|
|
383
|
+
/**
|
|
384
|
+
* Content hash of the schema THIS client was built against (the same
|
|
385
|
+
* `schemaHash()` the CLI push + server compute). Used purely to detect
|
|
386
|
+
* schema drift: when the server reports a different active hash on bootstrap,
|
|
387
|
+
* the SDK warns the developer to run `ablo push` — otherwise drift only
|
|
388
|
+
* surfaces later as an opaque DB constraint error. Advisory, not enforced.
|
|
389
|
+
*/
|
|
390
|
+
expectedSchemaHash?: string;
|
|
383
391
|
}
|
|
384
392
|
/**
|
|
385
393
|
* Allows consumers to extend the WebSocket event map with
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* automatically invalidate the undo stack. Consumers should `clear()`
|
|
18
18
|
* the scope on sync error if they want strict correctness.
|
|
19
19
|
*/
|
|
20
|
+
import { getContext } from '../context.js';
|
|
20
21
|
import { createTransaction } from './Transaction.js';
|
|
21
22
|
import { parseUndoEntry } from './inverseOp.js';
|
|
22
23
|
import { resolveOps, DEFAULT_UNDO_CONFLICT_POLICY, } from './undoApply.js';
|
|
@@ -384,9 +385,9 @@ export class UndoScope {
|
|
|
384
385
|
}
|
|
385
386
|
catch (err) {
|
|
386
387
|
// A faulty observer must never break the editor's recording path.
|
|
387
|
-
|
|
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
|
};
|
|
@@ -18,6 +18,12 @@ export interface BootstrapData {
|
|
|
18
18
|
/** Model types whose server-side query failed (timeout, RLS error, etc.) */
|
|
19
19
|
failedModels?: string[];
|
|
20
20
|
timestamp: number;
|
|
21
|
+
/**
|
|
22
|
+
* The server's ACTIVE schema content hash for this tenant (same `schemaHash`
|
|
23
|
+
* the CLI push computes). Present once the tenant has pushed a schema; the
|
|
24
|
+
* client compares it to its own `config.expectedSchemaHash` to warn on drift.
|
|
25
|
+
*/
|
|
26
|
+
schemaHash?: string;
|
|
21
27
|
}
|
|
22
28
|
export interface BootstrapFetchResult {
|
|
23
29
|
notModified: boolean;
|
|
@@ -72,7 +78,17 @@ import { type ValidatedServerDelta } from './schemas.js';
|
|
|
72
78
|
export declare class BootstrapHelper {
|
|
73
79
|
private options;
|
|
74
80
|
private abortController;
|
|
81
|
+
/** Warn about schema drift at most once per helper. */
|
|
82
|
+
private schemaDriftWarned;
|
|
75
83
|
get baseUrl(): string;
|
|
84
|
+
/**
|
|
85
|
+
* Advisory schema-drift check: compare the server's active schema hash (on the
|
|
86
|
+
* bootstrap response) against the hash this client was built with. A mismatch
|
|
87
|
+
* means the app's schema and the deployed schema have diverged — reads/writes
|
|
88
|
+
* relying on undeployed changes will later fail with an opaque DB constraint
|
|
89
|
+
* error. Warn once, actionably; never throws or blocks the bootstrap.
|
|
90
|
+
*/
|
|
91
|
+
private warnOnSchemaDrift;
|
|
76
92
|
constructor(options: BootstrapOptions);
|
|
77
93
|
/**
|
|
78
94
|
* Update the offline-cache namespace once auth has resolved the server-side
|
|
@@ -10,9 +10,34 @@ import { parseBootstrapResponse } from './schemas.js';
|
|
|
10
10
|
export class BootstrapHelper {
|
|
11
11
|
options;
|
|
12
12
|
abortController = null;
|
|
13
|
+
/** Warn about schema drift at most once per helper. */
|
|
14
|
+
schemaDriftWarned = false;
|
|
13
15
|
get baseUrl() {
|
|
14
16
|
return this.options.baseUrl;
|
|
15
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Advisory schema-drift check: compare the server's active schema hash (on the
|
|
20
|
+
* bootstrap response) against the hash this client was built with. A mismatch
|
|
21
|
+
* means the app's schema and the deployed schema have diverged — reads/writes
|
|
22
|
+
* relying on undeployed changes will later fail with an opaque DB constraint
|
|
23
|
+
* error. Warn once, actionably; never throws or blocks the bootstrap.
|
|
24
|
+
*/
|
|
25
|
+
warnOnSchemaDrift(serverHash) {
|
|
26
|
+
if (this.schemaDriftWarned || !serverHash)
|
|
27
|
+
return;
|
|
28
|
+
const clientHash = getContext().config.expectedSchemaHash;
|
|
29
|
+
if (!clientHash || clientHash === serverHash)
|
|
30
|
+
return;
|
|
31
|
+
this.schemaDriftWarned = true;
|
|
32
|
+
// Self-brand the message ("Ablo:") rather than rely on the default logger's
|
|
33
|
+
// `[Ablo]` namespace — consumers wiring their own logger (pino, etc.) lose
|
|
34
|
+
// that prefix, and a drift warning that reads like the app's own log is
|
|
35
|
+
// worse than none. The brand tells them at a glance who is talking.
|
|
36
|
+
getContext().logger.warn(`Ablo: Schema drift detected — this app was built against schema ${clientHash}, ` +
|
|
37
|
+
`but the deployed schema is ${serverHash}. Operations that depend on schema ` +
|
|
38
|
+
`changes not yet deployed will fail later with an opaque database error. Run ` +
|
|
39
|
+
`\`ablo push\` to deploy your schema (or update this app to match the deployed one).`, { clientSchemaHash: clientHash, serverSchemaHash: serverHash });
|
|
40
|
+
}
|
|
16
41
|
constructor(options) {
|
|
17
42
|
// Defaults are spread first; the explicit `baseUrl` then takes precedence
|
|
18
43
|
// and is computed from `options.baseUrl` (or the localhost fallback).
|
|
@@ -254,6 +279,7 @@ export class BootstrapHelper {
|
|
|
254
279
|
}
|
|
255
280
|
const rawJson = await res.json();
|
|
256
281
|
const data = parseBootstrapResponse(rawJson);
|
|
282
|
+
this.warnOnSchemaDrift(data.schemaHash);
|
|
257
283
|
// Persist payload for offline
|
|
258
284
|
try {
|
|
259
285
|
if (this.options.cacheScope) {
|
|
@@ -326,6 +352,7 @@ export class BootstrapHelper {
|
|
|
326
352
|
}
|
|
327
353
|
const rawJson = await response.json();
|
|
328
354
|
const data = parseBootstrapResponse(rawJson);
|
|
355
|
+
this.warnOnSchemaDrift(data.schemaHash);
|
|
329
356
|
// Save a copy for offline
|
|
330
357
|
try {
|
|
331
358
|
if (this.options.cacheScope) {
|
|
@@ -388,7 +415,7 @@ export class BootstrapHelper {
|
|
|
388
415
|
});
|
|
389
416
|
}
|
|
390
417
|
catch (error) {
|
|
391
|
-
getContext().logger.
|
|
418
|
+
getContext().logger.debug('Failed to clear cache', { error });
|
|
392
419
|
}
|
|
393
420
|
}
|
|
394
421
|
// Cache helpers for offline bootstrap
|
|
@@ -402,7 +429,7 @@ export class BootstrapHelper {
|
|
|
402
429
|
localStorage.setItem(this.getBootstrapCacheKey(orgId), JSON.stringify(data));
|
|
403
430
|
}
|
|
404
431
|
catch (e) {
|
|
405
|
-
getContext().logger.
|
|
432
|
+
getContext().logger.debug('Failed to cache bootstrap payload', {
|
|
406
433
|
error: e instanceof Error ? e.message : String(e),
|
|
407
434
|
});
|
|
408
435
|
}
|
|
@@ -141,7 +141,7 @@ export class ConnectionManager {
|
|
|
141
141
|
this.callbacks?.onStateChange?.(nextState, prevState);
|
|
142
142
|
}
|
|
143
143
|
catch (err) {
|
|
144
|
-
getContext().logger.
|
|
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,
|
package/dist/sync/schemas.d.ts
CHANGED
|
@@ -70,6 +70,7 @@ export declare const BootstrapResponseSchema: z.ZodObject<{
|
|
|
70
70
|
deltaCount: z.ZodOptional<z.ZodNumber>;
|
|
71
71
|
failedModels: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
72
72
|
timestamp: z.ZodDefault<z.ZodNumber>;
|
|
73
|
+
schemaHash: z.ZodOptional<z.ZodString>;
|
|
73
74
|
}, z.core.$loose>;
|
|
74
75
|
export type ValidatedBootstrapResponse = z.infer<typeof BootstrapResponseSchema>;
|
|
75
76
|
/**
|
package/dist/sync/schemas.js
CHANGED
|
@@ -54,6 +54,9 @@ export const BootstrapResponseSchema = z
|
|
|
54
54
|
deltaCount: z.number().optional(),
|
|
55
55
|
failedModels: z.array(z.string()).optional(),
|
|
56
56
|
timestamp: z.number().default(() => Date.now()),
|
|
57
|
+
// Server's active schema hash (drift detection). Optional: absent from
|
|
58
|
+
// older servers / tenants that have never pushed a schema.
|
|
59
|
+
schemaHash: z.string().optional(),
|
|
57
60
|
})
|
|
58
61
|
.passthrough();
|
|
59
62
|
// ─── Parse Helpers ───────────────────────────────────────────────────────────
|