@abloatai/humans 0.45.0 → 0.47.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 +3 -0
- package/dist/local/BaseSyncedStore.js +1 -0
- 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 +17 -32
- 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/createClaimStream.js +33 -24
- package/dist/local/transactions/mutations/MutationQueue.d.ts +20 -1
- package/dist/local/transactions/mutations/MutationQueue.js +20 -2
- package/dist/local/transactions/mutations/commitLane.d.ts +7 -0
- package/dist/local/transactions/mutations/commitLane.js +8 -5
- package/dist/local/transactions/mutations/commitPayload.d.ts +2 -0
- package/dist/local/transactions/mutations/failureHandling.d.ts +2 -1
- package/dist/local/transactions/mutations/failureHandling.js +18 -13
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +2 -1
- package/package.json +3 -2
- package/src/local/BaseSyncedStore.ts +4 -0
- package/src/local/client/clientPrelude.ts +5 -1
- package/src/local/client/createModelProxy.ts +30 -24
- package/src/local/client/options.ts +20 -34
- 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/createClaimStream.ts +55 -31
- package/src/local/transactions/mutations/MutationQueue.ts +30 -4
- package/src/local/transactions/mutations/commitLane.ts +21 -5
- package/src/local/transactions/mutations/commitPayload.ts +2 -0
- package/src/local/transactions/mutations/failureHandling.ts +27 -12
- 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
|
|
@@ -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.
|
|
@@ -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:
|
|
@@ -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();
|
|
@@ -83,6 +83,28 @@ export function createClaimStream(config, transport = null) {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
};
|
|
86
|
+
const observeForeignClaim = (heldBy, claim, participantKind, isAgent) => {
|
|
87
|
+
const description = claim.description ??
|
|
88
|
+
descriptionFromMeta(claim.meta) ??
|
|
89
|
+
'editing';
|
|
90
|
+
const { meta, ...details } = subTarget(claim);
|
|
91
|
+
activeByClaimId.set(claim.claimId, {
|
|
92
|
+
object: 'claim',
|
|
93
|
+
id: claim.claimId,
|
|
94
|
+
status: 'active',
|
|
95
|
+
heldBy,
|
|
96
|
+
participantKind: participantKindFromWire(participantKind, isAgent),
|
|
97
|
+
target: {
|
|
98
|
+
...streamTarget(claim),
|
|
99
|
+
...details,
|
|
100
|
+
...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
|
|
101
|
+
},
|
|
102
|
+
description,
|
|
103
|
+
ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
|
|
104
|
+
createdAt: claim.declaredAt,
|
|
105
|
+
expiresAt: claim.expiresAt,
|
|
106
|
+
});
|
|
107
|
+
};
|
|
86
108
|
// ── Wire wiring ──────────────────────────────────────────────────
|
|
87
109
|
let attached = null;
|
|
88
110
|
const unsubs = [];
|
|
@@ -125,30 +147,7 @@ export function createClaimStream(config, transport = null) {
|
|
|
125
147
|
// `settled()`. Absent status means active (wire back-compat).
|
|
126
148
|
if (claim.status && claim.status !== 'active')
|
|
127
149
|
continue;
|
|
128
|
-
|
|
129
|
-
// carries the value in `meta` rather than as an explicit description.
|
|
130
|
-
const description = claim.description ??
|
|
131
|
-
descriptionFromMeta(claim.meta) ??
|
|
132
|
-
'editing';
|
|
133
|
-
// The frame is parsed permissively, on purpose; `declaredMeta` is where
|
|
134
|
-
// that wire value becomes the shape the program declared.
|
|
135
|
-
const { meta, ...details } = subTarget(claim);
|
|
136
|
-
activeByClaimId.set(claim.claimId, {
|
|
137
|
-
object: 'claim',
|
|
138
|
-
id: claim.claimId,
|
|
139
|
-
status: 'active',
|
|
140
|
-
heldBy: event.userId,
|
|
141
|
-
participantKind: participantKindFromWire(event.participantKind, event.isAgent),
|
|
142
|
-
target: {
|
|
143
|
-
...streamTarget(claim),
|
|
144
|
-
...details,
|
|
145
|
-
...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
|
|
146
|
-
},
|
|
147
|
-
description,
|
|
148
|
-
ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
|
|
149
|
-
createdAt: claim.declaredAt,
|
|
150
|
-
expiresAt: claim.expiresAt,
|
|
151
|
-
});
|
|
150
|
+
observeForeignClaim(event.userId, claim, event.participantKind, event.isAgent);
|
|
152
151
|
mutated = true;
|
|
153
152
|
}
|
|
154
153
|
if (mutated)
|
|
@@ -168,6 +167,16 @@ export function createClaimStream(config, transport = null) {
|
|
|
168
167
|
// a claim the server already rejected (would just spam both
|
|
169
168
|
// sides with conflicts).
|
|
170
169
|
ownClaims.delete(rejection.claimId);
|
|
170
|
+
// A holder on another server may have claimed before this client joined
|
|
171
|
+
// the row group, so its one-shot presence frame was missed. A conflict
|
|
172
|
+
// reply carries the authoritative holder summary; seed the same local
|
|
173
|
+
// state immediately instead of continuing to report the row as free.
|
|
174
|
+
if (rejection.reason === 'conflict' &&
|
|
175
|
+
rejection.heldBy &&
|
|
176
|
+
rejection.heldByClaim) {
|
|
177
|
+
observeForeignClaim(rejection.heldBy, rejection.heldByClaim, rejection.heldByKind);
|
|
178
|
+
notifyListeners();
|
|
179
|
+
}
|
|
171
180
|
for (const l of rejectionListeners) {
|
|
172
181
|
try {
|
|
173
182
|
l(rejection);
|
|
@@ -45,6 +45,15 @@ export interface MutationQueueConfig {
|
|
|
45
45
|
maxBatchSize: number;
|
|
46
46
|
batchDelay: number;
|
|
47
47
|
maxRetries: number;
|
|
48
|
+
/**
|
|
49
|
+
* Minimum wall-clock window for retrying transient write failures with the
|
|
50
|
+
* same durable envelope and idempotency key. This absorbs managed-database
|
|
51
|
+
* promotion and brief regional network incidents without double-applying a
|
|
52
|
+
* write. Defaults to 120 seconds: the Aurora promotion drill recovered
|
|
53
|
+
* writes just beyond 60 seconds, so a one-minute boundary discarded exact
|
|
54
|
+
* envelopes at the instant the new writer became usable.
|
|
55
|
+
*/
|
|
56
|
+
availabilityRetryWindowMs: number;
|
|
48
57
|
conflictResolution: ConflictResolution;
|
|
49
58
|
enablePersistence: boolean;
|
|
50
59
|
enableOptimistic: boolean;
|
|
@@ -127,6 +136,7 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
127
136
|
private replicationLagTimeouts;
|
|
128
137
|
private replicationLagErrors;
|
|
129
138
|
private commitProcessing;
|
|
139
|
+
private commitRetryTimer;
|
|
130
140
|
private lastCommitSequence;
|
|
131
141
|
private durableReplayBlock;
|
|
132
142
|
/** Browser-backed strict outbox; absent for standalone/in-memory consumers. */
|
|
@@ -220,7 +230,7 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
220
230
|
private dispatchCommitBounded;
|
|
221
231
|
private clearReplicationLagState;
|
|
222
232
|
/**
|
|
223
|
-
* Bounds the public
|
|
233
|
+
* Bounds the public model-write confirmation promise without changing the
|
|
224
234
|
* accepted write's lifecycle. A lag timeout is not a rejection from the
|
|
225
235
|
* source database, so it must never emit `transaction:failed`, roll back
|
|
226
236
|
* optimistic state, or remove the durable replay envelope.
|
|
@@ -418,6 +428,15 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
418
428
|
maxBatchSize: number;
|
|
419
429
|
batchDelay: number;
|
|
420
430
|
maxRetries: number;
|
|
431
|
+
/**
|
|
432
|
+
* Minimum wall-clock window for retrying transient write failures with the
|
|
433
|
+
* same durable envelope and idempotency key. This absorbs managed-database
|
|
434
|
+
* promotion and brief regional network incidents without double-applying a
|
|
435
|
+
* write. Defaults to 120 seconds: the Aurora promotion drill recovered
|
|
436
|
+
* writes just beyond 60 seconds, so a one-minute boundary discarded exact
|
|
437
|
+
* envelopes at the instant the new writer became usable.
|
|
438
|
+
*/
|
|
439
|
+
availabilityRetryWindowMs: number;
|
|
421
440
|
conflictResolution: ConflictResolution;
|
|
422
441
|
enablePersistence: boolean;
|
|
423
442
|
enableOptimistic: boolean;
|
|
@@ -104,6 +104,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
104
104
|
replicationLagTimeouts = new Map();
|
|
105
105
|
replicationLagErrors = new Map();
|
|
106
106
|
commitProcessing = false;
|
|
107
|
+
commitRetryTimer = null;
|
|
107
108
|
lastCommitSequence = 0;
|
|
108
109
|
durableReplayBlock = null;
|
|
109
110
|
/** Browser-backed strict outbox; absent for standalone/in-memory consumers. */
|
|
@@ -125,7 +126,11 @@ export class MutationQueue extends EventEmitter {
|
|
|
125
126
|
get commitLaneContext() {
|
|
126
127
|
return {
|
|
127
128
|
runtime: this.runtime,
|
|
128
|
-
config: {
|
|
129
|
+
config: {
|
|
130
|
+
maxRetries: this.config.maxRetries,
|
|
131
|
+
availabilityRetryWindowMs: this.config.availabilityRetryWindowMs,
|
|
132
|
+
retryBackoff: this.config.retryBackoff,
|
|
133
|
+
},
|
|
129
134
|
commitLane: this.commitLane,
|
|
130
135
|
commitNotifications: this.commitNotifications,
|
|
131
136
|
commitMissingIds: this.commitMissingIds,
|
|
@@ -147,6 +152,14 @@ export class MutationQueue extends EventEmitter {
|
|
|
147
152
|
noteAck: (syncId) => this.noteAck(syncId),
|
|
148
153
|
isDefinitiveRejection: (error) => this.isDefinitiveRejection(error),
|
|
149
154
|
isPermanentError: (error) => this.isPermanentError(error),
|
|
155
|
+
scheduleRetry: (delayMs) => {
|
|
156
|
+
if (this.commitRetryTimer !== null)
|
|
157
|
+
clearTimeout(this.commitRetryTimer);
|
|
158
|
+
this.commitRetryTimer = setTimeout(() => {
|
|
159
|
+
this.commitRetryTimer = null;
|
|
160
|
+
void this.processCommitLane();
|
|
161
|
+
}, delayMs);
|
|
162
|
+
},
|
|
150
163
|
emitCommitLifecycle: (event, payload) => this.emitCommitLifecycle(event, payload),
|
|
151
164
|
};
|
|
152
165
|
}
|
|
@@ -516,6 +529,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
516
529
|
maxBatchSize: 50, // send up to this many operations per commit
|
|
517
530
|
batchDelay: 150, // milliseconds to wait for more operations before sending
|
|
518
531
|
maxRetries: 3,
|
|
532
|
+
availabilityRetryWindowMs: 120_000,
|
|
519
533
|
conflictResolution: {
|
|
520
534
|
strategy: 'last-write-wins',
|
|
521
535
|
},
|
|
@@ -726,7 +740,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
726
740
|
this.replicationLagErrors.delete(transactionId);
|
|
727
741
|
}
|
|
728
742
|
/**
|
|
729
|
-
* Bounds the public
|
|
743
|
+
* Bounds the public model-write confirmation promise without changing the
|
|
730
744
|
* accepted write's lifecycle. A lag timeout is not a rejection from the
|
|
731
745
|
* source database, so it must never emit `transaction:failed`, roll back
|
|
732
746
|
* optimistic state, or remove the durable replay envelope.
|
|
@@ -1506,6 +1520,10 @@ export class MutationQueue extends EventEmitter {
|
|
|
1506
1520
|
clearTimeout(this.commitOfflineGraceTimer);
|
|
1507
1521
|
this.commitOfflineGraceTimer = null;
|
|
1508
1522
|
}
|
|
1523
|
+
if (this.commitRetryTimer !== null) {
|
|
1524
|
+
clearTimeout(this.commitRetryTimer);
|
|
1525
|
+
this.commitRetryTimer = null;
|
|
1526
|
+
}
|
|
1509
1527
|
// Clear store
|
|
1510
1528
|
this.store.clear();
|
|
1511
1529
|
this.localMutationPort.updates.clear();
|
|
@@ -21,6 +21,7 @@ export interface CommitTransaction {
|
|
|
21
21
|
createdAt: number;
|
|
22
22
|
attempts: number;
|
|
23
23
|
transientAttempts?: number;
|
|
24
|
+
firstTransientFailureAt?: number;
|
|
24
25
|
lastSyncId?: number;
|
|
25
26
|
correlationId?: string;
|
|
26
27
|
error?: Error;
|
|
@@ -34,6 +35,11 @@ export interface CommitLaneContext {
|
|
|
34
35
|
readonly runtime: RuntimeContext;
|
|
35
36
|
readonly config: {
|
|
36
37
|
maxRetries: number;
|
|
38
|
+
availabilityRetryWindowMs: number;
|
|
39
|
+
retryBackoff: {
|
|
40
|
+
baseMs: number;
|
|
41
|
+
capMs: number;
|
|
42
|
+
};
|
|
37
43
|
};
|
|
38
44
|
readonly commitLane: CommitTransaction[];
|
|
39
45
|
readonly commitNotifications: Map<string, StaleNotification[]>;
|
|
@@ -52,6 +58,7 @@ export interface CommitLaneContext {
|
|
|
52
58
|
readonly noteAck: (syncId: number | undefined) => void;
|
|
53
59
|
readonly isDefinitiveRejection: (error: Error) => boolean;
|
|
54
60
|
readonly isPermanentError: (error: Error) => boolean;
|
|
61
|
+
readonly scheduleRetry: (delayMs: number) => void;
|
|
55
62
|
readonly emitCommitLifecycle: (event: string, payload: object) => void;
|
|
56
63
|
}
|
|
57
64
|
export interface CommitReceiptContext {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { transientRetryDelayMs } from './failureHandling.js';
|
|
2
2
|
export function waitForCommitReceipt(ctx, clientTxId) {
|
|
3
3
|
const drainNotifications = () => {
|
|
4
4
|
const notifications = ctx.commitNotifications.get(clientTxId);
|
|
@@ -112,15 +112,18 @@ export async function processCommitLane(ctx) {
|
|
|
112
112
|
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
113
113
|
if (dispatchStarted && ctx.isDefinitiveRejection(error))
|
|
114
114
|
await ctx.removeDurableCommit(tx.id);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
115
|
+
tx.transientAttempts = (tx.transientAttempts ?? 0) + 1;
|
|
116
|
+
tx.firstTransientFailureAt ??= Date.now();
|
|
117
|
+
const outsideAvailabilityWindow = Date.now() - tx.firstTransientFailureAt >= ctx.config.availabilityRetryWindowMs;
|
|
118
|
+
const exhausted = tx.transientAttempts > ctx.config.maxRetries && outsideAvailabilityWindow;
|
|
118
119
|
if (!ctx.isPermanentError(error) && !exhausted) {
|
|
119
120
|
tx.status = 'pending';
|
|
121
|
+
const delayMs = transientRetryDelayMs(error, tx.transientAttempts, ctx.config.retryBackoff);
|
|
120
122
|
ctx.runtime.logger.debug('[MutationQueue] commit lane transient', {
|
|
121
123
|
txId: tx.id.slice(0, 12), attempts: tx.attempts,
|
|
122
|
-
transientAttempts: tx.transientAttempts
|
|
124
|
+
transientAttempts: tx.transientAttempts, delayMs, message: error.message,
|
|
123
125
|
});
|
|
126
|
+
ctx.scheduleRetry(delayMs);
|
|
124
127
|
break;
|
|
125
128
|
}
|
|
126
129
|
tx.status = 'failed';
|
|
@@ -54,6 +54,8 @@ export interface QueuedMutation {
|
|
|
54
54
|
status: 'pending' | 'executing' | 'awaiting_delta' | 'completed' | 'failed' | 'rolled_back';
|
|
55
55
|
createdAt: number;
|
|
56
56
|
attempts: number;
|
|
57
|
+
/** First transient dispatch failure in the current availability incident. */
|
|
58
|
+
firstTransientFailureAt?: number;
|
|
57
59
|
priority: 'normal' | 'high';
|
|
58
60
|
priorityScore: number;
|
|
59
61
|
writeOptions?: WriteOptions;
|
|
@@ -4,7 +4,7 @@ import type { QueuedMutation } from './commitPayload.js';
|
|
|
4
4
|
import type { MutationStore } from './MutationStore.js';
|
|
5
5
|
export interface FailureHandlingContext {
|
|
6
6
|
readonly runtime: RuntimeContext;
|
|
7
|
-
readonly config: Pick<MutationQueueConfig, 'enableOptimistic' | 'maxRetries' | 'retryBackoff'>;
|
|
7
|
+
readonly config: Pick<MutationQueueConfig, 'enableOptimistic' | 'maxRetries' | 'retryBackoff' | 'availabilityRetryWindowMs'>;
|
|
8
8
|
readonly store: MutationStore;
|
|
9
9
|
readonly isPermanentError: (error: Error) => boolean;
|
|
10
10
|
readonly rollbackOptimistic: (transaction: QueuedMutation, reason: string, error?: Error) => Promise<void>;
|
|
@@ -13,4 +13,5 @@ export interface FailureHandlingContext {
|
|
|
13
13
|
readonly setLastPermanentErrorSignature: (signature: string) => void;
|
|
14
14
|
readonly emit: (event: string, payload: object) => boolean;
|
|
15
15
|
}
|
|
16
|
+
export declare function transientRetryDelayMs(error: Error, attempt: number, retryBackoff: MutationQueueConfig['retryBackoff']): number;
|
|
16
17
|
export declare function handleFailure(ctx: FailureHandlingContext, transaction: QueuedMutation, error: Error): Promise<void>;
|