@abloatai/humans 0.51.0 → 0.53.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 (50) hide show
  1. package/dist/client.d.ts +4 -4
  2. package/dist/local/Database.d.ts +1 -34
  3. package/dist/local/Database.js +16 -55
  4. package/dist/local/InstanceCache.d.ts +9 -0
  5. package/dist/local/InstanceCache.js +9 -0
  6. package/dist/local/Model.js +0 -18
  7. package/dist/local/SyncClient.d.ts +23 -11
  8. package/dist/local/SyncClient.js +44 -53
  9. package/dist/local/client/createInternalComponents.js +4 -0
  10. package/dist/local/client/createModelProxy.js +21 -3
  11. package/dist/local/interfaces/index.d.ts +3 -3
  12. package/dist/local/rowWatermarks.d.ts +40 -0
  13. package/dist/local/rowWatermarks.js +53 -0
  14. package/dist/local/sync/OnDemandLoader.d.ts +22 -13
  15. package/dist/local/sync/OnDemandLoader.js +58 -73
  16. package/dist/local/sync/bootstrapApply.d.ts +2 -4
  17. package/dist/local/sync/deltaPipeline.js +1 -1
  18. package/dist/local/sync/initialize.js +2 -2
  19. package/dist/local/transactions/mutations/MutationQueue.d.ts +1 -1
  20. package/dist/local/transactions/mutations/MutationQueue.js +1 -1
  21. package/dist/local/transactions/persistedTransaction.d.ts +39 -0
  22. package/dist/local/transactions/persistedTransaction.js +53 -0
  23. package/dist/local/utils/mobxSetup.js +1 -1
  24. package/dist/plugin.d.ts +7 -0
  25. package/dist/react/AbloProvider.d.ts +2 -2
  26. package/dist/react/AbloProvider.js +2 -2
  27. package/dist/react/context.d.ts +2 -2
  28. package/dist/react/useAblo.d.ts +3 -3
  29. package/package.json +2 -2
  30. package/src/client.ts +4 -4
  31. package/src/local/BaseSyncedStore.ts +1 -1
  32. package/src/local/Database.ts +22 -110
  33. package/src/local/InstanceCache.ts +10 -0
  34. package/src/local/Model.ts +0 -20
  35. package/src/local/SyncClient.ts +81 -66
  36. package/src/local/client/createInternalComponents.ts +4 -0
  37. package/src/local/client/createModelProxy.ts +26 -5
  38. package/src/local/interfaces/index.ts +3 -3
  39. package/src/local/rowWatermarks.ts +54 -0
  40. package/src/local/sync/OnDemandLoader.ts +99 -70
  41. package/src/local/sync/bootstrapApply.ts +2 -1
  42. package/src/local/sync/deltaPipeline.ts +1 -1
  43. package/src/local/sync/initialize.ts +2 -2
  44. package/src/local/transactions/mutations/MutationQueue.ts +1 -1
  45. package/src/local/transactions/persistedTransaction.ts +112 -0
  46. package/src/local/utils/mobxSetup.ts +1 -1
  47. package/src/plugin.ts +7 -0
  48. package/src/react/AbloProvider.tsx +2 -2
  49. package/src/react/context.ts +2 -2
  50. package/src/react/useAblo.ts +3 -3
package/src/client.ts CHANGED
@@ -131,10 +131,10 @@ export type AbloClient<S extends SchemaRecord> = {
131
131
  * server verifies it. The browser must never see the `sk_` key, only the
132
132
  * per-user session token.
133
133
  *
134
- * Pass `{ user: { id }, can: { tasks: ['read', 'update'] } }` for an end-user
134
+ * Pass `{ user: { id }, can: { items: ['read', 'update'] } }` for an end-user
135
135
  * session. It mints an `ek_` and attributes writes to a user (recorded as
136
136
  * `actor_kind` on the delta row). Pass `{ agent: { id }, can: {
137
- * tasks: ['update'] } }` for a scoped agent session, which mints an `rk_`.
137
+ * items: ['update'] } }` for a scoped agent session, which mints an `rk_`.
138
138
  * Both kinds require `can`, typed against your schema's model names. This
139
139
  * always authenticates with the original `sk_`, never the client's exchanged
140
140
  * sync credential.
@@ -150,10 +150,10 @@ export type AbloClient<S extends SchemaRecord> = {
150
150
  * ```ts
151
151
  * const agent = await ablo.agents.create({
152
152
  * name: 'researcher', // readable label (optional)
153
- * can: { documents: ['read', 'update'] },
153
+ * can: { records: ['read', 'update'] },
154
154
  * // id omitted → a fresh uuid: a distinct, independent participant
155
155
  * });
156
- * await agent.documents.update({ id, data, claim });
156
+ * await agent.records.update({ id, data, claim });
157
157
  * await agent.dispose(); // when the agent is done
158
158
  * ```
159
159
  *
@@ -84,7 +84,7 @@ import type { CommitLatencySample } from './transactions/mutations/commitLatency
84
84
  export type ModelConstructor<T extends Model> = abstract new (...args: never[]) => T;
85
85
 
86
86
  /** Concrete constructor type for instantiation */
87
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Constructor args vary per model (PrismaTask, Record<string, unknown>, etc.)
87
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Constructor args vary per model (PrismaItem, Record<string, unknown>, etc.)
88
88
  export type ConcreteModelConstructor<T extends Model> = new (data?: any) => T;
89
89
 
90
90
  // ModelData is defined in a separate module to break the type cycle between
@@ -23,123 +23,32 @@ import type { BootstrapFetcher, BootstrapData } from './sync/BootstrapFetcher.js
23
23
  import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
24
24
  import { logPositionSchema } from './logPosition.js';
25
25
  import type { SyncDeltaAction } from '@abloatai/transaction/wire/delta';
26
- import type { OnStaleMode } from '@abloatai/transaction/coordination/schema';
27
26
  import type { BootstrapType } from '@abloatai/transaction/types';
28
27
  import { highestPersistedPrefixSyncId } from './sync/persistedPrefix.js';
28
+ import {
29
+ isAcceptedOutboxPromotion,
30
+ isSameOutboxRecord,
31
+ type PersistedTransaction,
32
+ } from './transactions/persistedTransaction.js';
29
33
 
30
34
  /** Generic record type for model data */
31
35
  type ModelData = Record<string, unknown>;
32
36
 
33
- /** Persisted mutation in a transaction */
34
- interface PersistedMutation {
35
- type: 'create' | 'update' | 'delete' | 'archive';
36
- modelData: ModelData;
37
- modelName: string;
38
- timestamp: string;
39
- writeOptions?: {
40
- readAt?: number | null;
41
- onStale?: OnStaleMode | null;
42
- };
43
- }
44
-
45
- /** Persisted transaction for offline/retry support.
46
- *
47
- * Index signature is part of the contract: this interface targets
48
- * the generic record-shaped storage layer (`InMemoryObjectStore.put`
49
- * + the IDB ObjectStore equivalent), both of which take
50
- * `Record<string, unknown>`. Every declared field below already
51
- * satisfies `unknown`; the index signature just makes the
52
- * interface assignable to the storage parameter without a cast. */
53
- interface PersistedTransaction {
54
- id: string;
55
- type?: string;
56
- timestamp?: number;
57
- createdAt?: number;
58
- mutations?: PersistedMutation[];
59
- // Persist awaiting-delta transactions so they survive a tab close. On the
60
- // next session, WebSocket reconnect plus delta catch-up confirms them.
61
- awaitingDelta?: {
62
- syncIdNeeded: number;
63
- modelName: string;
64
- modelId: string;
65
- operationType: string;
66
- };
67
- [key: string]: unknown;
68
- }
69
-
70
37
  /**
71
- * Request identity excludes local timing metadata for re-entrant seals: a
72
- * retry rebuilds its envelope with a fresh `sequence`/seal clock, so comparing
73
- * those volatile fields would reject every legitimate same-request re-seal as
74
- * an idempotency conflict. Only the fields that define the wire request count.
38
+ * Carry each input delta's log position onto the change that answers it.
39
+ * `processDeltaBatch` builds its results index-aligned with its input, so the
40
+ * position is stamped once here rather than at every construction site.
75
41
  */
76
- function isSameOutboxRecord(
77
- existing: PersistedTransaction,
78
- candidate: PersistedTransaction,
79
- ): boolean {
80
- if (
81
- existing.type === 'http_commit_envelope' &&
82
- candidate.type === 'http_commit_envelope'
83
- ) {
84
- const identity = (record: PersistedTransaction): unknown => ({
85
- id: record.id,
86
- type: record.type,
87
- storageVersion: record.storageVersion,
88
- idempotencyKey: record.idempotencyKey,
89
- // HTTP outbox rows written before protocol versioning are v1. Normalize
90
- // them so a same-request re-seal remains idempotent after an upgrade.
91
- protocolVersion: record.protocolVersion ?? 1,
92
- request: record.request,
93
- scopeNamespace: record.scopeNamespace,
94
- });
95
- if (
96
- existing.correlationId !== undefined &&
97
- candidate.correlationId !== undefined &&
98
- existing.correlationId !== candidate.correlationId
99
- ) {
100
- return false;
101
- }
102
- return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
103
- }
104
- if (
105
- existing.type === 'commit_envelope' &&
106
- candidate.type === 'commit_envelope'
107
- ) {
108
- const identity = (record: PersistedTransaction): unknown => ({
109
- id: record.id,
110
- type: record.type,
111
- storageVersion: record.storageVersion,
112
- origin: record.origin,
113
- idempotencyKey: record.idempotencyKey,
114
- operations: record.operations,
115
- sourceMutationIds: record.sourceMutationIds,
116
- commitOptions: record.commitOptions,
117
- scope: record.scope,
118
- });
119
- if (
120
- existing.correlationId !== undefined &&
121
- candidate.correlationId !== undefined &&
122
- existing.correlationId !== candidate.correlationId
123
- ) {
124
- return false;
125
- }
126
- return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
42
+ function stampSyncIds(
43
+ results: AppliedChange[],
44
+ deltas: readonly { syncId?: number }[],
45
+ ): AppliedChange[] {
46
+ for (let index = 0; index < results.length; index++) {
47
+ const change = results[index];
48
+ const syncId = deltas[index]?.syncId;
49
+ if (change && typeof syncId === 'number') change.syncId = syncId;
127
50
  }
128
- return JSON.stringify(existing) === JSON.stringify(candidate);
129
- }
130
-
131
- function isAcceptedOutboxPromotion(
132
- existing: PersistedTransaction | undefined,
133
- candidate: PersistedTransaction,
134
- ): boolean {
135
- return (
136
- existing !== undefined &&
137
- (existing.type === 'commit_envelope' ||
138
- existing.type === 'http_commit_envelope') &&
139
- existing.type === candidate.type &&
140
- existing.acceptedAt === undefined &&
141
- candidate.acceptedAt !== undefined
142
- );
51
+ return results;
143
52
  }
144
53
 
145
54
  // Re-exported, not redeclared. `@abloatai/transaction`'s `types` module owns this
@@ -1227,7 +1136,10 @@ export class Database {
1227
1136
  updatedAt: new Date(),
1228
1137
  };
1229
1138
  }
1230
- return { results: inMemResults, persistedSyncId: inMemPersistedSyncId };
1139
+ return {
1140
+ results: stampSyncIds(inMemResults, deltas),
1141
+ persistedSyncId: inMemPersistedSyncId,
1142
+ };
1231
1143
  }
1232
1144
 
1233
1145
  // Prepare results aligned with input order
@@ -1648,7 +1560,7 @@ export class Database {
1648
1560
  });
1649
1561
  }
1650
1562
 
1651
- return { results, persistedSyncId: highestPersistedSyncId };
1563
+ return { results: stampSyncIds(results, deltas), persistedSyncId: highestPersistedSyncId };
1652
1564
  }
1653
1565
 
1654
1566
  /** Get raw data for hydration */
@@ -16,6 +16,7 @@ import { AbloValidationError } from '@abloatai/transaction/errors';
16
16
  import { ModelScope, PropertyType } from '@abloatai/transaction/types';
17
17
  import { ViewRegistry } from './views/ViewRegistry.js';
18
18
  import { QueryView, type QueryViewOptions } from './views/QueryView.js';
19
+ import { RowWatermarks } from './rowWatermarks.js';
19
20
 
20
21
  /** Constructor type for Model subclasses - uses abstract to handle variance */
21
22
  type ModelConstructor<T extends Model> = abstract new (...args: never[]) => T;
@@ -124,6 +125,15 @@ export class InstanceCache {
124
125
  // ViewRegistry — tracks active QueryViews for incremental view maintenance
125
126
  readonly viewRegistry: ViewRegistry = new ViewRegistry();
126
127
 
128
+ /**
129
+ * The log position each pooled row is known to reflect. Every door a row
130
+ * enters through (delta, own ack, bootstrap, server read) advances it, and
131
+ * every snapshot that would overwrite a resident row is judged against it —
132
+ * see {@link RowWatermarks}. Keyed by instance so it lives and dies with the
133
+ * pooled model.
134
+ */
135
+ readonly watermarks = new RowWatermarks();
136
+
127
137
  // Subscription registry
128
138
  private subscriptions = new Map<string, Set<(model: Model) => void>>();
129
139
 
@@ -1108,26 +1108,6 @@ export abstract class Model {
1108
1108
  // Try to get model class by identifier
1109
1109
  let ModelClass = getActiveRegistry().getModelByName(modelIdentifier);
1110
1110
 
1111
- // If not found by registered name, try mapping to the class name
1112
- if (!ModelClass) {
1113
- const classNameMap: Record<string, string> = {
1114
- Task: 'TaskModel',
1115
- Project: 'Project',
1116
- Comment: 'CommentModel',
1117
- User: 'UserModel',
1118
- Organization: 'OrganizationModel',
1119
- StatusGroup: 'StatusGroupModel',
1120
- Team: 'TeamModel',
1121
- Member: 'MemberModel',
1122
- Role: 'RoleModel',
1123
- };
1124
-
1125
- const className = classNameMap[modelIdentifier];
1126
- if (className) {
1127
- ModelClass = getActiveRegistry().getModelByName(className);
1128
- }
1129
- }
1130
-
1131
1111
  if (!ModelClass) {
1132
1112
  throw new AbloValidationError(
1133
1113
  `Model class not found for: ${modelIdentifier}`,
@@ -34,7 +34,9 @@ import {
34
34
  type UnconfirmedWritesMetrics,
35
35
  } from './transactions/mutations/UnconfirmedWrites.js';
36
36
  import type { DurableWriteStore } from './transactions/mutations/durableWriteStore.js';
37
+ import type { CommitTransaction } from './transactions/mutations/commitLane.js';
37
38
  import type { Database } from './Database.js';
39
+ import type { BootstrapData } from './sync/BootstrapFetcher.js';
38
40
  import type { MutationPersistencePort } from './mutationPersistence.js';
39
41
  import type { WriteOptions } from './interfaces/index.js';
40
42
  import { LogPosition } from './logPosition.js';
@@ -73,30 +75,29 @@ export interface RehydrationStats {
73
75
  type EventHandler = () => void;
74
76
 
75
77
  /**
76
- * Reports whether an incoming snapshot record is strictly newer than the
77
- * model already in the pool. The comparison uses the server-stamped
78
- * `updatedAt` timestamp, since rows carry no numeric version and the delta
79
- * pipeline resolves order by arrival (last write wins). An undefined incoming
80
- * timestamp counts as not newer, so a known row is never clobbered; an
81
- * undefined existing timestamp means the pooled row is unversioned, so the
82
- * incoming record wins. The scoped hydrate-on-enter path uses this to drop
83
- * snapshot rows that a live delta has already advanced past.
78
+ * The slice of a bootstrap answer the pool applies: its rows, the models whose
79
+ * server query failed, and the log position the snapshot was taken at — the
80
+ * position every row in it reflects. `lastSyncId` is optional only for callers
81
+ * applying rows with no snapshot position to speak of; the fetcher always
82
+ * names one.
84
83
  */
85
- function rawRecordIsNewer(data: Record<string, unknown>, existing: Model): boolean {
86
- const raw = data.updatedAt;
87
- const inMs =
88
- raw instanceof Date
89
- ? raw.getTime()
90
- : typeof raw === 'string'
91
- ? (Number.isNaN(Date.parse(raw)) ? undefined : Date.parse(raw))
92
- : typeof raw === 'number'
93
- ? raw
94
- : undefined;
95
- const exMs = existing.updatedAt instanceof Date ? existing.updatedAt.getTime() : undefined;
96
- if (inMs === undefined) return false;
97
- if (exMs === undefined) return true;
98
- return inMs > exMs;
99
- }
84
+ export type BootstrapSnapshot = Pick<BootstrapData, 'models' | 'failedModels'> &
85
+ Partial<Pick<BootstrapData, 'lastSyncId'>>;
86
+
87
+ /**
88
+ * What `transaction:completed` carries: a model mutation (one row, confirmed
89
+ * at `syncIdNeededForCompletion`) or an explicit commit (one row per operation,
90
+ * confirmed at `lastSyncId`). Each arm projects its own queue record.
91
+ */
92
+ type CompletedTransaction =
93
+ | (Pick<QueuedMutation, 'id' | 'modelId' | 'syncIdNeededForCompletion'> & {
94
+ lastSyncId?: undefined;
95
+ operations?: undefined;
96
+ })
97
+ | (Pick<CommitTransaction, 'id' | 'lastSyncId' | 'operations'> & {
98
+ modelId?: undefined;
99
+ syncIdNeededForCompletion?: undefined;
100
+ });
100
101
 
101
102
  /**
102
103
  * Converts an untyped server `updatedAt` value — an ISO string, epoch number,
@@ -464,12 +465,18 @@ export class SyncClient extends EventEmitter {
464
465
  }
465
466
  );
466
467
 
467
- // Clean up persisted awaiting transactions when they're finally confirmed
468
+ // Clean up persisted awaiting transactions when they're finally confirmed,
469
+ // and record the confirmed position on every row the transaction wrote.
470
+ // The acknowledgement is the earliest proof of where this client's own
471
+ // write landed in the log — earlier than its delta echo, which the pool
472
+ // suppresses on apply — so a snapshot read before the write cannot regress
473
+ // the row in the window between the two.
468
474
  this.mutationQueue.on(
469
475
  'transaction:completed',
470
- (tx: { id: string; modelName: string; modelId: string }) => {
476
+ (tx: CompletedTransaction) => {
471
477
  // void is safe: the handler's body is fully try/catch'd.
472
478
  void this.removeAwaitingTransaction(tx.id);
479
+ this.noteOwnWritePositions(tx);
473
480
  }
474
481
  );
475
482
 
@@ -495,6 +502,23 @@ export class SyncClient extends EventEmitter {
495
502
  );
496
503
  }
497
504
 
505
+ /**
506
+ * Advance the pooled rows a completed transaction wrote to the log position
507
+ * its acknowledgement named. A model mutation names one row; an explicit
508
+ * commit names one per operation. Rows no longer pooled have nothing to
509
+ * advance — a fresh instance starts without evidence.
510
+ */
511
+ private noteOwnWritePositions(tx: CompletedTransaction): void {
512
+ const position = tx.lastSyncId ?? tx.syncIdNeededForCompletion;
513
+ if (position === undefined) return;
514
+ const rowIds =
515
+ tx.operations !== undefined ? tx.operations.map((op) => op.id) : [tx.modelId];
516
+ for (const rowId of rowIds) {
517
+ const row = this.objectPool.peek(rowId);
518
+ if (row) this.objectPool.watermarks.advance(row, position);
519
+ }
520
+ }
521
+
498
522
  /** Persist an unconfirmed transaction to IndexedDB (never rejects — failures are captured). */
499
523
  private async persistAwaitingTransaction(event: {
500
524
  txId: string;
@@ -1302,8 +1326,7 @@ export class SyncClient extends EventEmitter {
1302
1326
  const shouldForceAcceptServer =
1303
1327
  (serverData.deletedAt !== undefined && serverData.deletedAt !== null) ||
1304
1328
  (serverData.archivedAt !== undefined && serverData.archivedAt !== null) ||
1305
- serverData.isActive === false ||
1306
- (serverData.unassignedAt !== undefined && serverData.unassignedAt !== null);
1329
+ serverData.isActive === false;
1307
1330
 
1308
1331
  if (shouldForceAcceptServer) {
1309
1332
  this.runtime.logger.debug('Accepting server update - critical state change detected', {
@@ -1378,14 +1401,10 @@ export class SyncClient extends EventEmitter {
1378
1401
  critical.archivedAt = serverData.archivedAt;
1379
1402
  }
1380
1403
 
1381
- // Deactivation states - critical for assignments and similar entities
1404
+ // Deactivation states are always critical.
1382
1405
  if (serverData.isActive !== undefined && serverData.isActive === false) {
1383
1406
  critical.isActive = false;
1384
1407
  }
1385
- if (serverData.unassignedAt !== undefined) {
1386
- critical.unassignedAt = serverData.unassignedAt;
1387
- }
1388
-
1389
1408
  return critical;
1390
1409
  }
1391
1410
 
@@ -1824,27 +1843,6 @@ export class SyncClient extends EventEmitter {
1824
1843
  };
1825
1844
  }
1826
1845
 
1827
- // --- Best-practice assignment ops ---
1828
- async unassignEntity(entityType: string, entityId: string): Promise<void> {
1829
- // Call server-side unassign to avoid per-id races
1830
- await this.mutationExecutor.executeDelete('Assignment', entityId);
1831
- }
1832
-
1833
- async reassignEntity(
1834
- entityType: string,
1835
- entityId: string,
1836
- assigneeType: string,
1837
- assigneeId: string,
1838
- id?: string
1839
- ): Promise<void> {
1840
- await this.mutationExecutor.executeCreate('Assignment', id || '', {
1841
- entityType,
1842
- entityId,
1843
- assigneeType,
1844
- assigneeId,
1845
- });
1846
- }
1847
-
1848
1846
  // ── Delta + Bootstrap application (owns InstanceCache writes) ──────────────
1849
1847
 
1850
1848
  /**
@@ -1978,7 +1976,13 @@ export class SyncClient extends EventEmitter {
1978
1976
  }
1979
1977
 
1980
1978
  for (const result of dbResults) {
1981
- const { modelName, modelId, action, transactionId } = result;
1979
+ const { modelName, modelId, action, transactionId, syncId } = result;
1980
+
1981
+ // Every delta names the log position the row now reflects — recorded
1982
+ // before echo detection, because an own echo is exactly a position the
1983
+ // pooled row has reached even though its fields are not re-applied.
1984
+ const resident = this.objectPool.peek(modelId);
1985
+ if (resident) this.objectPool.watermarks.advance(resident, syncId);
1982
1986
 
1983
1987
  // Echo detection: if this delta carries a transaction id that matches
1984
1988
  // one already applied optimistically, the pool already reflects the
@@ -2016,7 +2020,10 @@ export class SyncClient extends EventEmitter {
2016
2020
  const model = this.objectPool.createFromData(data, undefined, {
2017
2021
  deferObservability: true,
2018
2022
  });
2019
- if (model) modelsToAdd.push(model);
2023
+ if (model) {
2024
+ this.objectPool.watermarks.advance(model, syncId);
2025
+ modelsToAdd.push(model);
2026
+ }
2020
2027
  }
2021
2028
  break;
2022
2029
  }
@@ -2084,17 +2091,18 @@ export class SyncClient extends EventEmitter {
2084
2091
  * Owns: model creation, batch upsert, ghost detection + removal.
2085
2092
  */
2086
2093
  applyBootstrapDataToPool(
2087
- bootstrapData: { models?: Record<string, unknown[]>; failedModels?: string[] },
2094
+ bootstrapData: BootstrapSnapshot,
2088
2095
  protectedIds?: ReadonlySet<string>,
2089
2096
  options?: {
2090
2097
  /**
2091
2098
  * Scoped backfill for the hydrate-on-enter path: the snapshot covers only
2092
2099
  * the groups just entered, not the whole model type. Two behaviors change
2093
- * so the subset cannot corrupt the pool. First, the upsert is
2094
- * version-guarded ({@link InstanceCache.upsertIfNewer}) so a concurrent live
2095
- * delta is not clobbered back to the snapshot version. Second, ghost
2096
- * removal is skipped, because a subset snapshot must never evict rows of
2097
- * the same type that belong to other, unhydrated groups.
2100
+ * so the subset cannot corrupt the pool. First, a row the pool already
2101
+ * knows to reflect a position beyond the snapshot's `lastSyncId` is
2102
+ * skipped, so a concurrent live delta is not clobbered back to the
2103
+ * snapshot version. Second, ghost removal is skipped, because a subset
2104
+ * snapshot must never evict rows of the same type that belong to other,
2105
+ * unhydrated groups.
2098
2106
  */
2099
2107
  scoped?: boolean;
2100
2108
  },
@@ -2102,6 +2110,7 @@ export class SyncClient extends EventEmitter {
2102
2110
  if (!bootstrapData.models) {
2103
2111
  return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0 };
2104
2112
  }
2113
+ const snapshotPosition = bootstrapData.lastSyncId;
2105
2114
 
2106
2115
  const allModels: Model[] = [];
2107
2116
  const serverIdsByType = new Map<string, Set<string>>();
@@ -2136,16 +2145,22 @@ export class SyncClient extends EventEmitter {
2136
2145
  // taken at a server watermark. If a concurrent live delta already
2137
2146
  // advanced this row past the snapshot, skip it. `createFromData`
2138
2147
  // mutates the pooled model in place to keep instances alive, so this
2139
- // version guard has to run before it; a guard at the upsert layer would
2140
- // be too late, because the row would already be clobbered.
2148
+ // guard has to run before it; a guard at the upsert layer would be too
2149
+ // late, because the row would already be clobbered.
2141
2150
  if (options?.scoped && recordId) {
2142
- const existing = this.objectPool.get(recordId);
2143
- if (existing && !rawRecordIsNewer(data, existing)) { skippedCount++; continue; }
2151
+ const existing = this.objectPool.peek(recordId);
2152
+ if (existing && this.objectPool.watermarks.isAheadOf(existing, snapshotPosition)) {
2153
+ skippedCount++;
2154
+ continue;
2155
+ }
2144
2156
  }
2145
2157
 
2146
2158
  try {
2147
2159
  const model = this.objectPool.createFromData(data);
2148
- if (model) allModels.push(model);
2160
+ if (model) {
2161
+ this.objectPool.watermarks.advance(model, snapshotPosition);
2162
+ allModels.push(model);
2163
+ }
2149
2164
  } catch {
2150
2165
  skippedCount++;
2151
2166
  }
@@ -121,6 +121,10 @@ export function createInternalComponents<S extends SchemaRecord>(
121
121
  baseUrl: bootstrapBaseUrl,
122
122
  getAuthToken: auth?.getAuthToken,
123
123
  runtime,
124
+ // The one canonical log position; the loader reads its floor when a query
125
+ // leaves so a late answer cannot overwrite a row the pool already knows to
126
+ // be further along.
127
+ position: syncClient.position,
124
128
  });
125
129
 
126
130
  // Drop the lazy-lane hydration ledger on reconnect. While connected, the
@@ -38,6 +38,7 @@ import {
38
38
  startClaimHeartbeatLoop,
39
39
  } from '@abloatai/transaction/coordination/claimHeartbeatLoop';
40
40
  import { assertWriteOptions } from '@abloatai/transaction/resources/writeOptionsSchema';
41
+ import { modelList, type ModelList } from '@abloatai/transaction/resources/httpResources';
41
42
  import { subTarget } from '@abloatai/transaction/coordination';
42
43
  // A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
43
44
  // .dependency-cruiser.cjs): the reactive proxy's self-claim targets are
@@ -159,7 +160,7 @@ type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
159
160
  // Model-agnostic by construction: every member below names a target by
160
161
  // `{ model, id }` and answers in claim/snapshot terms, so the row type never
161
162
  // appears. It carried a `<T>` that nothing in the body read, which made
162
- // `ModelCollaboration<Task>` and `ModelCollaboration<Invoice>` the same type
163
+ // `ModelCollaboration<Item>` and `ModelCollaboration<Invoice>` the same type
163
164
  // while reading as though they differed.
164
165
  export interface ModelCollaboration {
165
166
  /** Exact point evidence from the HTTP read boundary (stamp captured before data). */
@@ -458,7 +459,7 @@ export function createModelProxy<T, C>(
458
459
  }
459
460
 
460
461
  // The coordination plane must speak the same wire dialect as the commit
461
- // plane: the lowercased typename (`task`), not the schema key (`tasks`). The
462
+ // plane: the lowercased typename (`item`), not the schema key (`items`). The
462
463
  // server's commit-time claim guard probes the lease store with the commit
463
464
  // operation's model name, so a lease recorded under the schema key never
464
465
  // matches — which would silently disarm the guard for every model whose
@@ -504,6 +505,18 @@ export function createModelProxy<T, C>(
504
505
  };
505
506
 
506
507
  const load = async (options?: ServerReadOptions<T>): Promise<T[]> => {
508
+ if (options?.cursor !== undefined) {
509
+ // The live client hydrates a working set into the local graph rather than
510
+ // handing back pages, so there is no cursor for this read to resume from.
511
+ // Accepting the option and ignoring it would return page one every time
512
+ // while the caller believed it was advancing.
513
+ throw new AbloValidationError(
514
+ '`cursor` resumes a page of the stateless read. This client keeps a ' +
515
+ 'local graph and loads a working set instead of pages: narrow the ' +
516
+ '`where`, or construct the client with `transport: \'http\'` to page.',
517
+ { code: 'invalid_options', param: 'cursor' },
518
+ );
519
+ }
507
520
  const rows = await hydration.fetch<T>(schemaKey, options);
508
521
  return rows.map((row) => modelAsRow<T>(row));
509
522
  };
@@ -1146,10 +1159,18 @@ export function createModelProxy<T, C>(
1146
1159
 
1147
1160
  const list = guard(async (
1148
1161
  options?: ServerReadOptions<T>,
1149
- ): Promise<CapturedRow<T>[]> => {
1162
+ ): Promise<ModelList<CapturedRow<T>>> => {
1150
1163
  const registry = readSetContext?.getStore();
1151
1164
  const rows = await load(options);
1152
- if (!registry) return rows as CapturedRow<T>[];
1165
+ // This transport loads a working set rather than pages, so there is no
1166
+ // cursor to hand back. `limit` can still cut the set short, and a full
1167
+ // count is exactly the case where the caller cannot tell: report it rather
1168
+ // than claim completeness this read cannot vouch for.
1169
+ const page = modelList<CapturedRow<T>>(rows as CapturedRow<T>[], {
1170
+ hasMore: options?.limit !== undefined && rows.length >= options.limit,
1171
+ nextCursor: null,
1172
+ });
1173
+ if (!registry) return page;
1153
1174
  for (const row of rows) {
1154
1175
  const stamp = hydration.getReadEvidence?.(row as object);
1155
1176
  if (stamp === undefined) {
@@ -1174,7 +1195,7 @@ export function createModelProxy<T, C>(
1174
1195
  stamp,
1175
1196
  );
1176
1197
  }
1177
- return rows as CapturedRow<T>[];
1198
+ return page;
1178
1199
  });
1179
1200
 
1180
1201
  const operations: ModelOperations<T, C> = {
@@ -396,14 +396,14 @@ export interface RuntimeConfig {
396
396
  * Fields to preserve when merging a partial update into the local store. A
397
397
  * change usually carries only the fields that changed; listing a model's
398
398
  * essential fields here keeps them from being dropped during that merge.
399
- * For example: `{ Task: ['title', 'projectId'], Section: ['reportId', 'order'] }`.
399
+ * For example: `{ Item: ['title', 'projectId'], Section: ['reportId', 'order'] }`.
400
400
  */
401
401
  essentialFields: Readonly<Record<string, readonly string[]>>;
402
402
 
403
403
  /**
404
404
  * A fallback map from class name to model name, used to resolve a model's name
405
405
  * when the usual lookup fails — for instance, when a bundler has minified the
406
- * class names. For example: `{ TaskModel: 'Task', ProjectModel: 'Project' }`.
406
+ * class names. For example: `{ ItemModel: 'Item', ProjectModel: 'Project' }`.
407
407
  */
408
408
  classNameFallbackMap: Readonly<Record<string, string>>;
409
409
 
@@ -430,7 +430,7 @@ export interface RuntimeConfig {
430
430
 
431
431
  /**
432
432
  * Per-model content hashes of the schema this client was built against,
433
- * keyed by schema key (`tasks` → hash of that model's serialized JSON). The
433
+ * keyed by schema key (`items` → hash of that model's serialized JSON). The
434
434
  * semantic layer of the drift check: on a whole-schema mismatch the client
435
435
  * compares only the models IT declares against the server's per-model
436
436
  * surface, so a purely additive server-side change (new models this build