@abloatai/humans 0.59.1 → 0.60.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/README.md +1 -1
- package/dist/Ablo.d.ts +2 -10
- package/dist/Ablo.js +0 -1
- package/dist/client.d.ts +1 -48
- package/dist/local/BaseSyncedStore.d.ts +6 -8
- package/dist/local/BaseSyncedStore.js +4 -9
- package/dist/local/Model.js +2 -2
- package/dist/local/SyncClient.d.ts +3 -3
- package/dist/local/SyncClient.js +45 -11
- package/dist/local/client/createModelOperations.d.ts +3 -27
- package/dist/local/client/createModelOperations.js +14 -17
- package/dist/local/client/options.d.ts +14 -39
- package/dist/local/client/reactiveEngine.d.ts +3 -9
- package/dist/local/client/reactiveEngine.js +6 -151
- package/dist/local/client/storeLifecycle.js +5 -1
- package/dist/local/storeContract.d.ts +5 -5
- package/dist/local/sync/credentialLifecycle.d.ts +4 -5
- package/dist/local/sync/credentialLifecycle.js +4 -5
- package/dist/local/sync/deltaPipeline.d.ts +11 -3
- package/dist/local/sync/deltaPipeline.js +27 -80
- package/dist/local/sync/scopeGroups.d.ts +11 -0
- package/dist/local/sync/scopeGroups.js +75 -0
- package/dist/local/sync/wsFrameHandlers.d.ts +1 -1
- package/dist/local/transactions/mutations/failureHandling.js +9 -81
- package/dist/local/transactions/mutations/failureReporting.d.ts +10 -0
- package/dist/local/transactions/mutations/failureReporting.js +67 -0
- package/dist/react/AbloProvider.d.ts +11 -86
- package/dist/react/AbloProvider.js +10 -162
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/surface.d.ts +2 -2
- package/dist/surface.js +1 -4
- package/package.json +3 -2
- package/src/Ablo.ts +5 -17
- package/src/client.ts +0 -51
- package/src/local/BaseSyncedStore.ts +11 -17
- package/src/local/Model.ts +2 -2
- package/src/local/SyncClient.ts +63 -15
- package/src/local/client/createModelOperations.ts +23 -60
- package/src/local/client/options.ts +20 -43
- package/src/local/client/reactiveEngine.ts +7 -179
- package/src/local/client/storeLifecycle.ts +6 -1
- package/src/local/storeContract.ts +5 -5
- package/src/local/sync/SyncWebSocket.ts +1 -1
- package/src/local/sync/credentialLifecycle.ts +4 -5
- package/src/local/sync/deltaPipeline.ts +26 -82
- package/src/local/sync/scopeGroups.ts +91 -0
- package/src/local/sync/wsFrameHandlers.ts +0 -1
- package/src/local/transactions/mutations/failureHandling.ts +73 -132
- package/src/local/transactions/mutations/failureReporting.ts +93 -0
- package/src/react/AbloProvider.tsx +17 -249
- package/src/react.ts +1 -5
- package/src/surface.ts +1 -4
- package/dist/local/sync/participants.d.ts +0 -132
- package/dist/local/sync/participants.js +0 -342
- package/src/local/sync/participants.ts +0 -564
package/README.md
CHANGED
package/dist/Ablo.d.ts
CHANGED
|
@@ -50,11 +50,11 @@ export type Ablo<S extends SchemaRecord> = AbloClient<S>;
|
|
|
50
50
|
* ```
|
|
51
51
|
*
|
|
52
52
|
* In the browser (or any client that shouldn't hold a secret key), point
|
|
53
|
-
* `
|
|
53
|
+
* `session.endpoint` at your session-mint route instead — the SDK fetches it, keeps the
|
|
54
54
|
* short-lived token fresh, and re-mints on expiry:
|
|
55
55
|
*
|
|
56
56
|
* ```ts
|
|
57
|
-
* const ablo = Ablo({ schema,
|
|
57
|
+
* const ablo = Ablo({ schema, session: { endpoint: '/api/ablo-session' } });
|
|
58
58
|
* ```
|
|
59
59
|
*
|
|
60
60
|
* Server-side agents, workers, and services use `@abloatai/transaction`.
|
|
@@ -64,7 +64,6 @@ export declare function Ablo<const S extends SchemaRecord, const P extends reado
|
|
|
64
64
|
}): Ablo<S> & MergedSurface<P>;
|
|
65
65
|
export declare function Ablo<const S extends SchemaRecord>(options: AbloOptions<S>): Ablo<S>;
|
|
66
66
|
import type * as _Streams from '@abloatai/transaction/types/streams';
|
|
67
|
-
import type * as _Participants from './local/sync/participants.js';
|
|
68
67
|
import type * as _Mutators from './local/mutators/defineMutators.js';
|
|
69
68
|
import type * as _Tx from './local/mutators/Transaction.js';
|
|
70
69
|
import type * as _Undo from './local/mutators/UndoManager.js';
|
|
@@ -110,13 +109,6 @@ export declare namespace Ablo {
|
|
|
110
109
|
namespace Auth {
|
|
111
110
|
type Actor = _Streams.ParticipantRef;
|
|
112
111
|
}
|
|
113
|
-
namespace Participant {
|
|
114
|
-
type Manager = _Participants.ParticipantManager;
|
|
115
|
-
type Joined = _Participants.JoinedParticipant;
|
|
116
|
-
type Scope = _Participants.ParticipantScope;
|
|
117
|
-
type Status = _Participants.ParticipantStatus;
|
|
118
|
-
type JoinOptions = _Participants.ParticipantJoinOptions;
|
|
119
|
-
}
|
|
120
112
|
type Schema<S extends _SchemaTypes.SchemaRecord = _SchemaTypes.SchemaRecord> = _SchemaTypes.Schema<S>;
|
|
121
113
|
/**
|
|
122
114
|
* The schema this program has registered via `interface Register { Schema }`
|
package/dist/Ablo.js
CHANGED
package/dist/client.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { SyncStoreContract } from './react/context.js';
|
|
|
17
17
|
import type { SyncWebSocket, CoreSyncEventMap } from './local/sync/SyncWebSocket.js';
|
|
18
18
|
import type { SyncStatus } from './local/BaseSyncedStore.js';
|
|
19
19
|
import type { ModelOperations } from './local/client/createModelOperations.js';
|
|
20
|
-
import type { ClaimResource, CommitResource
|
|
20
|
+
import type { ClaimResource, CommitResource } from '@abloatai/transaction/client/resources/httpResources';
|
|
21
21
|
import type { EffectiveAuthority } from '@abloatai/transaction/auth';
|
|
22
22
|
import type { ReadDependency } from '@abloatai/transaction/coordination';
|
|
23
23
|
import type { CapturedRow } from '@abloatai/transaction/transport/http';
|
|
@@ -102,53 +102,6 @@ export type AbloClient<S extends SchemaRecord> = {
|
|
|
102
102
|
* the watchdog.
|
|
103
103
|
*/
|
|
104
104
|
nudgeReconnect(): void;
|
|
105
|
-
/**
|
|
106
|
-
* Mint a short-lived, scoped session token for one end user. Call this on your
|
|
107
|
-
* backend, where the `sk_` secret key lives, then hand the returned `token` to
|
|
108
|
-
* that user's browser — typically through a token route the browser's `apiKey`
|
|
109
|
-
* resolver fetches. The browser presents the token as its bearer, and the
|
|
110
|
-
* server verifies it. The browser must never see the `sk_` key, only the
|
|
111
|
-
* per-user session token.
|
|
112
|
-
*
|
|
113
|
-
* Pass `{ user: { id }, can: { items: ['read', 'update'] } }` for an end-user
|
|
114
|
-
* session. It mints an `ek_` and attributes writes to a user (recorded as
|
|
115
|
-
* `actor_kind` on the delta row). Pass `{ agent: { id }, can: {
|
|
116
|
-
* items: ['update'] } }` for a scoped agent session, which mints an `rk_`.
|
|
117
|
-
* Both kinds require `can`, typed against your schema's model names. This
|
|
118
|
-
* always authenticates with the original `sk_`, never the client's exchanged
|
|
119
|
-
* sync credential.
|
|
120
|
-
*/
|
|
121
|
-
sessions: SessionResource<S>;
|
|
122
|
-
/**
|
|
123
|
-
* Mint a scoped **agent identity** and return a ready-to-use client bound to
|
|
124
|
-
* it — the `ablo.<resource>.<verb>` shape for the agent use case. One call
|
|
125
|
-
* replaces `sessions.create({ agent, can })` + constructing a second
|
|
126
|
-
* `Ablo({ apiKey: token })`:
|
|
127
|
-
*
|
|
128
|
-
* ```ts
|
|
129
|
-
* const agent = await ablo.agents.create({
|
|
130
|
-
* name: 'researcher', // readable label (optional)
|
|
131
|
-
* can: { records: ['read', 'update'] },
|
|
132
|
-
* // id omitted → a fresh uuid: a distinct, independent participant
|
|
133
|
-
* });
|
|
134
|
-
* await agent.records.update({ id, data, claim });
|
|
135
|
-
* await agent.dispose(); // when the agent is done
|
|
136
|
-
* ```
|
|
137
|
-
*
|
|
138
|
-
* Server-side only: it requires the `sk_` secret key (like `sessions.create`)
|
|
139
|
-
* and throws `AbloAuthenticationError` in the browser. The returned client
|
|
140
|
-
* holds its own auto-refreshing `rk_`, so a long run never hits token expiry,
|
|
141
|
-
* and the `sk_` never leaves this process. Each call is a distinct participant
|
|
142
|
-
* by default (omit `id` for a fresh uuid), so even two agents sharing a `name`
|
|
143
|
-
* queue behind one another on a contended row — `name` is display only and
|
|
144
|
-
* never collapses identity. Humans don't get a server-built client; ship them a
|
|
145
|
-
* token via `sessions.create({ user, can })`. If you need the raw token for
|
|
146
|
-
* revocation, or a stable re-attachable id, use `sessions.create({ agent, can })`
|
|
147
|
-
* or pass `id`.
|
|
148
|
-
*/
|
|
149
|
-
agents: {
|
|
150
|
-
create(params: CreateAgentClientParams<S>): Promise<AbloClient<S>>;
|
|
151
|
-
};
|
|
152
105
|
/**
|
|
153
106
|
* The organization this client resolved to — `null` until `ready()`
|
|
154
107
|
* completes. Use it instead of scraping CLI output or hardcoding env vars:
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import type { RecoveryClass } from '@abloatai/transaction/errorCodes';
|
|
14
14
|
import { ConnectionManager } from './sync/ConnectionManager.js';
|
|
15
15
|
import { SubscriptionManager } from './sync/SubscriptionManager.js';
|
|
16
|
-
import { type
|
|
16
|
+
import { type GroupScope } from './sync/scopeGroups.js';
|
|
17
17
|
import type { SyncClient } from './SyncClient.js';
|
|
18
18
|
import type { Database, BootstrapResult, BootstrapRequirements } from './Database.js';
|
|
19
19
|
import type { InstanceCache } from './InstanceCache.js';
|
|
@@ -254,7 +254,7 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
|
|
|
254
254
|
* Hydration is best-effort — a failed backfill never rejects `enterScope`,
|
|
255
255
|
* and the live delta stream keeps flowing regardless.
|
|
256
256
|
*/
|
|
257
|
-
enterScope(scope:
|
|
257
|
+
enterScope(scope: GroupScope, opts?: {
|
|
258
258
|
hydrate?: boolean;
|
|
259
259
|
}): Promise<void>;
|
|
260
260
|
/**
|
|
@@ -266,11 +266,11 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
|
|
|
266
266
|
*/
|
|
267
267
|
protected hydrateGroups(syncGroups: readonly string[]): Promise<void>;
|
|
268
268
|
/** Leave a scope → its groups go warm (hysteresis), then drop on sweep. */
|
|
269
|
-
leaveScope(scope:
|
|
269
|
+
leaveScope(scope: GroupScope): Promise<void>;
|
|
270
270
|
/** Pin a scope (active claim / prominence) → never warms while pinned. */
|
|
271
|
-
pinScope(scope:
|
|
271
|
+
pinScope(scope: GroupScope): Promise<void>;
|
|
272
272
|
/** Release a pin → the group transitions to warm rather than dropping. */
|
|
273
|
-
unpinScope(scope:
|
|
273
|
+
unpinScope(scope: GroupScope): Promise<void>;
|
|
274
274
|
protected readonly queryProcessor: QueryProcessor;
|
|
275
275
|
/**
|
|
276
276
|
* Runtime behavior flags only — the schema/config arrays
|
|
@@ -647,9 +647,7 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
|
|
|
647
647
|
* (no plugins installed) and the `humans()` apply handler both call it.
|
|
648
648
|
*/
|
|
649
649
|
applyChangesToPool(changes: readonly AppliedChange[]): void;
|
|
650
|
-
/**
|
|
651
|
-
protected getStateFields(_modelName: string): string[];
|
|
652
|
-
/** Deduplicate deltas to the same entity — keep meaningful state transitions only */
|
|
650
|
+
/** Deduplicate repeated delivery of the same positive sync id. */
|
|
653
651
|
protected deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[];
|
|
654
652
|
/** Process incoming delta with smart batching */
|
|
655
653
|
protected processDeltaWithBatching(delta: SyncDelta): void;
|
|
@@ -15,7 +15,7 @@ import { AbloConnectionError, AbloValidationError, toAbloError } from '@abloatai
|
|
|
15
15
|
import { ConnectionManager } from './sync/ConnectionManager.js';
|
|
16
16
|
import { contextLogger, contextSocketObservability } from './sync/contextPorts.js';
|
|
17
17
|
import { SubscriptionManager } from './sync/SubscriptionManager.js';
|
|
18
|
-
import {
|
|
18
|
+
import { resolveScopeGroups, } from './sync/scopeGroups.js';
|
|
19
19
|
import { ModelRegistry } from './ModelRegistry.js';
|
|
20
20
|
import { PropertyType } from '@abloatai/transaction/types';
|
|
21
21
|
import { SyncWebSocket, } from './sync/SyncWebSocket.js';
|
|
@@ -180,7 +180,7 @@ export class BaseSyncedStore {
|
|
|
180
180
|
// {@link SubscriptionManager.reconcile}); the on-connect `resync` pushes
|
|
181
181
|
// whatever interest accumulated.
|
|
182
182
|
scopeToGroups(scope) {
|
|
183
|
-
return
|
|
183
|
+
return resolveScopeGroups(scope, this.schema);
|
|
184
184
|
}
|
|
185
185
|
/**
|
|
186
186
|
* Bring a scope into view and subscribe to its sync groups. With
|
|
@@ -1179,7 +1179,6 @@ export class BaseSyncedStore {
|
|
|
1179
1179
|
acknowledge: (syncId) => { this.syncWebSocket.acknowledge(syncId); },
|
|
1180
1180
|
get objectPool() { return store.objectPool; },
|
|
1181
1181
|
// Dynamic-dispatch hooks — protected override points on this class.
|
|
1182
|
-
getStateFields: (modelName) => this.getStateFields(modelName),
|
|
1183
1182
|
isCustomEntity: (modelName) => this.isCustomEntity(modelName),
|
|
1184
1183
|
createCustomEntity: (modelName, modelId, data) => this.createCustomEntity(modelName, modelId, data),
|
|
1185
1184
|
deduplicateDeltas: (deltas) => this.deduplicateDeltas(deltas),
|
|
@@ -1202,13 +1201,9 @@ export class BaseSyncedStore {
|
|
|
1202
1201
|
applyChangesToPool(changes) {
|
|
1203
1202
|
this.syncClient.applyDeltaBatchToPool(changes, (name, data) => this.enrichRelations(name, data));
|
|
1204
1203
|
}
|
|
1205
|
-
/**
|
|
1206
|
-
getStateFields(_modelName) {
|
|
1207
|
-
return ['status', 'state', 'isActive'];
|
|
1208
|
-
}
|
|
1209
|
-
/** Deduplicate deltas to the same entity — keep meaningful state transitions only */
|
|
1204
|
+
/** Deduplicate repeated delivery of the same positive sync id. */
|
|
1210
1205
|
deduplicateDeltas(deltas) {
|
|
1211
|
-
return deltaPipeline.deduplicateDeltas(
|
|
1206
|
+
return deltaPipeline.deduplicateDeltas(deltas);
|
|
1212
1207
|
}
|
|
1213
1208
|
/** Process incoming delta with smart batching */
|
|
1214
1209
|
processDeltaWithBatching(delta) {
|
package/dist/local/Model.js
CHANGED
|
@@ -306,7 +306,7 @@ export class Model {
|
|
|
306
306
|
*/
|
|
307
307
|
capturePreviousValues(keys, opts) {
|
|
308
308
|
const out = {};
|
|
309
|
-
const modified = this.modifiedProperties
|
|
309
|
+
const modified = this.modifiedProperties;
|
|
310
310
|
const original = this.getOriginalSnapshot();
|
|
311
311
|
for (const key of keys) {
|
|
312
312
|
if (key === 'id')
|
|
@@ -334,7 +334,7 @@ export class Model {
|
|
|
334
334
|
* is never consumed. With no `keys`, consumes every tracked field.
|
|
335
335
|
*/
|
|
336
336
|
consumeModifiedFields(keys) {
|
|
337
|
-
if (
|
|
337
|
+
if (this.modifiedProperties.size === 0) {
|
|
338
338
|
return;
|
|
339
339
|
}
|
|
340
340
|
const only = keys ? new Set(keys) : null;
|
|
@@ -186,9 +186,9 @@ export declare class SyncClient extends EventEmitter {
|
|
|
186
186
|
*/
|
|
187
187
|
private captureModelChanges;
|
|
188
188
|
/** Add new model (CREATE) - works offline */
|
|
189
|
-
add(model: Model, options?: WriteOptions): void;
|
|
189
|
+
add(model: Model, options?: WriteOptions): Promise<void> | undefined;
|
|
190
190
|
/** Update existing model (UPDATE) - works offline */
|
|
191
|
-
update(model: Model, options?: WriteOptions): void;
|
|
191
|
+
update(model: Model, options?: WriteOptions, capturedChanges?: Record<string, unknown>): Promise<void> | undefined;
|
|
192
192
|
/**
|
|
193
193
|
* Update existing model with pre-computed changes.
|
|
194
194
|
* Used by saveManyOptimized when incoming models have empty change-tracking
|
|
@@ -200,7 +200,7 @@ export declare class SyncClient extends EventEmitter {
|
|
|
200
200
|
* but still need optimistic pool updates at the sync layer. */
|
|
201
201
|
get gql(): import("./interfaces/index.js").MutationExecutor;
|
|
202
202
|
/** Delete model (DELETE) - works offline */
|
|
203
|
-
delete(model: Model, options?: WriteOptions): void;
|
|
203
|
+
delete(model: Model, options?: WriteOptions): Promise<void> | undefined;
|
|
204
204
|
/**
|
|
205
205
|
* Upload a file and create its attachment record. The upload runs through
|
|
206
206
|
* the {@link MutationQueue}, and a model is built from the server's
|
package/dist/local/SyncClient.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { runInAction } from 'mobx';
|
|
11
11
|
import { InstanceCache, ModelScope } from './InstanceCache.js';
|
|
12
12
|
import { Model } from './Model.js';
|
|
13
|
-
import { snapshotJsonValue } from '@abloatai/transaction/utils/json';
|
|
13
|
+
import { deepEqual, snapshotJsonValue } from '@abloatai/transaction/utils/json';
|
|
14
14
|
// ModelRegistry instance accessed via this.objectPool.registry
|
|
15
15
|
import { LoadStrategy } from '@abloatai/transaction/types';
|
|
16
16
|
import { globalRuntime } from './context.js';
|
|
@@ -758,7 +758,7 @@ export class SyncClient extends EventEmitter {
|
|
|
758
758
|
* capturing first ensures those changes are never lost. The captured set is
|
|
759
759
|
* frozen and handed to {@link queueMutation}.
|
|
760
760
|
*/
|
|
761
|
-
mutate(type, model, poolAction, writeOptions) {
|
|
761
|
+
mutate(type, model, poolAction, writeOptions, capturedChangesOverride) {
|
|
762
762
|
// No-op UPDATE guard (O(1)). An update with no dirty fields would travel
|
|
763
763
|
// to the server, get dropped by `coalesceOperations` Rule 4 (empty input),
|
|
764
764
|
// and — if it was the only op — come back as `lastSyncId: 0`. That trips
|
|
@@ -772,21 +772,28 @@ export class SyncClient extends EventEmitter {
|
|
|
772
772
|
// is false → we fall through to the normal path rather than risk dropping a
|
|
773
773
|
// real write. Only a genuine Model with an empty dirty-set is skipped.
|
|
774
774
|
const hasChanges = model.hasChanges;
|
|
775
|
-
if (type === 'update' &&
|
|
776
|
-
|
|
775
|
+
if (type === 'update' &&
|
|
776
|
+
hasChanges === false &&
|
|
777
|
+
capturedChangesOverride === undefined) {
|
|
778
|
+
return Promise.resolve();
|
|
777
779
|
}
|
|
778
780
|
// Capture changes before the pool action runs. Pool operations —
|
|
779
781
|
// upsert in particular — can clear the model's local changes, so
|
|
780
782
|
// capturing first ensures they are never lost.
|
|
781
|
-
const capturedChanges =
|
|
783
|
+
const capturedChanges = capturedChangesOverride !== undefined
|
|
784
|
+
? Object.freeze({ ...capturedChangesOverride })
|
|
785
|
+
: type === 'update' || type === 'create'
|
|
786
|
+
? this.captureModelChanges(model)
|
|
787
|
+
: undefined;
|
|
782
788
|
poolAction();
|
|
783
|
-
this.stageMutation(type, model, capturedChanges, writeOptions);
|
|
789
|
+
const confirmation = this.stageMutation(type, model, capturedChanges, writeOptions);
|
|
784
790
|
this.notifyObservers({
|
|
785
791
|
type,
|
|
786
792
|
modelType: model.getModelName(),
|
|
787
793
|
model: type !== 'delete' ? model : undefined,
|
|
788
794
|
modelId: model.id,
|
|
789
795
|
});
|
|
796
|
+
return confirmation;
|
|
790
797
|
// QueryProcessor uses `models:changed` to invalidate caches. Coalesce
|
|
791
798
|
// to one event per microtask: a paste of 100 rows should re-run
|
|
792
799
|
// affected queries ONCE, not 100×.
|
|
@@ -821,11 +828,11 @@ export class SyncClient extends EventEmitter {
|
|
|
821
828
|
}
|
|
822
829
|
/** Add new model (CREATE) - works offline */
|
|
823
830
|
add(model, options) {
|
|
824
|
-
this.mutate('create', model, () => { this.objectPool.add(model, ModelScope.live); }, options);
|
|
831
|
+
return this.mutate('create', model, () => { this.objectPool.add(model, ModelScope.live); }, options);
|
|
825
832
|
}
|
|
826
833
|
/** Update existing model (UPDATE) - works offline */
|
|
827
|
-
update(model, options) {
|
|
828
|
-
this.mutate('update', model, () => { this.objectPool.upsert(model, ModelScope.live); }, options);
|
|
834
|
+
update(model, options, capturedChanges) {
|
|
835
|
+
return this.mutate('update', model, () => { this.objectPool.upsert(model, ModelScope.live); }, options, capturedChanges);
|
|
829
836
|
}
|
|
830
837
|
/**
|
|
831
838
|
* Update existing model with pre-computed changes.
|
|
@@ -867,7 +874,7 @@ export class SyncClient extends EventEmitter {
|
|
|
867
874
|
delete(model, options) {
|
|
868
875
|
// Clear pending mutations first to prevent "not found" errors on fast delete
|
|
869
876
|
this.mutationQueue.cancelTransactionsForModel(model.id);
|
|
870
|
-
this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
|
|
877
|
+
return this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
|
|
871
878
|
}
|
|
872
879
|
/**
|
|
873
880
|
* Upload a file and create its attachment record. The upload runs through
|
|
@@ -979,13 +986,20 @@ export class SyncClient extends EventEmitter {
|
|
|
979
986
|
/** Stage one mutation through the queue, which owns durability and execution. */
|
|
980
987
|
stageMutation(type, model, capturedChanges, writeOptions) {
|
|
981
988
|
if (this.isDisposed)
|
|
982
|
-
return;
|
|
989
|
+
return Promise.resolve();
|
|
983
990
|
if (!this.userId || !this.organizationId) {
|
|
984
991
|
this.mutationQueue.deferMutation(type, model, capturedChanges, writeOptions);
|
|
985
992
|
return;
|
|
986
993
|
}
|
|
987
994
|
const context = { userId: this.userId, organizationId: this.organizationId };
|
|
988
995
|
const staging = this.mutationQueue.enqueueModelMutation(type, model, context, capturedChanges, writeOptions);
|
|
996
|
+
const confirmation = staging.then(async (transaction) => {
|
|
997
|
+
await transaction.confirmation;
|
|
998
|
+
});
|
|
999
|
+
// Most internal callers intentionally use fire-and-forget writes. Observe
|
|
1000
|
+
// their rejection without replacing the exact promise returned to model
|
|
1001
|
+
// operations that need authoritative per-transaction confirmation.
|
|
1002
|
+
void confirmation.catch(() => undefined);
|
|
989
1003
|
const pending = staging.then(() => undefined).catch((error) => {
|
|
990
1004
|
this.runtime.observability.captureMutationFailure({
|
|
991
1005
|
context: `stage-mutation-${type}`,
|
|
@@ -996,6 +1010,7 @@ export class SyncClient extends EventEmitter {
|
|
|
996
1010
|
});
|
|
997
1011
|
this.pendingStages.add(pending);
|
|
998
1012
|
void pending.finally(() => this.pendingStages.delete(pending));
|
|
1013
|
+
return confirmation;
|
|
999
1014
|
}
|
|
1000
1015
|
scheduleSync() {
|
|
1001
1016
|
if (!this.runtime.onlineStatus.isOnline() || this.isDisposed)
|
|
@@ -1588,6 +1603,25 @@ export class SyncClient extends EventEmitter {
|
|
|
1588
1603
|
// otherwise re-add it for the brief window before the matching delete
|
|
1589
1604
|
// confirmation lands.
|
|
1590
1605
|
if (this.echoTracker.consumeEcho(transactionId)) {
|
|
1606
|
+
// A direct assignment can re-enter change tracking while this
|
|
1607
|
+
// optimistic write is in flight. Leaving the acknowledged field dirty
|
|
1608
|
+
// makes conflict resolution preserve it over the next collaborator
|
|
1609
|
+
// delta, so peers appear desynchronized until refresh.
|
|
1610
|
+
//
|
|
1611
|
+
// Re-baseline only values this echo actually confirms. If the user has
|
|
1612
|
+
// edited the same field again since the write was sent, its current
|
|
1613
|
+
// dirty value differs from the echo and remains queued.
|
|
1614
|
+
if (resident && result.data) {
|
|
1615
|
+
const acknowledgedFields = [];
|
|
1616
|
+
for (const [field, change] of resident.modifiedProperties) {
|
|
1617
|
+
if (Object.prototype.hasOwnProperty.call(result.data, field) &&
|
|
1618
|
+
deepEqual(change.new, result.data[field])) {
|
|
1619
|
+
acknowledgedFields.push(field);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
resident.consumeModifiedFields(acknowledgedFields);
|
|
1623
|
+
resident.markAsSynced();
|
|
1624
|
+
}
|
|
1591
1625
|
continue;
|
|
1592
1626
|
}
|
|
1593
1627
|
// If a later op in this batch will remove this id, skip earlier
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `read` and `list`, with the same point lookup restricted to the local graph under
|
|
7
7
|
* `local`, the writes `create`, `update`, and `delete`, the coordination
|
|
8
8
|
* namespace `claim` (callable as `claim({ id })`, plus `claim.state`,
|
|
9
|
-
* `claim.queue`, `claim.release`, and `claim.reorder`),
|
|
9
|
+
* `claim.queue`, `claim.release`, and `claim.reorder`), and `onChange`.
|
|
10
10
|
* The factory returns a plain object; the client assembles the `ablo.<model>`
|
|
11
11
|
* lookup table from one of these per model.
|
|
12
12
|
*/
|
|
@@ -16,11 +16,10 @@ import type { ModelRegistry } from '../ModelRegistry.js';
|
|
|
16
16
|
import type { InstanceCache } from '../InstanceCache.js';
|
|
17
17
|
import type { SyncClient } from '../SyncClient.js';
|
|
18
18
|
import type { OnDemandLoader } from '../sync/OnDemandLoader.js';
|
|
19
|
-
import type { JoinedParticipant } from '../sync/participants.js';
|
|
20
19
|
import type { Duration, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease, ClaimWaitOptions } from '@abloatai/transaction/types/streams';
|
|
21
|
-
export type { ModelListScope, LocalReadOptions, LocalCountOptions, ServerReadOptions, ListAllOptions, ServerPointReadOptions, ClaimTargetOptions, ClaimParams, ClaimContentionOptions, ClaimAttemptEvent, ClaimQueueView, ClaimSkipOptions, ClaimSkipParams, ClaimLookupParams, ClaimReorderParams, ClaimOptions, ClaimReadApi, AwaitedClaimMethod, ClaimApi, ModelReadParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams,
|
|
20
|
+
export type { ModelListScope, LocalReadOptions, LocalCountOptions, ServerReadOptions, ListAllOptions, ServerPointReadOptions, ClaimTargetOptions, ClaimParams, ClaimContentionOptions, ClaimAttemptEvent, ClaimQueueView, ClaimSkipOptions, ClaimSkipParams, ClaimLookupParams, ClaimReorderParams, ClaimOptions, ClaimReadApi, AwaitedClaimMethod, ClaimApi, ModelReadParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, } from '@abloatai/transaction/client/resources/modelOperations';
|
|
22
21
|
export type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease };
|
|
23
|
-
import type { ClaimApi, ClaimAttemptEvent,
|
|
22
|
+
import type { ClaimApi, ClaimAttemptEvent, LocalCountOptions, LocalReadOptions } from '@abloatai/transaction/client/resources/modelOperations';
|
|
24
23
|
import type { HttpModelClient } from '@abloatai/transaction/transport/http';
|
|
25
24
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
26
25
|
import { type ReadSetContext } from '@abloatai/transaction/internal/read-set';
|
|
@@ -142,13 +141,6 @@ export interface ModelCollaboration {
|
|
|
142
141
|
* fire-and-forget, best-effort semantics as `enterScope`.
|
|
143
142
|
*/
|
|
144
143
|
pinScope?(scope: Record<string, string>): void | Promise<void>;
|
|
145
|
-
/**
|
|
146
|
-
* Opens a presence and claim subscription on this model's sync group(s) and
|
|
147
|
-
* returns the live participant handle. Backs `ablo.<model>.join(ids)`.
|
|
148
|
-
* WebSocket only, since presence needs a live socket; it is absent on other
|
|
149
|
-
* client constructions, where the surface throws a clear error.
|
|
150
|
-
*/
|
|
151
|
-
createJoin?(modelKey: string, ids: string | readonly string[], options?: JoinOptions): Promise<JoinedParticipant>;
|
|
152
144
|
}
|
|
153
145
|
/**
|
|
154
146
|
* The synchronous, local-only reads — reached as `ablo.<model>.local.*`.
|
|
@@ -222,22 +214,6 @@ interface ReactiveModelSurface<T, Fields = T> {
|
|
|
222
214
|
* ```
|
|
223
215
|
*/
|
|
224
216
|
claim: ClaimApi<T, Fields>;
|
|
225
|
-
/**
|
|
226
|
-
* Joins the sync group(s) for one or more rows of this model and returns a
|
|
227
|
-
* live participant handle — presence (`.peers`), the scoped claim stream
|
|
228
|
-
* (`.claims`), and `.leave()` / `await using` disposal. This is a presence
|
|
229
|
-
* subscription: it reports who else is here and what they hold, not row
|
|
230
|
-
* values changing — for the latter, use `onChange`.
|
|
231
|
-
*
|
|
232
|
-
* WebSocket only: presence needs a live socket, so this is absent on HTTP
|
|
233
|
-
* clients and throws on any non-WebSocket construction.
|
|
234
|
-
*
|
|
235
|
-
* ```ts
|
|
236
|
-
* await using participant = await ablo.sections.join(sectionIds, { ttl: '5m' });
|
|
237
|
-
* participant.peers; // who else is here
|
|
238
|
-
* ```
|
|
239
|
-
*/
|
|
240
|
-
join(ids: string | readonly string[], options?: JoinOptions): Promise<JoinedParticipant>;
|
|
241
217
|
/** Subscribe to changes; the callback runs on every change. */
|
|
242
218
|
onChange(callback: (entities: T[]) => void, options?: LocalReadOptions<T>): () => void;
|
|
243
219
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `read` and `list`, with the same point lookup restricted to the local graph under
|
|
7
7
|
* `local`, the writes `create`, `update`, and `delete`, the coordination
|
|
8
8
|
* namespace `claim` (callable as `claim({ id })`, plus `claim.state`,
|
|
9
|
-
* `claim.queue`, `claim.release`, and `claim.reorder`),
|
|
9
|
+
* `claim.queue`, `claim.release`, and `claim.reorder`), and `onChange`.
|
|
10
10
|
* The factory returns a plain object; the client assembles the `ablo.<model>`
|
|
11
11
|
* lookup table from one of these per model.
|
|
12
12
|
*/
|
|
@@ -130,7 +130,7 @@ hydration, collaboration, readSetContext) {
|
|
|
130
130
|
const rows = await hydration.fetch(schemaKey, options);
|
|
131
131
|
return rows.map((row) => modelAsRow(row));
|
|
132
132
|
};
|
|
133
|
-
const waitForMutation = async (model) => {
|
|
133
|
+
const waitForMutation = async (model, exactConfirmation) => {
|
|
134
134
|
// Model writes are optimistic locally, but their promise has one stable
|
|
135
135
|
// meaning: authoritative confirmation. Callers that do not need the
|
|
136
136
|
// barrier can keep using the row immediately and leave the promise to the
|
|
@@ -142,7 +142,10 @@ hydration, collaboration, readSetContext) {
|
|
|
142
142
|
// coalescer and producing one SQL transaction per delta.
|
|
143
143
|
await Promise.resolve();
|
|
144
144
|
await syncClient.syncNow();
|
|
145
|
-
|
|
145
|
+
if (exactConfirmation)
|
|
146
|
+
await exactConfirmation;
|
|
147
|
+
else
|
|
148
|
+
await syncClient.waitForConfirmation(model.getModelName(), model.id);
|
|
146
149
|
};
|
|
147
150
|
const activeClaims = new Map();
|
|
148
151
|
const claimIdsByEntity = new Map();
|
|
@@ -811,8 +814,8 @@ hydration, collaboration, readSetContext) {
|
|
|
811
814
|
}
|
|
812
815
|
: {}),
|
|
813
816
|
};
|
|
814
|
-
syncClient.add(model, effective);
|
|
815
|
-
await waitForMutation(model);
|
|
817
|
+
const confirmation = syncClient.add(model, effective);
|
|
818
|
+
await waitForMutation(model, confirmation);
|
|
816
819
|
return modelAsRow(model);
|
|
817
820
|
}
|
|
818
821
|
finally {
|
|
@@ -888,8 +891,8 @@ hydration, collaboration, readSetContext) {
|
|
|
888
891
|
: {}),
|
|
889
892
|
};
|
|
890
893
|
model.applyChanges(patch);
|
|
891
|
-
syncClient.update(model, effective);
|
|
892
|
-
await waitForMutation(model);
|
|
894
|
+
const confirmation = syncClient.update(model, effective, patch);
|
|
895
|
+
await waitForMutation(model, confirmation);
|
|
893
896
|
return modelAsRow(model);
|
|
894
897
|
},
|
|
895
898
|
});
|
|
@@ -939,8 +942,8 @@ hydration, collaboration, readSetContext) {
|
|
|
939
942
|
// the server. (`updateFromData` is the hydration path and would discard
|
|
940
943
|
// the tracking, producing an empty `input: {}` no-op mutation.)
|
|
941
944
|
model.applyChanges(params.data);
|
|
942
|
-
syncClient.update(model, effective);
|
|
943
|
-
await waitForMutation(model);
|
|
945
|
+
const confirmation = syncClient.update(model, effective, params.data);
|
|
946
|
+
await waitForMutation(model, confirmation);
|
|
944
947
|
const updated = modelAsRow(model);
|
|
945
948
|
await settleClaimsAfterWrite(id, handle);
|
|
946
949
|
return updated;
|
|
@@ -997,19 +1000,13 @@ hydration, collaboration, readSetContext) {
|
|
|
997
1000
|
...opts,
|
|
998
1001
|
...(selected ? { claimRef: { id: selected.id } } : {}),
|
|
999
1002
|
};
|
|
1000
|
-
syncClient.delete(model, effective);
|
|
1001
|
-
await waitForMutation(model);
|
|
1003
|
+
const confirmation = syncClient.delete(model, effective);
|
|
1004
|
+
await waitForMutation(model, confirmation);
|
|
1002
1005
|
await settleClaimsAfterWrite(id, handle);
|
|
1003
1006
|
}),
|
|
1004
1007
|
// `claim` is a callable namespace (take a claim) carrying the coordination
|
|
1005
1008
|
// readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
|
|
1006
1009
|
claim: claimApi,
|
|
1007
|
-
join: guard((ids, options) => {
|
|
1008
|
-
if (!collaboration?.createJoin) {
|
|
1009
|
-
throw new AbloValidationError(`Model "${schemaKey}" was built without a WebSocket runtime, so join() is unavailable here. Presence needs a live socket — use the standard Ablo({ schema, apiKey }) client (not the HTTP transport).`, { code: 'model_join_not_configured' });
|
|
1010
|
-
}
|
|
1011
|
-
return collaboration.createJoin(schemaKey, ids, options);
|
|
1012
|
-
}),
|
|
1013
1010
|
onChange(callback, options) {
|
|
1014
1011
|
return autorun(() => {
|
|
1015
1012
|
callback(local.list(options));
|
|
@@ -17,6 +17,7 @@ import type { CommitOutboxScope } from '@abloatai/transaction/commit';
|
|
|
17
17
|
*/
|
|
18
18
|
export type { CredentialProvider } from '@abloatai/transaction/auth/apiKey';
|
|
19
19
|
import type { CredentialProvider } from '@abloatai/transaction/auth/apiKey';
|
|
20
|
+
import type { SessionCredential, SessionEndpoint, SessionProvider } from '@abloatai/transaction/sessions';
|
|
20
21
|
import type { AbloPlugin } from '../../plugin.js';
|
|
21
22
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
22
23
|
/**
|
|
@@ -56,18 +57,19 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
56
57
|
* `ABLO_API_KEY` environment variable, so you usually pass nothing. A
|
|
57
58
|
* long-lived key needs no refresh; the client uses it as-is.
|
|
58
59
|
*
|
|
59
|
-
* - **An async resolver**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* the same renewal machinery as the endpoint form.
|
|
63
|
-
*
|
|
64
|
-
* The endpoint and resolver forms share one contract: return a token; return
|
|
65
|
-
* `null` when the login itself is gone (terminal — the client signs out and
|
|
66
|
-
* fails `ready()` with `session_expired`); or throw on a transient failure, which
|
|
67
|
-
* backs off and retries without signing out. The endpoint form maps HTTP onto
|
|
68
|
-
* this for you: only a structured `401 session_expired` means signed out.
|
|
60
|
+
* - **An async resolver** for advanced process-owned key rotation, such as a
|
|
61
|
+
* vault or workload-identity exchange. Scoped actor renewal belongs in
|
|
62
|
+
* `session` instead.
|
|
69
63
|
*/
|
|
70
64
|
apiKey?: string | CredentialProvider | null | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* Scoped actor identity. Pass a session returned by `sessions.create()` for
|
|
67
|
+
* bounded work, a provider that re-mints it for a long-lived client, or
|
|
68
|
+
* `{ endpoint: '/api/ablo-session' }` in a browser. Endpoint responses use
|
|
69
|
+
* the canonical credential protocol; only a structured `401
|
|
70
|
+
* session_expired` ends the underlying login.
|
|
71
|
+
*/
|
|
72
|
+
session?: SessionCredential | SessionProvider | SessionEndpoint | null | undefined;
|
|
71
73
|
/**
|
|
72
74
|
* Pins this client to one Ablo project. During `ready()` the server resolves
|
|
73
75
|
* the API key's actual project and the client refuses to start when it differs.
|
|
@@ -84,30 +86,6 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
84
86
|
* environment could reach this process.
|
|
85
87
|
*/
|
|
86
88
|
branchId?: string | null | undefined;
|
|
87
|
-
/**
|
|
88
|
-
* The session-mint endpoint — the browser-side auth field, and the named
|
|
89
|
-
* endpoint for the route that mints the signed-in user's short-lived token:
|
|
90
|
-
*
|
|
91
|
-
* ```ts
|
|
92
|
-
* const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
|
|
93
|
-
* ```
|
|
94
|
-
*
|
|
95
|
-
* The client owns the whole exchange: it POSTs the route (same-origin, cookies
|
|
96
|
-
* included), validates the canonical auth response contract, keeps it fresh
|
|
97
|
-
* ahead of expiry, and re-mints when the server reports the token stale. Only
|
|
98
|
-
* a structured `401 session_expired` response means signed out. It also
|
|
99
|
-
* accepts an async resolver `() => Promise<string | null>` when the exchange
|
|
100
|
-
* needs custom headers or a body — the same contract as the resolver form of
|
|
101
|
-
* `apiKey`.
|
|
102
|
-
*
|
|
103
|
-
* Mutually exclusive with `apiKey`: a server holds a key, a browser holds a mint
|
|
104
|
-
* route, and passing both is a validation error.
|
|
105
|
-
*/
|
|
106
|
-
authEndpoint?: string | CredentialProvider | null | undefined;
|
|
107
|
-
/** Timeout for a session-mint request. @default 10000 */
|
|
108
|
-
authTimeoutMs?: number | undefined;
|
|
109
|
-
/** Explicit opt-in for a cross-origin session-mint endpoint. */
|
|
110
|
-
allowCrossOriginAuthEndpoint?: boolean | undefined;
|
|
111
89
|
/**
|
|
112
90
|
* Local persistence mode. Pass `indexeddb` only when you want offline
|
|
113
91
|
* queueing and a reload-surviving browser cache.
|
|
@@ -222,13 +200,10 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
222
200
|
* the original available as `cause`.
|
|
223
201
|
*/
|
|
224
202
|
apiKey?: string | CredentialProvider | null | undefined;
|
|
203
|
+
/** A scoped session, or a provider that re-mints it for a long-lived client. */
|
|
204
|
+
session?: SessionCredential | SessionProvider | SessionEndpoint | null | undefined;
|
|
225
205
|
/** Expected project assertion; see {@link AbloOptions.projectId}. */
|
|
226
206
|
projectId?: string | null | undefined;
|
|
227
|
-
/**
|
|
228
|
-
* Session-mint endpoint (string or async resolver) — see
|
|
229
|
-
* {@link AbloOptions.authEndpoint}. Mutually exclusive with `apiKey`.
|
|
230
|
-
*/
|
|
231
|
-
authEndpoint?: string | CredentialProvider | null | undefined;
|
|
232
207
|
/**
|
|
233
208
|
* A bearer auth token, sent as `Authorization: Bearer <token>` on every request.
|
|
234
209
|
*
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* The reactive engine assembly (ADR 0016). `Ablo({ ... })` resolves auth and
|
|
3
3
|
* capabilities; `humans().init` constructs the store cluster; the lifecycle
|
|
4
4
|
* — first mint, identity, ready() — lives in `./storeLifecycle.ts`. What
|
|
5
|
-
* remains here is assembly around those parts: the claim
|
|
6
|
-
*
|
|
7
|
-
* commit
|
|
5
|
+
* remains here is assembly around those parts: the claim and presence streams,
|
|
6
|
+
* options validation, the typed model proxies, and the
|
|
7
|
+
* commit and claim resources — composed into the reactive client.
|
|
8
8
|
*
|
|
9
9
|
* Extracted from the factory so the composition root stays a root: resolve,
|
|
10
10
|
* dispatch, return. The remaining assembly converts to decoration of a
|
|
@@ -43,11 +43,5 @@ export interface ReactiveEngineInputs<S extends SchemaRecord> extends ClientPrel
|
|
|
43
43
|
* assembles around it and constructs none of it.
|
|
44
44
|
*/
|
|
45
45
|
cluster: StoreCluster;
|
|
46
|
-
/**
|
|
47
|
-
* Constructs a sibling client (`ablo.agents.create(...)` mints a scoped key
|
|
48
|
-
* and builds a second engine with it). Injected by the factory — a direct
|
|
49
|
-
* import back into it would close a runtime cycle.
|
|
50
|
-
*/
|
|
51
|
-
createSibling: (options: AbloOptions<S>) => Ablo<S>;
|
|
52
46
|
}
|
|
53
47
|
export declare function buildReactiveEngine<const S extends SchemaRecord>(inputs: ReactiveEngineInputs<S>): Ablo<S>;
|