@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
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* The reactive engine assembly (ADR 0016). `Ablo({ ... })` resolves auth and
|
|
3
3
|
* capabilities; `humans().init` constructs the store cluster; the lifecycle
|
|
4
4
|
* — first mint, identity, ready() — lives in `./storeLifecycle.ts`. What
|
|
5
|
-
* remains here is assembly around those parts: the claim
|
|
6
|
-
*
|
|
7
|
-
* commit
|
|
5
|
+
* remains here is assembly around those parts: the claim and presence streams,
|
|
6
|
+
* options validation, the typed model proxies, and the
|
|
7
|
+
* commit and claim resources — composed into the reactive client.
|
|
8
8
|
*
|
|
9
9
|
* Extracted from the factory so the composition root stays a root: resolve,
|
|
10
10
|
* dispatch, return. The remaining assembly converts to decoration of a
|
|
@@ -13,17 +13,13 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { omittedModelError } from '@abloatai/transaction/schema/select';
|
|
15
15
|
import { durableCommitOperationSchema, } from '@abloatai/transaction/commit';
|
|
16
|
-
import {
|
|
16
|
+
import { AbloConnectionError, AbloValidationError, claimedError } from '@abloatai/transaction/errors';
|
|
17
17
|
import { batchFence, claimIdFor, fenceTokenFor, modelTarget, streamTarget, subTarget, } from '@abloatai/transaction/coordination';
|
|
18
18
|
import { validateAbloOptions } from './validateAbloOptions.js';
|
|
19
|
-
import { mintSession } from '@abloatai/transaction/auth/sessionMint';
|
|
20
|
-
import { revokeCapability, rotateCapability, } from '@abloatai/transaction/auth/capabilityLifecycle';
|
|
21
|
-
import { modelWireNames } from '@abloatai/transaction/auth/capability';
|
|
22
19
|
import { startStoreLifecycle } from './storeLifecycle.js';
|
|
23
20
|
import { createClaimStream } from '../sync/createClaimStream.js';
|
|
24
21
|
import { awaitClaimGrant } from '@abloatai/transaction/claims';
|
|
25
22
|
import { bindClaimLifetime, claimLifetimeOf, } from '@abloatai/transaction/claims/lifetime';
|
|
26
|
-
import { createParticipantManager } from '../sync/participants.js';
|
|
27
23
|
import { resolveApiKeyValue, resolveBootstrapBaseUrl } from '@abloatai/transaction/auth/apiKey';
|
|
28
24
|
import { claimAttemptFailure, emitClaimStatus, } from '@abloatai/transaction/client/resources/modelOperations';
|
|
29
25
|
import { createModelOperations } from './createModelOperations.js';
|
|
@@ -33,7 +29,7 @@ import { translateHttpError, } from '@abloatai/transaction/errors';
|
|
|
33
29
|
import { kReadEvidence, prepareReadSet, } from '@abloatai/transaction/internal/read-set';
|
|
34
30
|
import { contextOnChange } from '../sync/contextOnChange.js';
|
|
35
31
|
export function buildReactiveEngine(inputs) {
|
|
36
|
-
const { options, internalOptions, url, logger, configuredApiKey, configuredAuthToken, credentialResolver, authCredentials, transport, participantId, kind, presence, cluster,
|
|
32
|
+
const { options, internalOptions, url, logger, configuredApiKey, configuredAuthToken, credentialResolver, authCredentials, transport, participantId, kind, presence, cluster, } = inputs;
|
|
37
33
|
const schema = options.schema;
|
|
38
34
|
const pointReadBaseUrl = resolveBootstrapBaseUrl({
|
|
39
35
|
url,
|
|
@@ -109,7 +105,7 @@ export function buildReactiveEngine(inputs) {
|
|
|
109
105
|
if (configuredApiKey && (internalOptions.kind || internalOptions.agentId)) {
|
|
110
106
|
logger.warn('Ablo: `kind` / `agentId` are ignored when an `apiKey` is configured — ' +
|
|
111
107
|
'the server derives participant identity from the key’s scope. Remove ' +
|
|
112
|
-
'them (or mint a scoped session
|
|
108
|
+
'them (or mint a scoped session with `Sessions({ schema, apiKey }).create({ agent })` ' +
|
|
113
109
|
'for a distinct agent identity). They apply only to the self-hosted ' +
|
|
114
110
|
'`capabilityToken` path.');
|
|
115
111
|
}
|
|
@@ -149,13 +145,6 @@ export function buildReactiveEngine(inputs) {
|
|
|
149
145
|
},
|
|
150
146
|
});
|
|
151
147
|
const ready = lifecycle.ready;
|
|
152
|
-
const participantManager = createParticipantManager({
|
|
153
|
-
ready,
|
|
154
|
-
transport,
|
|
155
|
-
presence: presenceStream,
|
|
156
|
-
claims: claimStream,
|
|
157
|
-
schema,
|
|
158
|
-
});
|
|
159
148
|
// 9b. waitForFlush — drains pending mutations using the store's
|
|
160
149
|
// pendingChanges counter (already maintained by BaseSyncedStore based
|
|
161
150
|
// on MutationQueue events). Polls every 50ms; uses the existing
|
|
@@ -448,16 +437,6 @@ export function buildReactiveEngine(inputs) {
|
|
|
448
437
|
// reconcile errors so read interest never makes a read reject or stall.
|
|
449
438
|
enterScope: (scope) => store.enterScope(scope),
|
|
450
439
|
pinScope: (scope) => store.pinScope(scope),
|
|
451
|
-
// `ablo.<model>.join(ids, { ttl })` performs a scoped participant join
|
|
452
|
-
// on this model's sync group(s). WebSocket only — `join` throws
|
|
453
|
-
// `AbloConnectionError` if the socket isn't ready.
|
|
454
|
-
// `ttl` passes straight through — both surfaces spell the lease the
|
|
455
|
-
// same way now, so there is no rename here to make a field's name
|
|
456
|
-
// disagree with the value it carries.
|
|
457
|
-
createJoin: (modelKey, ids, options) => participantManager.join({
|
|
458
|
-
scope: { [modelKey]: ids },
|
|
459
|
-
...(options?.ttl !== undefined ? { ttl: options.ttl } : {}),
|
|
460
|
-
}),
|
|
461
440
|
}, readSetContext);
|
|
462
441
|
}
|
|
463
442
|
const commits = {
|
|
@@ -532,45 +511,6 @@ export function buildReactiveEngine(inputs) {
|
|
|
532
511
|
return commitRecordListSchema.parse(body);
|
|
533
512
|
},
|
|
534
513
|
};
|
|
535
|
-
/**
|
|
536
|
-
* The control-plane credential: always the original configured secret key.
|
|
537
|
-
* Never reads `authCredentials` — that holds the exchanged sync credential
|
|
538
|
-
* (a wide-scope `rk_` on the hosted path), which control-plane routes
|
|
539
|
-
* rightly refuse (e.g. the user-session mint is sk_-gated). Counterpart to
|
|
540
|
-
* `getAuthToken()`, which resolves the sync-plane token.
|
|
541
|
-
*
|
|
542
|
-
* The secret-key-only rule is enforced on the server; the credential-kind taxonomy
|
|
543
|
-
* (secret/restricted/ephemeral/publishable) lives in `auth/credentialPolicy`.
|
|
544
|
-
*/
|
|
545
|
-
async function controlPlaneApiKey() {
|
|
546
|
-
return resolveApiKeyValue(configuredApiKey);
|
|
547
|
-
}
|
|
548
|
-
/**
|
|
549
|
-
* Resolve the control-plane context a session/agent mint needs (sk_ +
|
|
550
|
-
* bootstrap base URL + the schema-key→typename map the server gates on).
|
|
551
|
-
* Shared by `sessions.create` and `agents.create` so the two mint doors
|
|
552
|
-
* can never drift on how a token is minted. Throws if no `sk_` is present —
|
|
553
|
-
* minting is a backend-only operation.
|
|
554
|
-
*/
|
|
555
|
-
async function buildMintContext(resource) {
|
|
556
|
-
const apiKey = await controlPlaneApiKey();
|
|
557
|
-
if (!apiKey) {
|
|
558
|
-
throw new AbloAuthenticationError(`${resource} requires a secret (sk_) API key — call it from your backend, not the browser.`, { code: 'apikey_missing' });
|
|
559
|
-
}
|
|
560
|
-
return {
|
|
561
|
-
apiKey,
|
|
562
|
-
baseUrl: resolveBootstrapBaseUrl({
|
|
563
|
-
url,
|
|
564
|
-
bootstrapBaseUrl: internalOptions.bootstrapBaseUrl,
|
|
565
|
-
}),
|
|
566
|
-
...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
|
|
567
|
-
// Map every `can` schema-key to the wire typename the server gates on, so a
|
|
568
|
-
// typename override (`documents` → `Document`) doesn't mint a capability
|
|
569
|
-
// the server then denies. Derived from this client's schema by the one rule
|
|
570
|
-
// the HTTP client and the mint route also read. See `MintSessionContext`.
|
|
571
|
-
modelTypenames: modelWireNames(schema.models),
|
|
572
|
-
};
|
|
573
|
-
}
|
|
574
514
|
const engine = {
|
|
575
515
|
...modelProxies,
|
|
576
516
|
ready,
|
|
@@ -594,11 +534,6 @@ export function buildReactiveEngine(inputs) {
|
|
|
594
534
|
// is the canonical credential; fall back to a configured API key.
|
|
595
535
|
//
|
|
596
536
|
// This is the sync-plane token (bootstrap, WebSocket, query HTTP). Control-plane
|
|
597
|
-
// calls (sessions.create, datasource registration) never use it — they
|
|
598
|
-
// present the original secret key via `controlPlaneApiKey()` below. The
|
|
599
|
-
// split matters: after the startup exchange this resolver returns the
|
|
600
|
-
// derived wide-scope `rk_`, a credential the control-plane routes
|
|
601
|
-
// correctly refuse (an agent token must never mint humans).
|
|
602
537
|
return (authCredentials.getAuthToken() ??
|
|
603
538
|
(await resolveApiKeyValue(configuredApiKey)) ??
|
|
604
539
|
configuredAuthToken ??
|
|
@@ -618,86 +553,6 @@ export function buildReactiveEngine(inputs) {
|
|
|
618
553
|
nudgeReconnect() {
|
|
619
554
|
store.nudgeReconnect();
|
|
620
555
|
},
|
|
621
|
-
sessions: {
|
|
622
|
-
// A backend (holding `sk_`) mints a short-lived scoped token for one end
|
|
623
|
-
// user or one agent.
|
|
624
|
-
//
|
|
625
|
-
// Both arms authenticate with the original secret key
|
|
626
|
-
// (`controlPlaneApiKey()`), never the wide-scope `rk_` the startup exchange
|
|
627
|
-
// installed as the sync credential. A derived agent credential silently
|
|
628
|
-
// replacing the secret key on control-plane calls is how humans would get
|
|
629
|
-
// minted as agents — and correct attribution is the point.
|
|
630
|
-
async create(params) {
|
|
631
|
-
// Both mint paths (`{ user }` → /v1/ephemeral_keys → `ek_`,
|
|
632
|
-
// `{ agent, can }` → /v1/capabilities → scoped `rk_`) resolve their
|
|
633
|
-
// control-plane context through the shared `buildMintContext`, so this
|
|
634
|
-
// client, `agents.create`, and the stateless HTTP client can't drift on
|
|
635
|
-
// how a token is minted.
|
|
636
|
-
return mintSession(params, await buildMintContext('sessions.create'));
|
|
637
|
-
},
|
|
638
|
-
async revoke({ id }) {
|
|
639
|
-
const context = await buildMintContext('sessions.revoke');
|
|
640
|
-
return revokeCapability({
|
|
641
|
-
apiKey: context.apiKey,
|
|
642
|
-
baseUrl: context.baseUrl,
|
|
643
|
-
id,
|
|
644
|
-
...(context.fetch ? { fetch: context.fetch } : {}),
|
|
645
|
-
});
|
|
646
|
-
},
|
|
647
|
-
async rotate({ id, graceSeconds, ttlSeconds }) {
|
|
648
|
-
const context = await buildMintContext('sessions.rotate');
|
|
649
|
-
return rotateCapability({
|
|
650
|
-
apiKey: context.apiKey,
|
|
651
|
-
baseUrl: context.baseUrl,
|
|
652
|
-
id,
|
|
653
|
-
...(graceSeconds !== undefined ? { graceSeconds } : {}),
|
|
654
|
-
...(ttlSeconds !== undefined ? { ttlSeconds } : {}),
|
|
655
|
-
...(context.fetch ? { fetch: context.fetch } : {}),
|
|
656
|
-
});
|
|
657
|
-
},
|
|
658
|
-
},
|
|
659
|
-
// Mint a scoped agent identity and hand back a connected client bound to it —
|
|
660
|
-
// `sessions.create({ agent })` plus a typed `Ablo({ schema, apiKey })` client,
|
|
661
|
-
// for agents that run in this (secret-key-holding) process. Omitting `id`
|
|
662
|
-
// yields a fresh uuid per call, so concurrent agents are distinct participants
|
|
663
|
-
// that queue behind each other (even when they share a `name`). Humans don't
|
|
664
|
-
// get a server-built client — ship them a token via `sessions.create({ user })`.
|
|
665
|
-
agents: {
|
|
666
|
-
async create(params) {
|
|
667
|
-
// Distinct participant by default: omit `id` → a fresh uuid, so even two
|
|
668
|
-
// agents that share a `name` are independent participants and queue
|
|
669
|
-
// behind one another. `name` is display only (→ userMeta.name); it never
|
|
670
|
-
// derives the id. Pass an explicit `id` only to re-attach an agent to
|
|
671
|
-
// its own held claims.
|
|
672
|
-
const id = params.id ?? globalThis.crypto.randomUUID();
|
|
673
|
-
const userMeta = params.name !== undefined ? { ...params.userMeta, name: params.name } : params.userMeta;
|
|
674
|
-
const sessionParams = {
|
|
675
|
-
agent: { id },
|
|
676
|
-
can: params.can,
|
|
677
|
-
...(params.onBehalfOf ? { onBehalfOf: params.onBehalfOf } : {}),
|
|
678
|
-
...(params.syncGroups ? { syncGroups: params.syncGroups } : {}),
|
|
679
|
-
...(params.ttlSeconds !== undefined ? { ttlSeconds: params.ttlSeconds } : {}),
|
|
680
|
-
...(userMeta ? { userMeta } : {}),
|
|
681
|
-
};
|
|
682
|
-
// Re-mint the `rk_` on every resolver call so a long-lived agent client
|
|
683
|
-
// never hits token expiry; the `sk_` stays in this process — the child
|
|
684
|
-
// only ever sees its own short-lived `rk_`.
|
|
685
|
-
const mintToken = async () => (await mintSession(sessionParams, await buildMintContext('agents.create')))
|
|
686
|
-
.token;
|
|
687
|
-
// Mint once up front so a bad key / denied scope throws HERE, not later
|
|
688
|
-
// inside the child's bootstrap; reuse that first token, re-mint on refresh.
|
|
689
|
-
let pending = await mintToken();
|
|
690
|
-
const apiKey = async () => {
|
|
691
|
-
if (pending !== null) {
|
|
692
|
-
const token = pending;
|
|
693
|
-
pending = null;
|
|
694
|
-
return token;
|
|
695
|
-
}
|
|
696
|
-
return mintToken();
|
|
697
|
-
};
|
|
698
|
-
return createSibling({ ...internalOptions, apiKey });
|
|
699
|
-
},
|
|
700
|
-
},
|
|
701
556
|
async dispose() {
|
|
702
557
|
lifecycle.dispose();
|
|
703
558
|
try {
|
|
@@ -60,7 +60,11 @@ export function startStoreLifecycle(deps) {
|
|
|
60
60
|
// unambiguously a deliberate server client). User-kind clients in Node (an
|
|
61
61
|
// SSR/RSC module evaluating scaffolded browser code) stay reactive-only.
|
|
62
62
|
if (credentialResolver) {
|
|
63
|
-
const rawEndpoint = internalOptions.
|
|
63
|
+
const rawEndpoint = internalOptions.session &&
|
|
64
|
+
typeof internalOptions.session === 'object' &&
|
|
65
|
+
'endpoint' in internalOptions.session
|
|
66
|
+
? internalOptions.session.endpoint
|
|
67
|
+
: internalOptions.apiKey;
|
|
64
68
|
const absoluteEndpoint = typeof rawEndpoint === 'string' && /^https?:\/\//i.test(rawEndpoint);
|
|
65
69
|
store.startCredentialLifecycle(credentialResolver, {
|
|
66
70
|
/* eslint-disable @typescript-eslint/no-deprecated -- `kind` gates the self-hosted proactive pre-roll; hosted path derives it from the apiKey scope */
|
|
@@ -13,7 +13,7 @@ import type { Model } from './Model.js';
|
|
|
13
13
|
import type { ModelScope } from '@abloatai/transaction/types';
|
|
14
14
|
import type { QueryView, QueryViewOptions } from './views/QueryView.js';
|
|
15
15
|
import type { ViewRegistry } from './views/ViewRegistry.js';
|
|
16
|
-
import type {
|
|
16
|
+
import type { GroupScope } from './sync/scopeGroups.js';
|
|
17
17
|
/**
|
|
18
18
|
* A snapshot of the client's synchronization state, shaped for binding to UI.
|
|
19
19
|
* {@link SyncStoreContract.syncStatus} exposes a reactive instance of this, and
|
|
@@ -129,12 +129,12 @@ export interface SyncStoreContract {
|
|
|
129
129
|
* read subscriptions and write claims always agree on which group they refer
|
|
130
130
|
* to. These are optional and do nothing until the connection is open.
|
|
131
131
|
*/
|
|
132
|
-
enterScope?(scope:
|
|
132
|
+
enterScope?(scope: GroupScope, opts?: {
|
|
133
133
|
hydrate?: boolean;
|
|
134
134
|
}): Promise<void>;
|
|
135
|
-
leaveScope?(scope:
|
|
136
|
-
pinScope?(scope:
|
|
137
|
-
unpinScope?(scope:
|
|
135
|
+
leaveScope?(scope: GroupScope): Promise<void>;
|
|
136
|
+
pinScope?(scope: GroupScope): Promise<void>;
|
|
137
|
+
unpinScope?(scope: GroupScope): Promise<void>;
|
|
138
138
|
/**
|
|
139
139
|
* The full reactive {@link SyncStatus} record. The `useSyncStatus()` hook
|
|
140
140
|
* reads its fields — `state`, `progress`, `pendingChanges`, `isSessionError`,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* stay unchanged.
|
|
2
|
+
* The shared session subsystem owns renewal for both browser and agent
|
|
3
|
+
* sessions. This local boundary keeps the reactive store pointed downward at
|
|
4
|
+
* that one lifecycle implementation.
|
|
6
5
|
*/
|
|
7
|
-
export { DEFAULT_PREROLL_INTERVAL_MS, MIN_PREROLL_DELAY_MS, computePrerollDelayMs, CredentialLifecycle, type CredentialRefreshOutcome, type CredentialRecoveryOutcome, type CredentialRefreshResult, type CredentialRefresher, type CredentialLifecycleContext, } from '@abloatai/transaction/
|
|
6
|
+
export { DEFAULT_PREROLL_INTERVAL_MS, MIN_PREROLL_DELAY_MS, computePrerollDelayMs, CredentialLifecycle, type CredentialRefreshOutcome, type CredentialRecoveryOutcome, type CredentialRefreshResult, type CredentialRefresher, type CredentialLifecycleContext, } from '@abloatai/transaction/sessions';
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* stay unchanged.
|
|
2
|
+
* The shared session subsystem owns renewal for both browser and agent
|
|
3
|
+
* sessions. This local boundary keeps the reactive store pointed downward at
|
|
4
|
+
* that one lifecycle implementation.
|
|
6
5
|
*/
|
|
7
|
-
export { DEFAULT_PREROLL_INTERVAL_MS, MIN_PREROLL_DELAY_MS, computePrerollDelayMs, CredentialLifecycle, } from '@abloatai/transaction/
|
|
6
|
+
export { DEFAULT_PREROLL_INTERVAL_MS, MIN_PREROLL_DELAY_MS, computePrerollDelayMs, CredentialLifecycle, } from '@abloatai/transaction/sessions';
|
|
@@ -73,7 +73,6 @@ export interface DeltaPipelineContext {
|
|
|
73
73
|
* {@link handleGroupHandlerFailure}). */
|
|
74
74
|
clear(): void;
|
|
75
75
|
};
|
|
76
|
-
getStateFields(modelName: string): string[];
|
|
77
76
|
isCustomEntity(modelName: string): boolean;
|
|
78
77
|
createCustomEntity(modelName: string, modelId: string, data: Record<string, unknown>): Model | null;
|
|
79
78
|
deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[];
|
|
@@ -94,8 +93,17 @@ export interface DeltaPipelineContext {
|
|
|
94
93
|
* It never throws, because it runs inside the pipeline's fire-and-forget path.
|
|
95
94
|
*/
|
|
96
95
|
export declare function handleGroupHandlerFailure(ctx: DeltaPipelineContext, delta: SyncDelta, error: unknown): void;
|
|
97
|
-
/**
|
|
98
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Deduplicate repeated delivery of the same log entry.
|
|
98
|
+
*
|
|
99
|
+
* A row may legitimately change several times in one receive frame. Those
|
|
100
|
+
* changes are ordered facts, even when a small subset of fields (such as
|
|
101
|
+
* `status`) happens to remain equal. Collapsing by entity or a partial state
|
|
102
|
+
* signature can therefore discard the newest row image while the cursor still
|
|
103
|
+
* advances past it. Only an identical positive sync id proves duplicate
|
|
104
|
+
* delivery; non-positive ids carry no usable log identity and stay untouched.
|
|
105
|
+
*/
|
|
106
|
+
export declare function deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[];
|
|
99
107
|
/**
|
|
100
108
|
* Performs per-delta bookkeeping and enqueues the delta. Returns `true` when
|
|
101
109
|
* the delta was pushed onto `pendingDeltas` — a regular batchable insert,
|
|
@@ -52,90 +52,37 @@ export function handleGroupHandlerFailure(ctx, delta, error) {
|
|
|
52
52
|
// Best-effort: the reconnect/bootstrap cycle self-heals on next connect.
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return signature;
|
|
73
|
-
}
|
|
74
|
-
function isSameState(a, b) {
|
|
75
|
-
if (!a || !b)
|
|
76
|
-
return false;
|
|
77
|
-
const keys = Object.keys(a);
|
|
78
|
-
if (keys.length !== Object.keys(b).length)
|
|
79
|
-
return false;
|
|
80
|
-
return keys.every((k) => a[k] === b[k]);
|
|
81
|
-
}
|
|
82
|
-
/** Deduplicate deltas to the same entity — keep meaningful state transitions only */
|
|
83
|
-
export function deduplicateDeltas(ctx, deltas) {
|
|
84
|
-
// The dominant live-publication shape is a frame of independent entity
|
|
85
|
-
// creates. When every entity key occurs once, reconciliation cannot remove
|
|
86
|
-
// or reorder anything: preserve the already commit-ordered input directly
|
|
87
|
-
// and avoid allocating a bucket array, state signature, and two sorts per
|
|
88
|
-
// delta. The first duplicate falls through to the full transition logic.
|
|
89
|
-
const uniqueEntities = new Set();
|
|
90
|
-
let hasDuplicateEntity = false;
|
|
91
|
-
for (const delta of deltas) {
|
|
92
|
-
const key = `${delta.modelName}:${delta.modelId}`;
|
|
93
|
-
if (uniqueEntities.has(key)) {
|
|
94
|
-
hasDuplicateEntity = true;
|
|
55
|
+
/**
|
|
56
|
+
* Deduplicate repeated delivery of the same log entry.
|
|
57
|
+
*
|
|
58
|
+
* A row may legitimately change several times in one receive frame. Those
|
|
59
|
+
* changes are ordered facts, even when a small subset of fields (such as
|
|
60
|
+
* `status`) happens to remain equal. Collapsing by entity or a partial state
|
|
61
|
+
* signature can therefore discard the newest row image while the cursor still
|
|
62
|
+
* advances past it. Only an identical positive sync id proves duplicate
|
|
63
|
+
* delivery; non-positive ids carry no usable log identity and stay untouched.
|
|
64
|
+
*/
|
|
65
|
+
export function deduplicateDeltas(deltas) {
|
|
66
|
+
if (deltas.length < 2 || deltas.some((delta) => delta.id <= 0))
|
|
67
|
+
return deltas;
|
|
68
|
+
let strictlyOrdered = true;
|
|
69
|
+
for (let index = 1; index < deltas.length; index += 1) {
|
|
70
|
+
if (deltas[index - 1].id >= deltas[index].id) {
|
|
71
|
+
strictlyOrdered = false;
|
|
95
72
|
break;
|
|
96
73
|
}
|
|
97
|
-
uniqueEntities.add(key);
|
|
98
74
|
}
|
|
99
|
-
if (
|
|
75
|
+
if (strictlyOrdered)
|
|
100
76
|
return deltas;
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const sorted = entityDeltas.sort((a, b) => a.id - b.id);
|
|
111
|
-
// DELETE wins — it's the final state
|
|
112
|
-
const del = sorted.find((d) => d.actionType === 'D');
|
|
113
|
-
if (del) {
|
|
114
|
-
result.push(del);
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
// Keep deltas that represent different states
|
|
118
|
-
const unique = [];
|
|
119
|
-
let prev = null;
|
|
120
|
-
for (const d of sorted) {
|
|
121
|
-
const sig = extractStateSignature(ctx, d);
|
|
122
|
-
if (!isSameState(prev, sig)) {
|
|
123
|
-
unique.push(d);
|
|
124
|
-
prev = sig;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
if (unique.length > 0) {
|
|
128
|
-
result.push(...unique);
|
|
129
|
-
}
|
|
130
|
-
else {
|
|
131
|
-
// `sorted` is never empty (every byEntity bucket gets at least one
|
|
132
|
-
// delta pushed) — the guard only narrows the indexed access.
|
|
133
|
-
const last = sorted.at(-1);
|
|
134
|
-
if (last)
|
|
135
|
-
result.push(last);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
return result.sort((a, b) => a.id - b.id);
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
return [...deltas]
|
|
79
|
+
.sort((a, b) => a.id - b.id)
|
|
80
|
+
.filter((delta) => {
|
|
81
|
+
if (seen.has(delta.id))
|
|
82
|
+
return false;
|
|
83
|
+
seen.add(delta.id);
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
139
86
|
}
|
|
140
87
|
/**
|
|
141
88
|
* Performs per-delta bookkeeping and enqueues the delta. Returns `true` when
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ClaimTarget } from '@abloatai/transaction/types/streams';
|
|
2
|
+
import type { Schema } from '@abloatai/transaction/schema/schema';
|
|
3
|
+
/** A schema-shaped selector used to narrow connection groups and presence reads. */
|
|
4
|
+
export type GroupScope = ClaimTarget | readonly ClaimTarget[] | string | readonly string[] | {
|
|
5
|
+
readonly syncGroup: string;
|
|
6
|
+
} | {
|
|
7
|
+
readonly syncGroups: readonly string[];
|
|
8
|
+
} | Record<string, string | readonly string[] | undefined>;
|
|
9
|
+
/** Resolve an application-shaped scope into the wire groups owned by the schema. */
|
|
10
|
+
export declare function resolveScopeGroups(scope: GroupScope | undefined, schema?: Schema): string[];
|
|
11
|
+
export declare function groupFromEntityRef(ref: ClaimTarget, schema?: Schema): string;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { scopeKindOf } from '@abloatai/transaction/schema/model';
|
|
2
|
+
/** Resolve an application-shaped scope into the wire groups owned by the schema. */
|
|
3
|
+
export function resolveScopeGroups(scope, schema) {
|
|
4
|
+
if (!scope)
|
|
5
|
+
return [];
|
|
6
|
+
if (typeof scope === 'string')
|
|
7
|
+
return [scope];
|
|
8
|
+
if (Array.isArray(scope)) {
|
|
9
|
+
const groups = [];
|
|
10
|
+
for (const entry of scope) {
|
|
11
|
+
if (typeof entry === 'string')
|
|
12
|
+
groups.push(entry);
|
|
13
|
+
else if (isEntityScope(entry))
|
|
14
|
+
groups.push(groupFromEntityRef(entry, schema));
|
|
15
|
+
}
|
|
16
|
+
return groups;
|
|
17
|
+
}
|
|
18
|
+
const direct = scope;
|
|
19
|
+
if (isEntityScope(scope))
|
|
20
|
+
return [groupFromEntityRef(scope, schema)];
|
|
21
|
+
if (typeof direct.syncGroup === 'string')
|
|
22
|
+
return [direct.syncGroup];
|
|
23
|
+
if (Array.isArray(direct.syncGroups)) {
|
|
24
|
+
return direct.syncGroups.filter((group) => typeof group === 'string');
|
|
25
|
+
}
|
|
26
|
+
const groups = [];
|
|
27
|
+
for (const [key, value] of Object.entries(scope)) {
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
continue;
|
|
30
|
+
if (Array.isArray(value)) {
|
|
31
|
+
for (const id of value) {
|
|
32
|
+
if (typeof id === 'string')
|
|
33
|
+
groups.push(groupFromSchemaKey(key, id, schema));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else if (typeof value === 'string') {
|
|
37
|
+
groups.push(groupFromSchemaKey(key, value, schema));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return groups;
|
|
41
|
+
}
|
|
42
|
+
export function groupFromEntityRef(ref, schema) {
|
|
43
|
+
const match = findModelForEntityRef(ref, schema);
|
|
44
|
+
const kind = match
|
|
45
|
+
? groupKindForModel(match.def, match.key)
|
|
46
|
+
: ref.type.toLowerCase();
|
|
47
|
+
return `${kind}:${ref.id}`;
|
|
48
|
+
}
|
|
49
|
+
function groupFromSchemaKey(schemaKey, id, schema) {
|
|
50
|
+
const def = schema?.models[schemaKey];
|
|
51
|
+
const kind = def ? groupKindForModel(def, schemaKey) : schemaKey.toLowerCase();
|
|
52
|
+
return `${kind}:${id}`;
|
|
53
|
+
}
|
|
54
|
+
function groupKindForModel(def, key) {
|
|
55
|
+
return scopeKindOf(def, key) ?? (def.typename ?? key).toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
function findModelForEntityRef(ref, schema) {
|
|
58
|
+
if (!schema?.models)
|
|
59
|
+
return null;
|
|
60
|
+
const wanted = ref.type.toLowerCase();
|
|
61
|
+
for (const [key, def] of Object.entries(schema.models)) {
|
|
62
|
+
const typename = def.typename ?? key;
|
|
63
|
+
if (typename.toLowerCase() === wanted || key.toLowerCase() === wanted) {
|
|
64
|
+
return { key, def };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
function isEntityScope(scope) {
|
|
70
|
+
return (typeof scope === 'object' &&
|
|
71
|
+
scope !== null &&
|
|
72
|
+
!Array.isArray(scope) &&
|
|
73
|
+
typeof scope.type === 'string' &&
|
|
74
|
+
typeof scope.id === 'string');
|
|
75
|
+
}
|
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
* the transport object. This path re-exports it so existing importers stay
|
|
6
6
|
* unchanged.
|
|
7
7
|
*/
|
|
8
|
-
export { isRecord, readWsInboundFrame, wsFrameHandlers, dispatchWsFrame, type PendingCommit, type
|
|
8
|
+
export { isRecord, readWsInboundFrame, wsFrameHandlers, dispatchWsFrame, type PendingCommit, type PendingSubscription, type WsInboundFrame, type WsSession, type WsFrameHandler, } from '@abloatai/transaction/transport/websocket';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { AbloError } from '@abloatai/transaction/errors';
|
|
2
1
|
import { extractStatusCode } from './commitPayload.js';
|
|
2
|
+
import { reportPermanentMutationFailure } from './failureReporting.js';
|
|
3
3
|
export function transientRetryDelayMs(error, attempt, retryBackoff) {
|
|
4
4
|
const { baseMs, capMs } = retryBackoff;
|
|
5
5
|
let base = baseMs;
|
|
@@ -16,85 +16,12 @@ export async function handleFailure(ctx, transaction, error) {
|
|
|
16
16
|
transaction.attempts++;
|
|
17
17
|
// Check whether this is a permanent error that should not be retried.
|
|
18
18
|
if (ctx.isPermanentError(error)) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
const abloErr = error instanceof AbloError ? error : undefined;
|
|
27
|
-
const details = {
|
|
28
|
-
txId: transaction.id.slice(0, 8),
|
|
29
|
-
type: transaction.type,
|
|
30
|
-
model: transaction.modelName,
|
|
31
|
-
modelId: transaction.modelId.slice(0, 12),
|
|
32
|
-
errorType: abloErr?.type ?? error?.name,
|
|
33
|
-
errorCode: abloErr?.code,
|
|
34
|
-
httpStatus: abloErr?.httpStatus,
|
|
35
|
-
requestId: abloErr?.requestId,
|
|
36
|
-
message: error?.message,
|
|
37
|
-
inputKeys: transaction.data ? Object.keys(transaction.data) : undefined,
|
|
38
|
-
};
|
|
39
|
-
// A `create` whose id already exists is the benign idempotency case:
|
|
40
|
-
// "this row is already there." It's the least alarming permanent
|
|
41
|
-
// error, so it doesn't warrant a `warn` — `info` keeps it visible
|
|
42
|
-
// without crying wolf. Everything else (FK violation, auth expiry,
|
|
43
|
-
// server 500) stays at `warn`.
|
|
44
|
-
const isBenignIdempotent = transaction.type === 'create' &&
|
|
45
|
-
(abloErr?.code === 'unique_violation' ||
|
|
46
|
-
abloErr?.type === 'AbloIdempotencyError');
|
|
47
|
-
// Demote exact repeats (same write rejected for the same reason on
|
|
48
|
-
// each reconnect replay) to `debug` so the loop logs once.
|
|
49
|
-
const sig = `${details.type}:${details.model}:${details.modelId}:${details.errorCode ?? details.errorType}`;
|
|
50
|
-
const isRepeat = sig === ctx.getLastPermanentErrorSignature();
|
|
51
|
-
ctx.setLastPermanentErrorSignature(sig);
|
|
52
|
-
const logger = ctx.runtime.logger;
|
|
53
|
-
// Two registers from one call site, split by log level (the default
|
|
54
|
-
// logger is gated at `warn`, so `debug` stays hidden unless
|
|
55
|
-
// ABLO_LOG_LEVEL=debug is set to inspect the engine):
|
|
56
|
-
// - the default-visible line speaks the application developer's
|
|
57
|
-
// language: their verb (such as `update`), their model, the typed
|
|
58
|
-
// error's own message, and the wire `code` for searching. It uses
|
|
59
|
-
// no engine jargon and prints no JSON dump, which would alarm
|
|
60
|
-
// without helping.
|
|
61
|
-
// - the forensic `details` ride a companion `debug` line for anyone
|
|
62
|
-
// debugging the engine internals.
|
|
63
|
-
const revertNote = ctx.config.enableOptimistic
|
|
64
|
-
? ' The local change was reverted.'
|
|
65
|
-
: '';
|
|
66
|
-
const reason = abloErr?.message ? ` — ${abloErr.message}` : '';
|
|
67
|
-
const code = abloErr?.code ? ` (code: ${abloErr.code})` : '';
|
|
68
|
-
const requestRef = abloErr?.requestId
|
|
69
|
-
? ` [request_id: ${abloErr.requestId}]`
|
|
70
|
-
: '';
|
|
71
|
-
// An optimistic write resolves before the server answers, so a later
|
|
72
|
-
// rejection has no caller left to return to and this log is the only
|
|
73
|
-
// place it appears. That reads to an application developer as their own
|
|
74
|
-
// save silently failing — the write showed, then vanished — and sends
|
|
75
|
-
// them into their editor instead of here. Name the subscription that
|
|
76
|
-
// hands them the same typed error, so the application can say what
|
|
77
|
-
// happened rather than only the console.
|
|
78
|
-
const channelNote = ctx.config.enableOptimistic
|
|
79
|
-
? ' To surface this in your app, subscribe with `ablo.onMutationFailure(…)`.'
|
|
80
|
-
: '';
|
|
81
|
-
const headline = `Your ${transaction.type} to "${transaction.modelName}" was not saved${reason}${code}${requestRef}.${revertNote}${channelNote}`;
|
|
82
|
-
if (isRepeat) {
|
|
83
|
-
// Same write rejected for the same reason on each reconnect replay —
|
|
84
|
-
// log the forensics once, stay quiet after.
|
|
85
|
-
logger.debug('write rejected again (same reason)', details);
|
|
86
|
-
}
|
|
87
|
-
else if (isBenignIdempotent) {
|
|
88
|
-
// Already-exists on a `create` is expected on replay, not a problem.
|
|
89
|
-
logger.info(`Your ${transaction.type} to "${transaction.modelName}" was skipped — this row already exists.`);
|
|
90
|
-
logger.debug('idempotent skip — details', details);
|
|
91
|
-
}
|
|
92
|
-
else {
|
|
93
|
-
logger.warn(headline);
|
|
94
|
-
logger.debug('write rejection — details', details);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
catch { }
|
|
19
|
+
reportPermanentMutationFailure({
|
|
20
|
+
runtime: ctx.runtime,
|
|
21
|
+
enableOptimistic: ctx.config.enableOptimistic,
|
|
22
|
+
getLastPermanentErrorSignature: ctx.getLastPermanentErrorSignature,
|
|
23
|
+
setLastPermanentErrorSignature: ctx.setLastPermanentErrorSignature,
|
|
24
|
+
}, transaction, error);
|
|
98
25
|
// Mark as failed immediately and rollback
|
|
99
26
|
ctx.store.updateStatus(transaction.id, 'failed');
|
|
100
27
|
if (ctx.config.enableOptimistic) {
|
|
@@ -108,7 +35,8 @@ export async function handleFailure(ctx, transaction, error) {
|
|
|
108
35
|
return;
|
|
109
36
|
}
|
|
110
37
|
transaction.firstTransientFailureAt ??= Date.now();
|
|
111
|
-
const insideAvailabilityWindow = Date.now() - transaction.firstTransientFailureAt <
|
|
38
|
+
const insideAvailabilityWindow = Date.now() - transaction.firstTransientFailureAt <
|
|
39
|
+
ctx.config.availabilityRetryWindowMs;
|
|
112
40
|
if (transaction.attempts < ctx.config.maxRetries || insideAvailabilityWindow) {
|
|
113
41
|
// Exponential backoff with full jitter on every transient retry:
|
|
114
42
|
// `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses
|