@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.
Files changed (96) hide show
  1. package/README.md +1 -1
  2. package/dist/Ablo.d.ts +2 -10
  3. package/dist/Ablo.js +0 -1
  4. package/dist/client.d.ts +1 -48
  5. package/dist/humans.d.ts +1 -1
  6. package/dist/local/BaseSyncedStore.d.ts +5 -5
  7. package/dist/local/BaseSyncedStore.js +7 -7
  8. package/dist/local/Database.d.ts +2 -2
  9. package/dist/local/LazyReferenceCollection.d.ts +1 -1
  10. package/dist/local/Model.js +46 -56
  11. package/dist/local/NetworkMonitor.js +2 -0
  12. package/dist/local/RuntimeContext.js +2 -0
  13. package/dist/local/SyncClient.d.ts +12 -36
  14. package/dist/local/SyncClient.js +50 -108
  15. package/dist/local/client/createModelOperations.d.ts +3 -27
  16. package/dist/local/client/createModelOperations.js +20 -21
  17. package/dist/local/client/options.d.ts +14 -39
  18. package/dist/local/client/reactiveEngine.d.ts +3 -9
  19. package/dist/local/client/reactiveEngine.js +6 -151
  20. package/dist/local/client/storeLifecycle.js +5 -1
  21. package/dist/local/fileUploads.d.ts +27 -0
  22. package/dist/local/fileUploads.js +55 -0
  23. package/dist/local/storeContract.d.ts +5 -5
  24. package/dist/local/stores/syncAction.d.ts +4 -4
  25. package/dist/local/sync/contextOnChange.js +1 -1
  26. package/dist/local/sync/createClaimStream.js +1 -1
  27. package/dist/local/sync/credentialLifecycle.d.ts +4 -5
  28. package/dist/local/sync/credentialLifecycle.js +4 -5
  29. package/dist/local/sync/deltaPipeline.js +12 -6
  30. package/dist/local/sync/schemas.d.ts +10 -10
  31. package/dist/local/sync/scopeGroups.d.ts +11 -0
  32. package/dist/local/sync/scopeGroups.js +75 -0
  33. package/dist/local/sync/wsFrameHandlers.d.ts +1 -1
  34. package/dist/local/transactions/localMutation.js +3 -3
  35. package/dist/local/transactions/mutations/MutationQueue.d.ts +4 -5
  36. package/dist/local/transactions/mutations/MutationQueue.js +25 -51
  37. package/dist/local/transactions/mutations/batchProcessing.js +23 -10
  38. package/dist/local/transactions/mutations/commitPayload.d.ts +9 -2
  39. package/dist/local/transactions/mutations/commitTransport.js +3 -1
  40. package/dist/local/transactions/mutations/executionSelection.d.ts +0 -1
  41. package/dist/local/transactions/mutations/executionSelection.js +9 -17
  42. package/dist/local/transactions/mutations/failureHandling.js +9 -0
  43. package/dist/local/transactions/mutations/localMutation.js +3 -3
  44. package/dist/local/transactions/mutations/queueCoalescing.js +8 -0
  45. package/dist/local/transactions/mutations/replayValidation.d.ts +15 -15
  46. package/dist/react/AbloProvider.d.ts +11 -86
  47. package/dist/react/AbloProvider.js +11 -163
  48. package/dist/react/ClientSideSuspense.d.ts +1 -1
  49. package/dist/react/DefaultFallback.d.ts +1 -1
  50. package/dist/react/createAbloReact.js +1 -1
  51. package/dist/react/useErrorListener.js +1 -1
  52. package/dist/react/useMutationFailureListener.js +1 -1
  53. package/dist/react.d.ts +1 -1
  54. package/dist/react.js +1 -1
  55. package/dist/surface.d.ts +3 -3
  56. package/dist/surface.js +1 -4
  57. package/package.json +3 -3
  58. package/src/Ablo.ts +5 -17
  59. package/src/client.ts +0 -51
  60. package/src/local/BaseSyncedStore.ts +14 -14
  61. package/src/local/Model.ts +45 -55
  62. package/src/local/NetworkMonitor.ts +2 -0
  63. package/src/local/RuntimeContext.ts +2 -0
  64. package/src/local/SyncClient.ts +73 -140
  65. package/src/local/client/createModelOperations.ts +30 -64
  66. package/src/local/client/options.ts +20 -43
  67. package/src/local/client/reactiveEngine.ts +7 -179
  68. package/src/local/client/storeLifecycle.ts +6 -1
  69. package/src/local/fileUploads.ts +97 -0
  70. package/src/local/storeContract.ts +5 -5
  71. package/src/local/sync/contextOnChange.ts +1 -1
  72. package/src/local/sync/createClaimStream.ts +1 -1
  73. package/src/local/sync/credentialLifecycle.ts +4 -5
  74. package/src/local/sync/deltaPipeline.ts +10 -6
  75. package/src/local/sync/scopeGroups.ts +91 -0
  76. package/src/local/sync/wsFrameHandlers.ts +0 -1
  77. package/src/local/transactions/localMutation.ts +3 -3
  78. package/src/local/transactions/mutations/MutationQueue.ts +24 -53
  79. package/src/local/transactions/mutations/batchProcessing.ts +25 -10
  80. package/src/local/transactions/mutations/commitPayload.ts +11 -1
  81. package/src/local/transactions/mutations/commitTransport.ts +2 -2
  82. package/src/local/transactions/mutations/executionSelection.ts +9 -15
  83. package/src/local/transactions/mutations/failureHandling.ts +10 -0
  84. package/src/local/transactions/mutations/localMutation.ts +3 -3
  85. package/src/local/transactions/mutations/queueCoalescing.ts +6 -0
  86. package/src/react/AbloProvider.tsx +17 -249
  87. package/src/react/useErrorListener.ts +1 -1
  88. package/src/react/useMutationFailureListener.ts +1 -1
  89. package/src/react.ts +1 -5
  90. package/src/surface.ts +1 -4
  91. package/dist/local/sync/participants.d.ts +0 -132
  92. package/dist/local/sync/participants.js +0 -342
  93. package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
  94. package/dist/local/transactions/mutations/pendingDrain.js +0 -117
  95. package/src/local/sync/participants.ts +0 -564
  96. 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 stream and
6
- * participant manager, options validation, the typed model proxies, and the
7
- * commit/claim/session resources — composed into the reactive client.
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 { AbloAuthenticationError, AbloConnectionError, AbloValidationError, claimedError } from '@abloatai/transaction/errors';
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, createSibling, } = inputs;
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 via `ablo.sessions.create({ agent })` ' +
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.authEndpoint ?? internalOptions.apiKey;
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 { ParticipantScope } from './sync/participants.js';
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: ParticipantScope, opts?: {
132
+ enterScope?(scope: GroupScope, opts?: {
133
133
  hydrate?: boolean;
134
134
  }): Promise<void>;
135
- leaveScope?(scope: ParticipantScope): Promise<void>;
136
- pinScope?(scope: ParticipantScope): Promise<void>;
137
- unpinScope?(scope: ParticipantScope): Promise<void>;
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 (!resident || resident.getModelName().toLowerCase() !== read.model.toLowerCase()) {
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
- * Moved to the confirmation core with the duplex transport (ADR 0016): keeping
3
- * a long-lived socket's credential fresh is connection plumbing an agent needs
4
- * as much as a browser does. This path re-exports it so existing importers
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/transport/connection';
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
- * Moved to the confirmation core with the duplex transport (ADR 0016): keeping
3
- * a long-lived socket's credential fresh is connection plumbing an agent needs
4
- * as much as a browser does. This path re-exports it so existing importers
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/transport/connection';
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
- if (deltas[index - 1].id >= deltas[index].id) {
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 transactionId = changes[index].transactionId;
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 (end < changes.length && changes[end].transactionId === transactionId)
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
- : (delta.data);
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 (let index = 0; index < slices.length; index++) {
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 PendingClaim, type PendingSubscription, type WsInboundFrame, type WsSession, type WsFrameHandler, } from '@abloatai/transaction/transport/websocket';
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)