@abloatai/humans 0.37.1 → 0.39.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 (48) hide show
  1. package/dist/core.d.ts +1 -0
  2. package/dist/core.js +4 -0
  3. package/dist/local/BaseSyncedStore.d.ts +4 -2
  4. package/dist/local/BaseSyncedStore.js +7 -1
  5. package/dist/local/Database.d.ts +20 -0
  6. package/dist/local/Database.js +83 -49
  7. package/dist/local/InstanceCache.d.ts +18 -8
  8. package/dist/local/InstanceCache.js +74 -74
  9. package/dist/local/Model.d.ts +18 -0
  10. package/dist/local/Model.js +83 -32
  11. package/dist/local/SyncClient.d.ts +1 -4
  12. package/dist/local/SyncClient.js +55 -60
  13. package/dist/local/client/createModelProxy.js +14 -12
  14. package/dist/local/client/options.d.ts +7 -0
  15. package/dist/local/client/reactiveEngine.js +23 -3
  16. package/dist/local/client/storeLifecycle.js +6 -3
  17. package/dist/local/stores/DatabaseManager.d.ts +2 -2
  18. package/dist/local/stores/DatabaseManager.js +2 -2
  19. package/dist/local/stores/persistenceIdentity.d.ts +7 -8
  20. package/dist/local/stores/persistenceIdentity.js +4 -5
  21. package/dist/local/sync/SyncWebSocket.d.ts +7 -0
  22. package/dist/local/sync/SyncWebSocket.js +21 -6
  23. package/dist/local/sync/deltaPipeline.js +31 -13
  24. package/dist/local/sync/drainProfile.d.ts +104 -0
  25. package/dist/local/sync/drainProfile.js +182 -0
  26. package/dist/local/sync/initialize.js +2 -2
  27. package/dist/local/transactions/mutations/MutationQueue.js +32 -12
  28. package/dist/local/transactions/mutations/pendingDrain.d.ts +1 -1
  29. package/dist/local/transactions/mutations/pendingDrain.js +2 -1
  30. package/package.json +2 -2
  31. package/src/core.ts +15 -0
  32. package/src/local/BaseSyncedStore.ts +11 -3
  33. package/src/local/Database.ts +87 -50
  34. package/src/local/InstanceCache.ts +77 -71
  35. package/src/local/Model.ts +98 -37
  36. package/src/local/SyncClient.ts +57 -61
  37. package/src/local/client/createModelProxy.ts +14 -12
  38. package/src/local/client/options.ts +9 -0
  39. package/src/local/client/reactiveEngine.ts +23 -2
  40. package/src/local/client/storeLifecycle.ts +10 -4
  41. package/src/local/stores/DatabaseManager.ts +4 -4
  42. package/src/local/stores/persistenceIdentity.ts +10 -12
  43. package/src/local/sync/SyncWebSocket.ts +20 -6
  44. package/src/local/sync/deltaPipeline.ts +51 -21
  45. package/src/local/sync/drainProfile.ts +257 -0
  46. package/src/local/sync/initialize.ts +2 -2
  47. package/src/local/transactions/mutations/MutationQueue.ts +31 -12
  48. package/src/local/transactions/mutations/pendingDrain.ts +7 -2
@@ -1290,34 +1290,25 @@ export class SyncClient extends EventEmitter {
1290
1290
  * local model has unsynced changes, so the two sides stay consistent.
1291
1291
  */
1292
1292
  resolveConflicts(localModel: Model, serverData: Record<string, unknown>): Model {
1293
- const hasLocalChanges = localModel.hasChanges;
1294
- // Safely get timestamp, handling both Date objects and strings
1295
- const localUpdatedAt = localModel.updatedAt
1296
- ? localModel.updatedAt instanceof Date
1297
- ? localModel.updatedAt.getTime()
1298
- : new Date(localModel.updatedAt).getTime()
1299
- : 0;
1300
- const serverUpdatedAt = toEpochMs(serverData.updatedAt);
1301
-
1302
- this.runtime.logger.debug('Conflict resolution', {
1303
- modelId: localModel.id,
1304
- modelType: localModel.getModelName(),
1305
- hasLocalChanges,
1306
- localUpdatedAt: localModel.updatedAt?.toString(),
1307
- serverUpdatedAt: serverData.updatedAt,
1308
- localChanges: localModel.getChanges(),
1309
- serverState: this.extractCriticalState(serverData),
1310
- });
1311
-
1312
- // PRIORITY 1: Check for critical server states that must be respected
1313
- // These states override any local changes to maintain data consistency
1314
- const criticalServerStates = this.extractCriticalState(serverData);
1315
- const shouldForceAcceptServer = this.hasCriticalStateChange(criticalServerStates);
1293
+ // No entry-point debug here: this runs once per incoming update delta,
1294
+ // and a debug call's payload is BUILT even when the logger discards it —
1295
+ // a per-delta model scan on the apply hot path. The outcome branches
1296
+ // below log the cases worth reading.
1297
+
1298
+ // PRIORITY 1: Check for critical server states that must be respected.
1299
+ // These states override any local changes to maintain data consistency.
1300
+ // Checked inline — the collected-object form (`extractCriticalState`)
1301
+ // only materializes on the rare force-accept branch, for its log line.
1302
+ const shouldForceAcceptServer =
1303
+ (serverData.deletedAt !== undefined && serverData.deletedAt !== null) ||
1304
+ (serverData.archivedAt !== undefined && serverData.archivedAt !== null) ||
1305
+ serverData.isActive === false ||
1306
+ (serverData.unassignedAt !== undefined && serverData.unassignedAt !== null);
1316
1307
 
1317
1308
  if (shouldForceAcceptServer) {
1318
1309
  this.runtime.logger.debug('Accepting server update - critical state change detected', {
1319
1310
  modelId: localModel.id,
1320
- criticalStates: criticalServerStates,
1311
+ criticalStates: this.extractCriticalState(serverData),
1321
1312
  });
1322
1313
 
1323
1314
  // Force accept server state for critical changes
@@ -1329,7 +1320,7 @@ export class SyncClient extends EventEmitter {
1329
1320
 
1330
1321
  // Local-first: if we have local dirty fields, merge by field.
1331
1322
  // Keep locally changed fields; apply server for the rest.
1332
- if (hasLocalChanges) {
1323
+ if (localModel.hasChanges) {
1333
1324
  const localChanges = localModel.getChanges();
1334
1325
  this.runtime.logger.debug('Merging server update with local dirty fields', {
1335
1326
  modelId: localModel.id,
@@ -1341,6 +1332,13 @@ export class SyncClient extends EventEmitter {
1341
1332
 
1342
1333
  // Preserve the most recent updatedAt without clearing dirty flags
1343
1334
  if (serverData.updatedAt || localModel.updatedAt) {
1335
+ // Safely get timestamp, handling both Date objects and strings
1336
+ const localUpdatedAt = localModel.updatedAt
1337
+ ? localModel.updatedAt instanceof Date
1338
+ ? localModel.updatedAt.getTime()
1339
+ : new Date(localModel.updatedAt).getTime()
1340
+ : 0;
1341
+ const serverUpdatedAt = toEpochMs(serverData.updatedAt);
1344
1342
  const mergedUpdatedAt = new Date(Math.max(localUpdatedAt, serverUpdatedAt));
1345
1343
  // updateFromData accepts Date or ISO string for dates
1346
1344
  merged.updatedAt = mergedUpdatedAt;
@@ -1351,10 +1349,9 @@ export class SyncClient extends EventEmitter {
1351
1349
  return localModel;
1352
1350
  }
1353
1351
 
1354
- // No local changes: fall back to LWW to converge
1355
- // Accept server regardless of timestamp equality to stay in sync
1356
- const acceptReason = serverUpdatedAt > localUpdatedAt ? 'server is newer' : 'no local changes';
1357
- this.runtime.logger.debug(`Accepting server update - ${acceptReason}`);
1352
+ // No local changes: fall back to LWW to converge. Accept server
1353
+ // regardless of timestamp equality to stay in sync. Not logged — this is
1354
+ // the common path for every collaborator update a client receives.
1358
1355
  localModel.updateFromData(serverData);
1359
1356
  localModel.clearChanges();
1360
1357
  localModel.markAsSynced();
@@ -1392,17 +1389,6 @@ export class SyncClient extends EventEmitter {
1392
1389
  return critical;
1393
1390
  }
1394
1391
 
1395
- /**
1396
- * Check if critical state changes exist that require forcing server state
1397
- */
1398
- private hasCriticalStateChange(criticalStates: Record<string, unknown>): boolean {
1399
- // Any critical state present means we should force accept server
1400
- return (
1401
- Object.keys(criticalStates).length > 0 &&
1402
- Object.values(criticalStates).some((v) => v !== null && v !== undefined)
1403
- );
1404
- }
1405
-
1406
1392
  /**
1407
1393
  * Handle network reconnection
1408
1394
  */
@@ -1901,6 +1887,22 @@ export class SyncClient extends EventEmitter {
1901
1887
  applyDeltaBatchToPool(
1902
1888
  dbResults: readonly AppliedChange[],
1903
1889
  enrichRelations: (modelName: string, data: Record<string, unknown>) => Record<string, unknown>,
1890
+ ): void {
1891
+ // The WHOLE batch — conflict resolution and model mutation included, not
1892
+ // just the pool bookkeeping at the end — runs in one MobX action.
1893
+ // `resolveConflicts` writes model fields via `updateFromData`; when those
1894
+ // writes ran before the action, every delta opened its own top-level
1895
+ // action and flushed reactions at its boundary, so a large frame paid one
1896
+ // reaction pass per delta and an observer could see a partially applied
1897
+ // frame between them.
1898
+ runInAction(() => {
1899
+ this.applyDeltaBatchToPoolInAction(dbResults, enrichRelations);
1900
+ });
1901
+ }
1902
+
1903
+ private applyDeltaBatchToPoolInAction(
1904
+ dbResults: readonly AppliedChange[],
1905
+ enrichRelations: (modelName: string, data: Record<string, unknown>) => Record<string, unknown>,
1904
1906
  ): void {
1905
1907
  const modelsToAdd: Model[] = [];
1906
1908
  const modelsToUpsert: Model[] = [];
@@ -2002,27 +2004,21 @@ export class SyncClient extends EventEmitter {
2002
2004
  }
2003
2005
  }
2004
2006
 
2005
- // Reveal the whole frame in a single MobX action. `addBatch`,
2006
- // `upsertBatch`, `removeBatch`, and `updateScope` are each individually
2007
- // wrapped in an action, so calling them in sequence flushes reactions at
2008
- // every action boundary a catch-up frame that adds, updates, and removes
2009
- // would fire every dependent reaction several times in a row, re-rendering
2010
- // and re-sorting on each. Wrapping them in one outer `runInAction` defers
2011
- // all reaction flushes to a single boundary, so dependents recompute
2012
- // exactly once regardless of how many models or operation kinds the frame
2013
- // touched. The app therefore never observes a partially applied frame.
2014
- runInAction(() => {
2015
- if (modelsToAdd.length > 0) this.objectPool.addBatch(modelsToAdd, ModelScope.live);
2016
- if (modelsToUpsert.length > 0) this.objectPool.upsertBatch(modelsToUpsert, ModelScope.live);
2017
- if (idsToRemove.length > 0) this.objectPool.removeBatch(idsToRemove);
2018
- for (const id of idsToArchive) this.objectPool.updateScope(id, ModelScope.archived);
2019
-
2020
- // Emit changed model types so QueryProcessor can auto-invalidate.
2021
- // Kept inside the action so any observable query-cache state it
2022
- // flips is part of the same atomic reveal.
2023
- const changedTypes = new Set(dbResults.map(r => r.modelName));
2024
- if (changedTypes.size > 0) this.emit('models:changed', changedTypes);
2025
- });
2007
+ // Reveal the whole frame at one reaction boundary: the caller's single
2008
+ // action covers the collection loop above and these batch pool writes, so
2009
+ // dependents recompute exactly once regardless of how many models or
2010
+ // operation kinds the frame touched, and the app never observes a
2011
+ // partially applied frame.
2012
+ if (modelsToAdd.length > 0) this.objectPool.addBatch(modelsToAdd, ModelScope.live);
2013
+ if (modelsToUpsert.length > 0) this.objectPool.upsertBatch(modelsToUpsert, ModelScope.live);
2014
+ if (idsToRemove.length > 0) this.objectPool.removeBatch(idsToRemove);
2015
+ for (const id of idsToArchive) this.objectPool.updateScope(id, ModelScope.archived);
2016
+
2017
+ // Emit changed model types so QueryProcessor can auto-invalidate.
2018
+ // Kept inside the action so any observable query-cache state it
2019
+ // flips is part of the same atomic reveal.
2020
+ const changedTypes = new Set(dbResults.map(r => r.modelName));
2021
+ if (changedTypes.size > 0) this.emit('models:changed', changedTypes);
2026
2022
  }
2027
2023
 
2028
2024
  /**
@@ -521,15 +521,17 @@ export function createModelProxy<T, C>(
521
521
  | ModelDeleteParams<T, C>,
522
522
  ): MutationOptions => {
523
523
  const rest: MutationOptions = {
524
- idempotencyKey: params.idempotencyKey,
525
- label: params.label,
526
- wait: params.wait,
527
- readAt: params.readAt,
528
- onStale: params.onStale,
529
- fenceToken: params.fenceToken,
530
- claimRef: params.claimRef,
531
- reads: params.reads,
532
- track: params.track,
524
+ ...(params.idempotencyKey !== undefined
525
+ ? { idempotencyKey: params.idempotencyKey }
526
+ : {}),
527
+ ...(params.label !== undefined ? { label: params.label } : {}),
528
+ ...(params.wait !== undefined ? { wait: params.wait } : {}),
529
+ ...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
530
+ ...(params.onStale !== undefined ? { onStale: params.onStale } : {}),
531
+ ...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
532
+ ...(params.claimRef !== undefined ? { claimRef: params.claimRef } : {}),
533
+ ...(params.reads !== undefined ? { reads: params.reads } : {}),
534
+ ...(params.track !== undefined ? { track: params.track } : {}),
533
535
  };
534
536
  // The write-options schema — the runtime twin of the compile-time params.
535
537
  // Catches plain-JavaScript callers (for example `onStale: 'rejct'`) at the
@@ -713,7 +715,7 @@ export function createModelProxy<T, C>(
713
715
  return {
714
716
  object: 'claim',
715
717
  id: lease.id,
716
- readAt: snapshot.stamp,
718
+ readAt: lease.readAt ?? snapshot.stamp,
717
719
  // The fencing token the server minted for this grant, forwarded from the
718
720
  // lease so writes taken under this handle carry it (Option B).
719
721
  ...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
@@ -1227,7 +1229,7 @@ export function createModelProxy<T, C>(
1227
1229
  const effective: MutationOptions | undefined = claimed
1228
1230
  ? {
1229
1231
  wait: 'confirmed',
1230
- readAt: claimed.snapshot.stamp,
1232
+ readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
1231
1233
  onStale: 'reject',
1232
1234
  claimRef: { id: claimed.lease.id },
1233
1235
  ...opts,
@@ -1311,7 +1313,7 @@ export function createModelProxy<T, C>(
1311
1313
  const effective: MutationOptions | undefined = claimed
1312
1314
  ? {
1313
1315
  wait: 'confirmed',
1314
- readAt: claimed.snapshot.stamp,
1316
+ readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
1315
1317
  onStale: 'reject',
1316
1318
  claimRef: { id: claimed.lease.id },
1317
1319
  ...(claimed.lease.fenceToken !== undefined
@@ -520,6 +520,15 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
520
520
  */
521
521
  organizationId?: string;
522
522
 
523
+ /**
524
+ * Immutable branch selected by a self-hosted credential. Hosted clients
525
+ * receive this from the credential exchange.
526
+ */
527
+ branchId?: string;
528
+
529
+ /** Whether the selected self-hosted branch is the project's root branch. */
530
+ branchRoot?: boolean;
531
+
523
532
  /** The client-wide write default — see {@link AbloOptions.wait}. Projected
524
533
  * from the public option rather than restated, so the two cannot diverge. */
525
534
  wait?: AbloOptions['wait'];
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
16
+ import { omittedModelError } from '@abloatai/transaction/schema/select';
16
17
  import {
17
18
  durableCommitOperationSchema,
18
19
  type DurableCommitOperation,
@@ -401,6 +402,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
401
402
  claim: Claim,
402
403
  waited = false,
403
404
  fenceToken?: number,
405
+ readAt?: number,
404
406
  ): Claim {
405
407
  const release = (): Promise<void> => {
406
408
  claim.revoke?.();
@@ -416,6 +418,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
416
418
  description: claim.description,
417
419
  target: claim.target,
418
420
  waited,
421
+ ...(readAt !== undefined ? { readAt } : {}),
419
422
  ...(resolvedFenceToken !== undefined ? { fenceToken: resolvedFenceToken } : {}),
420
423
  release,
421
424
  revoke: claim.revoke,
@@ -448,9 +451,10 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
448
451
  // holds the lease, never a half-claimed one racing the queue.
449
452
  let waited = false;
450
453
  let fenceToken: number | undefined;
454
+ let readAt: number | undefined;
451
455
  if (claimOptions.queue) {
452
456
  try {
453
- ({ waited, fenceToken } = await awaitClaimGrant(transport, claim.id, {
457
+ ({ waited, fenceToken, readAt } = await awaitClaimGrant(transport, claim.id, {
454
458
  timeoutMs: claimOptions.waitTimeoutMs,
455
459
  maxQueueDepth: claimOptions.maxQueueDepth,
456
460
  signal: claimOptions.signal,
@@ -464,7 +468,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
464
468
  throw err;
465
469
  }
466
470
  }
467
- return wrapClaimHandle(claim, waited, fenceToken);
471
+ return wrapClaimHandle(claim, waited, fenceToken, readAt);
468
472
  },
469
473
  list(target?: Partial<ModelTarget>): readonly ModelClaim[] {
470
474
  return listModelClaims(target);
@@ -931,5 +935,22 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
931
935
  },
932
936
  } as Ablo<S>;
933
937
 
938
+ // A model the schema projection left out answers with an error naming the
939
+ // model and the fix, not `undefined`. An app can compile against the full
940
+ // source schema while running a projection, so the type system never sees
941
+ // this gap; without the stub the caller crashes one property later with a
942
+ // bare TypeError ("reading 'local'") that names neither. Non-enumerable so
943
+ // spread, Object.keys, and JSON.stringify walk past the stubs untriggered.
944
+ for (const name of schema.omittedModels ?? []) {
945
+ if (name in engine) continue;
946
+ Object.defineProperty(engine, name, {
947
+ get() {
948
+ throw omittedModelError(name);
949
+ },
950
+ enumerable: false,
951
+ configurable: true,
952
+ });
953
+ }
954
+
934
955
  return engine;
935
956
  }
@@ -194,8 +194,8 @@ export function startStoreLifecycle<S extends SchemaRecord>(
194
194
  userId,
195
195
  accountScope,
196
196
  projectId,
197
- environment,
198
- sandboxId,
197
+ branchId,
198
+ branchRoot,
199
199
  teamIds,
200
200
  capabilityToken,
201
201
  syncGroups,
@@ -257,13 +257,19 @@ export function startStoreLifecycle<S extends SchemaRecord>(
257
257
  // option doc) and everyone else defaults to 'full'.
258
258
  const resolvedBootstrapMode: 'full' | 'none' =
259
259
  internalOptions.bootstrapMode ?? (participantKind === 'agent' ? 'none' : 'full');
260
+ if (!branchId) {
261
+ throw new AbloConnectionError(
262
+ 'The server did not resolve an Ablo branch for this credential.',
263
+ { code: 'invalid_request' },
264
+ );
265
+ }
260
266
 
261
267
  const gen = store.initialize({
262
268
  userId,
263
269
  organizationId: accountScope,
264
270
  projectId,
265
- environment,
266
- sandboxId,
271
+ branchId,
272
+ branchRoot,
267
273
  teamIds,
268
274
  kind: participantKind,
269
275
  capabilityToken,
@@ -31,8 +31,8 @@ export interface DatabaseInfo {
31
31
  workspaceId: string;
32
32
  participantKind: string;
33
33
  projectId: string | null;
34
- environment: 'sandbox' | 'production' | null;
35
- sandboxId: string | null;
34
+ branchId: string;
35
+ branchRoot: boolean;
36
36
  schemaHash: string;
37
37
  schemaVersion: number;
38
38
  userVersion?: number;
@@ -173,8 +173,8 @@ export class DatabaseManager {
173
173
  workspaceId: identity.organizationId,
174
174
  participantKind: identity.participantKind,
175
175
  projectId: identity.projectId,
176
- environment: identity.environment,
177
- sandboxId: identity.sandboxId,
176
+ branchId: identity.branchId,
177
+ branchRoot: identity.branchRoot,
178
178
  schemaHash,
179
179
  schemaVersion,
180
180
  userVersion,
@@ -1,17 +1,16 @@
1
1
  import { AbloConnectionError } from '@abloatai/transaction/errors';
2
2
 
3
3
  /**
4
- * The complete authenticated plane that owns one local replica. The same
5
- * participant can have different data in a project, sandbox, or environment;
6
- * those replicas must never share a namespace.
4
+ * The complete authenticated branch that owns one local replica. A branch id
5
+ * is authoritative.
7
6
  */
8
7
  export interface PersistenceIdentity {
9
8
  readonly participantId: string;
10
9
  readonly participantKind: string;
11
10
  readonly organizationId: string;
12
11
  readonly projectId: string | null;
13
- readonly environment: 'sandbox' | 'production' | null;
14
- readonly sandboxId: string | null;
12
+ readonly branchId: string;
13
+ readonly branchRoot: boolean;
15
14
  }
16
15
 
17
16
  export interface PersistedIdentityMetadata {
@@ -20,11 +19,11 @@ export interface PersistedIdentityMetadata {
20
19
  readonly workspaceId: string;
21
20
  readonly participantKind?: string;
22
21
  readonly projectId?: string | null;
23
- readonly environment?: 'sandbox' | 'production' | null;
24
- readonly sandboxId?: string | null;
22
+ readonly branchId?: string;
23
+ readonly branchRoot?: boolean;
25
24
  }
26
25
 
27
- export const PERSISTENCE_NAMESPACE_VERSION = 2;
26
+ export const PERSISTENCE_NAMESPACE_VERSION = 4;
28
27
 
29
28
  function canonicalIdentity(
30
29
  identity: PersistenceIdentity,
@@ -33,8 +32,7 @@ function canonicalIdentity(
33
32
  return JSON.stringify([
34
33
  PERSISTENCE_NAMESPACE_VERSION,
35
34
  identity.projectId,
36
- identity.environment,
37
- identity.sandboxId,
35
+ ['branch', identity.branchId, identity.branchRoot],
38
36
  identity.organizationId,
39
37
  identity.participantKind,
40
38
  identity.participantId,
@@ -77,7 +75,7 @@ export function persistenceIdentityMatches(
77
75
  info.workspaceId === identity.organizationId &&
78
76
  info.participantKind === identity.participantKind &&
79
77
  (info.projectId ?? null) === identity.projectId &&
80
- (info.environment ?? null) === identity.environment &&
81
- (info.sandboxId ?? null) === identity.sandboxId
78
+ info.branchId === identity.branchId &&
79
+ (info.branchRoot ?? false) === identity.branchRoot
82
80
  );
83
81
  }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { getContext } from '../context.js';
14
14
  import { clientSyncDeltaSchema, type ClientSyncDelta } from '@abloatai/transaction/wire/delta';
15
+ import { drainProfilingEnabled, observeDrainStage } from './drainProfile.js';
15
16
  import {
16
17
  WsTransport,
17
18
  type WsTransportOptions,
@@ -212,7 +213,23 @@ export class SyncWebSocket<
212
213
  * and an observability breadcrumb; it is never applied. There is one parse per
213
214
  * delta — callers must not re-parse.
214
215
  */
216
+ /**
217
+ * Wire validation runs once per delta, so at drain scale it is a per-delta
218
+ * fixed cost rather than a payload-proportional one. The guard keeps the
219
+ * normal path free: when profiling is off this is a boolean test and a
220
+ * direct call, with no closure allocated per delta.
221
+ */
215
222
  private normalizeWireDelta(raw: unknown): SyncDelta | null {
223
+ if (!drainProfilingEnabled()) return this.parseWireDelta(raw);
224
+ const startedAt = performance.now();
225
+ try {
226
+ return this.parseWireDelta(raw);
227
+ } finally {
228
+ observeDrainStage('parse', performance.now() - startedAt);
229
+ }
230
+ }
231
+
232
+ private parseWireDelta(raw: unknown): SyncDelta | null {
216
233
  let candidate: unknown = raw;
217
234
  if (isRecord(raw)) {
218
235
  const normalized: Record<string, unknown> = { ...raw };
@@ -255,12 +272,9 @@ export class SyncWebSocket<
255
272
  protected override handleDelta(rawDelta: unknown): void {
256
273
  const delta = this.normalizeWireDelta(rawDelta);
257
274
  if (!delta) return;
258
- getContext().logger.debug('Received delta', {
259
- action: delta.actionType,
260
- model: delta.modelName,
261
- id: delta.modelId,
262
- syncId: delta.id,
263
- });
275
+ // No per-delta debug here: the payload object is built even when the
276
+ // logger discards it, and this runs at the full live wire rate. Dropped
277
+ // malformed deltas are still logged by `normalizeWireDelta`.
264
278
 
265
279
  // Do not advance `this.cursor.lastSyncId` on receipt. The runtime cursor
266
280
  // must stay consistent with what has been persisted locally; otherwise the
@@ -24,6 +24,14 @@ import {
24
24
  type AbloPlugin,
25
25
  type AppliedChange,
26
26
  } from '../../plugin.js';
27
+ import {
28
+ observeDrainBatch,
29
+ observeDrainAcknowledge,
30
+ timeDrainStage,
31
+ timeDrainStageAsync,
32
+ openDrainBatchRow,
33
+ closeDrainBatchRow,
34
+ } from './drainProfile.js';
27
35
 
28
36
  /**
29
37
  * What the pipeline needs back from the surrounding store: the shared mutable
@@ -407,9 +415,22 @@ function yieldToHost(): Promise<void> {
407
415
  async function flushDeltaBatch(
408
416
  ctx: DeltaPipelineContext,
409
417
  queuedDeltas: SyncDelta[],
418
+ ): Promise<void> {
419
+ openDrainBatchRow(queuedDeltas.length);
420
+ try {
421
+ await flushDeltaBatchInner(ctx, queuedDeltas);
422
+ } finally {
423
+ closeDrainBatchRow();
424
+ }
425
+ }
426
+
427
+ async function flushDeltaBatchInner(
428
+ ctx: DeltaPipelineContext,
429
+ queuedDeltas: SyncDelta[],
410
430
  ): Promise<void> {
411
431
  const stagePlugins = ctx.stagePlugins ?? [];
412
- const deduplicatedDeltas = ctx.deduplicateDeltas(queuedDeltas);
432
+ const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
433
+ observeDrainBatch(queuedDeltas.length, deduplicatedDeltas.length);
413
434
  runStage(stagePlugins, 'dedupe', { deltas: deduplicatedDeltas });
414
435
 
415
436
  // Custom entities → apply straight to the pool, skipping the local store.
@@ -444,17 +465,19 @@ async function flushDeltaBatch(
444
465
  // handleGroupRemoved) and never reach here, though the persistence
445
466
  // signature accepts them defensively.
446
467
  const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
447
- const batch = await ctx.processDeltaBatch(
448
- regularDeltas.map((d) => ({
449
- syncId: d.id,
450
- actionType: d.actionType,
451
- modelName: d.modelName,
452
- modelId: d.modelId,
453
- data: typeof d.data === 'string' ? JSON.parse(d.data) : d.data,
454
- // Thread `transactionId` through so the receive layer can recognize
455
- // echoes of locally-applied transactions and skip the pool mutation.
456
- transactionId: d.transactionId,
457
- }))
468
+ const batch = await timeDrainStageAsync('persist', () =>
469
+ ctx.processDeltaBatch(
470
+ regularDeltas.map((d) => ({
471
+ syncId: d.id,
472
+ actionType: d.actionType,
473
+ modelName: d.modelName,
474
+ modelId: d.modelId,
475
+ data: typeof d.data === 'string' ? JSON.parse(d.data) : d.data,
476
+ // Thread `transactionId` through so the receive layer can recognize
477
+ // echoes of locally-applied transactions and skip the pool mutation.
478
+ transactionId: d.transactionId,
479
+ }))
480
+ )
458
481
  );
459
482
  const dbResults = batch.results;
460
483
  runStage(stagePlugins, 'persist', { deltas: regularDeltas });
@@ -464,11 +487,13 @@ async function flushDeltaBatch(
464
487
  // materialiser attached where it said it would. The direct call is the
465
488
  // bridge for stores constructed without plugins (subclasses, tests),
466
489
  // whose own apply is the whole pipeline.
467
- if (pluginsForStage(stagePlugins, 'apply').length > 0) {
468
- runStage(stagePlugins, 'apply', { changes: dbResults });
469
- } else {
470
- ctx.applyDeltaBatchToPool(dbResults);
471
- }
490
+ timeDrainStage('apply', () => {
491
+ if (pluginsForStage(stagePlugins, 'apply').length > 0) {
492
+ runStage(stagePlugins, 'apply', { changes: dbResults });
493
+ } else {
494
+ ctx.applyDeltaBatchToPool(dbResults);
495
+ }
496
+ });
472
497
 
473
498
  // Acknowledge and advance the sync cursor, gated on persistence.
474
499
  //
@@ -480,11 +505,16 @@ async function flushDeltaBatch(
480
505
  // be lost. The cursor and the persisted state must move together.
481
506
  const persistedSyncId = batch.persistedSyncId;
482
507
  if (persistedSyncId > ctx.lastAckedId) {
483
- ctx.acknowledge(persistedSyncId);
484
- ctx.advancePersisted(persistedSyncId);
485
- runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
508
+ timeDrainStage('acknowledge', () => {
509
+ ctx.acknowledge(persistedSyncId);
510
+ ctx.advancePersisted(persistedSyncId);
511
+ observeDrainAcknowledge(persistedSyncId);
512
+ runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
513
+ });
486
514
  }
487
515
 
488
516
  // Cache invalidation happens automatically via the 'models:changed' event.
489
- runStage(stagePlugins, 'notify', { changes: dbResults });
517
+ timeDrainStage('notify', () => {
518
+ runStage(stagePlugins, 'notify', { changes: dbResults });
519
+ });
490
520
  }