@abloatai/humans 0.64.5 → 0.65.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.
@@ -11,7 +11,7 @@
11
11
  * lookup table from one of these per model.
12
12
  */
13
13
  import { autorun } from 'mobx';
14
- import { AbloClaimedError, AbloValidationError, toAbloError, } from '@abloatai/transaction/errors';
14
+ import { AbloClaimedError, AbloStaleContextError, AbloValidationError, toAbloError, } from '@abloatai/transaction/errors';
15
15
  import { reconcileFunctionalUpdate, } from '@abloatai/transaction/client/resources/functionalUpdate';
16
16
  import { claimDescription, } from '@abloatai/transaction/coordination/schema';
17
17
  import { Model, modelAsRow } from '../Model.js';
@@ -31,7 +31,7 @@ import { ModelScope } from '@abloatai/transaction/types';
31
31
  import { bindClaimLifetime, claimLifetimeOf, } from '@abloatai/transaction/claims/lifetime';
32
32
  import { claimQueueView, resolveClaimContentionOptions, } from '@abloatai/transaction/client/resources/modelOperations';
33
33
  import { modelEventInputSchema } from '@abloatai/transaction/collaboration';
34
- import { capturePointRead, prepareReadSet, } from '@abloatai/transaction/internal/read-set';
34
+ import { capturePointRead, prepareReadSet, targetGuardForRow, } from '@abloatai/transaction/internal/read-set';
35
35
  const ignoreSeparatelyObservedMutationFailure = () => undefined;
36
36
  const ignoreBestEffortClaimReleaseFailure = () => undefined;
37
37
  const ignoreBestEffortScopeFailure = () => undefined;
@@ -215,8 +215,9 @@ hydration, collaboration, readSetContext) {
215
215
  typeof value.id === 'string' &&
216
216
  typeof value.release === 'function';
217
217
  const preparedMutation = (params) => {
218
- const prepared = prepareReadSet(readSetContext, readSetClientIdentity, undefined, params.idempotencyKey, params.reads);
218
+ const prepared = prepareReadSet(readSetContext, readSetClientIdentity, params.readAt, params.idempotencyKey, params.reads);
219
219
  const rest = {
220
+ ...(prepared.readAt !== undefined ? { readAt: prepared.readAt } : {}),
220
221
  ...(prepared.idempotencyKey !== undefined
221
222
  ? { idempotencyKey: prepared.idempotencyKey }
222
223
  : params.idempotencyKey !== undefined
@@ -225,9 +226,7 @@ hydration, collaboration, readSetContext) {
225
226
  ...(params.label !== undefined ? { label: params.label } : {}),
226
227
  ...(prepared.reads !== undefined
227
228
  ? { reads: prepared.reads === null ? null : [...prepared.reads] }
228
- : params.reads !== undefined
229
- ? { reads: params.reads }
230
- : {}),
229
+ : {}),
231
230
  };
232
231
  // The write-options schema — the runtime twin of the compile-time params.
233
232
  // Catches plain-JavaScript callers at the call site with a typed error
@@ -235,6 +234,15 @@ hydration, collaboration, readSetContext) {
235
234
  assertWriteOptions(rest, `${schemaKey} write`);
236
235
  return rest;
237
236
  };
237
+ const guardedMutation = (params) => {
238
+ if (params.ifUnchanged === undefined)
239
+ return params;
240
+ const { ifUnchanged, ...rest } = params;
241
+ return {
242
+ ...rest,
243
+ ...targetGuardForRow(readSetContext, readSetClientIdentity, wireModel, ifUnchanged),
244
+ };
245
+ };
238
246
  const releaseClaim = async (claimId) => {
239
247
  const held = removeActiveClaim(claimId);
240
248
  if (!held)
@@ -969,7 +977,7 @@ hydration, collaboration, readSetContext) {
969
977
  },
970
978
  });
971
979
  }
972
- const params = arg;
980
+ const params = guardedMutation(arg);
973
981
  // Named before anything reads it. Without this the row lookup below
974
982
  // reports `Entity not found: Model/undefined`, which sends the reader
975
983
  // looking for a missing row rather than at the unaddressed write.
@@ -991,6 +999,9 @@ hydration, collaboration, readSetContext) {
991
999
  }
992
1000
  const { id } = params;
993
1001
  const model = ownRowOrThrow(id);
1002
+ if (!model && params.readAt !== undefined) {
1003
+ throw new AbloStaleContextError(`Update rejected: ${registeredModelName}/${id} changed since read. Re-read and retry.`, { code: 'stale_context', httpStatus: 409 });
1004
+ }
994
1005
  if (!model)
995
1006
  throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
996
1007
  const opts = preparedMutation(params);
@@ -1025,7 +1036,8 @@ hydration, collaboration, readSetContext) {
1025
1036
  }
1026
1037
  return update;
1027
1038
  })(),
1028
- delete: guardWrite(async (params) => {
1039
+ delete: guardWrite(async (input) => {
1040
+ const params = guardedMutation(input);
1029
1041
  // Before the idempotent "ensure absent" below can read this as a row that
1030
1042
  // is simply not here. An unaddressed delete is a mistake, not an absence.
1031
1043
  assertWriteTarget('delete', registeredModelName, params.id);
@@ -1055,6 +1067,9 @@ hydration, collaboration, readSetContext) {
1055
1067
  // HTTP client and makes delete safe to retry or race (two actors deleting
1056
1068
  // the same row).
1057
1069
  if (!model) {
1070
+ if (params.readAt !== undefined) {
1071
+ throw new AbloStaleContextError(`Delete rejected: ${registeredModelName}/${id} changed since read. Re-read and retry.`, { code: 'stale_context', httpStatus: 409 });
1072
+ }
1058
1073
  const handle = isClaimHandle(params.claim) ? params.claim : undefined;
1059
1074
  await settleClaimsAfterWrite(id, handle);
1060
1075
  return;
@@ -73,6 +73,9 @@ export async function processBatch(ctx) {
73
73
  }
74
74
  }
75
75
  else {
76
+ for (const tx of batch) {
77
+ ctx.emitCommitLifecycle('model:sealing', { clientTxId: tx.id });
78
+ }
76
79
  durableEnvelope = await ctx.sealDurableCommit({
77
80
  idempotencyKey: commitIdempotencyKey,
78
81
  origin: 'model_batch',
@@ -85,6 +88,7 @@ export async function processBatch(ctx) {
85
88
  });
86
89
  for (const transaction of batch) {
87
90
  transaction.durableEnvelope = durableEnvelope;
91
+ ctx.emitCommitLifecycle('model:sealed', { clientTxId: transaction.id });
88
92
  }
89
93
  }
90
94
  const operations = durableEnvelope.operations;
@@ -34,7 +34,7 @@ export interface CommitEventSource {
34
34
  }
35
35
  /** One completed commit, broken into its local and remote halves. */
36
36
  export interface CommitLatencySample {
37
- /** The commit's `clientTxId`, identical to its transaction id. */
37
+ /** Local transaction id; for atomic commits this is also the wire clientTxId. */
38
38
  clientTxId: string;
39
39
  /** Milliseconds sealing the durable envelope locally. */
40
40
  sealMs: number;
@@ -64,6 +64,9 @@ export function observeCommitLatency(source, onSample) {
64
64
  const id = readClientTxId(payload);
65
65
  if (id === null)
66
66
  return;
67
+ // Retain the first seal attempt across retries rather than hiding its wait.
68
+ if (pending.has(id))
69
+ return;
67
70
  // Map preserves insertion order, so the first key is the stalest entry.
68
71
  if (pending.size >= MAX_PENDING_COMMITS) {
69
72
  const oldest = pending.keys().next();
@@ -79,7 +82,7 @@ export function observeCommitLatency(source, onSample) {
79
82
  const timing = pending.get(id);
80
83
  if (timing === undefined)
81
84
  return;
82
- timing.sealedAt = nowMs();
85
+ timing.sealedAt ??= nowMs();
83
86
  };
84
87
  const handleCompleted = (payload) => {
85
88
  const id = readTransactionId(payload);
@@ -116,12 +119,18 @@ export function observeCommitLatency(source, onSample) {
116
119
  };
117
120
  source.on('commit:staging', handleStaging);
118
121
  source.on('commit:created', handleCreated);
122
+ // Model batches share an envelope, but confirmation belongs to each local
123
+ // transaction. Restored envelopes have no observed seal and emit no sample.
124
+ source.on('model:sealing', handleStaging);
125
+ source.on('model:sealed', handleCreated);
119
126
  source.on('commit:seal_failed', handleSealFailed);
120
127
  source.on('transaction:completed', handleCompleted);
121
128
  source.on('transaction:failed', handleFailed);
122
129
  return () => {
123
130
  source.off('commit:staging', handleStaging);
124
131
  source.off('commit:created', handleCreated);
132
+ source.off('model:sealing', handleStaging);
133
+ source.off('model:sealed', handleCreated);
125
134
  source.off('commit:seal_failed', handleSealFailed);
126
135
  source.off('transaction:completed', handleCompleted);
127
136
  source.off('transaction:failed', handleFailed);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.64.5",
3
+ "version": "0.65.0",
4
4
  "description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -85,7 +85,7 @@
85
85
  "directory": "packages/humans"
86
86
  },
87
87
  "dependencies": {
88
- "@abloatai/transaction": "0.64.5",
88
+ "@abloatai/transaction": "0.65.0",
89
89
  "events": "^3.3.0",
90
90
  "mobx": "^6.13.7",
91
91
  "uuid": "^11.1.0",
@@ -14,6 +14,7 @@
14
14
  import { autorun } from 'mobx';
15
15
  import {
16
16
  AbloClaimedError,
17
+ AbloStaleContextError,
17
18
  AbloValidationError,
18
19
  toAbloError,
19
20
  } from '@abloatai/transaction/errors';
@@ -49,7 +50,10 @@ import type {
49
50
  CommitCreateOptions,
50
51
  CommitReceipt,
51
52
  } from '@abloatai/transaction/client/resources/httpResources';
52
- import type { HttpModelMutationParams } from '@abloatai/transaction/transport/http';
53
+ import type {
54
+ HttpGuardedMutationParams,
55
+ HttpModelMutationParams,
56
+ } from '@abloatai/transaction/transport/http';
53
57
  import {
54
58
  collectModelList,
55
59
  modelList,
@@ -154,6 +158,7 @@ import { modelEventInputSchema } from '@abloatai/transaction/collaboration';
154
158
  import {
155
159
  capturePointRead,
156
160
  prepareReadSet,
161
+ targetGuardForRow,
157
162
  type ReadSetContext,
158
163
  } from '@abloatai/transaction/internal/read-set';
159
164
 
@@ -684,19 +689,22 @@ export function createModelOperations<T, C>(
684
689
  typeof (value as { release?: unknown }).release === 'function';
685
690
 
686
691
  const preparedMutation = (
687
- params:
688
- | ModelCreateParams<T, C>
689
- | ModelUpdateParams<T, C>
690
- | ModelDeleteParams<T, C>,
692
+ params: {
693
+ readonly idempotencyKey?: string | null;
694
+ readonly label?: string;
695
+ readonly readAt?: number;
696
+ readonly reads?: readonly unknown[] | null;
697
+ },
691
698
  ): MutationOptions => {
692
699
  const prepared = prepareReadSet(
693
700
  readSetContext,
694
701
  readSetClientIdentity,
695
- undefined,
702
+ params.readAt,
696
703
  params.idempotencyKey,
697
704
  params.reads,
698
705
  );
699
706
  const rest: MutationOptions = {
707
+ ...(prepared.readAt !== undefined ? { readAt: prepared.readAt } : {}),
700
708
  ...(prepared.idempotencyKey !== undefined
701
709
  ? { idempotencyKey: prepared.idempotencyKey }
702
710
  : params.idempotencyKey !== undefined
@@ -705,9 +713,7 @@ export function createModelOperations<T, C>(
705
713
  ...(params.label !== undefined ? { label: params.label } : {}),
706
714
  ...(prepared.reads !== undefined
707
715
  ? { reads: prepared.reads === null ? null : [...prepared.reads] }
708
- : params.reads !== undefined
709
- ? { reads: params.reads }
710
- : {}),
716
+ : {}),
711
717
  };
712
718
  // The write-options schema — the runtime twin of the compile-time params.
713
719
  // Catches plain-JavaScript callers at the call site with a typed error
@@ -716,6 +722,22 @@ export function createModelOperations<T, C>(
716
722
  return rest;
717
723
  };
718
724
 
725
+ const guardedMutation = <P extends { readonly ifUnchanged?: unknown }>(
726
+ params: P,
727
+ ): Omit<P, 'ifUnchanged'> & { readonly readAt?: number } => {
728
+ if (params.ifUnchanged === undefined) return params;
729
+ const { ifUnchanged, ...rest } = params;
730
+ return {
731
+ ...rest,
732
+ ...targetGuardForRow(
733
+ readSetContext,
734
+ readSetClientIdentity,
735
+ wireModel,
736
+ ifUnchanged,
737
+ ),
738
+ };
739
+ };
740
+
719
741
  const releaseClaim = async (claimId: string): Promise<void> => {
720
742
  const held = removeActiveClaim(claimId);
721
743
  if (!held) return;
@@ -1547,7 +1569,9 @@ export function createModelOperations<T, C>(
1547
1569
  update: ((): ModelOperations<T, C>['update'] => {
1548
1570
  const updateImpl = guardWrite(
1549
1571
  async (
1550
- arg: ModelUpdateParams<T, C> | string,
1572
+ arg:
1573
+ | HttpGuardedMutationParams<ModelUpdateParams<T, C>, T>
1574
+ | string,
1551
1575
  updater?: ModelUpdater<T>,
1552
1576
  contention?: FunctionalUpdateOptions<ReadDependency | CapturedRow>,
1553
1577
  ): Promise<T | undefined> => {
@@ -1634,7 +1658,7 @@ export function createModelOperations<T, C>(
1634
1658
  },
1635
1659
  });
1636
1660
  }
1637
- const params = arg;
1661
+ const params = guardedMutation(arg);
1638
1662
  // Named before anything reads it. Without this the row lookup below
1639
1663
  // reports `Entity not found: Model/undefined`, which sends the reader
1640
1664
  // looking for a missing row rather than at the unaddressed write.
@@ -1659,6 +1683,12 @@ export function createModelOperations<T, C>(
1659
1683
  }
1660
1684
  const { id } = params;
1661
1685
  const model = ownRowOrThrow(id);
1686
+ if (!model && params.readAt !== undefined) {
1687
+ throw new AbloStaleContextError(
1688
+ `Update rejected: ${registeredModelName}/${id} changed since read. Re-read and retry.`,
1689
+ { code: 'stale_context', httpStatus: 409 },
1690
+ );
1691
+ }
1662
1692
  if (!model)
1663
1693
  throw new AbloValidationError(
1664
1694
  `Entity not found: ${registeredModelName}/${id}`,
@@ -1696,14 +1726,18 @@ export function createModelOperations<T, C>(
1696
1726
  return updated;
1697
1727
  },
1698
1728
  );
1699
- function update(params: ModelUpdateParams<T, C>): Promise<T>;
1729
+ function update(
1730
+ params: HttpGuardedMutationParams<ModelUpdateParams<T, C>, T>,
1731
+ ): Promise<T>;
1700
1732
  function update(
1701
1733
  id: string,
1702
1734
  updater: ModelUpdater<T>,
1703
1735
  options?: FunctionalUpdateOptions<ReadDependency | CapturedRow>,
1704
1736
  ): Promise<T | undefined>;
1705
1737
  function update(
1706
- arg: ModelUpdateParams<T, C> | string,
1738
+ arg:
1739
+ | HttpGuardedMutationParams<ModelUpdateParams<T, C>, T>
1740
+ | string,
1707
1741
  updater?: ModelUpdater<T>,
1708
1742
  contention?: FunctionalUpdateOptions<ReadDependency | CapturedRow>,
1709
1743
  ): Promise<T | undefined> {
@@ -1712,7 +1746,10 @@ export function createModelOperations<T, C>(
1712
1746
  return update;
1713
1747
  })(),
1714
1748
 
1715
- delete: guardWrite(async (params: ModelDeleteParams<T, C>): Promise<void> => {
1749
+ delete: guardWrite(async (
1750
+ input: HttpGuardedMutationParams<ModelDeleteParams<T, C>, T>,
1751
+ ): Promise<void> => {
1752
+ const params = guardedMutation(input);
1716
1753
  // Before the idempotent "ensure absent" below can read this as a row that
1717
1754
  // is simply not here. An unaddressed delete is a mistake, not an absence.
1718
1755
  assertWriteTarget('delete', registeredModelName, params.id);
@@ -1745,6 +1782,12 @@ export function createModelOperations<T, C>(
1745
1782
  // HTTP client and makes delete safe to retry or race (two actors deleting
1746
1783
  // the same row).
1747
1784
  if (!model) {
1785
+ if (params.readAt !== undefined) {
1786
+ throw new AbloStaleContextError(
1787
+ `Delete rejected: ${registeredModelName}/${id} changed since read. Re-read and retry.`,
1788
+ { code: 'stale_context', httpStatus: 409 },
1789
+ );
1790
+ }
1748
1791
  const handle = isClaimHandle(params.claim) ? params.claim : undefined;
1749
1792
  await settleClaimsAfterWrite(id, handle);
1750
1793
  return;
@@ -144,6 +144,9 @@ export async function processBatch(ctx: BatchProcessingContext): Promise<void> {
144
144
  throw new Error('Cannot replay a model batch with inconsistent durable envelopes');
145
145
  }
146
146
  } else {
147
+ for (const tx of batch) {
148
+ ctx.emitCommitLifecycle('model:sealing', { clientTxId: tx.id });
149
+ }
147
150
  durableEnvelope = await ctx.sealDurableCommit({
148
151
  idempotencyKey: commitIdempotencyKey,
149
152
  origin: 'model_batch',
@@ -156,6 +159,7 @@ export async function processBatch(ctx: BatchProcessingContext): Promise<void> {
156
159
  });
157
160
  for (const transaction of batch) {
158
161
  transaction.durableEnvelope = durableEnvelope;
162
+ ctx.emitCommitLifecycle('model:sealed', { clientTxId: transaction.id });
159
163
  }
160
164
  }
161
165
  const operations = durableEnvelope.operations;
@@ -36,7 +36,7 @@ export interface CommitEventSource {
36
36
 
37
37
  /** One completed commit, broken into its local and remote halves. */
38
38
  export interface CommitLatencySample {
39
- /** The commit's `clientTxId`, identical to its transaction id. */
39
+ /** Local transaction id; for atomic commits this is also the wire clientTxId. */
40
40
  clientTxId: string;
41
41
  /** Milliseconds sealing the durable envelope locally. */
42
42
  sealMs: number;
@@ -98,6 +98,8 @@ export function observeCommitLatency(
98
98
  const handleStaging = (payload: unknown): void => {
99
99
  const id = readClientTxId(payload);
100
100
  if (id === null) return;
101
+ // Retain the first seal attempt across retries rather than hiding its wait.
102
+ if (pending.has(id)) return;
101
103
  // Map preserves insertion order, so the first key is the stalest entry.
102
104
  if (pending.size >= MAX_PENDING_COMMITS) {
103
105
  const oldest = pending.keys().next();
@@ -111,7 +113,7 @@ export function observeCommitLatency(
111
113
  if (id === null) return;
112
114
  const timing = pending.get(id);
113
115
  if (timing === undefined) return;
114
- timing.sealedAt = nowMs();
116
+ timing.sealedAt ??= nowMs();
115
117
  };
116
118
 
117
119
  const handleCompleted = (payload: unknown): void => {
@@ -149,6 +151,10 @@ export function observeCommitLatency(
149
151
 
150
152
  source.on('commit:staging', handleStaging);
151
153
  source.on('commit:created', handleCreated);
154
+ // Model batches share an envelope, but confirmation belongs to each local
155
+ // transaction. Restored envelopes have no observed seal and emit no sample.
156
+ source.on('model:sealing', handleStaging);
157
+ source.on('model:sealed', handleCreated);
152
158
  source.on('commit:seal_failed', handleSealFailed);
153
159
  source.on('transaction:completed', handleCompleted);
154
160
  source.on('transaction:failed', handleFailed);
@@ -156,6 +162,8 @@ export function observeCommitLatency(
156
162
  return () => {
157
163
  source.off('commit:staging', handleStaging);
158
164
  source.off('commit:created', handleCreated);
165
+ source.off('model:sealing', handleStaging);
166
+ source.off('model:sealed', handleCreated);
159
167
  source.off('commit:seal_failed', handleSealFailed);
160
168
  source.off('transaction:completed', handleCompleted);
161
169
  source.off('transaction:failed', handleFailed);