@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
|
@@ -18,9 +18,9 @@ import { ConnectionManager } from './sync/ConnectionManager.js';
|
|
|
18
18
|
import { contextLogger, contextSocketObservability } from './sync/contextPorts.js';
|
|
19
19
|
import { SubscriptionManager } from './sync/SubscriptionManager.js';
|
|
20
20
|
import {
|
|
21
|
-
|
|
22
|
-
type
|
|
23
|
-
} from './sync/
|
|
21
|
+
resolveScopeGroups,
|
|
22
|
+
type GroupScope,
|
|
23
|
+
} from './sync/scopeGroups.js';
|
|
24
24
|
import type { SyncClient } from './SyncClient.js';
|
|
25
25
|
import type { Database, BootstrapResult, BootstrapRequirements } from './Database.js';
|
|
26
26
|
import type { BootstrapData } from './sync/BootstrapFetcher.js';
|
|
@@ -394,8 +394,8 @@ export class BaseSyncedStore<
|
|
|
394
394
|
// {@link SubscriptionManager.reconcile}); the on-connect `resync` pushes
|
|
395
395
|
// whatever interest accumulated.
|
|
396
396
|
|
|
397
|
-
private scopeToGroups(scope:
|
|
398
|
-
return
|
|
397
|
+
private scopeToGroups(scope: GroupScope): string[] {
|
|
398
|
+
return resolveScopeGroups(scope, this.schema);
|
|
399
399
|
}
|
|
400
400
|
|
|
401
401
|
/**
|
|
@@ -406,7 +406,7 @@ export class BaseSyncedStore<
|
|
|
406
406
|
* Hydration is best-effort — a failed backfill never rejects `enterScope`,
|
|
407
407
|
* and the live delta stream keeps flowing regardless.
|
|
408
408
|
*/
|
|
409
|
-
enterScope(scope:
|
|
409
|
+
enterScope(scope: GroupScope, opts?: { hydrate?: boolean }): Promise<void> {
|
|
410
410
|
const groups = this.scopeToGroups(scope);
|
|
411
411
|
const subscribed = Promise.all(groups.map((g) => this.areaOfInterest.enter(g))).then(
|
|
412
412
|
() => undefined,
|
|
@@ -456,21 +456,21 @@ export class BaseSyncedStore<
|
|
|
456
456
|
}
|
|
457
457
|
|
|
458
458
|
/** Leave a scope → its groups go warm (hysteresis), then drop on sweep. */
|
|
459
|
-
leaveScope(scope:
|
|
459
|
+
leaveScope(scope: GroupScope): Promise<void> {
|
|
460
460
|
return Promise.all(
|
|
461
461
|
this.scopeToGroups(scope).map((g) => this.areaOfInterest.leave(g)),
|
|
462
462
|
).then(() => undefined);
|
|
463
463
|
}
|
|
464
464
|
|
|
465
465
|
/** Pin a scope (active claim / prominence) → never warms while pinned. */
|
|
466
|
-
pinScope(scope:
|
|
466
|
+
pinScope(scope: GroupScope): Promise<void> {
|
|
467
467
|
return Promise.all(
|
|
468
468
|
this.scopeToGroups(scope).map((g) => this.areaOfInterest.pin(g)),
|
|
469
469
|
).then(() => undefined);
|
|
470
470
|
}
|
|
471
471
|
|
|
472
472
|
/** Release a pin → the group transitions to warm rather than dropping. */
|
|
473
|
-
unpinScope(scope:
|
|
473
|
+
unpinScope(scope: GroupScope): Promise<void> {
|
|
474
474
|
return Promise.all(
|
|
475
475
|
this.scopeToGroups(scope).map((g) => this.areaOfInterest.unpin(g)),
|
|
476
476
|
).then(() => undefined);
|
|
@@ -1578,7 +1578,6 @@ export class BaseSyncedStore<
|
|
|
1578
1578
|
acknowledge: (syncId) => { this.syncWebSocket.acknowledge(syncId); },
|
|
1579
1579
|
get objectPool() { return store.objectPool; },
|
|
1580
1580
|
// Dynamic-dispatch hooks — protected override points on this class.
|
|
1581
|
-
getStateFields: (modelName) => this.getStateFields(modelName),
|
|
1582
1581
|
isCustomEntity: (modelName) => this.isCustomEntity(modelName),
|
|
1583
1582
|
createCustomEntity: (modelName, modelId, data) =>
|
|
1584
1583
|
this.createCustomEntity(modelName, modelId, data),
|
|
@@ -1607,14 +1606,9 @@ export class BaseSyncedStore<
|
|
|
1607
1606
|
);
|
|
1608
1607
|
}
|
|
1609
1608
|
|
|
1610
|
-
/**
|
|
1611
|
-
protected getStateFields(_modelName: string): string[] {
|
|
1612
|
-
return ['status', 'state', 'isActive'];
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
/** Deduplicate deltas to the same entity — keep meaningful state transitions only */
|
|
1609
|
+
/** Deduplicate repeated delivery of the same positive sync id. */
|
|
1616
1610
|
protected deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[] {
|
|
1617
|
-
return deltaPipeline.deduplicateDeltas(
|
|
1611
|
+
return deltaPipeline.deduplicateDeltas(deltas);
|
|
1618
1612
|
}
|
|
1619
1613
|
|
|
1620
1614
|
/** Process incoming delta with smart batching */
|
package/src/local/Model.ts
CHANGED
|
@@ -412,7 +412,7 @@ export abstract class Model {
|
|
|
412
412
|
opts?: { fallbackToLive?: boolean },
|
|
413
413
|
): ModelData {
|
|
414
414
|
const out: ModelData = {};
|
|
415
|
-
const modified = this.modifiedProperties
|
|
415
|
+
const modified = this.modifiedProperties;
|
|
416
416
|
const original = this.getOriginalSnapshot();
|
|
417
417
|
for (const key of keys) {
|
|
418
418
|
if (key === 'id') continue;
|
|
@@ -438,7 +438,7 @@ export abstract class Model {
|
|
|
438
438
|
* is never consumed. With no `keys`, consumes every tracked field.
|
|
439
439
|
*/
|
|
440
440
|
consumeModifiedFields(keys?: Iterable<string>): void {
|
|
441
|
-
if (
|
|
441
|
+
if (this.modifiedProperties.size === 0) {
|
|
442
442
|
return;
|
|
443
443
|
}
|
|
444
444
|
const only = keys ? new Set(keys) : null;
|
package/src/local/SyncClient.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { InstanceCache, ModelScope } from './InstanceCache.js';
|
|
|
13
13
|
import { Model } from './Model.js';
|
|
14
14
|
import type { ModelData } from '@abloatai/transaction/types/modelData';
|
|
15
15
|
import type { AppliedChange } from '../plugin.js';
|
|
16
|
-
import { snapshotJsonValue } from '@abloatai/transaction/utils/json';
|
|
16
|
+
import { deepEqual, snapshotJsonValue } from '@abloatai/transaction/utils/json';
|
|
17
17
|
// ModelRegistry instance accessed via this.objectPool.registry
|
|
18
18
|
import { LoadStrategy } from '@abloatai/transaction/types';
|
|
19
19
|
import { globalRuntime } from './context.js';
|
|
@@ -935,7 +935,8 @@ export class SyncClient extends EventEmitter {
|
|
|
935
935
|
model: Model,
|
|
936
936
|
poolAction: () => void,
|
|
937
937
|
writeOptions?: WriteOptions,
|
|
938
|
-
|
|
938
|
+
capturedChangesOverride?: Record<string, unknown>,
|
|
939
|
+
): Promise<void> | undefined {
|
|
939
940
|
// No-op UPDATE guard (O(1)). An update with no dirty fields would travel
|
|
940
941
|
// to the server, get dropped by `coalesceOperations` Rule 4 (empty input),
|
|
941
942
|
// and — if it was the only op — come back as `lastSyncId: 0`. That trips
|
|
@@ -949,24 +950,32 @@ export class SyncClient extends EventEmitter {
|
|
|
949
950
|
// is false → we fall through to the normal path rather than risk dropping a
|
|
950
951
|
// real write. Only a genuine Model with an empty dirty-set is skipped.
|
|
951
952
|
const hasChanges: unknown = model.hasChanges;
|
|
952
|
-
if (
|
|
953
|
-
|
|
953
|
+
if (
|
|
954
|
+
type === 'update' &&
|
|
955
|
+
hasChanges === false &&
|
|
956
|
+
capturedChangesOverride === undefined
|
|
957
|
+
) {
|
|
958
|
+
return Promise.resolve();
|
|
954
959
|
}
|
|
955
960
|
|
|
956
961
|
// Capture changes before the pool action runs. Pool operations —
|
|
957
962
|
// upsert in particular — can clear the model's local changes, so
|
|
958
963
|
// capturing first ensures they are never lost.
|
|
959
|
-
const capturedChanges =
|
|
960
|
-
|
|
964
|
+
const capturedChanges = capturedChangesOverride !== undefined
|
|
965
|
+
? Object.freeze({ ...capturedChangesOverride })
|
|
966
|
+
: type === 'update' || type === 'create'
|
|
967
|
+
? this.captureModelChanges(model)
|
|
968
|
+
: undefined;
|
|
961
969
|
|
|
962
970
|
poolAction();
|
|
963
|
-
this.stageMutation(type, model, capturedChanges, writeOptions);
|
|
971
|
+
const confirmation = this.stageMutation(type, model, capturedChanges, writeOptions);
|
|
964
972
|
this.notifyObservers({
|
|
965
973
|
type,
|
|
966
974
|
modelType: model.getModelName(),
|
|
967
975
|
model: type !== 'delete' ? model : undefined,
|
|
968
976
|
modelId: model.id,
|
|
969
977
|
});
|
|
978
|
+
return confirmation;
|
|
970
979
|
|
|
971
980
|
// QueryProcessor uses `models:changed` to invalidate caches. Coalesce
|
|
972
981
|
// to one event per microtask: a paste of 100 rows should re-run
|
|
@@ -1004,13 +1013,23 @@ export class SyncClient extends EventEmitter {
|
|
|
1004
1013
|
}
|
|
1005
1014
|
|
|
1006
1015
|
/** Add new model (CREATE) - works offline */
|
|
1007
|
-
add(model: Model, options?: WriteOptions): void {
|
|
1008
|
-
this.mutate('create', model, () => { this.objectPool.add(model, ModelScope.live); }, options);
|
|
1016
|
+
add(model: Model, options?: WriteOptions): Promise<void> | undefined {
|
|
1017
|
+
return this.mutate('create', model, () => { this.objectPool.add(model, ModelScope.live); }, options);
|
|
1009
1018
|
}
|
|
1010
1019
|
|
|
1011
1020
|
/** Update existing model (UPDATE) - works offline */
|
|
1012
|
-
update(
|
|
1013
|
-
|
|
1021
|
+
update(
|
|
1022
|
+
model: Model,
|
|
1023
|
+
options?: WriteOptions,
|
|
1024
|
+
capturedChanges?: Record<string, unknown>,
|
|
1025
|
+
): Promise<void> | undefined {
|
|
1026
|
+
return this.mutate(
|
|
1027
|
+
'update',
|
|
1028
|
+
model,
|
|
1029
|
+
() => { this.objectPool.upsert(model, ModelScope.live); },
|
|
1030
|
+
options,
|
|
1031
|
+
capturedChanges,
|
|
1032
|
+
);
|
|
1014
1033
|
}
|
|
1015
1034
|
|
|
1016
1035
|
/**
|
|
@@ -1055,10 +1074,10 @@ export class SyncClient extends EventEmitter {
|
|
|
1055
1074
|
}
|
|
1056
1075
|
|
|
1057
1076
|
/** Delete model (DELETE) - works offline */
|
|
1058
|
-
delete(model: Model, options?: WriteOptions): void {
|
|
1077
|
+
delete(model: Model, options?: WriteOptions): Promise<void> | undefined {
|
|
1059
1078
|
// Clear pending mutations first to prevent "not found" errors on fast delete
|
|
1060
1079
|
this.mutationQueue.cancelTransactionsForModel(model.id);
|
|
1061
|
-
this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
|
|
1080
|
+
return this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
|
|
1062
1081
|
}
|
|
1063
1082
|
|
|
1064
1083
|
/**
|
|
@@ -1204,8 +1223,8 @@ export class SyncClient extends EventEmitter {
|
|
|
1204
1223
|
model: Model,
|
|
1205
1224
|
capturedChanges?: Record<string, unknown>,
|
|
1206
1225
|
writeOptions?: WriteOptions,
|
|
1207
|
-
): void {
|
|
1208
|
-
if (this.isDisposed) return;
|
|
1226
|
+
): Promise<void> | undefined {
|
|
1227
|
+
if (this.isDisposed) return Promise.resolve();
|
|
1209
1228
|
if (!this.userId || !this.organizationId) {
|
|
1210
1229
|
this.mutationQueue.deferMutation(type, model, capturedChanges, writeOptions);
|
|
1211
1230
|
return;
|
|
@@ -1218,6 +1237,13 @@ export class SyncClient extends EventEmitter {
|
|
|
1218
1237
|
capturedChanges,
|
|
1219
1238
|
writeOptions,
|
|
1220
1239
|
);
|
|
1240
|
+
const confirmation = staging.then(async (transaction) => {
|
|
1241
|
+
await transaction.confirmation;
|
|
1242
|
+
});
|
|
1243
|
+
// Most internal callers intentionally use fire-and-forget writes. Observe
|
|
1244
|
+
// their rejection without replacing the exact promise returned to model
|
|
1245
|
+
// operations that need authoritative per-transaction confirmation.
|
|
1246
|
+
void confirmation.catch(() => undefined);
|
|
1221
1247
|
const pending = staging.then(() => undefined).catch((error: Error) => {
|
|
1222
1248
|
this.runtime.observability.captureMutationFailure({
|
|
1223
1249
|
context: `stage-mutation-${type}`,
|
|
@@ -1228,6 +1254,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1228
1254
|
});
|
|
1229
1255
|
this.pendingStages.add(pending);
|
|
1230
1256
|
void pending.finally(() => this.pendingStages.delete(pending));
|
|
1257
|
+
return confirmation;
|
|
1231
1258
|
}
|
|
1232
1259
|
|
|
1233
1260
|
private scheduleSync(): void {
|
|
@@ -1938,6 +1965,27 @@ export class SyncClient extends EventEmitter {
|
|
|
1938
1965
|
// otherwise re-add it for the brief window before the matching delete
|
|
1939
1966
|
// confirmation lands.
|
|
1940
1967
|
if (this.echoTracker.consumeEcho(transactionId)) {
|
|
1968
|
+
// A direct assignment can re-enter change tracking while this
|
|
1969
|
+
// optimistic write is in flight. Leaving the acknowledged field dirty
|
|
1970
|
+
// makes conflict resolution preserve it over the next collaborator
|
|
1971
|
+
// delta, so peers appear desynchronized until refresh.
|
|
1972
|
+
//
|
|
1973
|
+
// Re-baseline only values this echo actually confirms. If the user has
|
|
1974
|
+
// edited the same field again since the write was sent, its current
|
|
1975
|
+
// dirty value differs from the echo and remains queued.
|
|
1976
|
+
if (resident && result.data) {
|
|
1977
|
+
const acknowledgedFields: string[] = [];
|
|
1978
|
+
for (const [field, change] of resident.modifiedProperties) {
|
|
1979
|
+
if (
|
|
1980
|
+
Object.prototype.hasOwnProperty.call(result.data, field) &&
|
|
1981
|
+
deepEqual(change.new, result.data[field])
|
|
1982
|
+
) {
|
|
1983
|
+
acknowledgedFields.push(field);
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
resident.consumeModifiedFields(acknowledgedFields);
|
|
1987
|
+
resident.markAsSynced();
|
|
1988
|
+
}
|
|
1941
1989
|
continue;
|
|
1942
1990
|
}
|
|
1943
1991
|
|
|
@@ -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
|
*/
|
|
@@ -66,7 +66,6 @@ import type { ModelRegistry } from '../ModelRegistry.js';
|
|
|
66
66
|
import type { InstanceCache } from '../InstanceCache.js';
|
|
67
67
|
import type { SyncClient } from '../SyncClient.js';
|
|
68
68
|
import type { OnDemandLoader } from '../sync/OnDemandLoader.js';
|
|
69
|
-
import type { JoinedParticipant } from '../sync/participants.js';
|
|
70
69
|
import { ModelScope } from '@abloatai/transaction/types';
|
|
71
70
|
import type {
|
|
72
71
|
Duration,
|
|
@@ -112,7 +111,6 @@ export type {
|
|
|
112
111
|
ModelCreateParams,
|
|
113
112
|
ModelUpdateParams,
|
|
114
113
|
ModelDeleteParams,
|
|
115
|
-
JoinOptions,
|
|
116
114
|
} from '@abloatai/transaction/client/resources/modelOperations';
|
|
117
115
|
export type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease };
|
|
118
116
|
|
|
@@ -126,7 +124,6 @@ import type {
|
|
|
126
124
|
ClaimAttemptEvent,
|
|
127
125
|
ClaimQueueView,
|
|
128
126
|
ClaimReorderParams,
|
|
129
|
-
JoinOptions,
|
|
130
127
|
LocalCountOptions,
|
|
131
128
|
LocalReadOptions,
|
|
132
129
|
ModelCreateManyParams,
|
|
@@ -284,17 +281,6 @@ export interface ModelCollaboration {
|
|
|
284
281
|
* fire-and-forget, best-effort semantics as `enterScope`.
|
|
285
282
|
*/
|
|
286
283
|
pinScope?(scope: Record<string, string>): void | Promise<void>;
|
|
287
|
-
/**
|
|
288
|
-
* Opens a presence and claim subscription on this model's sync group(s) and
|
|
289
|
-
* returns the live participant handle. Backs `ablo.<model>.join(ids)`.
|
|
290
|
-
* WebSocket only, since presence needs a live socket; it is absent on other
|
|
291
|
-
* client constructions, where the surface throws a clear error.
|
|
292
|
-
*/
|
|
293
|
-
createJoin?(
|
|
294
|
-
modelKey: string,
|
|
295
|
-
ids: string | readonly string[],
|
|
296
|
-
options?: JoinOptions,
|
|
297
|
-
): Promise<JoinedParticipant>;
|
|
298
284
|
}
|
|
299
285
|
|
|
300
286
|
|
|
@@ -375,26 +361,6 @@ interface ReactiveModelSurface<T, Fields = T> {
|
|
|
375
361
|
*/
|
|
376
362
|
claim: ClaimApi<T, Fields>;
|
|
377
363
|
|
|
378
|
-
/**
|
|
379
|
-
* Joins the sync group(s) for one or more rows of this model and returns a
|
|
380
|
-
* live participant handle — presence (`.peers`), the scoped claim stream
|
|
381
|
-
* (`.claims`), and `.leave()` / `await using` disposal. This is a presence
|
|
382
|
-
* subscription: it reports who else is here and what they hold, not row
|
|
383
|
-
* values changing — for the latter, use `onChange`.
|
|
384
|
-
*
|
|
385
|
-
* WebSocket only: presence needs a live socket, so this is absent on HTTP
|
|
386
|
-
* clients and throws on any non-WebSocket construction.
|
|
387
|
-
*
|
|
388
|
-
* ```ts
|
|
389
|
-
* await using participant = await ablo.sections.join(sectionIds, { ttl: '5m' });
|
|
390
|
-
* participant.peers; // who else is here
|
|
391
|
-
* ```
|
|
392
|
-
*/
|
|
393
|
-
join(
|
|
394
|
-
ids: string | readonly string[],
|
|
395
|
-
options?: JoinOptions,
|
|
396
|
-
): Promise<JoinedParticipant>;
|
|
397
|
-
|
|
398
364
|
/** Subscribe to changes; the callback runs on every change. */
|
|
399
365
|
onChange(
|
|
400
366
|
callback: (entities: T[]) => void,
|
|
@@ -546,7 +512,10 @@ export function createModelOperations<T, C>(
|
|
|
546
512
|
return rows.map((row) => modelAsRow<T>(row));
|
|
547
513
|
};
|
|
548
514
|
|
|
549
|
-
const waitForMutation = async (
|
|
515
|
+
const waitForMutation = async (
|
|
516
|
+
model: Model,
|
|
517
|
+
exactConfirmation?: Promise<void>,
|
|
518
|
+
): Promise<void> => {
|
|
550
519
|
// Model writes are optimistic locally, but their promise has one stable
|
|
551
520
|
// meaning: authoritative confirmation. Callers that do not need the
|
|
552
521
|
// barrier can keep using the row immediately and leave the promise to the
|
|
@@ -558,7 +527,8 @@ export function createModelOperations<T, C>(
|
|
|
558
527
|
// coalescer and producing one SQL transaction per delta.
|
|
559
528
|
await Promise.resolve();
|
|
560
529
|
await syncClient.syncNow();
|
|
561
|
-
|
|
530
|
+
if (exactConfirmation) await exactConfirmation;
|
|
531
|
+
else await syncClient.waitForConfirmation(model.getModelName(), model.id);
|
|
562
532
|
};
|
|
563
533
|
|
|
564
534
|
// Claims this model surface currently holds, keyed by the exact grant id.
|
|
@@ -1402,8 +1372,8 @@ export function createModelOperations<T, C>(
|
|
|
1402
1372
|
}
|
|
1403
1373
|
: {}),
|
|
1404
1374
|
};
|
|
1405
|
-
syncClient.add(model, effective);
|
|
1406
|
-
await waitForMutation(model);
|
|
1375
|
+
const confirmation = syncClient.add(model, effective);
|
|
1376
|
+
await waitForMutation(model, confirmation);
|
|
1407
1377
|
return modelAsRow<T>(model);
|
|
1408
1378
|
} finally {
|
|
1409
1379
|
await autoLease?.release?.().catch(() => {});
|
|
@@ -1528,8 +1498,12 @@ export function createModelOperations<T, C>(
|
|
|
1528
1498
|
: {}),
|
|
1529
1499
|
};
|
|
1530
1500
|
model.applyChanges(patch);
|
|
1531
|
-
syncClient.update(
|
|
1532
|
-
|
|
1501
|
+
const confirmation = syncClient.update(
|
|
1502
|
+
model,
|
|
1503
|
+
effective,
|
|
1504
|
+
patch as Record<string, unknown>,
|
|
1505
|
+
);
|
|
1506
|
+
await waitForMutation(model, confirmation);
|
|
1533
1507
|
return modelAsRow<T>(model);
|
|
1534
1508
|
},
|
|
1535
1509
|
});
|
|
@@ -1585,8 +1559,12 @@ export function createModelOperations<T, C>(
|
|
|
1585
1559
|
// the server. (`updateFromData` is the hydration path and would discard
|
|
1586
1560
|
// the tracking, producing an empty `input: {}` no-op mutation.)
|
|
1587
1561
|
model.applyChanges(params.data);
|
|
1588
|
-
syncClient.update(
|
|
1589
|
-
|
|
1562
|
+
const confirmation = syncClient.update(
|
|
1563
|
+
model,
|
|
1564
|
+
effective,
|
|
1565
|
+
params.data as Record<string, unknown>,
|
|
1566
|
+
);
|
|
1567
|
+
await waitForMutation(model, confirmation);
|
|
1590
1568
|
const updated = modelAsRow<T>(model);
|
|
1591
1569
|
await settleClaimsAfterWrite(id, handle);
|
|
1592
1570
|
return updated;
|
|
@@ -1658,8 +1636,8 @@ export function createModelOperations<T, C>(
|
|
|
1658
1636
|
...opts,
|
|
1659
1637
|
...(selected ? { claimRef: { id: selected.id } } : {}),
|
|
1660
1638
|
};
|
|
1661
|
-
syncClient.delete(model, effective);
|
|
1662
|
-
await waitForMutation(model);
|
|
1639
|
+
const confirmation = syncClient.delete(model, effective);
|
|
1640
|
+
await waitForMutation(model, confirmation);
|
|
1663
1641
|
await settleClaimsAfterWrite(id, handle);
|
|
1664
1642
|
}),
|
|
1665
1643
|
|
|
@@ -1667,21 +1645,6 @@ export function createModelOperations<T, C>(
|
|
|
1667
1645
|
// readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
|
|
1668
1646
|
claim: claimApi,
|
|
1669
1647
|
|
|
1670
|
-
join: guard(
|
|
1671
|
-
(
|
|
1672
|
-
ids: string | readonly string[],
|
|
1673
|
-
options?: JoinOptions,
|
|
1674
|
-
): Promise<JoinedParticipant> => {
|
|
1675
|
-
if (!collaboration?.createJoin) {
|
|
1676
|
-
throw new AbloValidationError(
|
|
1677
|
-
`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).`,
|
|
1678
|
-
{ code: 'model_join_not_configured' },
|
|
1679
|
-
);
|
|
1680
|
-
}
|
|
1681
|
-
return collaboration.createJoin(schemaKey, ids, options);
|
|
1682
|
-
},
|
|
1683
|
-
),
|
|
1684
|
-
|
|
1685
1648
|
onChange(callback, options): () => void {
|
|
1686
1649
|
return autorun(() => {
|
|
1687
1650
|
callback(local.list(options));
|
|
@@ -32,6 +32,11 @@ import type { CommitOutboxScope } from '@abloatai/transaction/commit';
|
|
|
32
32
|
*/
|
|
33
33
|
export type { CredentialProvider } from '@abloatai/transaction/auth/apiKey';
|
|
34
34
|
import type { CredentialProvider } from '@abloatai/transaction/auth/apiKey';
|
|
35
|
+
import type {
|
|
36
|
+
SessionCredential,
|
|
37
|
+
SessionEndpoint,
|
|
38
|
+
SessionProvider,
|
|
39
|
+
} from '@abloatai/transaction/sessions';
|
|
35
40
|
import type { AbloPlugin } from '../../plugin.js';
|
|
36
41
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
37
42
|
|
|
@@ -74,19 +79,21 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
74
79
|
* `ABLO_API_KEY` environment variable, so you usually pass nothing. A
|
|
75
80
|
* long-lived key needs no refresh; the client uses it as-is.
|
|
76
81
|
*
|
|
77
|
-
* - **An async resolver**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* the same renewal machinery as the endpoint form.
|
|
81
|
-
*
|
|
82
|
-
* The endpoint and resolver forms share one contract: return a token; return
|
|
83
|
-
* `null` when the login itself is gone (terminal — the client signs out and
|
|
84
|
-
* fails `ready()` with `session_expired`); or throw on a transient failure, which
|
|
85
|
-
* backs off and retries without signing out. The endpoint form maps HTTP onto
|
|
86
|
-
* this for you: only a structured `401 session_expired` means signed out.
|
|
82
|
+
* - **An async resolver** for advanced process-owned key rotation, such as a
|
|
83
|
+
* vault or workload-identity exchange. Scoped actor renewal belongs in
|
|
84
|
+
* `session` instead.
|
|
87
85
|
*/
|
|
88
86
|
apiKey?: string | CredentialProvider | null | undefined;
|
|
89
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Scoped actor identity. Pass a session returned by `sessions.create()` for
|
|
90
|
+
* bounded work, a provider that re-mints it for a long-lived client, or
|
|
91
|
+
* `{ endpoint: '/api/ablo-session' }` in a browser. Endpoint responses use
|
|
92
|
+
* the canonical credential protocol; only a structured `401
|
|
93
|
+
* session_expired` ends the underlying login.
|
|
94
|
+
*/
|
|
95
|
+
session?: SessionCredential | SessionProvider | SessionEndpoint | null | undefined;
|
|
96
|
+
|
|
90
97
|
/**
|
|
91
98
|
* Pins this client to one Ablo project. During `ready()` the server resolves
|
|
92
99
|
* the API key's actual project and the client refuses to start when it differs.
|
|
@@ -105,33 +112,6 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
105
112
|
*/
|
|
106
113
|
branchId?: string | null | undefined;
|
|
107
114
|
|
|
108
|
-
/**
|
|
109
|
-
* The session-mint endpoint — the browser-side auth field, and the named
|
|
110
|
-
* endpoint for the route that mints the signed-in user's short-lived token:
|
|
111
|
-
*
|
|
112
|
-
* ```ts
|
|
113
|
-
* const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
|
|
114
|
-
* ```
|
|
115
|
-
*
|
|
116
|
-
* The client owns the whole exchange: it POSTs the route (same-origin, cookies
|
|
117
|
-
* included), validates the canonical auth response contract, keeps it fresh
|
|
118
|
-
* ahead of expiry, and re-mints when the server reports the token stale. Only
|
|
119
|
-
* a structured `401 session_expired` response means signed out. It also
|
|
120
|
-
* accepts an async resolver `() => Promise<string | null>` when the exchange
|
|
121
|
-
* needs custom headers or a body — the same contract as the resolver form of
|
|
122
|
-
* `apiKey`.
|
|
123
|
-
*
|
|
124
|
-
* Mutually exclusive with `apiKey`: a server holds a key, a browser holds a mint
|
|
125
|
-
* route, and passing both is a validation error.
|
|
126
|
-
*/
|
|
127
|
-
authEndpoint?: string | CredentialProvider | null | undefined;
|
|
128
|
-
|
|
129
|
-
/** Timeout for a session-mint request. @default 10000 */
|
|
130
|
-
authTimeoutMs?: number | undefined;
|
|
131
|
-
|
|
132
|
-
/** Explicit opt-in for a cross-origin session-mint endpoint. */
|
|
133
|
-
allowCrossOriginAuthEndpoint?: boolean | undefined;
|
|
134
|
-
|
|
135
115
|
/**
|
|
136
116
|
* Local persistence mode. Pass `indexeddb` only when you want offline
|
|
137
117
|
* queueing and a reload-surviving browser cache.
|
|
@@ -264,15 +244,12 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
264
244
|
*/
|
|
265
245
|
apiKey?: string | CredentialProvider | null | undefined;
|
|
266
246
|
|
|
247
|
+
/** A scoped session, or a provider that re-mints it for a long-lived client. */
|
|
248
|
+
session?: SessionCredential | SessionProvider | SessionEndpoint | null | undefined;
|
|
249
|
+
|
|
267
250
|
/** Expected project assertion; see {@link AbloOptions.projectId}. */
|
|
268
251
|
projectId?: string | null | undefined;
|
|
269
252
|
|
|
270
|
-
/**
|
|
271
|
-
* Session-mint endpoint (string or async resolver) — see
|
|
272
|
-
* {@link AbloOptions.authEndpoint}. Mutually exclusive with `apiKey`.
|
|
273
|
-
*/
|
|
274
|
-
authEndpoint?: string | CredentialProvider | null | undefined;
|
|
275
|
-
|
|
276
253
|
/**
|
|
277
254
|
* A bearer auth token, sent as `Authorization: Bearer <token>` on every request.
|
|
278
255
|
*
|