@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.
- package/dist/local/client/createModelOperations.js +23 -8
- package/dist/local/transactions/mutations/batchProcessing.js +4 -0
- package/dist/local/transactions/mutations/commitLatency.d.ts +1 -1
- package/dist/local/transactions/mutations/commitLatency.js +10 -1
- package/package.json +2 -2
- package/src/local/client/createModelOperations.ts +57 -14
- package/src/local/transactions/mutations/batchProcessing.ts +4 -0
- package/src/local/transactions/mutations/commitLatency.ts +10 -2
|
@@ -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,
|
|
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
|
-
:
|
|
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 (
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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:
|
|
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(
|
|
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:
|
|
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 (
|
|
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
|
-
/**
|
|
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
|
|
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);
|