@abloatai/humans 0.46.0 → 0.48.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/dist/local/BaseSyncedStore.d.ts +4 -1
- package/dist/local/BaseSyncedStore.js +1 -0
- package/dist/local/Database.js +1 -1
- package/dist/local/client/clientPrelude.js +5 -1
- package/dist/local/client/createModelProxy.d.ts +1 -3
- package/dist/local/client/createModelProxy.js +28 -23
- package/dist/local/client/options.d.ts +18 -33
- package/dist/local/client/reactiveEngine.js +1 -3
- package/dist/local/client/resourceTypes.d.ts +1 -1
- package/dist/local/client/storeLifecycle.d.ts +4 -0
- package/dist/local/client/storeLifecycle.js +30 -1
- package/dist/local/sync/initialize.js +2 -2
- package/dist/local/transactions/mutations/MutationQueue.d.ts +1 -1
- package/dist/local/transactions/mutations/MutationQueue.js +1 -1
- package/dist/local/transactions/mutations/failureHandling.js +2 -2
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +2 -1
- package/package.json +3 -2
- package/src/local/BaseSyncedStore.ts +5 -1
- package/src/local/Database.ts +1 -1
- package/src/local/client/clientPrelude.ts +5 -1
- package/src/local/client/createModelProxy.ts +30 -24
- package/src/local/client/options.ts +21 -35
- package/src/local/client/reactiveEngine.ts +0 -2
- package/src/local/client/resourceTypes.ts +1 -0
- package/src/local/client/storeLifecycle.ts +43 -0
- package/src/local/sync/initialize.ts +2 -2
- package/src/local/transactions/mutations/MutationQueue.ts +3 -3
- package/src/local/transactions/mutations/failureHandling.ts +2 -2
- package/src/surface.ts +2 -1
|
@@ -32,6 +32,7 @@ import type { EnrichmentPlanEntry, ForeignKeyIndexSpec } from './sync/syncPlan.j
|
|
|
32
32
|
import { type CredentialRefresher } from './sync/credentialLifecycle.js';
|
|
33
33
|
import type { RehydrationStats } from './sync/bootstrapApply.js';
|
|
34
34
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
35
|
+
import type { DeliveryPartitionRoute } from '@abloatai/transaction/auth/deliveryPartition';
|
|
35
36
|
import type { QueuedMutation } from './transactions/mutations/MutationQueue.js';
|
|
36
37
|
import type { CommitLatencySample } from './transactions/mutations/commitLatency.js';
|
|
37
38
|
/** Constructor type for Model subclasses (accepts abstract classes) */
|
|
@@ -90,6 +91,8 @@ export interface UserContext {
|
|
|
90
91
|
branchId: string;
|
|
91
92
|
/** True only when branchId is the project's production root. */
|
|
92
93
|
branchRoot?: boolean;
|
|
94
|
+
/** Server-resolved WebSocket gateway route; never an authorization claim. */
|
|
95
|
+
deliveryPartition?: DeliveryPartitionRoute | null;
|
|
93
96
|
role?: string;
|
|
94
97
|
teamIds?: string[];
|
|
95
98
|
/** Participant kind on the wire. Default 'user' for browser
|
|
@@ -115,7 +118,7 @@ export interface UserContext {
|
|
|
115
118
|
* - `'none'`: open the WebSocket and process live deltas only.
|
|
116
119
|
* Reads go through `model.get()` / filtered subscriptions
|
|
117
120
|
* backfilled by `Covering` deltas. Suitable for transactional
|
|
118
|
-
* participants —
|
|
121
|
+
* participants — headless workers, video pipelines, routine runners —
|
|
119
122
|
* that don't need a local replica of the org's tenant plane.
|
|
120
123
|
*/
|
|
121
124
|
bootstrapMode?: 'full' | 'none';
|
|
@@ -1067,6 +1067,7 @@ export class BaseSyncedStore {
|
|
|
1067
1067
|
}
|
|
1068
1068
|
const syncGroups = this.resolveSyncGroups(context);
|
|
1069
1069
|
this.syncWebSocket.setSyncGroups(syncGroups);
|
|
1070
|
+
this.syncWebSocket.setDeliveryPartition(context.deliveryPartition ?? null);
|
|
1070
1071
|
this.syncWebSocket.setLastSyncId(lastSyncId || 0);
|
|
1071
1072
|
// The permanent base scopes for read interest — same set the connection
|
|
1072
1073
|
// subscribes to at upgrade, so the two can never disagree.
|
package/dist/local/Database.js
CHANGED
|
@@ -791,7 +791,7 @@ export class Database {
|
|
|
791
791
|
// ── inMemory short-circuit ───────────────────────────────────────
|
|
792
792
|
//
|
|
793
793
|
// The batched IDB transaction path below assumes `this.storeManager`
|
|
794
|
-
// and `workspaceDb`. In inMemory mode (
|
|
794
|
+
// and `workspaceDb`. In inMemory mode (headless workers, tests) those
|
|
795
795
|
// don't exist. Without this branch, every live delta arriving over
|
|
796
796
|
// the WebSocket is silently dropped — the local pool never updates,
|
|
797
797
|
// `subscribe()` autoruns never re-fire, lazy-model dispatchers
|
|
@@ -22,8 +22,12 @@ import { createConsoleLogger, resolveLogLevel } from './consoleLogger.js';
|
|
|
22
22
|
* different project than the one being addressed (a warning, not a throw).
|
|
23
23
|
*/
|
|
24
24
|
export function resolveClientPrelude(options) {
|
|
25
|
-
const internalOptions = options;
|
|
26
25
|
const env = readProcessEnv();
|
|
26
|
+
const internalOptions = {
|
|
27
|
+
...options,
|
|
28
|
+
projectId: options.projectId ?? env.ABLO_PROJECT_ID,
|
|
29
|
+
branchId: options.branchId ?? env.ABLO_BRANCH_ID,
|
|
30
|
+
};
|
|
27
31
|
const authInput = { options, env };
|
|
28
32
|
const configuredApiKey = resolveApiKey(authInput);
|
|
29
33
|
const configuredAuthToken = resolveAuthToken(authInput);
|
|
@@ -245,6 +245,4 @@ export declare function createModelProxy<T, C>(schemaKey: string, registeredMode
|
|
|
245
245
|
* collaborator, a test most of all, is pushed into a cast through `unknown`
|
|
246
246
|
* to supply the one method that is actually read.
|
|
247
247
|
*/
|
|
248
|
-
hydration: Pick<OnDemandLoader, 'fetch'>, collaboration?: ModelCollaboration,
|
|
249
|
-
/** The client-wide `wait` default; a per-call `wait` still wins over it. */
|
|
250
|
-
defaultWait?: 'queued' | 'confirmed'): ModelOperations<T, C>;
|
|
248
|
+
hydration: Pick<OnDemandLoader, 'fetch'>, collaboration?: ModelCollaboration): ModelOperations<T, C>;
|
|
@@ -43,9 +43,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
43
43
|
* collaborator, a test most of all, is pushed into a cast through `unknown`
|
|
44
44
|
* to supply the one method that is actually read.
|
|
45
45
|
*/
|
|
46
|
-
hydration, collaboration
|
|
47
|
-
/** The client-wide `wait` default; a per-call `wait` still wins over it. */
|
|
48
|
-
defaultWait) {
|
|
46
|
+
hydration, collaboration) {
|
|
49
47
|
/**
|
|
50
48
|
* Resolve a row **this** resource owns.
|
|
51
49
|
*
|
|
@@ -100,17 +98,30 @@ defaultWait) {
|
|
|
100
98
|
}
|
|
101
99
|
};
|
|
102
100
|
};
|
|
101
|
+
const guardWrite = (fn) => {
|
|
102
|
+
const guarded = guard(fn);
|
|
103
|
+
return (...args) => {
|
|
104
|
+
const confirmation = guarded(...args);
|
|
105
|
+
// Optimistic writes are intentionally useful without awaiting them. The
|
|
106
|
+
// transaction pipeline already reports a later refusal through
|
|
107
|
+
// `onMutationFailure`; attach an observer here as well so choosing not to
|
|
108
|
+
// await does not create an unhandled-rejection process error. Returning
|
|
109
|
+
// the original promise preserves normal rejection for callers that do
|
|
110
|
+
// await or attach their own catch handler.
|
|
111
|
+
void confirmation.catch(() => undefined);
|
|
112
|
+
return confirmation;
|
|
113
|
+
};
|
|
114
|
+
};
|
|
103
115
|
const load = async (options) => {
|
|
104
116
|
const rows = await hydration.fetch(schemaKey, options);
|
|
105
117
|
return rows.map((row) => modelAsRow(row));
|
|
106
118
|
};
|
|
107
|
-
const waitForMutation = async (model
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
return;
|
|
119
|
+
const waitForMutation = async (model) => {
|
|
120
|
+
// Model writes are optimistic locally, but their promise has one stable
|
|
121
|
+
// meaning: authoritative confirmation. Callers that do not need the
|
|
122
|
+
// barrier can keep using the row immediately and leave the promise to the
|
|
123
|
+
// global mutation-failure handler; awaiting the promise never means merely
|
|
124
|
+
// "placed in the local queue".
|
|
114
125
|
// Let sibling writes from the same synchronous burst enter the mutation
|
|
115
126
|
// queue before forcing a drain. Without this yield, every confirmed
|
|
116
127
|
// create/update calls syncNow() alone, defeating the queue's microtask
|
|
@@ -147,7 +158,6 @@ defaultWait) {
|
|
|
147
158
|
? { idempotencyKey: params.idempotencyKey }
|
|
148
159
|
: {}),
|
|
149
160
|
...(params.label !== undefined ? { label: params.label } : {}),
|
|
150
|
-
...(params.wait !== undefined ? { wait: params.wait } : {}),
|
|
151
161
|
...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
|
|
152
162
|
...(params.onStale !== undefined ? { onStale: params.onStale } : {}),
|
|
153
163
|
...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
|
|
@@ -614,7 +624,7 @@ defaultWait) {
|
|
|
614
624
|
// No automatic scope enrolment on bulk `list`: that would subscribe to an
|
|
615
625
|
// unbounded set of rows' entity groups.
|
|
616
626
|
list: guard(load),
|
|
617
|
-
create:
|
|
627
|
+
create: guardWrite(async (params) => {
|
|
618
628
|
const id = params.id ?? Model.generateId();
|
|
619
629
|
const opts = mutationOptions(params);
|
|
620
630
|
const claim = params.claim;
|
|
@@ -665,7 +675,7 @@ defaultWait) {
|
|
|
665
675
|
};
|
|
666
676
|
try {
|
|
667
677
|
syncClient.add(model, effective);
|
|
668
|
-
await waitForMutation(model
|
|
678
|
+
await waitForMutation(model);
|
|
669
679
|
return modelAsRow(model);
|
|
670
680
|
}
|
|
671
681
|
finally {
|
|
@@ -677,7 +687,7 @@ defaultWait) {
|
|
|
677
687
|
// wrapping while exposing the two public signatures (a plain `guard(...)`
|
|
678
688
|
// would collapse them to one).
|
|
679
689
|
update: (() => {
|
|
680
|
-
const updateImpl =
|
|
690
|
+
const updateImpl = guardWrite(async (arg, updater, contention) => {
|
|
681
691
|
// Functional form: update(id, current => next). Same guarantee as the
|
|
682
692
|
// HTTP client (shared reconcile loop), implemented with this transport's
|
|
683
693
|
// own read-fresh + confirmed compare-and-swap. A forced server round-trip
|
|
@@ -715,13 +725,12 @@ defaultWait) {
|
|
|
715
725
|
throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
|
|
716
726
|
}
|
|
717
727
|
const effective = {
|
|
718
|
-
wait: 'confirmed',
|
|
719
728
|
readAt,
|
|
720
729
|
onStale: 'reject',
|
|
721
730
|
};
|
|
722
731
|
model.applyChanges(patch);
|
|
723
732
|
syncClient.update(model, effective);
|
|
724
|
-
await waitForMutation(model
|
|
733
|
+
await waitForMutation(model);
|
|
725
734
|
return modelAsRow(model);
|
|
726
735
|
},
|
|
727
736
|
});
|
|
@@ -753,7 +762,6 @@ defaultWait) {
|
|
|
753
762
|
const handle = isClaimHandle(params.claim) ? params.claim : undefined;
|
|
754
763
|
const effective = claimed
|
|
755
764
|
? {
|
|
756
|
-
wait: 'confirmed',
|
|
757
765
|
readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
|
|
758
766
|
onStale: 'reject',
|
|
759
767
|
claimRef: { id: claimed.lease.id },
|
|
@@ -765,7 +773,6 @@ defaultWait) {
|
|
|
765
773
|
// works across clients (HTTP-minted handles included).
|
|
766
774
|
...(handle?.readAt !== undefined
|
|
767
775
|
? {
|
|
768
|
-
wait: 'confirmed',
|
|
769
776
|
readAt: handle.readAt,
|
|
770
777
|
onStale: 'reject',
|
|
771
778
|
...(handle.fenceToken !== undefined
|
|
@@ -782,7 +789,7 @@ defaultWait) {
|
|
|
782
789
|
// the tracking, producing an empty `input: {}` no-op mutation.)
|
|
783
790
|
model.applyChanges(params.data);
|
|
784
791
|
syncClient.update(model, effective);
|
|
785
|
-
await waitForMutation(model
|
|
792
|
+
await waitForMutation(model);
|
|
786
793
|
return modelAsRow(model);
|
|
787
794
|
});
|
|
788
795
|
function update(arg, updater, contention) {
|
|
@@ -790,7 +797,7 @@ defaultWait) {
|
|
|
790
797
|
}
|
|
791
798
|
return update;
|
|
792
799
|
})(),
|
|
793
|
-
delete:
|
|
800
|
+
delete: guardWrite(async (params) => {
|
|
794
801
|
const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
|
|
795
802
|
if (autoClaim) {
|
|
796
803
|
const handle = await takeClaim({ ...autoClaim, id: params.id });
|
|
@@ -823,7 +830,6 @@ defaultWait) {
|
|
|
823
830
|
const handle = isClaimHandle(params.claim) ? params.claim : undefined;
|
|
824
831
|
const effective = claimed
|
|
825
832
|
? {
|
|
826
|
-
wait: 'confirmed',
|
|
827
833
|
readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
|
|
828
834
|
onStale: 'reject',
|
|
829
835
|
claimRef: { id: claimed.lease.id },
|
|
@@ -835,7 +841,6 @@ defaultWait) {
|
|
|
835
841
|
: {
|
|
836
842
|
...(handle?.readAt !== undefined
|
|
837
843
|
? {
|
|
838
|
-
wait: 'confirmed',
|
|
839
844
|
readAt: handle.readAt,
|
|
840
845
|
onStale: 'reject',
|
|
841
846
|
}
|
|
@@ -844,7 +849,7 @@ defaultWait) {
|
|
|
844
849
|
...(handle ? { claim: { id: handle.id } } : {}),
|
|
845
850
|
};
|
|
846
851
|
syncClient.delete(model, effective);
|
|
847
|
-
await waitForMutation(model
|
|
852
|
+
await waitForMutation(model);
|
|
848
853
|
}),
|
|
849
854
|
// `claim` is a callable namespace (take a claim) carrying the coordination
|
|
850
855
|
// readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
|
|
@@ -68,6 +68,19 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
68
68
|
* this for you: only a structured `401 session_expired` means signed out.
|
|
69
69
|
*/
|
|
70
70
|
apiKey?: string | CredentialProvider | null | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Pins this client to one Ablo project. During `ready()` the server resolves
|
|
73
|
+
* the API key's actual project and the client refuses to start when it differs.
|
|
74
|
+
* Defaults to `ABLO_PROJECT_ID`; `ablo dev` writes that value beside the key.
|
|
75
|
+
* This is an assertion, never a routing selector — the key remains authoritative.
|
|
76
|
+
*/
|
|
77
|
+
projectId?: string | null | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* Pins this client to one immutable Ablo branch. Defaults to
|
|
80
|
+
* `ABLO_BRANCH_ID`; `ablo dev` writes it beside the branch key. Like
|
|
81
|
+
* `projectId`, this is a startup assertion and never selects a branch.
|
|
82
|
+
*/
|
|
83
|
+
branchId?: string | null | undefined;
|
|
71
84
|
/**
|
|
72
85
|
* The session-mint endpoint — the browser-side auth field, and the named
|
|
73
86
|
* endpoint for the route that mints the signed-in user's short-lived token:
|
|
@@ -116,7 +129,7 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
116
129
|
* Ablo({
|
|
117
130
|
* schema,
|
|
118
131
|
* apiKey,
|
|
119
|
-
* durableWrites: { store, namespace: '
|
|
132
|
+
* durableWrites: { store, namespace: 'headless-worker' },
|
|
120
133
|
* })
|
|
121
134
|
* ```
|
|
122
135
|
*/
|
|
@@ -192,33 +205,6 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
192
205
|
* session token (`ek_`/`rk_`) or you route through a controlled server proxy.
|
|
193
206
|
*/
|
|
194
207
|
dangerouslyAllowBrowser?: boolean | undefined;
|
|
195
|
-
/**
|
|
196
|
-
* How far a write goes before its promise settles, for every model write on
|
|
197
|
-
* this client. The same word each write already takes per call
|
|
198
|
-
* (`create({ …, wait: 'confirmed' })`); setting it here makes it the default
|
|
199
|
-
* instead of repeating it.
|
|
200
|
-
*
|
|
201
|
-
* A write resolves as soon as it is applied locally and queued. That is what
|
|
202
|
-
* makes the UI immediate, and it is right for most writes — but it means a
|
|
203
|
-
* write the server later REFUSES has no caller left to tell. The rejection
|
|
204
|
-
* reverts the local row and reaches `ablo.onMutationFailure(…)`, and an
|
|
205
|
-
* application that subscribes to neither shows the change, then loses it,
|
|
206
|
-
* with nothing thrown anywhere.
|
|
207
|
-
*
|
|
208
|
-
* ```ts
|
|
209
|
-
* const ablo = new Ablo({ schema, apiKey, wait: 'confirmed' });
|
|
210
|
-
* try {
|
|
211
|
-
* await ablo.documents.update({ id, data }); // throws if refused
|
|
212
|
-
* } catch (err) {
|
|
213
|
-
* if (err instanceof AbloError) show(err.message);
|
|
214
|
-
* }
|
|
215
|
-
* ```
|
|
216
|
-
*
|
|
217
|
-
* The cost is real: each write now waits for the server's answer, so it is a
|
|
218
|
-
* choice between immediacy and certainty rather than a strict improvement.
|
|
219
|
-
* A per-call `wait` still wins over this.
|
|
220
|
-
*/
|
|
221
|
-
wait?: 'queued' | 'confirmed' | undefined;
|
|
222
208
|
}
|
|
223
209
|
export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
224
210
|
/**
|
|
@@ -233,6 +219,8 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
233
219
|
* the original available as `cause`.
|
|
234
220
|
*/
|
|
235
221
|
apiKey?: string | CredentialProvider | null | undefined;
|
|
222
|
+
/** Expected project assertion; see {@link AbloOptions.projectId}. */
|
|
223
|
+
projectId?: string | null | undefined;
|
|
236
224
|
/**
|
|
237
225
|
* Session-mint endpoint (string or async resolver) — see
|
|
238
226
|
* {@link AbloOptions.authEndpoint}. Mutually exclusive with `apiKey`.
|
|
@@ -435,13 +423,10 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
435
423
|
*/
|
|
436
424
|
organizationId?: string;
|
|
437
425
|
/**
|
|
438
|
-
*
|
|
439
|
-
*
|
|
426
|
+
* Expected immutable branch. Hosted clients compare it with the credential
|
|
427
|
+
* exchange; self-hosted clients use it as their locally selected branch.
|
|
440
428
|
*/
|
|
441
429
|
branchId?: string;
|
|
442
430
|
/** Whether the selected self-hosted branch is the project's root branch. */
|
|
443
431
|
branchRoot?: boolean;
|
|
444
|
-
/** The client-wide write default — see {@link AbloOptions.wait}. Projected
|
|
445
|
-
* from the public option rather than restated, so the two cannot diverge. */
|
|
446
|
-
wait?: AbloOptions['wait'];
|
|
447
432
|
}
|
|
@@ -429,9 +429,7 @@ export function buildReactiveEngine(inputs) {
|
|
|
429
429
|
scope: { [modelKey]: ids },
|
|
430
430
|
...(options?.ttl !== undefined ? { ttl: options.ttl } : {}),
|
|
431
431
|
}),
|
|
432
|
-
}
|
|
433
|
-
// The client-wide `wait` default; a per-call `wait` still wins.
|
|
434
|
-
internalOptions.wait);
|
|
432
|
+
});
|
|
435
433
|
}
|
|
436
434
|
const commits = {
|
|
437
435
|
async create(commitOptions) {
|
|
@@ -8,5 +8,5 @@
|
|
|
8
8
|
* live participant handle.
|
|
9
9
|
*/
|
|
10
10
|
export * from '@abloatai/transaction/resources/httpResources';
|
|
11
|
-
export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimContentionOptions, ClaimAttemptEvent, ClaimQueueView, ClaimLookupParams, ClaimReorderParams, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease, } from '@abloatai/transaction/resources/modelOperations';
|
|
11
|
+
export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelWriteOptions, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimContentionOptions, ClaimAttemptEvent, ClaimQueueView, ClaimLookupParams, ClaimReorderParams, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease, } from '@abloatai/transaction/resources/modelOperations';
|
|
12
12
|
export type { ModelOperations } from './createModelProxy.js';
|
|
@@ -51,6 +51,10 @@ export interface StoreLifecycleDeps<S extends SchemaRecord> {
|
|
|
51
51
|
/** Seeds resolved identity into the engine's own state: the self locals and the streams. */
|
|
52
52
|
readonly onIdentityResolved: (seed: IdentitySeed) => void;
|
|
53
53
|
}
|
|
54
|
+
/** Refuse a credential for another project before the store opens its socket. */
|
|
55
|
+
export declare function assertExpectedProject(expectedProjectId: string | null | undefined, actualProjectId: string | null): void;
|
|
56
|
+
/** Refuse a credential for another branch before the store opens its socket. */
|
|
57
|
+
export declare function assertExpectedBranch(expectedBranchId: string | null | undefined, actualBranchId: string | null): void;
|
|
54
58
|
/**
|
|
55
59
|
* Wires the credential machinery onto the cluster and returns the `ready`
|
|
56
60
|
* the client exposes. Wiring happens now — the refresh lifecycle, the
|
|
@@ -10,6 +10,32 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { resolveParticipantIdentity } from '@abloatai/transaction/auth/identity';
|
|
12
12
|
import { AbloAuthenticationError, AbloConnectionError, toAbloError, } from '@abloatai/transaction/errors';
|
|
13
|
+
/** Refuse a credential for another project before the store opens its socket. */
|
|
14
|
+
export function assertExpectedProject(expectedProjectId, actualProjectId) {
|
|
15
|
+
const expected = expectedProjectId?.trim();
|
|
16
|
+
if (!expected || actualProjectId === expected)
|
|
17
|
+
return;
|
|
18
|
+
throw new AbloAuthenticationError(`ABLO_API_KEY belongs to project ${actualProjectId ?? '(none)'}, but this app is pinned to ${expected} by projectId/ABLO_PROJECT_ID.`, {
|
|
19
|
+
code: 'project_scope_denied',
|
|
20
|
+
details: {
|
|
21
|
+
expectedProjectId: expected,
|
|
22
|
+
actualProjectId,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** Refuse a credential for another branch before the store opens its socket. */
|
|
27
|
+
export function assertExpectedBranch(expectedBranchId, actualBranchId) {
|
|
28
|
+
const expected = expectedBranchId?.trim();
|
|
29
|
+
if (!expected || actualBranchId === expected)
|
|
30
|
+
return;
|
|
31
|
+
throw new AbloAuthenticationError(`ABLO_API_KEY belongs to branch ${actualBranchId ?? '(none)'}, but this app is pinned to ${expected} by branchId/ABLO_BRANCH_ID.`, {
|
|
32
|
+
code: 'branch_scope_denied',
|
|
33
|
+
details: {
|
|
34
|
+
expectedBranchId: expected,
|
|
35
|
+
actualBranchId,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
13
39
|
/**
|
|
14
40
|
* Wires the credential machinery onto the cluster and returns the `ready`
|
|
15
41
|
* the client exposes. Wiring happens now — the refresh lifecycle, the
|
|
@@ -107,7 +133,9 @@ export function startStoreLifecycle(deps) {
|
|
|
107
133
|
auth: authCredentials,
|
|
108
134
|
logger,
|
|
109
135
|
});
|
|
110
|
-
const { userId, accountScope, projectId, branchId, branchRoot, teamIds, capabilityToken, syncGroups, participantKind, } = resolved;
|
|
136
|
+
const { userId, accountScope, projectId, branchId, branchRoot, teamIds, capabilityToken, syncGroups, participantKind, deliveryPartition, } = resolved;
|
|
137
|
+
assertExpectedProject(internalOptions.projectId, projectId);
|
|
138
|
+
assertExpectedBranch(internalOptions.branchId, branchId);
|
|
111
139
|
// Fail-loud guard: detect the degenerate "no real sync groups
|
|
112
140
|
// resolved" state before opening the socket. It is the same class of bug as
|
|
113
141
|
// a sensible-looking default that's functionally broken: the
|
|
@@ -168,6 +196,7 @@ export function startStoreLifecycle(deps) {
|
|
|
168
196
|
kind: participantKind,
|
|
169
197
|
capabilityToken,
|
|
170
198
|
syncGroups,
|
|
199
|
+
deliveryPartition,
|
|
171
200
|
bootstrapMode: resolvedBootstrapMode,
|
|
172
201
|
});
|
|
173
202
|
let current = gen.next();
|
|
@@ -42,7 +42,7 @@ export function* initialize(host, context, signal) {
|
|
|
42
42
|
host.setupWebSocketSync(context, lastSyncId);
|
|
43
43
|
// Bootstrap from server if needed.
|
|
44
44
|
//
|
|
45
|
-
// `bootstrapMode: 'none'` participants (
|
|
45
|
+
// `bootstrapMode: 'none'` participants (headless workers and
|
|
46
46
|
// task runners) skip baseline replication — they read via
|
|
47
47
|
// `model.get()` round-trips and rely on covering deltas
|
|
48
48
|
// from filtered subscriptions to populate the pool lazily. The
|
|
@@ -54,7 +54,7 @@ export function* initialize(host, context, signal) {
|
|
|
54
54
|
// `setupWebSocketSync` above creates the SyncWebSocket and
|
|
55
55
|
// initiates the upgrade, but it does NOT await the 'connected'
|
|
56
56
|
// event — it returns synchronously after wiring listeners.
|
|
57
|
-
// For bootstrapMode='none' consumers (
|
|
57
|
+
// For bootstrapMode='none' consumers (headless workers and
|
|
58
58
|
// task runners), this branch is the entire body of initialize()
|
|
59
59
|
// after the WS is set up, so `ready()` would otherwise resolve
|
|
60
60
|
// while the WS is still in 'connecting' state. The very next
|
|
@@ -230,7 +230,7 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
230
230
|
private dispatchCommitBounded;
|
|
231
231
|
private clearReplicationLagState;
|
|
232
232
|
/**
|
|
233
|
-
* Bounds the public
|
|
233
|
+
* Bounds the public model-write confirmation promise without changing the
|
|
234
234
|
* accepted write's lifecycle. A lag timeout is not a rejection from the
|
|
235
235
|
* source database, so it must never emit `transaction:failed`, roll back
|
|
236
236
|
* optimistic state, or remove the durable replay envelope.
|
|
@@ -740,7 +740,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
740
740
|
this.replicationLagErrors.delete(transactionId);
|
|
741
741
|
}
|
|
742
742
|
/**
|
|
743
|
-
* Bounds the public
|
|
743
|
+
* Bounds the public model-write confirmation promise without changing the
|
|
744
744
|
* accepted write's lifecycle. A lag timeout is not a rejection from the
|
|
745
745
|
* source database, so it must never emit `transaction:failed`, roll back
|
|
746
746
|
* optimistic state, or remove the durable replay envelope.
|
|
@@ -101,8 +101,8 @@ export async function handleFailure(ctx, transaction, error) {
|
|
|
101
101
|
await ctx.rollbackOptimistic(transaction, 'permanent_error', error);
|
|
102
102
|
}
|
|
103
103
|
ctx.emit('transaction:failed', { transaction, error, permanent: true });
|
|
104
|
-
// The id-suffixed event is what
|
|
105
|
-
// `
|
|
104
|
+
// The id-suffixed event is what the awaited model-write promise listens
|
|
105
|
+
// on through `waitForConfirmation` — without it a permanently
|
|
106
106
|
// rejected write left the caller's promise hanging forever.
|
|
107
107
|
ctx.emit(`transaction:failed:${transaction.id}`, { error });
|
|
108
108
|
return;
|
package/dist/surface.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ export declare const PUBLIC_LIST_OPTION_KEYS: readonly ["where", "filter", "orde
|
|
|
30
30
|
* The keys of the client constructor options, {@link AbloOptions}. Only
|
|
31
31
|
* `schema` is required; every other key is optional.
|
|
32
32
|
*/
|
|
33
|
-
export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "authEndpoint", "authTimeoutMs", "allowCrossOriginAuthEndpoint", "persistence", "durableWrites", "commitOutbox", "commitOutboxScope", "debug", "logLevel", "logger", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser", "collaborationEvents", "plugins"
|
|
33
|
+
export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "projectId", "branchId", "authEndpoint", "authTimeoutMs", "allowCrossOriginAuthEndpoint", "persistence", "durableWrites", "commitOutbox", "commitOutboxScope", "debug", "logLevel", "logger", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser", "collaborationEvents", "plugins"];
|
|
34
34
|
export type ModelVerb = (typeof PUBLIC_MODEL_VERBS)[number];
|
|
35
35
|
export type ListOptionKey = (typeof PUBLIC_LIST_OPTION_KEYS)[number];
|
|
36
36
|
export type AbloOptionKey = (typeof PUBLIC_ABLO_OPTION_KEYS)[number];
|
package/dist/surface.js
CHANGED
|
@@ -55,6 +55,8 @@ export const PUBLIC_LIST_OPTION_KEYS = [
|
|
|
55
55
|
export const PUBLIC_ABLO_OPTION_KEYS = [
|
|
56
56
|
'schema',
|
|
57
57
|
'apiKey',
|
|
58
|
+
'projectId',
|
|
59
|
+
'branchId',
|
|
58
60
|
'authEndpoint',
|
|
59
61
|
'authTimeoutMs',
|
|
60
62
|
'allowCrossOriginAuthEndpoint',
|
|
@@ -73,5 +75,4 @@ export const PUBLIC_ABLO_OPTION_KEYS = [
|
|
|
73
75
|
'dangerouslyAllowBrowser',
|
|
74
76
|
'collaborationEvents',
|
|
75
77
|
'plugins',
|
|
76
|
-
'wait',
|
|
77
78
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/humans",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"directory": "packages/humans"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@abloatai/transaction": "^0.
|
|
87
|
+
"@abloatai/transaction": "^0.48.0",
|
|
88
88
|
"mobx": "^6.13.7",
|
|
89
89
|
"uuid": "^11.1.0",
|
|
90
90
|
"zod": "^4.4.3"
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
},
|
|
100
100
|
"devDependencies": {
|
|
101
101
|
"@jest/globals": "^30.2.0",
|
|
102
|
+
"@testing-library/dom": "^10.0.0",
|
|
102
103
|
"@testing-library/react": "^16.0.0",
|
|
103
104
|
"@types/jest": "^30.0.0",
|
|
104
105
|
"@types/react": "^19.0.0",
|
|
@@ -73,6 +73,7 @@ import type { PoolContext, RehydrationStats } from './sync/bootstrapApply.js';
|
|
|
73
73
|
import * as deltaPipeline from './sync/deltaPipeline.js';
|
|
74
74
|
import type { DeltaPipelineContext } from './sync/deltaPipeline.js';
|
|
75
75
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
76
|
+
import type { DeliveryPartitionRoute } from '@abloatai/transaction/auth/deliveryPartition';
|
|
76
77
|
import { queryByClass as runQueryByClass, countModels } from './store/queryApi.js';
|
|
77
78
|
import type { QueuedMutation } from './transactions/mutations/MutationQueue.js';
|
|
78
79
|
import type { CommitLatencySample } from './transactions/mutations/commitLatency.js';
|
|
@@ -150,6 +151,8 @@ export interface UserContext {
|
|
|
150
151
|
branchId: string;
|
|
151
152
|
/** True only when branchId is the project's production root. */
|
|
152
153
|
branchRoot?: boolean;
|
|
154
|
+
/** Server-resolved WebSocket gateway route; never an authorization claim. */
|
|
155
|
+
deliveryPartition?: DeliveryPartitionRoute | null;
|
|
153
156
|
role?: string;
|
|
154
157
|
teamIds?: string[];
|
|
155
158
|
/** Participant kind on the wire. Default 'user' for browser
|
|
@@ -175,7 +178,7 @@ export interface UserContext {
|
|
|
175
178
|
* - `'none'`: open the WebSocket and process live deltas only.
|
|
176
179
|
* Reads go through `model.get()` / filtered subscriptions
|
|
177
180
|
* backfilled by `Covering` deltas. Suitable for transactional
|
|
178
|
-
* participants —
|
|
181
|
+
* participants — headless workers, video pipelines, routine runners —
|
|
179
182
|
* that don't need a local replica of the org's tenant plane.
|
|
180
183
|
*/
|
|
181
184
|
bootstrapMode?: 'full' | 'none';
|
|
@@ -1457,6 +1460,7 @@ export class BaseSyncedStore<
|
|
|
1457
1460
|
}
|
|
1458
1461
|
const syncGroups = this.resolveSyncGroups(context);
|
|
1459
1462
|
this.syncWebSocket.setSyncGroups(syncGroups);
|
|
1463
|
+
this.syncWebSocket.setDeliveryPartition(context.deliveryPartition ?? null);
|
|
1460
1464
|
this.syncWebSocket.setLastSyncId(lastSyncId || 0);
|
|
1461
1465
|
// The permanent base scopes for read interest — same set the connection
|
|
1462
1466
|
// subscribes to at upgrade, so the two can never disagree.
|
package/src/local/Database.ts
CHANGED
|
@@ -1125,7 +1125,7 @@ export class Database {
|
|
|
1125
1125
|
// ── inMemory short-circuit ───────────────────────────────────────
|
|
1126
1126
|
//
|
|
1127
1127
|
// The batched IDB transaction path below assumes `this.storeManager`
|
|
1128
|
-
// and `workspaceDb`. In inMemory mode (
|
|
1128
|
+
// and `workspaceDb`. In inMemory mode (headless workers, tests) those
|
|
1129
1129
|
// don't exist. Without this branch, every live delta arriving over
|
|
1130
1130
|
// the WebSocket is silently dropped — the local pool never updates,
|
|
1131
1131
|
// `subscribe()` autoruns never re-fire, lazy-model dispatchers
|
|
@@ -69,8 +69,12 @@ export interface ClientPrelude<S extends SchemaRecord> {
|
|
|
69
69
|
export function resolveClientPrelude<S extends SchemaRecord>(
|
|
70
70
|
options: AbloOptions<S>,
|
|
71
71
|
): ClientPrelude<S> {
|
|
72
|
-
const internalOptions = options as InternalAbloOptions<S>;
|
|
73
72
|
const env = readProcessEnv();
|
|
73
|
+
const internalOptions = {
|
|
74
|
+
...options,
|
|
75
|
+
projectId: options.projectId ?? env.ABLO_PROJECT_ID,
|
|
76
|
+
branchId: options.branchId ?? env.ABLO_BRANCH_ID,
|
|
77
|
+
} as InternalAbloOptions<S>;
|
|
74
78
|
const authInput = { options, env };
|
|
75
79
|
const configuredApiKey = resolveApiKey(authInput);
|
|
76
80
|
const configuredAuthToken = resolveAuthToken(authInput);
|
|
@@ -405,8 +405,6 @@ export function createModelProxy<T, C>(
|
|
|
405
405
|
*/
|
|
406
406
|
hydration: Pick<OnDemandLoader, 'fetch'>,
|
|
407
407
|
collaboration?: ModelCollaboration,
|
|
408
|
-
/** The client-wide `wait` default; a per-call `wait` still wins over it. */
|
|
409
|
-
defaultWait?: 'queued' | 'confirmed',
|
|
410
408
|
): ModelOperations<T, C> {
|
|
411
409
|
/**
|
|
412
410
|
* Resolve a row **this** resource owns.
|
|
@@ -471,20 +469,34 @@ export function createModelProxy<T, C>(
|
|
|
471
469
|
};
|
|
472
470
|
};
|
|
473
471
|
|
|
472
|
+
const guardWrite = <A extends unknown[], R>(
|
|
473
|
+
fn: (...args: A) => Promise<R>,
|
|
474
|
+
): ((...args: A) => Promise<R>) => {
|
|
475
|
+
const guarded = guard(fn);
|
|
476
|
+
return (...args: A): Promise<R> => {
|
|
477
|
+
const confirmation = guarded(...args);
|
|
478
|
+
// Optimistic writes are intentionally useful without awaiting them. The
|
|
479
|
+
// transaction pipeline already reports a later refusal through
|
|
480
|
+
// `onMutationFailure`; attach an observer here as well so choosing not to
|
|
481
|
+
// await does not create an unhandled-rejection process error. Returning
|
|
482
|
+
// the original promise preserves normal rejection for callers that do
|
|
483
|
+
// await or attach their own catch handler.
|
|
484
|
+
void confirmation.catch(() => undefined);
|
|
485
|
+
return confirmation;
|
|
486
|
+
};
|
|
487
|
+
};
|
|
488
|
+
|
|
474
489
|
const load = async (options?: ServerReadOptions<T>): Promise<T[]> => {
|
|
475
490
|
const rows = await hydration.fetch<T>(schemaKey, options);
|
|
476
491
|
return rows.map((row) => modelAsRow<T>(row));
|
|
477
492
|
};
|
|
478
493
|
|
|
479
|
-
const waitForMutation = async (
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
// client configured that way rejects on a refused write everywhere rather
|
|
486
|
-
// than in the one place a caller remembered to ask.
|
|
487
|
-
if ((options?.wait ?? defaultWait) !== 'confirmed') return;
|
|
494
|
+
const waitForMutation = async (model: Model): Promise<void> => {
|
|
495
|
+
// Model writes are optimistic locally, but their promise has one stable
|
|
496
|
+
// meaning: authoritative confirmation. Callers that do not need the
|
|
497
|
+
// barrier can keep using the row immediately and leave the promise to the
|
|
498
|
+
// global mutation-failure handler; awaiting the promise never means merely
|
|
499
|
+
// "placed in the local queue".
|
|
488
500
|
// Let sibling writes from the same synchronous burst enter the mutation
|
|
489
501
|
// queue before forcing a drain. Without this yield, every confirmed
|
|
490
502
|
// create/update calls syncNow() alone, defeating the queue's microtask
|
|
@@ -540,7 +552,6 @@ export function createModelProxy<T, C>(
|
|
|
540
552
|
? { idempotencyKey: params.idempotencyKey }
|
|
541
553
|
: {}),
|
|
542
554
|
...(params.label !== undefined ? { label: params.label } : {}),
|
|
543
|
-
...(params.wait !== undefined ? { wait: params.wait } : {}),
|
|
544
555
|
...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
|
|
545
556
|
...(params.onStale !== undefined ? { onStale: params.onStale } : {}),
|
|
546
557
|
...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
|
|
@@ -1086,7 +1097,7 @@ export function createModelProxy<T, C>(
|
|
|
1086
1097
|
// unbounded set of rows' entity groups.
|
|
1087
1098
|
list: guard(load),
|
|
1088
1099
|
|
|
1089
|
-
create:
|
|
1100
|
+
create: guardWrite(async (params: ModelCreateParams<T, C>): Promise<T> => {
|
|
1090
1101
|
const id = params.id ?? Model.generateId();
|
|
1091
1102
|
const opts = mutationOptions(params);
|
|
1092
1103
|
const claim = params.claim;
|
|
@@ -1142,7 +1153,7 @@ export function createModelProxy<T, C>(
|
|
|
1142
1153
|
};
|
|
1143
1154
|
try {
|
|
1144
1155
|
syncClient.add(model, effective);
|
|
1145
|
-
await waitForMutation(model
|
|
1156
|
+
await waitForMutation(model);
|
|
1146
1157
|
return modelAsRow<T>(model);
|
|
1147
1158
|
} finally {
|
|
1148
1159
|
await autoLease?.release?.().catch(() => {});
|
|
@@ -1154,7 +1165,7 @@ export function createModelProxy<T, C>(
|
|
|
1154
1165
|
// wrapping while exposing the two public signatures (a plain `guard(...)`
|
|
1155
1166
|
// would collapse them to one).
|
|
1156
1167
|
update: ((): ModelOperations<T, C>['update'] => {
|
|
1157
|
-
const updateImpl =
|
|
1168
|
+
const updateImpl = guardWrite(
|
|
1158
1169
|
async (
|
|
1159
1170
|
arg: ModelUpdateParams<T, C> | string,
|
|
1160
1171
|
updater?: ModelUpdater<T>,
|
|
@@ -1206,13 +1217,12 @@ export function createModelProxy<T, C>(
|
|
|
1206
1217
|
);
|
|
1207
1218
|
}
|
|
1208
1219
|
const effective: MutationOptions = {
|
|
1209
|
-
wait: 'confirmed',
|
|
1210
1220
|
readAt,
|
|
1211
1221
|
onStale: 'reject',
|
|
1212
1222
|
};
|
|
1213
1223
|
model.applyChanges(patch);
|
|
1214
1224
|
syncClient.update(model, effective);
|
|
1215
|
-
await waitForMutation(model
|
|
1225
|
+
await waitForMutation(model);
|
|
1216
1226
|
return modelAsRow<T>(model);
|
|
1217
1227
|
},
|
|
1218
1228
|
});
|
|
@@ -1250,7 +1260,6 @@ export function createModelProxy<T, C>(
|
|
|
1250
1260
|
const handle = isClaimHandle(params.claim) ? params.claim : undefined;
|
|
1251
1261
|
const effective: MutationOptions | undefined = claimed
|
|
1252
1262
|
? {
|
|
1253
|
-
wait: 'confirmed',
|
|
1254
1263
|
readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
|
|
1255
1264
|
onStale: 'reject',
|
|
1256
1265
|
claimRef: { id: claimed.lease.id },
|
|
@@ -1262,7 +1271,6 @@ export function createModelProxy<T, C>(
|
|
|
1262
1271
|
// works across clients (HTTP-minted handles included).
|
|
1263
1272
|
...(handle?.readAt !== undefined
|
|
1264
1273
|
? {
|
|
1265
|
-
wait: 'confirmed' as const,
|
|
1266
1274
|
readAt: handle.readAt,
|
|
1267
1275
|
onStale: 'reject' as const,
|
|
1268
1276
|
...(handle.fenceToken !== undefined
|
|
@@ -1279,7 +1287,7 @@ export function createModelProxy<T, C>(
|
|
|
1279
1287
|
// the tracking, producing an empty `input: {}` no-op mutation.)
|
|
1280
1288
|
model.applyChanges(params.data);
|
|
1281
1289
|
syncClient.update(model, effective);
|
|
1282
|
-
await waitForMutation(model
|
|
1290
|
+
await waitForMutation(model);
|
|
1283
1291
|
return modelAsRow<T>(model);
|
|
1284
1292
|
},
|
|
1285
1293
|
);
|
|
@@ -1299,7 +1307,7 @@ export function createModelProxy<T, C>(
|
|
|
1299
1307
|
return update;
|
|
1300
1308
|
})(),
|
|
1301
1309
|
|
|
1302
|
-
delete:
|
|
1310
|
+
delete: guardWrite(async (params: ModelDeleteParams<T, C>): Promise<void> => {
|
|
1303
1311
|
const autoClaim =
|
|
1304
1312
|
params.claim && !isClaimHandle(params.claim) ? params.claim : null;
|
|
1305
1313
|
if (autoClaim) {
|
|
@@ -1334,7 +1342,6 @@ export function createModelProxy<T, C>(
|
|
|
1334
1342
|
const handle = isClaimHandle(params.claim) ? params.claim : undefined;
|
|
1335
1343
|
const effective: MutationOptions | undefined = claimed
|
|
1336
1344
|
? {
|
|
1337
|
-
wait: 'confirmed',
|
|
1338
1345
|
readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
|
|
1339
1346
|
onStale: 'reject',
|
|
1340
1347
|
claimRef: { id: claimed.lease.id },
|
|
@@ -1346,7 +1353,6 @@ export function createModelProxy<T, C>(
|
|
|
1346
1353
|
: {
|
|
1347
1354
|
...(handle?.readAt !== undefined
|
|
1348
1355
|
? {
|
|
1349
|
-
wait: 'confirmed' as const,
|
|
1350
1356
|
readAt: handle.readAt,
|
|
1351
1357
|
onStale: 'reject' as const,
|
|
1352
1358
|
}
|
|
@@ -1355,7 +1361,7 @@ export function createModelProxy<T, C>(
|
|
|
1355
1361
|
...(handle ? { claim: { id: handle.id } } : {}),
|
|
1356
1362
|
};
|
|
1357
1363
|
syncClient.delete(model, effective);
|
|
1358
|
-
await waitForMutation(model
|
|
1364
|
+
await waitForMutation(model);
|
|
1359
1365
|
}),
|
|
1360
1366
|
|
|
1361
1367
|
// `claim` is a callable namespace (take a claim) carrying the coordination
|
|
@@ -87,6 +87,21 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
87
87
|
*/
|
|
88
88
|
apiKey?: string | CredentialProvider | null | undefined;
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Pins this client to one Ablo project. During `ready()` the server resolves
|
|
92
|
+
* the API key's actual project and the client refuses to start when it differs.
|
|
93
|
+
* Defaults to `ABLO_PROJECT_ID`; `ablo dev` writes that value beside the key.
|
|
94
|
+
* This is an assertion, never a routing selector — the key remains authoritative.
|
|
95
|
+
*/
|
|
96
|
+
projectId?: string | null | undefined;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pins this client to one immutable Ablo branch. Defaults to
|
|
100
|
+
* `ABLO_BRANCH_ID`; `ablo dev` writes it beside the branch key. Like
|
|
101
|
+
* `projectId`, this is a startup assertion and never selects a branch.
|
|
102
|
+
*/
|
|
103
|
+
branchId?: string | null | undefined;
|
|
104
|
+
|
|
90
105
|
/**
|
|
91
106
|
* The session-mint endpoint — the browser-side auth field, and the named
|
|
92
107
|
* endpoint for the route that mints the signed-in user's short-lived token:
|
|
@@ -139,7 +154,7 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
139
154
|
* Ablo({
|
|
140
155
|
* schema,
|
|
141
156
|
* apiKey,
|
|
142
|
-
* durableWrites: { store, namespace: '
|
|
157
|
+
* durableWrites: { store, namespace: 'headless-worker' },
|
|
143
158
|
* })
|
|
144
159
|
* ```
|
|
145
160
|
*/
|
|
@@ -230,34 +245,6 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
230
245
|
* session token (`ek_`/`rk_`) or you route through a controlled server proxy.
|
|
231
246
|
*/
|
|
232
247
|
dangerouslyAllowBrowser?: boolean | undefined;
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* How far a write goes before its promise settles, for every model write on
|
|
236
|
-
* this client. The same word each write already takes per call
|
|
237
|
-
* (`create({ …, wait: 'confirmed' })`); setting it here makes it the default
|
|
238
|
-
* instead of repeating it.
|
|
239
|
-
*
|
|
240
|
-
* A write resolves as soon as it is applied locally and queued. That is what
|
|
241
|
-
* makes the UI immediate, and it is right for most writes — but it means a
|
|
242
|
-
* write the server later REFUSES has no caller left to tell. The rejection
|
|
243
|
-
* reverts the local row and reaches `ablo.onMutationFailure(…)`, and an
|
|
244
|
-
* application that subscribes to neither shows the change, then loses it,
|
|
245
|
-
* with nothing thrown anywhere.
|
|
246
|
-
*
|
|
247
|
-
* ```ts
|
|
248
|
-
* const ablo = new Ablo({ schema, apiKey, wait: 'confirmed' });
|
|
249
|
-
* try {
|
|
250
|
-
* await ablo.documents.update({ id, data }); // throws if refused
|
|
251
|
-
* } catch (err) {
|
|
252
|
-
* if (err instanceof AbloError) show(err.message);
|
|
253
|
-
* }
|
|
254
|
-
* ```
|
|
255
|
-
*
|
|
256
|
-
* The cost is real: each write now waits for the server's answer, so it is a
|
|
257
|
-
* choice between immediacy and certainty rather than a strict improvement.
|
|
258
|
-
* A per-call `wait` still wins over this.
|
|
259
|
-
*/
|
|
260
|
-
wait?: 'queued' | 'confirmed' | undefined;
|
|
261
248
|
}
|
|
262
249
|
|
|
263
250
|
export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
@@ -274,6 +261,9 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
274
261
|
*/
|
|
275
262
|
apiKey?: string | CredentialProvider | null | undefined;
|
|
276
263
|
|
|
264
|
+
/** Expected project assertion; see {@link AbloOptions.projectId}. */
|
|
265
|
+
projectId?: string | null | undefined;
|
|
266
|
+
|
|
277
267
|
/**
|
|
278
268
|
* Session-mint endpoint (string or async resolver) — see
|
|
279
269
|
* {@link AbloOptions.authEndpoint}. Mutually exclusive with `apiKey`.
|
|
@@ -521,15 +511,11 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
521
511
|
organizationId?: string;
|
|
522
512
|
|
|
523
513
|
/**
|
|
524
|
-
*
|
|
525
|
-
*
|
|
514
|
+
* Expected immutable branch. Hosted clients compare it with the credential
|
|
515
|
+
* exchange; self-hosted clients use it as their locally selected branch.
|
|
526
516
|
*/
|
|
527
517
|
branchId?: string;
|
|
528
518
|
|
|
529
519
|
/** Whether the selected self-hosted branch is the project's root branch. */
|
|
530
520
|
branchRoot?: boolean;
|
|
531
|
-
|
|
532
|
-
/** The client-wide write default — see {@link AbloOptions.wait}. Projected
|
|
533
|
-
* from the public option rather than restated, so the two cannot diverge. */
|
|
534
|
-
wait?: AbloOptions['wait'];
|
|
535
521
|
}
|
|
@@ -607,8 +607,6 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
607
607
|
...(options?.ttl !== undefined ? { ttl: options.ttl } : {}),
|
|
608
608
|
}),
|
|
609
609
|
},
|
|
610
|
-
// The client-wide `wait` default; a per-call `wait` still wins.
|
|
611
|
-
internalOptions.wait,
|
|
612
610
|
);
|
|
613
611
|
}
|
|
614
612
|
|
|
@@ -63,6 +63,44 @@ export interface StoreLifecycleDeps<S extends SchemaRecord> {
|
|
|
63
63
|
readonly onIdentityResolved: (seed: IdentitySeed) => void;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/** Refuse a credential for another project before the store opens its socket. */
|
|
67
|
+
export function assertExpectedProject(
|
|
68
|
+
expectedProjectId: string | null | undefined,
|
|
69
|
+
actualProjectId: string | null
|
|
70
|
+
): void {
|
|
71
|
+
const expected = expectedProjectId?.trim();
|
|
72
|
+
if (!expected || actualProjectId === expected) return;
|
|
73
|
+
throw new AbloAuthenticationError(
|
|
74
|
+
`ABLO_API_KEY belongs to project ${actualProjectId ?? '(none)'}, but this app is pinned to ${expected} by projectId/ABLO_PROJECT_ID.`,
|
|
75
|
+
{
|
|
76
|
+
code: 'project_scope_denied',
|
|
77
|
+
details: {
|
|
78
|
+
expectedProjectId: expected,
|
|
79
|
+
actualProjectId,
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Refuse a credential for another branch before the store opens its socket. */
|
|
86
|
+
export function assertExpectedBranch(
|
|
87
|
+
expectedBranchId: string | null | undefined,
|
|
88
|
+
actualBranchId: string | null
|
|
89
|
+
): void {
|
|
90
|
+
const expected = expectedBranchId?.trim();
|
|
91
|
+
if (!expected || actualBranchId === expected) return;
|
|
92
|
+
throw new AbloAuthenticationError(
|
|
93
|
+
`ABLO_API_KEY belongs to branch ${actualBranchId ?? '(none)'}, but this app is pinned to ${expected} by branchId/ABLO_BRANCH_ID.`,
|
|
94
|
+
{
|
|
95
|
+
code: 'branch_scope_denied',
|
|
96
|
+
details: {
|
|
97
|
+
expectedBranchId: expected,
|
|
98
|
+
actualBranchId,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
66
104
|
/**
|
|
67
105
|
* Wires the credential machinery onto the cluster and returns the `ready`
|
|
68
106
|
* the client exposes. Wiring happens now — the refresh lifecycle, the
|
|
@@ -200,8 +238,12 @@ export function startStoreLifecycle<S extends SchemaRecord>(
|
|
|
200
238
|
capabilityToken,
|
|
201
239
|
syncGroups,
|
|
202
240
|
participantKind,
|
|
241
|
+
deliveryPartition,
|
|
203
242
|
} = resolved;
|
|
204
243
|
|
|
244
|
+
assertExpectedProject(internalOptions.projectId, projectId);
|
|
245
|
+
assertExpectedBranch(internalOptions.branchId, branchId);
|
|
246
|
+
|
|
205
247
|
// Fail-loud guard: detect the degenerate "no real sync groups
|
|
206
248
|
// resolved" state before opening the socket. It is the same class of bug as
|
|
207
249
|
// a sensible-looking default that's functionally broken: the
|
|
@@ -274,6 +316,7 @@ export function startStoreLifecycle<S extends SchemaRecord>(
|
|
|
274
316
|
kind: participantKind,
|
|
275
317
|
capabilityToken,
|
|
276
318
|
syncGroups,
|
|
319
|
+
deliveryPartition,
|
|
277
320
|
bootstrapMode: resolvedBootstrapMode,
|
|
278
321
|
});
|
|
279
322
|
let current = gen.next();
|
|
@@ -91,7 +91,7 @@ export function* initialize<TCollaboration extends EventMap<TCollaboration>>(
|
|
|
91
91
|
|
|
92
92
|
// Bootstrap from server if needed.
|
|
93
93
|
//
|
|
94
|
-
// `bootstrapMode: 'none'` participants (
|
|
94
|
+
// `bootstrapMode: 'none'` participants (headless workers and
|
|
95
95
|
// task runners) skip baseline replication — they read via
|
|
96
96
|
// `model.get()` round-trips and rely on covering deltas
|
|
97
97
|
// from filtered subscriptions to populate the pool lazily. The
|
|
@@ -109,7 +109,7 @@ export function* initialize<TCollaboration extends EventMap<TCollaboration>>(
|
|
|
109
109
|
// `setupWebSocketSync` above creates the SyncWebSocket and
|
|
110
110
|
// initiates the upgrade, but it does NOT await the 'connected'
|
|
111
111
|
// event — it returns synchronously after wiring listeners.
|
|
112
|
-
// For bootstrapMode='none' consumers (
|
|
112
|
+
// For bootstrapMode='none' consumers (headless workers and
|
|
113
113
|
// task runners), this branch is the entire body of initialize()
|
|
114
114
|
// after the WS is set up, so `ready()` would otherwise resolve
|
|
115
115
|
// while the WS is still in 'connecting' state. The very next
|
|
@@ -175,8 +175,8 @@ export interface MutationQueueConfig {
|
|
|
175
175
|
maxExecutingTransactions: number;
|
|
176
176
|
// How long to wait, in milliseconds, for a change's confirming sync delta
|
|
177
177
|
// before the retry-and-reconciliation cycle begins. For a source-forwarded
|
|
178
|
-
// write this is also the
|
|
179
|
-
//
|
|
178
|
+
// write this is also the awaited model-write deadline: expiry rejects the
|
|
179
|
+
// waiter with `replication_lag_timeout` while the accepted write remains
|
|
180
180
|
// pending. Defaults to 30000 (30 seconds); raise it for slow networks.
|
|
181
181
|
deltaConfirmationTimeout: number;
|
|
182
182
|
/**
|
|
@@ -1087,7 +1087,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
1087
1087
|
}
|
|
1088
1088
|
|
|
1089
1089
|
/**
|
|
1090
|
-
* Bounds the public
|
|
1090
|
+
* Bounds the public model-write confirmation promise without changing the
|
|
1091
1091
|
* accepted write's lifecycle. A lag timeout is not a rejection from the
|
|
1092
1092
|
* source database, so it must never emit `transaction:failed`, roll back
|
|
1093
1093
|
* optimistic state, or remove the durable replay envelope.
|
|
@@ -131,8 +131,8 @@ export async function handleFailure(ctx: FailureHandlingContext, transaction: Qu
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
ctx.emit('transaction:failed', { transaction, error, permanent: true });
|
|
134
|
-
// The id-suffixed event is what
|
|
135
|
-
// `
|
|
134
|
+
// The id-suffixed event is what the awaited model-write promise listens
|
|
135
|
+
// on through `waitForConfirmation` — without it a permanently
|
|
136
136
|
// rejected write left the caller's promise hanging forever.
|
|
137
137
|
ctx.emit(`transaction:failed:${transaction.id}`, { error });
|
|
138
138
|
return;
|
package/src/surface.ts
CHANGED
|
@@ -76,6 +76,8 @@ type _ListOptionKeysExact = Expect<
|
|
|
76
76
|
export const PUBLIC_ABLO_OPTION_KEYS = [
|
|
77
77
|
'schema',
|
|
78
78
|
'apiKey',
|
|
79
|
+
'projectId',
|
|
80
|
+
'branchId',
|
|
79
81
|
'authEndpoint',
|
|
80
82
|
'authTimeoutMs',
|
|
81
83
|
'allowCrossOriginAuthEndpoint',
|
|
@@ -94,7 +96,6 @@ export const PUBLIC_ABLO_OPTION_KEYS = [
|
|
|
94
96
|
'dangerouslyAllowBrowser',
|
|
95
97
|
'collaborationEvents',
|
|
96
98
|
'plugins',
|
|
97
|
-
'wait',
|
|
98
99
|
] as const;
|
|
99
100
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
100
101
|
type _AbloOptionKeysExact = Expect<
|