@abloatai/humans 0.59.2 → 0.61.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/humans.d.ts +1 -1
- package/dist/local/BaseSyncedStore.d.ts +5 -5
- package/dist/local/BaseSyncedStore.js +7 -7
- package/dist/local/Database.d.ts +2 -2
- package/dist/local/LazyReferenceCollection.d.ts +1 -1
- package/dist/local/Model.js +46 -56
- package/dist/local/NetworkMonitor.js +2 -0
- package/dist/local/RuntimeContext.js +2 -0
- package/dist/local/SyncClient.d.ts +12 -36
- package/dist/local/SyncClient.js +50 -108
- package/dist/local/client/createModelOperations.d.ts +3 -27
- package/dist/local/client/createModelOperations.js +20 -21
- 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/fileUploads.d.ts +27 -0
- package/dist/local/fileUploads.js +55 -0
- package/dist/local/storeContract.d.ts +5 -5
- package/dist/local/stores/syncAction.d.ts +4 -4
- package/dist/local/sync/contextOnChange.js +1 -1
- package/dist/local/sync/createClaimStream.js +1 -1
- package/dist/local/sync/credentialLifecycle.d.ts +4 -5
- package/dist/local/sync/credentialLifecycle.js +4 -5
- package/dist/local/sync/deltaPipeline.js +12 -6
- package/dist/local/sync/schemas.d.ts +10 -10
- 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/localMutation.js +3 -3
- package/dist/local/transactions/mutations/MutationQueue.d.ts +4 -5
- package/dist/local/transactions/mutations/MutationQueue.js +25 -51
- package/dist/local/transactions/mutations/batchProcessing.js +23 -10
- package/dist/local/transactions/mutations/commitPayload.d.ts +9 -2
- package/dist/local/transactions/mutations/commitTransport.js +3 -1
- package/dist/local/transactions/mutations/executionSelection.d.ts +0 -1
- package/dist/local/transactions/mutations/executionSelection.js +9 -17
- package/dist/local/transactions/mutations/failureHandling.js +9 -0
- package/dist/local/transactions/mutations/localMutation.js +3 -3
- package/dist/local/transactions/mutations/queueCoalescing.js +8 -0
- package/dist/local/transactions/mutations/replayValidation.d.ts +15 -15
- package/dist/react/AbloProvider.d.ts +11 -86
- package/dist/react/AbloProvider.js +11 -163
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/createAbloReact.js +1 -1
- package/dist/react/useErrorListener.js +1 -1
- package/dist/react/useMutationFailureListener.js +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/surface.d.ts +3 -3
- package/dist/surface.js +1 -4
- package/package.json +3 -3
- package/src/Ablo.ts +5 -17
- package/src/client.ts +0 -51
- package/src/local/BaseSyncedStore.ts +14 -14
- package/src/local/Model.ts +45 -55
- package/src/local/NetworkMonitor.ts +2 -0
- package/src/local/RuntimeContext.ts +2 -0
- package/src/local/SyncClient.ts +73 -140
- package/src/local/client/createModelOperations.ts +30 -64
- 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/fileUploads.ts +97 -0
- package/src/local/storeContract.ts +5 -5
- package/src/local/sync/contextOnChange.ts +1 -1
- package/src/local/sync/createClaimStream.ts +1 -1
- package/src/local/sync/credentialLifecycle.ts +4 -5
- package/src/local/sync/deltaPipeline.ts +10 -6
- package/src/local/sync/scopeGroups.ts +91 -0
- package/src/local/sync/wsFrameHandlers.ts +0 -1
- package/src/local/transactions/localMutation.ts +3 -3
- package/src/local/transactions/mutations/MutationQueue.ts +24 -53
- package/src/local/transactions/mutations/batchProcessing.ts +25 -10
- package/src/local/transactions/mutations/commitPayload.ts +11 -1
- package/src/local/transactions/mutations/commitTransport.ts +2 -2
- package/src/local/transactions/mutations/executionSelection.ts +9 -15
- package/src/local/transactions/mutations/failureHandling.ts +10 -0
- package/src/local/transactions/mutations/localMutation.ts +3 -3
- package/src/local/transactions/mutations/queueCoalescing.ts +6 -0
- package/src/react/AbloProvider.tsx +17 -249
- package/src/react/useErrorListener.ts +1 -1
- package/src/react/useMutationFailureListener.ts +1 -1
- 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/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
- package/dist/local/transactions/mutations/pendingDrain.js +0 -117
- package/src/local/sync/participants.ts +0 -564
- package/src/local/transactions/mutations/pendingDrain.ts +0 -169
|
@@ -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 */
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** File-upload behavior owned beneath the SyncClient boundary. */
|
|
2
|
+
import type { RuntimeContext } from './RuntimeContext.js';
|
|
3
|
+
import { Model } from './Model.js';
|
|
4
|
+
import { InstanceCache } from './InstanceCache.js';
|
|
5
|
+
import type { MutationQueue } from './transactions/mutations/MutationQueue.js';
|
|
6
|
+
export interface FileUploadOptions {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly attachableType: string;
|
|
9
|
+
readonly attachableId: string;
|
|
10
|
+
readonly metadata?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
export interface BatchFileUploadOptions {
|
|
13
|
+
readonly ids: string[];
|
|
14
|
+
readonly attachableType: string;
|
|
15
|
+
readonly attachableId: string;
|
|
16
|
+
readonly metadata?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
export interface FileUploadContext {
|
|
19
|
+
readonly userId: string | null;
|
|
20
|
+
readonly organizationId: string | null;
|
|
21
|
+
readonly mutationQueue: MutationQueue;
|
|
22
|
+
readonly objectPool: InstanceCache;
|
|
23
|
+
readonly observability: RuntimeContext['observability'];
|
|
24
|
+
readonly notifyCreated: (model: Model) => void;
|
|
25
|
+
}
|
|
26
|
+
export declare function uploadFile(context: FileUploadContext, file: File, options: FileUploadOptions): Promise<Model | null>;
|
|
27
|
+
export declare function batchUploadFiles(context: FileUploadContext, files: File[], options: BatchFileUploadOptions): Promise<Model[]>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** File-upload behavior owned beneath the SyncClient boundary. */
|
|
2
|
+
import { AbloAuthenticationError } from '@abloatai/transaction/errors';
|
|
3
|
+
import { Model } from './Model.js';
|
|
4
|
+
import { InstanceCache, ModelScope } from './InstanceCache.js';
|
|
5
|
+
function authenticatedContext(context) {
|
|
6
|
+
if (!context.userId || !context.organizationId) {
|
|
7
|
+
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
8
|
+
code: 'file_upload_auth_required',
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
return { userId: context.userId, organizationId: context.organizationId };
|
|
12
|
+
}
|
|
13
|
+
function acceptUploadedModel(context, data) {
|
|
14
|
+
const model = context.objectPool.createFromData(data);
|
|
15
|
+
if (!model)
|
|
16
|
+
return null;
|
|
17
|
+
context.objectPool.add(model, ModelScope.live);
|
|
18
|
+
context.notifyCreated(model);
|
|
19
|
+
return model;
|
|
20
|
+
}
|
|
21
|
+
export async function uploadFile(context, file, options) {
|
|
22
|
+
const identity = authenticatedContext(context);
|
|
23
|
+
try {
|
|
24
|
+
const result = await context.mutationQueue.uploadAttachment(file, {
|
|
25
|
+
id: options.id,
|
|
26
|
+
attachableType: options.attachableType,
|
|
27
|
+
attachableId: options.attachableId,
|
|
28
|
+
metadata: options.metadata,
|
|
29
|
+
}, identity);
|
|
30
|
+
return result
|
|
31
|
+
? acceptUploadedModel(context, { id: options.id, ...result })
|
|
32
|
+
: null;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
context.observability.captureMutationFailure({
|
|
36
|
+
context: 'file-upload',
|
|
37
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
38
|
+
});
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function batchUploadFiles(context, files, options) {
|
|
43
|
+
const identity = authenticatedContext(context);
|
|
44
|
+
const items = options.ids.map((id) => ({
|
|
45
|
+
id,
|
|
46
|
+
attachableType: options.attachableType,
|
|
47
|
+
attachableId: options.attachableId,
|
|
48
|
+
metadata: options.metadata,
|
|
49
|
+
}));
|
|
50
|
+
const results = await context.mutationQueue.batchUploadAttachments(files, items, identity);
|
|
51
|
+
return results.flatMap((result) => {
|
|
52
|
+
const model = acceptUploadedModel(context, { ...result });
|
|
53
|
+
return model ? [model] : [];
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -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`,
|
|
@@ -10,14 +10,14 @@ export declare const syncActionSchema: z.ZodObject<{
|
|
|
10
10
|
modelName: z.ZodString;
|
|
11
11
|
modelId: z.ZodString;
|
|
12
12
|
action: z.ZodEnum<{
|
|
13
|
+
I: "I";
|
|
14
|
+
U: "U";
|
|
15
|
+
D: "D";
|
|
13
16
|
A: "A";
|
|
17
|
+
V: "V";
|
|
14
18
|
C: "C";
|
|
15
|
-
D: "D";
|
|
16
19
|
G: "G";
|
|
17
|
-
I: "I";
|
|
18
20
|
S: "S";
|
|
19
|
-
U: "U";
|
|
20
|
-
V: "V";
|
|
21
21
|
}>;
|
|
22
22
|
data: z.ZodUnknown;
|
|
23
23
|
__class: z.ZodDefault<z.ZodLiteral<"SyncAction">>;
|
|
@@ -37,7 +37,7 @@ export function contextOnChange(transport, pool, reads, listener) {
|
|
|
37
37
|
// already advanced this exact row in the pool.
|
|
38
38
|
for (const read of rowReads) {
|
|
39
39
|
const resident = pool.peek(read.id);
|
|
40
|
-
if (
|
|
40
|
+
if (resident?.getModelName().toLowerCase() !== read.model.toLowerCase()) {
|
|
41
41
|
continue;
|
|
42
42
|
}
|
|
43
43
|
const observed = pool.watermarks.of(resident);
|
|
@@ -284,7 +284,7 @@ export function createClaimStream(config, transport = null) {
|
|
|
284
284
|
}
|
|
285
285
|
ownClaims.clear();
|
|
286
286
|
for (const claimId of [...pendingHeartbeats.keys()]) {
|
|
287
|
-
settleHeartbeat(claimId, ({ reject }) => reject(error));
|
|
287
|
+
settleHeartbeat(claimId, ({ reject }) => { reject(error); });
|
|
288
288
|
}
|
|
289
289
|
}));
|
|
290
290
|
}
|
|
@@ -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';
|
|
@@ -67,7 +67,9 @@ export function deduplicateDeltas(deltas) {
|
|
|
67
67
|
return deltas;
|
|
68
68
|
let strictlyOrdered = true;
|
|
69
69
|
for (let index = 1; index < deltas.length; index += 1) {
|
|
70
|
-
|
|
70
|
+
const previous = deltas[index - 1];
|
|
71
|
+
const current = deltas[index];
|
|
72
|
+
if (!previous || !current || previous.id >= current.id) {
|
|
71
73
|
strictlyOrdered = false;
|
|
72
74
|
break;
|
|
73
75
|
}
|
|
@@ -277,10 +279,13 @@ export function sliceApplyChanges(changes, maxDeltas) {
|
|
|
277
279
|
while (index < changes.length) {
|
|
278
280
|
// The indivisible unit starting here: one transaction's run, or a single
|
|
279
281
|
// untransacted change.
|
|
280
|
-
const
|
|
282
|
+
const change = changes[index];
|
|
283
|
+
if (!change)
|
|
284
|
+
break;
|
|
285
|
+
const transactionId = change.transactionId;
|
|
281
286
|
let end = index + 1;
|
|
282
287
|
if (transactionId !== undefined) {
|
|
283
|
-
while (
|
|
288
|
+
while (changes[end]?.transactionId === transactionId)
|
|
284
289
|
end += 1;
|
|
285
290
|
}
|
|
286
291
|
const groupSize = end - index;
|
|
@@ -327,9 +332,11 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
|
|
|
327
332
|
if (customDeltas.length > 0) {
|
|
328
333
|
runInAction(() => {
|
|
329
334
|
for (const delta of customDeltas) {
|
|
335
|
+
if (delta.data === null)
|
|
336
|
+
continue;
|
|
330
337
|
const data = typeof delta.data === 'string'
|
|
331
338
|
? JSON.parse(delta.data)
|
|
332
|
-
:
|
|
339
|
+
: delta.data;
|
|
333
340
|
// 'C' (Covering) is treated identically to 'I' here — the client
|
|
334
341
|
// gained permission to see the entity, so we insert it into the
|
|
335
342
|
// pool as if newly created.
|
|
@@ -395,7 +402,7 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
|
|
|
395
402
|
// slice. Slices stay the atomicity unit; the budget only decides where
|
|
396
403
|
// the loop breathes.
|
|
397
404
|
let sliceStartedAt = performance.now();
|
|
398
|
-
for (
|
|
405
|
+
for (const [index, slice] of slices.entries()) {
|
|
399
406
|
if (index > 0 && performance.now() - sliceStartedAt > APPLY_YIELD_BUDGET_MS) {
|
|
400
407
|
pipelineDebug.phase = `apply-yield-${index}`;
|
|
401
408
|
pipelineDebug.applyYields += 1;
|
|
@@ -404,7 +411,6 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
|
|
|
404
411
|
}
|
|
405
412
|
pipelineDebug.phase = `apply-slice-${index}`;
|
|
406
413
|
pipelineDebug.applySlices += 1;
|
|
407
|
-
const slice = slices[index];
|
|
408
414
|
if (hasApplyPlugins) {
|
|
409
415
|
runStage(stagePlugins, 'apply', { changes: slice });
|
|
410
416
|
}
|
|
@@ -18,19 +18,19 @@ import type { RuntimeContext } from "../RuntimeContext.js";
|
|
|
18
18
|
*/
|
|
19
19
|
export declare const ServerDeltaSchema: z.ZodObject<{
|
|
20
20
|
id: z.ZodNumber;
|
|
21
|
+
data: z.ZodNullable<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodString]>>;
|
|
21
22
|
actionType: z.ZodEnum<{
|
|
23
|
+
I: "I";
|
|
24
|
+
U: "U";
|
|
25
|
+
D: "D";
|
|
22
26
|
A: "A";
|
|
27
|
+
V: "V";
|
|
23
28
|
C: "C";
|
|
24
|
-
D: "D";
|
|
25
29
|
G: "G";
|
|
26
|
-
I: "I";
|
|
27
30
|
S: "S";
|
|
28
|
-
U: "U";
|
|
29
|
-
V: "V";
|
|
30
31
|
}>;
|
|
31
32
|
modelName: z.ZodString;
|
|
32
33
|
modelId: z.ZodString;
|
|
33
|
-
data: z.ZodNullable<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodString]>>;
|
|
34
34
|
}, z.core.$loose>;
|
|
35
35
|
export type ValidatedServerDelta = z.infer<typeof ServerDeltaSchema>;
|
|
36
36
|
export declare const BootstrapResponseSchema: z.ZodObject<{
|
|
@@ -42,19 +42,19 @@ export declare const BootstrapResponseSchema: z.ZodObject<{
|
|
|
42
42
|
models: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodPipe<z.ZodUnion<readonly [z.ZodArray<z.ZodUnknown>, z.ZodString, z.ZodNull]>, z.ZodTransform<unknown[], string | unknown[] | null>>>>;
|
|
43
43
|
deltas: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
44
44
|
id: z.ZodNumber;
|
|
45
|
+
data: z.ZodNullable<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodString]>>;
|
|
45
46
|
actionType: z.ZodEnum<{
|
|
47
|
+
I: "I";
|
|
48
|
+
U: "U";
|
|
49
|
+
D: "D";
|
|
46
50
|
A: "A";
|
|
51
|
+
V: "V";
|
|
47
52
|
C: "C";
|
|
48
|
-
D: "D";
|
|
49
53
|
G: "G";
|
|
50
|
-
I: "I";
|
|
51
54
|
S: "S";
|
|
52
|
-
U: "U";
|
|
53
|
-
V: "V";
|
|
54
55
|
}>;
|
|
55
56
|
modelName: z.ZodString;
|
|
56
57
|
modelId: z.ZodString;
|
|
57
|
-
data: z.ZodNullable<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodString]>>;
|
|
58
58
|
}, z.core.$loose>>>;
|
|
59
59
|
deltaCount: z.ZodOptional<z.ZodNumber>;
|
|
60
60
|
failedModels: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -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';
|
|
@@ -16,9 +16,9 @@ export function createLocalMutationPort(emit) {
|
|
|
16
16
|
};
|
|
17
17
|
return {
|
|
18
18
|
updates,
|
|
19
|
-
applyCreate: (model, transaction) => track('optimistic:create', model, transaction),
|
|
20
|
-
applyUpdate: (model, transaction) => track('optimistic:update', model, transaction),
|
|
21
|
-
applyDelete: (model, transaction) => track('optimistic:delete', model, transaction),
|
|
19
|
+
applyCreate: (model, transaction) => { track('optimistic:create', model, transaction); },
|
|
20
|
+
applyUpdate: (model, transaction) => { track('optimistic:update', model, transaction); },
|
|
21
|
+
applyDelete: (model, transaction) => { track('optimistic:delete', model, transaction); },
|
|
22
22
|
rollback: (transaction, reason, error) => {
|
|
23
23
|
const optimistic = updates.get(transaction.id);
|
|
24
24
|
if (!optimistic)
|