@abloatai/humans 0.59.2 → 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.
Files changed (56) 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 +2 -2
  8. package/dist/local/Database.d.ts +2 -2
  9. package/dist/local/LazyReferenceCollection.d.ts +1 -1
  10. package/dist/local/SyncClient.d.ts +7 -7
  11. package/dist/local/SyncClient.js +25 -10
  12. package/dist/local/client/createModelOperations.d.ts +3 -27
  13. package/dist/local/client/createModelOperations.js +14 -17
  14. package/dist/local/client/options.d.ts +14 -39
  15. package/dist/local/client/reactiveEngine.d.ts +3 -9
  16. package/dist/local/client/reactiveEngine.js +6 -151
  17. package/dist/local/client/storeLifecycle.js +5 -1
  18. package/dist/local/storeContract.d.ts +5 -5
  19. package/dist/local/stores/syncAction.d.ts +4 -4
  20. package/dist/local/sync/credentialLifecycle.d.ts +4 -5
  21. package/dist/local/sync/credentialLifecycle.js +4 -5
  22. package/dist/local/sync/schemas.d.ts +10 -10
  23. package/dist/local/sync/scopeGroups.d.ts +11 -0
  24. package/dist/local/sync/scopeGroups.js +75 -0
  25. package/dist/local/sync/wsFrameHandlers.d.ts +1 -1
  26. package/dist/local/transactions/mutations/MutationQueue.d.ts +3 -3
  27. package/dist/local/transactions/mutations/commitPayload.d.ts +1 -1
  28. package/dist/local/transactions/mutations/replayValidation.d.ts +15 -15
  29. package/dist/react/AbloProvider.d.ts +11 -86
  30. package/dist/react/AbloProvider.js +11 -163
  31. package/dist/react/ClientSideSuspense.d.ts +1 -1
  32. package/dist/react/DefaultFallback.d.ts +1 -1
  33. package/dist/react/createAbloReact.js +1 -1
  34. package/dist/react.d.ts +1 -1
  35. package/dist/react.js +1 -1
  36. package/dist/surface.d.ts +3 -3
  37. package/dist/surface.js +1 -4
  38. package/package.json +3 -2
  39. package/src/Ablo.ts +5 -17
  40. package/src/client.ts +0 -51
  41. package/src/local/BaseSyncedStore.ts +9 -9
  42. package/src/local/SyncClient.ts +41 -14
  43. package/src/local/client/createModelOperations.ts +23 -60
  44. package/src/local/client/options.ts +20 -43
  45. package/src/local/client/reactiveEngine.ts +7 -179
  46. package/src/local/client/storeLifecycle.ts +6 -1
  47. package/src/local/storeContract.ts +5 -5
  48. package/src/local/sync/credentialLifecycle.ts +4 -5
  49. package/src/local/sync/scopeGroups.ts +91 -0
  50. package/src/local/sync/wsFrameHandlers.ts +0 -1
  51. package/src/react/AbloProvider.tsx +17 -249
  52. package/src/react.ts +1 -5
  53. package/src/surface.ts +1 -4
  54. package/dist/local/sync/participants.d.ts +0 -132
  55. package/dist/local/sync/participants.js +0 -342
  56. 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 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
@@ -12,13 +12,13 @@
12
12
  * design step (docs/plans/package-split.md).
13
13
  */
14
14
 
15
- import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
15
+ import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
16
16
  import { omittedModelError } from '@abloatai/transaction/schema/select';
17
17
  import {
18
18
  durableCommitOperationSchema,
19
19
  type DurableCommitOperation,
20
20
  } from '@abloatai/transaction/commit';
21
- import { AbloAuthenticationError, AbloConnectionError, AbloValidationError, claimedError } from '@abloatai/transaction/errors';
21
+ import { AbloConnectionError, AbloValidationError, claimedError } from '@abloatai/transaction/errors';
22
22
  import type { ModelTarget, ModelClaim } from '@abloatai/transaction/coordination/schema';
23
23
  import type { BatchFence } from '@abloatai/transaction/coordination';
24
24
  import {
@@ -30,13 +30,6 @@ import {
30
30
  subTarget,
31
31
  } from '@abloatai/transaction/coordination';
32
32
  import { validateAbloOptions } from './validateAbloOptions.js';
33
- import { mintSession } from '@abloatai/transaction/auth/sessionMint';
34
- import type { MintSessionContext } from '@abloatai/transaction/auth/sessionMint';
35
- import {
36
- revokeCapability,
37
- rotateCapability,
38
- } from '@abloatai/transaction/auth/capabilityLifecycle';
39
- import { modelWireNames } from '@abloatai/transaction/auth/capability';
40
33
  import type { StoreCluster } from './storeCluster.js';
41
34
  import { startStoreLifecycle } from './storeLifecycle.js';
42
35
  import type { SyncWebSocket, CoreSyncEventMap } from '../sync/SyncWebSocket.js';
@@ -46,25 +39,19 @@ import {
46
39
  bindClaimLifetime,
47
40
  claimLifetimeOf,
48
41
  } from '@abloatai/transaction/claims/lifetime';
49
- import { createParticipantManager } from '../sync/participants.js';
50
42
  import type { AttachablePresenceStream } from '../../presenceStream.js';
51
43
  import type { ClaimWaitOptions } from '@abloatai/transaction/types/streams';
52
44
  import type { Claim } from '@abloatai/transaction/types/streams';
53
- import type { CredentialProvider } from '@abloatai/transaction/auth/apiKey';
54
45
  import { resolveApiKeyValue, resolveBootstrapBaseUrl } from '@abloatai/transaction/auth/apiKey';
55
46
  import type { AbloOptions } from './options.js';
56
47
  import type { ClientPrelude } from './clientPrelude.js';
57
48
  import type {
58
- AbloSession,
59
49
  ClaimCreateOptions,
60
50
  ClaimResource,
61
51
  CommitCreateOptions,
62
52
  CommitOperationInput,
63
53
  CommitReceipt,
64
54
  CommitResource,
65
- CreateAgentClientParams,
66
- CreateAgentSessionParams,
67
- CreateSessionParams,
68
55
  } from './resourceTypes.js';
69
56
  import {
70
57
  claimAttemptFailure,
@@ -116,12 +103,6 @@ export interface ReactiveEngineInputs<S extends SchemaRecord> extends ClientPrel
116
103
  * assembles around it and constructs none of it.
117
104
  */
118
105
  cluster: StoreCluster;
119
- /**
120
- * Constructs a sibling client (`ablo.agents.create(...)` mints a scoped key
121
- * and builds a second engine with it). Injected by the factory — a direct
122
- * import back into it would close a runtime cycle.
123
- */
124
- createSibling: (options: AbloOptions<S>) => Ablo<S>;
125
106
  }
126
107
 
127
108
  export function buildReactiveEngine<const S extends SchemaRecord>(
@@ -141,7 +122,6 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
141
122
  kind,
142
123
  presence,
143
124
  cluster,
144
- createSibling,
145
125
  } = inputs;
146
126
  const schema = options.schema;
147
127
  const pointReadBaseUrl = resolveBootstrapBaseUrl({
@@ -231,7 +211,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
231
211
  logger.warn(
232
212
  'Ablo: `kind` / `agentId` are ignored when an `apiKey` is configured — ' +
233
213
  'the server derives participant identity from the key’s scope. Remove ' +
234
- 'them (or mint a scoped session via `ablo.sessions.create({ agent })` ' +
214
+ 'them (or mint a scoped session with `Sessions({ schema, apiKey }).create({ agent })` ' +
235
215
  'for a distinct agent identity). They apply only to the self-hosted ' +
236
216
  '`capabilityToken` path.',
237
217
  );
@@ -274,14 +254,6 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
274
254
  });
275
255
  const ready = lifecycle.ready;
276
256
 
277
- const participantManager = createParticipantManager({
278
- ready,
279
- transport,
280
- presence: presenceStream,
281
- claims: claimStream,
282
- schema,
283
- });
284
-
285
257
  // 9b. waitForFlush — drains pending mutations using the store's
286
258
  // pendingChanges counter (already maintained by BaseSyncedStore based
287
259
  // on MutationQueue events). Polls every 50ms; uses the existing
@@ -644,17 +616,6 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
644
616
  // reconcile errors so read interest never makes a read reject or stall.
645
617
  enterScope: (scope) => store.enterScope(scope),
646
618
  pinScope: (scope) => store.pinScope(scope),
647
- // `ablo.<model>.join(ids, { ttl })` performs a scoped participant join
648
- // on this model's sync group(s). WebSocket only — `join` throws
649
- // `AbloConnectionError` if the socket isn't ready.
650
- // `ttl` passes straight through — both surfaces spell the lease the
651
- // same way now, so there is no rename here to make a field's name
652
- // disagree with the value it carries.
653
- createJoin: (modelKey, ids, options) =>
654
- participantManager.join({
655
- scope: { [modelKey]: ids },
656
- ...(options?.ttl !== undefined ? { ttl: options.ttl } : {}),
657
- }),
658
619
  },
659
620
  readSetContext,
660
621
  );
@@ -667,7 +628,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
667
628
  await ready();
668
629
  const prepared = prepareReadSet(
669
630
  cluster.readSetContext,
670
- syncClient as object,
631
+ syncClient,
671
632
  commitOptions.readAt,
672
633
  commitOptions.idempotencyKey,
673
634
  commitOptions.reads,
@@ -752,50 +713,6 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
752
713
  },
753
714
  };
754
715
 
755
- /**
756
- * The control-plane credential: always the original configured secret key.
757
- * Never reads `authCredentials` — that holds the exchanged sync credential
758
- * (a wide-scope `rk_` on the hosted path), which control-plane routes
759
- * rightly refuse (e.g. the user-session mint is sk_-gated). Counterpart to
760
- * `getAuthToken()`, which resolves the sync-plane token.
761
- *
762
- * The secret-key-only rule is enforced on the server; the credential-kind taxonomy
763
- * (secret/restricted/ephemeral/publishable) lives in `auth/credentialPolicy`.
764
- */
765
- async function controlPlaneApiKey(): Promise<string | null> {
766
- return resolveApiKeyValue(configuredApiKey);
767
- }
768
-
769
- /**
770
- * Resolve the control-plane context a session/agent mint needs (sk_ +
771
- * bootstrap base URL + the schema-key→typename map the server gates on).
772
- * Shared by `sessions.create` and `agents.create` so the two mint doors
773
- * can never drift on how a token is minted. Throws if no `sk_` is present —
774
- * minting is a backend-only operation.
775
- */
776
- async function buildMintContext(resource: string): Promise<MintSessionContext> {
777
- const apiKey = await controlPlaneApiKey();
778
- if (!apiKey) {
779
- throw new AbloAuthenticationError(
780
- `${resource} requires a secret (sk_) API key — call it from your backend, not the browser.`,
781
- { code: 'apikey_missing' },
782
- );
783
- }
784
- return {
785
- apiKey,
786
- baseUrl: resolveBootstrapBaseUrl({
787
- url,
788
- bootstrapBaseUrl: internalOptions.bootstrapBaseUrl,
789
- }),
790
- ...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
791
- // Map every `can` schema-key to the wire typename the server gates on, so a
792
- // typename override (`documents` → `Document`) doesn't mint a capability
793
- // the server then denies. Derived from this client's schema by the one rule
794
- // the HTTP client and the mint route also read. See `MintSessionContext`.
795
- modelTypenames: modelWireNames(schema.models),
796
- };
797
- }
798
-
799
716
  const engine = {
800
717
  ...modelProxies,
801
718
 
@@ -826,11 +743,6 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
826
743
  // is the canonical credential; fall back to a configured API key.
827
744
  //
828
745
  // This is the sync-plane token (bootstrap, WebSocket, query HTTP). Control-plane
829
- // calls (sessions.create, datasource registration) never use it — they
830
- // present the original secret key via `controlPlaneApiKey()` below. The
831
- // split matters: after the startup exchange this resolver returns the
832
- // derived wide-scope `rk_`, a credential the control-plane routes
833
- // correctly refuse (an agent token must never mint humans).
834
746
  return (
835
747
  authCredentials.getAuthToken() ??
836
748
  (await resolveApiKeyValue(configuredApiKey)) ??
@@ -857,90 +769,6 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
857
769
  store.nudgeReconnect();
858
770
  },
859
771
 
860
- sessions: {
861
- // A backend (holding `sk_`) mints a short-lived scoped token for one end
862
- // user or one agent.
863
- //
864
- // Both arms authenticate with the original secret key
865
- // (`controlPlaneApiKey()`), never the wide-scope `rk_` the startup exchange
866
- // installed as the sync credential. A derived agent credential silently
867
- // replacing the secret key on control-plane calls is how humans would get
868
- // minted as agents — and correct attribution is the point.
869
- async create(params: CreateSessionParams<S>): Promise<AbloSession> {
870
- // Both mint paths (`{ user }` → /v1/ephemeral_keys → `ek_`,
871
- // `{ agent, can }` → /v1/capabilities → scoped `rk_`) resolve their
872
- // control-plane context through the shared `buildMintContext`, so this
873
- // client, `agents.create`, and the stateless HTTP client can't drift on
874
- // how a token is minted.
875
- return mintSession(params, await buildMintContext('sessions.create'));
876
- },
877
- async revoke({ id }) {
878
- const context = await buildMintContext('sessions.revoke');
879
- return revokeCapability({
880
- apiKey: context.apiKey,
881
- baseUrl: context.baseUrl,
882
- id,
883
- ...(context.fetch ? { fetch: context.fetch } : {}),
884
- });
885
- },
886
- async rotate({ id, graceSeconds, ttlSeconds }) {
887
- const context = await buildMintContext('sessions.rotate');
888
- return rotateCapability({
889
- apiKey: context.apiKey,
890
- baseUrl: context.baseUrl,
891
- id,
892
- ...(graceSeconds !== undefined ? { graceSeconds } : {}),
893
- ...(ttlSeconds !== undefined ? { ttlSeconds } : {}),
894
- ...(context.fetch ? { fetch: context.fetch } : {}),
895
- });
896
- },
897
- },
898
-
899
- // Mint a scoped agent identity and hand back a connected client bound to it —
900
- // `sessions.create({ agent })` plus a typed `Ablo({ schema, apiKey })` client,
901
- // for agents that run in this (secret-key-holding) process. Omitting `id`
902
- // yields a fresh uuid per call, so concurrent agents are distinct participants
903
- // that queue behind each other (even when they share a `name`). Humans don't
904
- // get a server-built client — ship them a token via `sessions.create({ user })`.
905
- agents: {
906
- async create(params: CreateAgentClientParams<S>): Promise<Ablo<S>> {
907
- // Distinct participant by default: omit `id` → a fresh uuid, so even two
908
- // agents that share a `name` are independent participants and queue
909
- // behind one another. `name` is display only (→ userMeta.name); it never
910
- // derives the id. Pass an explicit `id` only to re-attach an agent to
911
- // its own held claims.
912
- const id = params.id ?? globalThis.crypto.randomUUID();
913
- const userMeta =
914
- params.name !== undefined ? { ...params.userMeta, name: params.name } : params.userMeta;
915
- const sessionParams = {
916
- agent: { id },
917
- can: params.can,
918
- ...(params.onBehalfOf ? { onBehalfOf: params.onBehalfOf } : {}),
919
- ...(params.syncGroups ? { syncGroups: params.syncGroups } : {}),
920
- ...(params.ttlSeconds !== undefined ? { ttlSeconds: params.ttlSeconds } : {}),
921
- ...(userMeta ? { userMeta } : {}),
922
- } satisfies CreateAgentSessionParams<S>;
923
- // Re-mint the `rk_` on every resolver call so a long-lived agent client
924
- // never hits token expiry; the `sk_` stays in this process — the child
925
- // only ever sees its own short-lived `rk_`.
926
- const mintToken = async (): Promise<string> =>
927
- (await mintSession(sessionParams, await buildMintContext('agents.create')))
928
- .token;
929
- // Mint once up front so a bad key / denied scope throws HERE, not later
930
- // inside the child's bootstrap; reuse that first token, re-mint on refresh.
931
- let pending: string | null = await mintToken();
932
- const apiKey: CredentialProvider = async () => {
933
- if (pending !== null) {
934
- const token = pending;
935
- pending = null;
936
- return token;
937
- }
938
- return mintToken();
939
- };
940
- return createSibling({ ...(internalOptions as AbloOptions<S>), apiKey });
941
- },
942
- },
943
-
944
772
  async dispose() {
945
773
  lifecycle.dispose();
946
774
  try {
@@ -143,7 +143,12 @@ export function startStoreLifecycle<S extends SchemaRecord>(
143
143
  // unambiguously a deliberate server client). User-kind clients in Node (an
144
144
  // SSR/RSC module evaluating scaffolded browser code) stay reactive-only.
145
145
  if (credentialResolver) {
146
- const rawEndpoint = internalOptions.authEndpoint ?? internalOptions.apiKey;
146
+ const rawEndpoint =
147
+ internalOptions.session &&
148
+ typeof internalOptions.session === 'object' &&
149
+ 'endpoint' in internalOptions.session
150
+ ? internalOptions.session.endpoint
151
+ : internalOptions.apiKey;
147
152
  const absoluteEndpoint =
148
153
  typeof rawEndpoint === 'string' && /^https?:\/\//i.test(rawEndpoint);
149
154
  store.startCredentialLifecycle(credentialResolver, {
@@ -14,7 +14,7 @@ import type { Model } from './Model.js';
14
14
  import type { ModelScope } from '@abloatai/transaction/types';
15
15
  import type { QueryView, QueryViewOptions } from './views/QueryView.js';
16
16
  import type { ViewRegistry } from './views/ViewRegistry.js';
17
- import type { ParticipantScope } from './sync/participants.js';
17
+ import type { GroupScope } from './sync/scopeGroups.js';
18
18
 
19
19
  /**
20
20
  * A snapshot of the client's synchronization state, shaped for binding to UI.
@@ -132,10 +132,10 @@ export interface SyncStoreContract {
132
132
  * read subscriptions and write claims always agree on which group they refer
133
133
  * to. These are optional and do nothing until the connection is open.
134
134
  */
135
- enterScope?(scope: ParticipantScope, opts?: { hydrate?: boolean }): Promise<void>;
136
- leaveScope?(scope: ParticipantScope): Promise<void>;
137
- pinScope?(scope: ParticipantScope): Promise<void>;
138
- unpinScope?(scope: ParticipantScope): Promise<void>;
135
+ enterScope?(scope: GroupScope, opts?: { hydrate?: boolean }): Promise<void>;
136
+ leaveScope?(scope: GroupScope): Promise<void>;
137
+ pinScope?(scope: GroupScope): Promise<void>;
138
+ unpinScope?(scope: GroupScope): Promise<void>;
139
139
  /**
140
140
  * The full reactive {@link SyncStatus} record. The `useSyncStatus()` hook
141
141
  * reads its fields — `state`, `progress`, `pendingChanges`, `isSessionError`,
@@ -1,8 +1,7 @@
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
6
 
8
7
  export {
@@ -15,4 +14,4 @@ export {
15
14
  type CredentialRefreshResult,
16
15
  type CredentialRefresher,
17
16
  type CredentialLifecycleContext,
18
- } from '@abloatai/transaction/transport/connection';
17
+ } from '@abloatai/transaction/sessions';
@@ -0,0 +1,91 @@
1
+ import type { ClaimTarget } from '@abloatai/transaction/types/streams';
2
+ import type { Schema } from '@abloatai/transaction/schema/schema';
3
+ import { scopeKindOf, type ModelDef } from '@abloatai/transaction/schema/model';
4
+
5
+ /** A schema-shaped selector used to narrow connection groups and presence reads. */
6
+ export type GroupScope =
7
+ | ClaimTarget
8
+ | readonly ClaimTarget[]
9
+ | string
10
+ | readonly string[]
11
+ | { readonly syncGroup: string }
12
+ | { readonly syncGroups: readonly string[] }
13
+ | Record<string, string | readonly string[] | undefined>;
14
+
15
+ /** Resolve an application-shaped scope into the wire groups owned by the schema. */
16
+ export function resolveScopeGroups(
17
+ scope: GroupScope | undefined,
18
+ schema?: Schema,
19
+ ): string[] {
20
+ if (!scope) return [];
21
+ if (typeof scope === 'string') return [scope];
22
+ if (Array.isArray(scope)) {
23
+ const groups: string[] = [];
24
+ for (const entry of scope as readonly unknown[]) {
25
+ if (typeof entry === 'string') groups.push(entry);
26
+ else if (isEntityScope(entry)) groups.push(groupFromEntityRef(entry, schema));
27
+ }
28
+ return groups;
29
+ }
30
+ const direct = scope as { syncGroup?: unknown; syncGroups?: unknown };
31
+ if (isEntityScope(scope)) return [groupFromEntityRef(scope, schema)];
32
+ if (typeof direct.syncGroup === 'string') return [direct.syncGroup];
33
+ if (Array.isArray(direct.syncGroups)) {
34
+ return direct.syncGroups.filter((group): group is string => typeof group === 'string');
35
+ }
36
+ const groups: string[] = [];
37
+ for (const [key, value] of Object.entries(scope) as [string, unknown][]) {
38
+ if (value === undefined) continue;
39
+ if (Array.isArray(value)) {
40
+ for (const id of value as unknown[]) {
41
+ if (typeof id === 'string') groups.push(groupFromSchemaKey(key, id, schema));
42
+ }
43
+ } else if (typeof value === 'string') {
44
+ groups.push(groupFromSchemaKey(key, value, schema));
45
+ }
46
+ }
47
+ return groups;
48
+ }
49
+
50
+ export function groupFromEntityRef(ref: ClaimTarget, schema?: Schema): string {
51
+ const match = findModelForEntityRef(ref, schema);
52
+ const kind = match
53
+ ? groupKindForModel(match.def, match.key)
54
+ : ref.type.toLowerCase();
55
+ return `${kind}:${ref.id}`;
56
+ }
57
+
58
+ function groupFromSchemaKey(schemaKey: string, id: string, schema?: Schema): string {
59
+ const def = schema?.models[schemaKey];
60
+ const kind = def ? groupKindForModel(def, schemaKey) : schemaKey.toLowerCase();
61
+ return `${kind}:${id}`;
62
+ }
63
+
64
+ function groupKindForModel(def: ModelDef, key: string): string {
65
+ return scopeKindOf(def, key) ?? (def.typename ?? key).toLowerCase();
66
+ }
67
+
68
+ function findModelForEntityRef(
69
+ ref: ClaimTarget,
70
+ schema?: Schema,
71
+ ): { key: string; def: ModelDef } | null {
72
+ if (!schema?.models) return null;
73
+ const wanted = ref.type.toLowerCase();
74
+ for (const [key, def] of Object.entries(schema.models) as [string, ModelDef][]) {
75
+ const typename = def.typename ?? key;
76
+ if (typename.toLowerCase() === wanted || key.toLowerCase() === wanted) {
77
+ return { key, def };
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+
83
+ function isEntityScope(scope: unknown): scope is ClaimTarget {
84
+ return (
85
+ typeof scope === 'object' &&
86
+ scope !== null &&
87
+ !Array.isArray(scope) &&
88
+ typeof (scope as { type?: unknown }).type === 'string' &&
89
+ typeof (scope as { id?: unknown }).id === 'string'
90
+ );
91
+ }
@@ -12,7 +12,6 @@ export {
12
12
  wsFrameHandlers,
13
13
  dispatchWsFrame,
14
14
  type PendingCommit,
15
- type PendingClaim,
16
15
  type PendingSubscription,
17
16
  type WsInboundFrame,
18
17
  type WsSession,