@abloatai/humans 0.42.0 → 0.43.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.
@@ -1173,6 +1173,7 @@ export class BaseSyncedStore {
1173
1173
  advancePersisted: (syncId) => { this.syncClient.position.advancePersisted(syncId); },
1174
1174
  // Persistence + pool writes.
1175
1175
  processDeltaBatch: (deltas) => this.database.processDeltaBatch(deltas),
1176
+ projectDeltaBatchForPool: (results) => this.syncClient.projectDeltaBatchForPool(results),
1176
1177
  applyDeltaBatchToPool: (results) => { this.applyChangesToPool(results); },
1177
1178
  acknowledge: (syncId) => { this.syncWebSocket.acknowledge(syncId); },
1178
1179
  get objectPool() { return store.objectPool; },
@@ -57,6 +57,18 @@ export declare class InstanceCache {
57
57
  */
58
58
  subscribe<T extends Model>(modelClass: ModelConstructor<T>, callback: (model: T) => void): () => void;
59
59
  private notifySubscribers;
60
+ /**
61
+ * Maximum fresh wire rows worth constructing from one atomic apply batch.
62
+ *
63
+ * A headless cache has no recency signal: every never-read resident is
64
+ * equally useful, while the local database remains the authoritative full
65
+ * state. Once that cache is full, replacing cold rows with other cold rows
66
+ * is pure construction/eviction churn, so only currently free slots are
67
+ * admitted. Active row subscribers and incremental views are different:
68
+ * they must observe every addition even when the cache is bounded, so
69
+ * those batches opt out of admission limiting.
70
+ */
71
+ wireAddRetentionLimit(modelNames: ReadonlySet<string>): number | undefined;
60
72
  constructor(config?: PoolConfig, modelRegistry?: ModelRegistry);
61
73
  private resolveModel;
62
74
  get<T extends Model = Model>(id: string): T | undefined;
@@ -111,6 +111,26 @@ export class InstanceCache {
111
111
  }
112
112
  }
113
113
  }
114
+ /**
115
+ * Maximum fresh wire rows worth constructing from one atomic apply batch.
116
+ *
117
+ * A headless cache has no recency signal: every never-read resident is
118
+ * equally useful, while the local database remains the authoritative full
119
+ * state. Once that cache is full, replacing cold rows with other cold rows
120
+ * is pure construction/eviction churn, so only currently free slots are
121
+ * admitted. Active row subscribers and incremental views are different:
122
+ * they must observe every addition even when the cache is bounded, so
123
+ * those batches opt out of admission limiting.
124
+ */
125
+ wireAddRetentionLimit(modelNames) {
126
+ for (const modelName of modelNames) {
127
+ if ((this.subscriptions.get(modelName)?.size ?? 0) > 0 ||
128
+ this.viewRegistry.hasViews(modelName)) {
129
+ return undefined;
130
+ }
131
+ }
132
+ return Math.max(0, this.config.maxSize - this.entries.size);
133
+ }
114
134
  constructor(config = {}, modelRegistry) {
115
135
  this.config = {
116
136
  maxSize: config.maxSize ?? 10000,
@@ -483,6 +483,17 @@ export declare class SyncClient extends EventEmitter {
483
483
  */
484
484
  getMutationQueue(): MutationQueue;
485
485
  applyDeltaBatchToPool(dbResults: readonly AppliedChange[], enrichRelations: (modelName: string, data: Record<string, unknown>) => Record<string, unknown>): void;
486
+ /**
487
+ * Drop fresh adds beyond a headless cache's currently free capacity. The
488
+ * rows are already durable; with no row-level consumer there is no recency
489
+ * signal that makes replacing one cold resident with another useful, so the
490
+ * projection avoids constructing thousands of doomed Model/MobX objects.
491
+ *
492
+ * This must run before apply slicing. A 300k catch-up batch is revealed in
493
+ * ~600-delta slices, each smaller than the 10k cache cap; projecting each
494
+ * slice independently would therefore miss the redundant work entirely.
495
+ */
496
+ projectDeltaBatchForPool(dbResults: readonly AppliedChange[]): readonly AppliedChange[];
486
497
  private applyDeltaBatchToPoolInAction;
487
498
  /**
488
499
  * Apply bootstrap data to the InstanceCache with ghost removal.
@@ -1525,6 +1525,7 @@ export class SyncClient extends EventEmitter {
1525
1525
  return this.mutationQueue;
1526
1526
  }
1527
1527
  applyDeltaBatchToPool(dbResults, enrichRelations) {
1528
+ const projectedResults = this.projectDeltaBatchForPool(dbResults);
1528
1529
  // The WHOLE batch — conflict resolution and model mutation included, not
1529
1530
  // just the pool bookkeeping at the end — runs in one MobX action.
1530
1531
  // `resolveConflicts` writes model fields via `updateFromData`; when those
@@ -1533,7 +1534,50 @@ export class SyncClient extends EventEmitter {
1533
1534
  // reaction pass per delta and an observer could see a partially applied
1534
1535
  // frame between them.
1535
1536
  runInAction(() => {
1536
- this.applyDeltaBatchToPoolInAction(dbResults, enrichRelations);
1537
+ this.applyDeltaBatchToPoolInAction(projectedResults, enrichRelations);
1538
+ });
1539
+ }
1540
+ /**
1541
+ * Drop fresh adds beyond a headless cache's currently free capacity. The
1542
+ * rows are already durable; with no row-level consumer there is no recency
1543
+ * signal that makes replacing one cold resident with another useful, so the
1544
+ * projection avoids constructing thousands of doomed Model/MobX objects.
1545
+ *
1546
+ * This must run before apply slicing. A 300k catch-up batch is revealed in
1547
+ * ~600-delta slices, each smaller than the 10k cache cap; projecting each
1548
+ * slice independently would therefore miss the redundant work entirely.
1549
+ */
1550
+ projectDeltaBatchForPool(dbResults) {
1551
+ const idsBeingRemoved = new Set();
1552
+ const freshAddTypes = new Set();
1553
+ let freshAddCount = 0;
1554
+ for (const result of dbResults) {
1555
+ if (result.action === 'remove')
1556
+ idsBeingRemoved.add(result.modelId);
1557
+ }
1558
+ for (const result of dbResults) {
1559
+ if (result.action === 'add' &&
1560
+ result.data &&
1561
+ !idsBeingRemoved.has(result.modelId) &&
1562
+ !this.objectPool.has(result.modelId)) {
1563
+ freshAddTypes.add(result.modelName);
1564
+ freshAddCount++;
1565
+ }
1566
+ }
1567
+ const retentionLimit = this.objectPool.wireAddRetentionLimit(freshAddTypes);
1568
+ let freshAddsToDiscard = retentionLimit === undefined ? 0 : Math.max(0, freshAddCount - retentionLimit);
1569
+ if (freshAddsToDiscard === 0)
1570
+ return dbResults;
1571
+ return dbResults.filter((result) => {
1572
+ if (freshAddsToDiscard > 0 &&
1573
+ result.action === 'add' &&
1574
+ result.data &&
1575
+ !idsBeingRemoved.has(result.modelId) &&
1576
+ !this.objectPool.has(result.modelId)) {
1577
+ freshAddsToDiscard--;
1578
+ return false;
1579
+ }
1580
+ return true;
1537
1581
  });
1538
1582
  }
1539
1583
  applyDeltaBatchToPoolInAction(dbResults, enrichRelations) {
@@ -60,6 +60,8 @@ export interface DeltaPipelineContext {
60
60
  }>;
61
61
  /** Applies persisted delta results to the in-memory pool, with the host's relation enrichment bound. */
62
62
  applyDeltaBatchToPool(results: AppliedChange[]): void;
63
+ /** Optional whole-batch projection performed before bounded apply slicing. */
64
+ projectDeltaBatchForPool?(results: readonly AppliedChange[]): readonly AppliedChange[];
63
65
  /** Acknowledges a sync id back to the server; a no-op when the socket is down. */
64
66
  acknowledge(syncId: number): void;
65
67
  readonly objectPool: {
@@ -437,7 +437,8 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
437
437
  // synchronous block. Each slice is still one MobX action (reactions fire
438
438
  // once per slice), and a transaction never splits across slices — the
439
439
  // commit remains the atomic unit of visibility.
440
- const slices = sliceApplyChanges(dbResults, ctx.smartSyncOptions.applySliceDeltas);
440
+ const poolResults = ctx.projectDeltaBatchForPool?.(dbResults) ?? dbResults;
441
+ const slices = sliceApplyChanges(poolResults, ctx.smartSyncOptions.applySliceDeltas);
441
442
  await timeDrainStageAsync('apply', async () => {
442
443
  const hasApplyPlugins = pluginsForStage(stagePlugins, 'apply').length > 0;
443
444
  // Yield on a TIME budget, not per slice: a host yield costs milliseconds
@@ -11,6 +11,7 @@ export declare class ViewRegistry {
11
11
  private views;
12
12
  register(typename: string, view: IncrementalView): void;
13
13
  unregister(typename: string, view: IncrementalView): void;
14
+ hasViews(typename: string): boolean;
14
15
  /** Called by InstanceCache after a model is added to the pool. */
15
16
  notifyAdded(typename: string, model: Model): void;
16
17
  /** Called by InstanceCache after a model is updated in the pool. */
@@ -25,6 +25,9 @@ export class ViewRegistry {
25
25
  this.views.delete(typename);
26
26
  }
27
27
  }
28
+ hasViews(typename) {
29
+ return (this.views.get(typename)?.size ?? 0) > 0;
30
+ }
28
31
  /** Called by InstanceCache after a model is added to the pool. */
29
32
  notifyAdded(typename, model) {
30
33
  const set = this.views.get(typename);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.42.0",
3
+ "version": "0.43.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",
@@ -84,7 +84,7 @@
84
84
  "directory": "packages/humans"
85
85
  },
86
86
  "dependencies": {
87
- "@abloatai/transaction": "^0.42.0",
87
+ "@abloatai/transaction": "^0.43.0",
88
88
  "mobx": "^6.13.7",
89
89
  "uuid": "^11.1.0",
90
90
  "zod": "^4.4.3"
@@ -1568,6 +1568,8 @@ export class BaseSyncedStore<
1568
1568
  advancePersisted: (syncId) => { this.syncClient.position.advancePersisted(syncId); },
1569
1569
  // Persistence + pool writes.
1570
1570
  processDeltaBatch: (deltas) => this.database.processDeltaBatch(deltas),
1571
+ projectDeltaBatchForPool: (results) =>
1572
+ this.syncClient.projectDeltaBatchForPool(results),
1571
1573
  applyDeltaBatchToPool: (results) => { this.applyChangesToPool(results); },
1572
1574
  acknowledge: (syncId) => { this.syncWebSocket.acknowledge(syncId); },
1573
1575
  get objectPool() { return store.objectPool; },
@@ -162,6 +162,29 @@ export class InstanceCache {
162
162
  }
163
163
  }
164
164
 
165
+ /**
166
+ * Maximum fresh wire rows worth constructing from one atomic apply batch.
167
+ *
168
+ * A headless cache has no recency signal: every never-read resident is
169
+ * equally useful, while the local database remains the authoritative full
170
+ * state. Once that cache is full, replacing cold rows with other cold rows
171
+ * is pure construction/eviction churn, so only currently free slots are
172
+ * admitted. Active row subscribers and incremental views are different:
173
+ * they must observe every addition even when the cache is bounded, so
174
+ * those batches opt out of admission limiting.
175
+ */
176
+ wireAddRetentionLimit(modelNames: ReadonlySet<string>): number | undefined {
177
+ for (const modelName of modelNames) {
178
+ if (
179
+ (this.subscriptions.get(modelName)?.size ?? 0) > 0 ||
180
+ this.viewRegistry.hasViews(modelName)
181
+ ) {
182
+ return undefined;
183
+ }
184
+ }
185
+ return Math.max(0, this.config.maxSize - this.entries.size);
186
+ }
187
+
165
188
  constructor(config: PoolConfig = {}, modelRegistry?: ModelRegistry) {
166
189
  this.config = {
167
190
  maxSize: config.maxSize ?? 10000,
@@ -1889,6 +1889,7 @@ export class SyncClient extends EventEmitter {
1889
1889
  dbResults: readonly AppliedChange[],
1890
1890
  enrichRelations: (modelName: string, data: Record<string, unknown>) => Record<string, unknown>,
1891
1891
  ): void {
1892
+ const projectedResults = this.projectDeltaBatchForPool(dbResults);
1892
1893
  // The WHOLE batch — conflict resolution and model mutation included, not
1893
1894
  // just the pool bookkeeping at the end — runs in one MobX action.
1894
1895
  // `resolveConflicts` writes model fields via `updateFromData`; when those
@@ -1897,7 +1898,57 @@ export class SyncClient extends EventEmitter {
1897
1898
  // reaction pass per delta and an observer could see a partially applied
1898
1899
  // frame between them.
1899
1900
  runInAction(() => {
1900
- this.applyDeltaBatchToPoolInAction(dbResults, enrichRelations);
1901
+ this.applyDeltaBatchToPoolInAction(projectedResults, enrichRelations);
1902
+ });
1903
+ }
1904
+
1905
+ /**
1906
+ * Drop fresh adds beyond a headless cache's currently free capacity. The
1907
+ * rows are already durable; with no row-level consumer there is no recency
1908
+ * signal that makes replacing one cold resident with another useful, so the
1909
+ * projection avoids constructing thousands of doomed Model/MobX objects.
1910
+ *
1911
+ * This must run before apply slicing. A 300k catch-up batch is revealed in
1912
+ * ~600-delta slices, each smaller than the 10k cache cap; projecting each
1913
+ * slice independently would therefore miss the redundant work entirely.
1914
+ */
1915
+ projectDeltaBatchForPool(
1916
+ dbResults: readonly AppliedChange[],
1917
+ ): readonly AppliedChange[] {
1918
+ const idsBeingRemoved = new Set<string>();
1919
+ const freshAddTypes = new Set<string>();
1920
+ let freshAddCount = 0;
1921
+ for (const result of dbResults) {
1922
+ if (result.action === 'remove') idsBeingRemoved.add(result.modelId);
1923
+ }
1924
+ for (const result of dbResults) {
1925
+ if (
1926
+ result.action === 'add' &&
1927
+ result.data &&
1928
+ !idsBeingRemoved.has(result.modelId) &&
1929
+ !this.objectPool.has(result.modelId)
1930
+ ) {
1931
+ freshAddTypes.add(result.modelName);
1932
+ freshAddCount++;
1933
+ }
1934
+ }
1935
+ const retentionLimit = this.objectPool.wireAddRetentionLimit(freshAddTypes);
1936
+ let freshAddsToDiscard =
1937
+ retentionLimit === undefined ? 0 : Math.max(0, freshAddCount - retentionLimit);
1938
+ if (freshAddsToDiscard === 0) return dbResults;
1939
+
1940
+ return dbResults.filter((result) => {
1941
+ if (
1942
+ freshAddsToDiscard > 0 &&
1943
+ result.action === 'add' &&
1944
+ result.data &&
1945
+ !idsBeingRemoved.has(result.modelId) &&
1946
+ !this.objectPool.has(result.modelId)
1947
+ ) {
1948
+ freshAddsToDiscard--;
1949
+ return false;
1950
+ }
1951
+ return true;
1901
1952
  });
1902
1953
  }
1903
1954
 
@@ -85,6 +85,8 @@ export interface DeltaPipelineContext {
85
85
  ): Promise<{ results: AppliedChange[]; persistedSyncId: number }>;
86
86
  /** Applies persisted delta results to the in-memory pool, with the host's relation enrichment bound. */
87
87
  applyDeltaBatchToPool(results: AppliedChange[]): void;
88
+ /** Optional whole-batch projection performed before bounded apply slicing. */
89
+ projectDeltaBatchForPool?(results: readonly AppliedChange[]): readonly AppliedChange[];
88
90
  /** Acknowledges a sync id back to the server; a no-op when the socket is down. */
89
91
  acknowledge(syncId: number): void;
90
92
 
@@ -572,7 +574,8 @@ async function flushDeltaBatchInner(
572
574
  // synchronous block. Each slice is still one MobX action (reactions fire
573
575
  // once per slice), and a transaction never splits across slices — the
574
576
  // commit remains the atomic unit of visibility.
575
- const slices = sliceApplyChanges(dbResults, ctx.smartSyncOptions.applySliceDeltas);
577
+ const poolResults = ctx.projectDeltaBatchForPool?.(dbResults) ?? dbResults;
578
+ const slices = sliceApplyChanges(poolResults, ctx.smartSyncOptions.applySliceDeltas);
576
579
  await timeDrainStageAsync('apply', async () => {
577
580
  const hasApplyPlugins = pluginsForStage(stagePlugins, 'apply').length > 0;
578
581
  // Yield on a TIME budget, not per slice: a host yield costs milliseconds
@@ -30,6 +30,10 @@ export class ViewRegistry {
30
30
  }
31
31
  }
32
32
 
33
+ hasViews(typename: string): boolean {
34
+ return (this.views.get(typename)?.size ?? 0) > 0;
35
+ }
36
+
33
37
  /** Called by InstanceCache after a model is added to the pool. */
34
38
  notifyAdded(typename: string, model: Model): void {
35
39
  const set = this.views.get(typename);