@abloatai/humans 0.59.1 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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/local/BaseSyncedStore.d.ts +6 -8
  6. package/dist/local/BaseSyncedStore.js +4 -9
  7. package/dist/local/Model.js +2 -2
  8. package/dist/local/SyncClient.d.ts +3 -3
  9. package/dist/local/SyncClient.js +45 -11
  10. package/dist/local/client/createModelOperations.d.ts +3 -27
  11. package/dist/local/client/createModelOperations.js +14 -17
  12. package/dist/local/client/options.d.ts +14 -39
  13. package/dist/local/client/reactiveEngine.d.ts +3 -9
  14. package/dist/local/client/reactiveEngine.js +6 -151
  15. package/dist/local/client/storeLifecycle.js +5 -1
  16. package/dist/local/storeContract.d.ts +5 -5
  17. package/dist/local/sync/credentialLifecycle.d.ts +4 -5
  18. package/dist/local/sync/credentialLifecycle.js +4 -5
  19. package/dist/local/sync/deltaPipeline.d.ts +11 -3
  20. package/dist/local/sync/deltaPipeline.js +27 -80
  21. package/dist/local/sync/scopeGroups.d.ts +11 -0
  22. package/dist/local/sync/scopeGroups.js +75 -0
  23. package/dist/local/sync/wsFrameHandlers.d.ts +1 -1
  24. package/dist/local/transactions/mutations/failureHandling.js +9 -81
  25. package/dist/local/transactions/mutations/failureReporting.d.ts +10 -0
  26. package/dist/local/transactions/mutations/failureReporting.js +67 -0
  27. package/dist/react/AbloProvider.d.ts +11 -86
  28. package/dist/react/AbloProvider.js +10 -162
  29. package/dist/react.d.ts +1 -1
  30. package/dist/react.js +1 -1
  31. package/dist/surface.d.ts +2 -2
  32. package/dist/surface.js +1 -4
  33. package/package.json +3 -2
  34. package/src/Ablo.ts +5 -17
  35. package/src/client.ts +0 -51
  36. package/src/local/BaseSyncedStore.ts +11 -17
  37. package/src/local/Model.ts +2 -2
  38. package/src/local/SyncClient.ts +63 -15
  39. package/src/local/client/createModelOperations.ts +23 -60
  40. package/src/local/client/options.ts +20 -43
  41. package/src/local/client/reactiveEngine.ts +7 -179
  42. package/src/local/client/storeLifecycle.ts +6 -1
  43. package/src/local/storeContract.ts +5 -5
  44. package/src/local/sync/SyncWebSocket.ts +1 -1
  45. package/src/local/sync/credentialLifecycle.ts +4 -5
  46. package/src/local/sync/deltaPipeline.ts +26 -82
  47. package/src/local/sync/scopeGroups.ts +91 -0
  48. package/src/local/sync/wsFrameHandlers.ts +0 -1
  49. package/src/local/transactions/mutations/failureHandling.ts +73 -132
  50. package/src/local/transactions/mutations/failureReporting.ts +93 -0
  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
@@ -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 */
@@ -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`,
@@ -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';
@@ -73,7 +73,6 @@ export interface DeltaPipelineContext {
73
73
  * {@link handleGroupHandlerFailure}). */
74
74
  clear(): void;
75
75
  };
76
- getStateFields(modelName: string): string[];
77
76
  isCustomEntity(modelName: string): boolean;
78
77
  createCustomEntity(modelName: string, modelId: string, data: Record<string, unknown>): Model | null;
79
78
  deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[];
@@ -94,8 +93,17 @@ export interface DeltaPipelineContext {
94
93
  * It never throws, because it runs inside the pipeline's fire-and-forget path.
95
94
  */
96
95
  export declare function handleGroupHandlerFailure(ctx: DeltaPipelineContext, delta: SyncDelta, error: unknown): void;
97
- /** Deduplicate deltas to the same entity — keep meaningful state transitions only */
98
- export declare function deduplicateDeltas(ctx: DeltaPipelineContext, deltas: SyncDelta[]): SyncDelta[];
96
+ /**
97
+ * Deduplicate repeated delivery of the same log entry.
98
+ *
99
+ * A row may legitimately change several times in one receive frame. Those
100
+ * changes are ordered facts, even when a small subset of fields (such as
101
+ * `status`) happens to remain equal. Collapsing by entity or a partial state
102
+ * signature can therefore discard the newest row image while the cursor still
103
+ * advances past it. Only an identical positive sync id proves duplicate
104
+ * delivery; non-positive ids carry no usable log identity and stay untouched.
105
+ */
106
+ export declare function deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[];
99
107
  /**
100
108
  * Performs per-delta bookkeeping and enqueues the delta. Returns `true` when
101
109
  * the delta was pushed onto `pendingDeltas` — a regular batchable insert,
@@ -52,90 +52,37 @@ export function handleGroupHandlerFailure(ctx, delta, error) {
52
52
  // Best-effort: the reconnect/bootstrap cycle self-heals on next connect.
53
53
  }
54
54
  }
55
- /** Builds a small signature of a delta's state fields, used to detect no-op duplicate deltas. */
56
- function extractStateSignature(ctx, delta) {
57
- if (!delta.data || typeof delta.data !== 'object')
58
- return null;
59
- const data = typeof delta.data === 'string'
60
- ? JSON.parse(delta.data)
61
- : (delta.data);
62
- // Generic state fields subclasses can override getStateFields() for model-specific fields
63
- const fieldsToCheck = ctx.getStateFields(delta.modelName);
64
- const signature = {
65
- actionType: delta.actionType,
66
- modelName: delta.modelName,
67
- };
68
- for (const field of fieldsToCheck) {
69
- if (field in data)
70
- signature[field] = data[field];
71
- }
72
- return signature;
73
- }
74
- function isSameState(a, b) {
75
- if (!a || !b)
76
- return false;
77
- const keys = Object.keys(a);
78
- if (keys.length !== Object.keys(b).length)
79
- return false;
80
- return keys.every((k) => a[k] === b[k]);
81
- }
82
- /** Deduplicate deltas to the same entity — keep meaningful state transitions only */
83
- export function deduplicateDeltas(ctx, deltas) {
84
- // The dominant live-publication shape is a frame of independent entity
85
- // creates. When every entity key occurs once, reconciliation cannot remove
86
- // or reorder anything: preserve the already commit-ordered input directly
87
- // and avoid allocating a bucket array, state signature, and two sorts per
88
- // delta. The first duplicate falls through to the full transition logic.
89
- const uniqueEntities = new Set();
90
- let hasDuplicateEntity = false;
91
- for (const delta of deltas) {
92
- const key = `${delta.modelName}:${delta.modelId}`;
93
- if (uniqueEntities.has(key)) {
94
- hasDuplicateEntity = true;
55
+ /**
56
+ * Deduplicate repeated delivery of the same log entry.
57
+ *
58
+ * A row may legitimately change several times in one receive frame. Those
59
+ * changes are ordered facts, even when a small subset of fields (such as
60
+ * `status`) happens to remain equal. Collapsing by entity or a partial state
61
+ * signature can therefore discard the newest row image while the cursor still
62
+ * advances past it. Only an identical positive sync id proves duplicate
63
+ * delivery; non-positive ids carry no usable log identity and stay untouched.
64
+ */
65
+ export function deduplicateDeltas(deltas) {
66
+ if (deltas.length < 2 || deltas.some((delta) => delta.id <= 0))
67
+ return deltas;
68
+ let strictlyOrdered = true;
69
+ for (let index = 1; index < deltas.length; index += 1) {
70
+ if (deltas[index - 1].id >= deltas[index].id) {
71
+ strictlyOrdered = false;
95
72
  break;
96
73
  }
97
- uniqueEntities.add(key);
98
74
  }
99
- if (!hasDuplicateEntity)
75
+ if (strictlyOrdered)
100
76
  return deltas;
101
- const byEntity = new Map();
102
- for (const d of deltas) {
103
- const key = `${d.modelName}:${d.modelId}`;
104
- if (!byEntity.has(key))
105
- byEntity.set(key, []);
106
- byEntity.get(key).push(d);
107
- }
108
- const result = [];
109
- for (const entityDeltas of byEntity.values()) {
110
- const sorted = entityDeltas.sort((a, b) => a.id - b.id);
111
- // DELETE wins — it's the final state
112
- const del = sorted.find((d) => d.actionType === 'D');
113
- if (del) {
114
- result.push(del);
115
- continue;
116
- }
117
- // Keep deltas that represent different states
118
- const unique = [];
119
- let prev = null;
120
- for (const d of sorted) {
121
- const sig = extractStateSignature(ctx, d);
122
- if (!isSameState(prev, sig)) {
123
- unique.push(d);
124
- prev = sig;
125
- }
126
- }
127
- if (unique.length > 0) {
128
- result.push(...unique);
129
- }
130
- else {
131
- // `sorted` is never empty (every byEntity bucket gets at least one
132
- // delta pushed) — the guard only narrows the indexed access.
133
- const last = sorted.at(-1);
134
- if (last)
135
- result.push(last);
136
- }
137
- }
138
- return result.sort((a, b) => a.id - b.id);
77
+ const seen = new Set();
78
+ return [...deltas]
79
+ .sort((a, b) => a.id - b.id)
80
+ .filter((delta) => {
81
+ if (seen.has(delta.id))
82
+ return false;
83
+ seen.add(delta.id);
84
+ return true;
85
+ });
139
86
  }
140
87
  /**
141
88
  * Performs per-delta bookkeeping and enqueues the delta. Returns `true` when
@@ -0,0 +1,11 @@
1
+ import type { ClaimTarget } from '@abloatai/transaction/types/streams';
2
+ import type { Schema } from '@abloatai/transaction/schema/schema';
3
+ /** A schema-shaped selector used to narrow connection groups and presence reads. */
4
+ export type GroupScope = ClaimTarget | readonly ClaimTarget[] | string | readonly string[] | {
5
+ readonly syncGroup: string;
6
+ } | {
7
+ readonly syncGroups: readonly string[];
8
+ } | Record<string, string | readonly string[] | undefined>;
9
+ /** Resolve an application-shaped scope into the wire groups owned by the schema. */
10
+ export declare function resolveScopeGroups(scope: GroupScope | undefined, schema?: Schema): string[];
11
+ export declare function groupFromEntityRef(ref: ClaimTarget, schema?: Schema): string;
@@ -0,0 +1,75 @@
1
+ import { scopeKindOf } from '@abloatai/transaction/schema/model';
2
+ /** Resolve an application-shaped scope into the wire groups owned by the schema. */
3
+ export function resolveScopeGroups(scope, schema) {
4
+ if (!scope)
5
+ return [];
6
+ if (typeof scope === 'string')
7
+ return [scope];
8
+ if (Array.isArray(scope)) {
9
+ const groups = [];
10
+ for (const entry of scope) {
11
+ if (typeof entry === 'string')
12
+ groups.push(entry);
13
+ else if (isEntityScope(entry))
14
+ groups.push(groupFromEntityRef(entry, schema));
15
+ }
16
+ return groups;
17
+ }
18
+ const direct = scope;
19
+ if (isEntityScope(scope))
20
+ return [groupFromEntityRef(scope, schema)];
21
+ if (typeof direct.syncGroup === 'string')
22
+ return [direct.syncGroup];
23
+ if (Array.isArray(direct.syncGroups)) {
24
+ return direct.syncGroups.filter((group) => typeof group === 'string');
25
+ }
26
+ const groups = [];
27
+ for (const [key, value] of Object.entries(scope)) {
28
+ if (value === undefined)
29
+ continue;
30
+ if (Array.isArray(value)) {
31
+ for (const id of value) {
32
+ if (typeof id === 'string')
33
+ groups.push(groupFromSchemaKey(key, id, schema));
34
+ }
35
+ }
36
+ else if (typeof value === 'string') {
37
+ groups.push(groupFromSchemaKey(key, value, schema));
38
+ }
39
+ }
40
+ return groups;
41
+ }
42
+ export function groupFromEntityRef(ref, schema) {
43
+ const match = findModelForEntityRef(ref, schema);
44
+ const kind = match
45
+ ? groupKindForModel(match.def, match.key)
46
+ : ref.type.toLowerCase();
47
+ return `${kind}:${ref.id}`;
48
+ }
49
+ function groupFromSchemaKey(schemaKey, id, schema) {
50
+ const def = schema?.models[schemaKey];
51
+ const kind = def ? groupKindForModel(def, schemaKey) : schemaKey.toLowerCase();
52
+ return `${kind}:${id}`;
53
+ }
54
+ function groupKindForModel(def, key) {
55
+ return scopeKindOf(def, key) ?? (def.typename ?? key).toLowerCase();
56
+ }
57
+ function findModelForEntityRef(ref, schema) {
58
+ if (!schema?.models)
59
+ return null;
60
+ const wanted = ref.type.toLowerCase();
61
+ for (const [key, def] of Object.entries(schema.models)) {
62
+ const typename = def.typename ?? key;
63
+ if (typename.toLowerCase() === wanted || key.toLowerCase() === wanted) {
64
+ return { key, def };
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+ function isEntityScope(scope) {
70
+ return (typeof scope === 'object' &&
71
+ scope !== null &&
72
+ !Array.isArray(scope) &&
73
+ typeof scope.type === 'string' &&
74
+ typeof scope.id === 'string');
75
+ }
@@ -5,4 +5,4 @@
5
5
  * the transport object. This path re-exports it so existing importers stay
6
6
  * unchanged.
7
7
  */
8
- export { isRecord, readWsInboundFrame, wsFrameHandlers, dispatchWsFrame, type PendingCommit, type 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';
@@ -1,5 +1,5 @@
1
- import { AbloError } from '@abloatai/transaction/errors';
2
1
  import { extractStatusCode } from './commitPayload.js';
2
+ import { reportPermanentMutationFailure } from './failureReporting.js';
3
3
  export function transientRetryDelayMs(error, attempt, retryBackoff) {
4
4
  const { baseMs, capMs } = retryBackoff;
5
5
  let base = baseMs;
@@ -16,85 +16,12 @@ export async function handleFailure(ctx, transaction, error) {
16
16
  transaction.attempts++;
17
17
  // Check whether this is a permanent error that should not be retried.
18
18
  if (ctx.isPermanentError(error)) {
19
- // Logged at warn: a permanent error means the server rejected the write,
20
- // so the developer should see the reason in the console. The typed
21
- // AbloError fields (`type`, `code`, `httpStatus`) are included so the
22
- // cause is visible — for example a foreign-key violation
23
- // (AbloValidationError) versus expired authentication
24
- // (AbloAuthenticationError).
25
- try {
26
- const abloErr = error instanceof AbloError ? error : undefined;
27
- const details = {
28
- txId: transaction.id.slice(0, 8),
29
- type: transaction.type,
30
- model: transaction.modelName,
31
- modelId: transaction.modelId.slice(0, 12),
32
- errorType: abloErr?.type ?? error?.name,
33
- errorCode: abloErr?.code,
34
- httpStatus: abloErr?.httpStatus,
35
- requestId: abloErr?.requestId,
36
- message: error?.message,
37
- inputKeys: transaction.data ? Object.keys(transaction.data) : undefined,
38
- };
39
- // A `create` whose id already exists is the benign idempotency case:
40
- // "this row is already there." It's the least alarming permanent
41
- // error, so it doesn't warrant a `warn` — `info` keeps it visible
42
- // without crying wolf. Everything else (FK violation, auth expiry,
43
- // server 500) stays at `warn`.
44
- const isBenignIdempotent = transaction.type === 'create' &&
45
- (abloErr?.code === 'unique_violation' ||
46
- abloErr?.type === 'AbloIdempotencyError');
47
- // Demote exact repeats (same write rejected for the same reason on
48
- // each reconnect replay) to `debug` so the loop logs once.
49
- const sig = `${details.type}:${details.model}:${details.modelId}:${details.errorCode ?? details.errorType}`;
50
- const isRepeat = sig === ctx.getLastPermanentErrorSignature();
51
- ctx.setLastPermanentErrorSignature(sig);
52
- const logger = ctx.runtime.logger;
53
- // Two registers from one call site, split by log level (the default
54
- // logger is gated at `warn`, so `debug` stays hidden unless
55
- // ABLO_LOG_LEVEL=debug is set to inspect the engine):
56
- // - the default-visible line speaks the application developer's
57
- // language: their verb (such as `update`), their model, the typed
58
- // error's own message, and the wire `code` for searching. It uses
59
- // no engine jargon and prints no JSON dump, which would alarm
60
- // without helping.
61
- // - the forensic `details` ride a companion `debug` line for anyone
62
- // debugging the engine internals.
63
- const revertNote = ctx.config.enableOptimistic
64
- ? ' The local change was reverted.'
65
- : '';
66
- const reason = abloErr?.message ? ` — ${abloErr.message}` : '';
67
- const code = abloErr?.code ? ` (code: ${abloErr.code})` : '';
68
- const requestRef = abloErr?.requestId
69
- ? ` [request_id: ${abloErr.requestId}]`
70
- : '';
71
- // An optimistic write resolves before the server answers, so a later
72
- // rejection has no caller left to return to and this log is the only
73
- // place it appears. That reads to an application developer as their own
74
- // save silently failing — the write showed, then vanished — and sends
75
- // them into their editor instead of here. Name the subscription that
76
- // hands them the same typed error, so the application can say what
77
- // happened rather than only the console.
78
- const channelNote = ctx.config.enableOptimistic
79
- ? ' To surface this in your app, subscribe with `ablo.onMutationFailure(…)`.'
80
- : '';
81
- const headline = `Your ${transaction.type} to "${transaction.modelName}" was not saved${reason}${code}${requestRef}.${revertNote}${channelNote}`;
82
- if (isRepeat) {
83
- // Same write rejected for the same reason on each reconnect replay —
84
- // log the forensics once, stay quiet after.
85
- logger.debug('write rejected again (same reason)', details);
86
- }
87
- else if (isBenignIdempotent) {
88
- // Already-exists on a `create` is expected on replay, not a problem.
89
- logger.info(`Your ${transaction.type} to "${transaction.modelName}" was skipped — this row already exists.`);
90
- logger.debug('idempotent skip — details', details);
91
- }
92
- else {
93
- logger.warn(headline);
94
- logger.debug('write rejection — details', details);
95
- }
96
- }
97
- catch { }
19
+ reportPermanentMutationFailure({
20
+ runtime: ctx.runtime,
21
+ enableOptimistic: ctx.config.enableOptimistic,
22
+ getLastPermanentErrorSignature: ctx.getLastPermanentErrorSignature,
23
+ setLastPermanentErrorSignature: ctx.setLastPermanentErrorSignature,
24
+ }, transaction, error);
98
25
  // Mark as failed immediately and rollback
99
26
  ctx.store.updateStatus(transaction.id, 'failed');
100
27
  if (ctx.config.enableOptimistic) {
@@ -108,7 +35,8 @@ export async function handleFailure(ctx, transaction, error) {
108
35
  return;
109
36
  }
110
37
  transaction.firstTransientFailureAt ??= Date.now();
111
- const insideAvailabilityWindow = Date.now() - transaction.firstTransientFailureAt < ctx.config.availabilityRetryWindowMs;
38
+ const insideAvailabilityWindow = Date.now() - transaction.firstTransientFailureAt <
39
+ ctx.config.availabilityRetryWindowMs;
112
40
  if (transaction.attempts < ctx.config.maxRetries || insideAvailabilityWindow) {
113
41
  // Exponential backoff with full jitter on every transient retry:
114
42
  // `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses