@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/dist/client.d.ts CHANGED
@@ -108,10 +108,10 @@ export type AbloClient<S extends SchemaRecord> = {
108
108
  * server verifies it. The browser must never see the `sk_` key, only the
109
109
  * per-user session token.
110
110
  *
111
- * Pass `{ user: { id }, can: { tasks: ['read', 'update'] } }` for an end-user
111
+ * Pass `{ user: { id }, can: { items: ['read', 'update'] } }` for an end-user
112
112
  * session. It mints an `ek_` and attributes writes to a user (recorded as
113
113
  * `actor_kind` on the delta row). Pass `{ agent: { id }, can: {
114
- * tasks: ['update'] } }` for a scoped agent session, which mints an `rk_`.
114
+ * items: ['update'] } }` for a scoped agent session, which mints an `rk_`.
115
115
  * Both kinds require `can`, typed against your schema's model names. This
116
116
  * always authenticates with the original `sk_`, never the client's exchanged
117
117
  * sync credential.
@@ -126,10 +126,10 @@ export type AbloClient<S extends SchemaRecord> = {
126
126
  * ```ts
127
127
  * const agent = await ablo.agents.create({
128
128
  * name: 'researcher', // readable label (optional)
129
- * can: { documents: ['read', 'update'] },
129
+ * can: { records: ['read', 'update'] },
130
130
  * // id omitted → a fresh uuid: a distinct, independent participant
131
131
  * });
132
- * await agent.documents.update({ id, data, claim });
132
+ * await agent.records.update({ id, data, claim });
133
133
  * await agent.dispose(); // when the agent is done
134
134
  * ```
135
135
  *
@@ -14,43 +14,10 @@ import type { AppliedChange } from '../plugin.js';
14
14
  import type { BootstrapFetcher, BootstrapData } from './sync/BootstrapFetcher.js';
15
15
  import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
16
16
  import type { SyncDeltaAction } from '@abloatai/transaction/wire/delta';
17
- import type { OnStaleMode } from '@abloatai/transaction/coordination/schema';
18
17
  import type { BootstrapType } from '@abloatai/transaction/types';
18
+ import { type PersistedTransaction } from './transactions/persistedTransaction.js';
19
19
  /** Generic record type for model data */
20
20
  type ModelData = Record<string, unknown>;
21
- /** Persisted mutation in a transaction */
22
- interface PersistedMutation {
23
- type: 'create' | 'update' | 'delete' | 'archive';
24
- modelData: ModelData;
25
- modelName: string;
26
- timestamp: string;
27
- writeOptions?: {
28
- readAt?: number | null;
29
- onStale?: OnStaleMode | null;
30
- };
31
- }
32
- /** Persisted transaction for offline/retry support.
33
- *
34
- * Index signature is part of the contract: this interface targets
35
- * the generic record-shaped storage layer (`InMemoryObjectStore.put`
36
- * + the IDB ObjectStore equivalent), both of which take
37
- * `Record<string, unknown>`. Every declared field below already
38
- * satisfies `unknown`; the index signature just makes the
39
- * interface assignable to the storage parameter without a cast. */
40
- interface PersistedTransaction {
41
- id: string;
42
- type?: string;
43
- timestamp?: number;
44
- createdAt?: number;
45
- mutations?: PersistedMutation[];
46
- awaitingDelta?: {
47
- syncIdNeeded: number;
48
- modelName: string;
49
- modelId: string;
50
- operationType: string;
51
- };
52
- [key: string]: unknown;
53
- }
54
21
  export type { BootstrapType };
55
22
  export interface BootstrapRequirements {
56
23
  type: BootstrapType;
@@ -15,62 +15,20 @@ import { persistenceDatabaseNamesForDeletion, purgeIndexedDbPersistence, } from
15
15
  import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
16
16
  import { logPositionSchema } from './logPosition.js';
17
17
  import { highestPersistedPrefixSyncId } from './sync/persistedPrefix.js';
18
+ import { isAcceptedOutboxPromotion, isSameOutboxRecord, } from './transactions/persistedTransaction.js';
18
19
  /**
19
- * Request identity excludes local timing metadata for re-entrant seals: a
20
- * retry rebuilds its envelope with a fresh `sequence`/seal clock, so comparing
21
- * those volatile fields would reject every legitimate same-request re-seal as
22
- * an idempotency conflict. Only the fields that define the wire request count.
20
+ * Carry each input delta's log position onto the change that answers it.
21
+ * `processDeltaBatch` builds its results index-aligned with its input, so the
22
+ * position is stamped once here rather than at every construction site.
23
23
  */
24
- function isSameOutboxRecord(existing, candidate) {
25
- if (existing.type === 'http_commit_envelope' &&
26
- candidate.type === 'http_commit_envelope') {
27
- const identity = (record) => ({
28
- id: record.id,
29
- type: record.type,
30
- storageVersion: record.storageVersion,
31
- idempotencyKey: record.idempotencyKey,
32
- // HTTP outbox rows written before protocol versioning are v1. Normalize
33
- // them so a same-request re-seal remains idempotent after an upgrade.
34
- protocolVersion: record.protocolVersion ?? 1,
35
- request: record.request,
36
- scopeNamespace: record.scopeNamespace,
37
- });
38
- if (existing.correlationId !== undefined &&
39
- candidate.correlationId !== undefined &&
40
- existing.correlationId !== candidate.correlationId) {
41
- return false;
42
- }
43
- return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
44
- }
45
- if (existing.type === 'commit_envelope' &&
46
- candidate.type === 'commit_envelope') {
47
- const identity = (record) => ({
48
- id: record.id,
49
- type: record.type,
50
- storageVersion: record.storageVersion,
51
- origin: record.origin,
52
- idempotencyKey: record.idempotencyKey,
53
- operations: record.operations,
54
- sourceMutationIds: record.sourceMutationIds,
55
- commitOptions: record.commitOptions,
56
- scope: record.scope,
57
- });
58
- if (existing.correlationId !== undefined &&
59
- candidate.correlationId !== undefined &&
60
- existing.correlationId !== candidate.correlationId) {
61
- return false;
62
- }
63
- return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
24
+ function stampSyncIds(results, deltas) {
25
+ for (let index = 0; index < results.length; index++) {
26
+ const change = results[index];
27
+ const syncId = deltas[index]?.syncId;
28
+ if (change && typeof syncId === 'number')
29
+ change.syncId = syncId;
64
30
  }
65
- return JSON.stringify(existing) === JSON.stringify(candidate);
66
- }
67
- function isAcceptedOutboxPromotion(existing, candidate) {
68
- return (existing !== undefined &&
69
- (existing.type === 'commit_envelope' ||
70
- existing.type === 'http_commit_envelope') &&
71
- existing.type === candidate.type &&
72
- existing.acceptedAt === undefined &&
73
- candidate.acceptedAt !== undefined);
31
+ return results;
74
32
  }
75
33
  export class Database {
76
34
  // Core database components
@@ -887,7 +845,10 @@ export class Database {
887
845
  updatedAt: new Date(),
888
846
  };
889
847
  }
890
- return { results: inMemResults, persistedSyncId: inMemPersistedSyncId };
848
+ return {
849
+ results: stampSyncIds(inMemResults, deltas),
850
+ persistedSyncId: inMemPersistedSyncId,
851
+ };
891
852
  }
892
853
  // Prepare results aligned with input order
893
854
  const results = new Array(deltas.length);
@@ -1242,7 +1203,7 @@ export class Database {
1242
1203
  gap: highestSyncId - highestPersistedSyncId,
1243
1204
  });
1244
1205
  }
1245
- return { results, persistedSyncId: highestPersistedSyncId };
1206
+ return { results: stampSyncIds(results, deltas), persistedSyncId: highestPersistedSyncId };
1246
1207
  }
1247
1208
  /** Get raw data for hydration */
1248
1209
  async hydrateModels(modelName) {
@@ -12,6 +12,7 @@ import type { RuntimeContext } from './RuntimeContext.js';
12
12
  import { ModelScope } from '@abloatai/transaction/types';
13
13
  import { ViewRegistry } from './views/ViewRegistry.js';
14
14
  import { QueryView, type QueryViewOptions } from './views/QueryView.js';
15
+ import { RowWatermarks } from './rowWatermarks.js';
15
16
  /** Constructor type for Model subclasses - uses abstract to handle variance */
16
17
  type ModelConstructor<T extends Model> = abstract new (...args: never[]) => T;
17
18
  export { ModelScope };
@@ -51,6 +52,14 @@ export declare class InstanceCache {
51
52
  private gcTimer?;
52
53
  readonly registry: ModelRegistry;
53
54
  readonly viewRegistry: ViewRegistry;
55
+ /**
56
+ * The log position each pooled row is known to reflect. Every door a row
57
+ * enters through (delta, own ack, bootstrap, server read) advances it, and
58
+ * every snapshot that would overwrite a resident row is judged against it —
59
+ * see {@link RowWatermarks}. Keyed by instance so it lives and dies with the
60
+ * pooled model.
61
+ */
62
+ readonly watermarks: RowWatermarks;
54
63
  private subscriptions;
55
64
  /**
56
65
  * Subscribe to updates for a specific model type.
@@ -14,6 +14,7 @@ import { AbloValidationError } from '@abloatai/transaction/errors';
14
14
  import { ModelScope, PropertyType } from '@abloatai/transaction/types';
15
15
  import { ViewRegistry } from './views/ViewRegistry.js';
16
16
  import { QueryView } from './views/QueryView.js';
17
+ import { RowWatermarks } from './rowWatermarks.js';
17
18
  // Re-exported so `import { ModelScope } from './InstanceCache.js'` resolves
18
19
  export { ModelScope };
19
20
  /**
@@ -80,6 +81,14 @@ export class InstanceCache {
80
81
  registry;
81
82
  // ViewRegistry — tracks active QueryViews for incremental view maintenance
82
83
  viewRegistry = new ViewRegistry();
84
+ /**
85
+ * The log position each pooled row is known to reflect. Every door a row
86
+ * enters through (delta, own ack, bootstrap, server read) advances it, and
87
+ * every snapshot that would overwrite a resident row is judged against it —
88
+ * see {@link RowWatermarks}. Keyed by instance so it lives and dies with the
89
+ * pooled model.
90
+ */
91
+ watermarks = new RowWatermarks();
83
92
  // Subscription registry
84
93
  subscriptions = new Map();
85
94
  /**
@@ -937,24 +937,6 @@ export class Model {
937
937
  }
938
938
  // Try to get model class by identifier
939
939
  let ModelClass = getActiveRegistry().getModelByName(modelIdentifier);
940
- // If not found by registered name, try mapping to the class name
941
- if (!ModelClass) {
942
- const classNameMap = {
943
- Task: 'TaskModel',
944
- Project: 'Project',
945
- Comment: 'CommentModel',
946
- User: 'UserModel',
947
- Organization: 'OrganizationModel',
948
- StatusGroup: 'StatusGroupModel',
949
- Team: 'TeamModel',
950
- Member: 'MemberModel',
951
- Role: 'RoleModel',
952
- };
953
- const className = classNameMap[modelIdentifier];
954
- if (className) {
955
- ModelClass = getActiveRegistry().getModelByName(className);
956
- }
957
- }
958
940
  if (!ModelClass) {
959
941
  throw new AbloValidationError(`Model class not found for: ${modelIdentifier}`, { code: 'model_class_not_registered' });
960
942
  }
@@ -17,6 +17,7 @@ import { type CommitLatencySample } from './transactions/mutations/commitLatency
17
17
  import { type UnconfirmedWritesMetrics } from './transactions/mutations/UnconfirmedWrites.js';
18
18
  import type { DurableWriteStore } from './transactions/mutations/durableWriteStore.js';
19
19
  import type { Database } from './Database.js';
20
+ import type { BootstrapData } from './sync/BootstrapFetcher.js';
20
21
  import type { WriteOptions } from './interfaces/index.js';
21
22
  import { LogPosition } from './logPosition.js';
22
23
  interface SyncObserver {
@@ -43,6 +44,14 @@ export interface RehydrationStats {
43
44
  healed: number;
44
45
  elapsedMs: number;
45
46
  }
47
+ /**
48
+ * The slice of a bootstrap answer the pool applies: its rows, the models whose
49
+ * server query failed, and the log position the snapshot was taken at — the
50
+ * position every row in it reflects. `lastSyncId` is optional only for callers
51
+ * applying rows with no snapshot position to speak of; the fetcher always
52
+ * names one.
53
+ */
54
+ export type BootstrapSnapshot = Pick<BootstrapData, 'models' | 'failedModels'> & Partial<Pick<BootstrapData, 'lastSyncId'>>;
46
55
  export declare class SyncClient extends EventEmitter {
47
56
  private readonly runtime;
48
57
  private objectPool;
@@ -127,6 +136,13 @@ export declare class SyncClient extends EventEmitter {
127
136
  * deliver the missing deltas and confirm the transaction.
128
137
  */
129
138
  private setupAwaitingTransactionPersistence;
139
+ /**
140
+ * Advance the pooled rows a completed transaction wrote to the log position
141
+ * its acknowledgement named. A model mutation names one row; an explicit
142
+ * commit names one per operation. Rows no longer pooled have nothing to
143
+ * advance — a fresh instance starts without evidence.
144
+ */
145
+ private noteOwnWritePositions;
130
146
  /** Persist an unconfirmed transaction to IndexedDB (never rejects — failures are captured). */
131
147
  private persistAwaitingTransaction;
132
148
  /** Drop the persisted awaiting-row once confirmed (never rejects). */
@@ -451,8 +467,6 @@ export declare class SyncClient extends EventEmitter {
451
467
  }[];
452
468
  };
453
469
  };
454
- unassignEntity(entityType: string, entityId: string): Promise<void>;
455
- reassignEntity(entityType: string, entityId: string, assigneeType: string, assigneeId: string, id?: string): Promise<void>;
456
470
  /**
457
471
  * Apply a batch of delta results from Database to the InstanceCache.
458
472
  * Owns: model creation, upsert, remove, archive, conflict resolution.
@@ -499,18 +513,16 @@ export declare class SyncClient extends EventEmitter {
499
513
  * Apply bootstrap data to the InstanceCache with ghost removal.
500
514
  * Owns: model creation, batch upsert, ghost detection + removal.
501
515
  */
502
- applyBootstrapDataToPool(bootstrapData: {
503
- models?: Record<string, unknown[]>;
504
- failedModels?: string[];
505
- }, protectedIds?: ReadonlySet<string>, options?: {
516
+ applyBootstrapDataToPool(bootstrapData: BootstrapSnapshot, protectedIds?: ReadonlySet<string>, options?: {
506
517
  /**
507
518
  * Scoped backfill for the hydrate-on-enter path: the snapshot covers only
508
519
  * the groups just entered, not the whole model type. Two behaviors change
509
- * so the subset cannot corrupt the pool. First, the upsert is
510
- * version-guarded ({@link InstanceCache.upsertIfNewer}) so a concurrent live
511
- * delta is not clobbered back to the snapshot version. Second, ghost
512
- * removal is skipped, because a subset snapshot must never evict rows of
513
- * the same type that belong to other, unhydrated groups.
520
+ * so the subset cannot corrupt the pool. First, a row the pool already
521
+ * knows to reflect a position beyond the snapshot's `lastSyncId` is
522
+ * skipped, so a concurrent live delta is not clobbered back to the
523
+ * snapshot version. Second, ghost removal is skipped, because a subset
524
+ * snapshot must never evict rows of the same type that belong to other,
525
+ * unhydrated groups.
514
526
  */
515
527
  scoped?: boolean;
516
528
  }): {
@@ -24,32 +24,6 @@ import { LogPosition } from './logPosition.js';
24
24
  import { createLocalMutationPort } from './transactions/localMutation.js';
25
25
  import { createReconnectDrain } from './transactions/reconnectDrain.js';
26
26
  import { DatabaseCommitOutboxStore } from './transactions/databaseCommitOutbox.js';
27
- /**
28
- * Reports whether an incoming snapshot record is strictly newer than the
29
- * model already in the pool. The comparison uses the server-stamped
30
- * `updatedAt` timestamp, since rows carry no numeric version and the delta
31
- * pipeline resolves order by arrival (last write wins). An undefined incoming
32
- * timestamp counts as not newer, so a known row is never clobbered; an
33
- * undefined existing timestamp means the pooled row is unversioned, so the
34
- * incoming record wins. The scoped hydrate-on-enter path uses this to drop
35
- * snapshot rows that a live delta has already advanced past.
36
- */
37
- function rawRecordIsNewer(data, existing) {
38
- const raw = data.updatedAt;
39
- const inMs = raw instanceof Date
40
- ? raw.getTime()
41
- : typeof raw === 'string'
42
- ? (Number.isNaN(Date.parse(raw)) ? undefined : Date.parse(raw))
43
- : typeof raw === 'number'
44
- ? raw
45
- : undefined;
46
- const exMs = existing.updatedAt instanceof Date ? existing.updatedAt.getTime() : undefined;
47
- if (inMs === undefined)
48
- return false;
49
- if (exMs === undefined)
50
- return true;
51
- return inMs > exMs;
52
- }
53
27
  /**
54
28
  * Converts an untyped server `updatedAt` value — an ISO string, epoch number,
55
29
  * or Date read off an untyped row — into epoch milliseconds for
@@ -352,10 +326,16 @@ export class SyncClient extends EventEmitter {
352
326
  // void is safe: the handler's body is fully try/catch'd.
353
327
  void this.persistAwaitingTransaction(event);
354
328
  });
355
- // Clean up persisted awaiting transactions when they're finally confirmed
329
+ // Clean up persisted awaiting transactions when they're finally confirmed,
330
+ // and record the confirmed position on every row the transaction wrote.
331
+ // The acknowledgement is the earliest proof of where this client's own
332
+ // write landed in the log — earlier than its delta echo, which the pool
333
+ // suppresses on apply — so a snapshot read before the write cannot regress
334
+ // the row in the window between the two.
356
335
  this.mutationQueue.on('transaction:completed', (tx) => {
357
336
  // void is safe: the handler's body is fully try/catch'd.
358
337
  void this.removeAwaitingTransaction(tx.id);
338
+ this.noteOwnWritePositions(tx);
359
339
  });
360
340
  // Echo detection bridge. When the queue stages a transaction, the
361
341
  // client has already optimistically applied the change to the
@@ -373,6 +353,23 @@ export class SyncClient extends EventEmitter {
373
353
  this.echoTracker.drainOnRollback(event.transaction.id);
374
354
  });
375
355
  }
356
+ /**
357
+ * Advance the pooled rows a completed transaction wrote to the log position
358
+ * its acknowledgement named. A model mutation names one row; an explicit
359
+ * commit names one per operation. Rows no longer pooled have nothing to
360
+ * advance — a fresh instance starts without evidence.
361
+ */
362
+ noteOwnWritePositions(tx) {
363
+ const position = tx.lastSyncId ?? tx.syncIdNeededForCompletion;
364
+ if (position === undefined)
365
+ return;
366
+ const rowIds = tx.operations !== undefined ? tx.operations.map((op) => op.id) : [tx.modelId];
367
+ for (const rowId of rowIds) {
368
+ const row = this.objectPool.peek(rowId);
369
+ if (row)
370
+ this.objectPool.watermarks.advance(row, position);
371
+ }
372
+ }
376
373
  /** Persist an unconfirmed transaction to IndexedDB (never rejects — failures are captured). */
377
374
  async persistAwaitingTransaction(event) {
378
375
  if (!this.database)
@@ -1048,8 +1045,7 @@ export class SyncClient extends EventEmitter {
1048
1045
  // only materializes on the rare force-accept branch, for its log line.
1049
1046
  const shouldForceAcceptServer = (serverData.deletedAt !== undefined && serverData.deletedAt !== null) ||
1050
1047
  (serverData.archivedAt !== undefined && serverData.archivedAt !== null) ||
1051
- serverData.isActive === false ||
1052
- (serverData.unassignedAt !== undefined && serverData.unassignedAt !== null);
1048
+ serverData.isActive === false;
1053
1049
  if (shouldForceAcceptServer) {
1054
1050
  this.runtime.logger.debug('Accepting server update - critical state change detected', {
1055
1051
  modelId: localModel.id,
@@ -1113,13 +1109,10 @@ export class SyncClient extends EventEmitter {
1113
1109
  if (serverData.archivedAt !== undefined) {
1114
1110
  critical.archivedAt = serverData.archivedAt;
1115
1111
  }
1116
- // Deactivation states - critical for assignments and similar entities
1112
+ // Deactivation states are always critical.
1117
1113
  if (serverData.isActive !== undefined && serverData.isActive === false) {
1118
1114
  critical.isActive = false;
1119
1115
  }
1120
- if (serverData.unassignedAt !== undefined) {
1121
- critical.unassignedAt = serverData.unassignedAt;
1122
- }
1123
1116
  return critical;
1124
1117
  }
1125
1118
  /**
@@ -1475,19 +1468,6 @@ export class SyncClient extends EventEmitter {
1475
1468
  mutationQueue: this.mutationQueue.getDebugInfo(),
1476
1469
  };
1477
1470
  }
1478
- // --- Best-practice assignment ops ---
1479
- async unassignEntity(entityType, entityId) {
1480
- // Call server-side unassign to avoid per-id races
1481
- await this.mutationExecutor.executeDelete('Assignment', entityId);
1482
- }
1483
- async reassignEntity(entityType, entityId, assigneeType, assigneeId, id) {
1484
- await this.mutationExecutor.executeCreate('Assignment', id || '', {
1485
- entityType,
1486
- entityId,
1487
- assigneeType,
1488
- assigneeId,
1489
- });
1490
- }
1491
1471
  // ── Delta + Bootstrap application (owns InstanceCache writes) ──────────────
1492
1472
  /**
1493
1473
  * Apply a batch of delta results from Database to the InstanceCache.
@@ -1602,7 +1582,13 @@ export class SyncClient extends EventEmitter {
1602
1582
  idsBeingRemoved.add(r.modelId);
1603
1583
  }
1604
1584
  for (const result of dbResults) {
1605
- const { modelName, modelId, action, transactionId } = result;
1585
+ const { modelName, modelId, action, transactionId, syncId } = result;
1586
+ // Every delta names the log position the row now reflects — recorded
1587
+ // before echo detection, because an own echo is exactly a position the
1588
+ // pooled row has reached even though its fields are not re-applied.
1589
+ const resident = this.objectPool.peek(modelId);
1590
+ if (resident)
1591
+ this.objectPool.watermarks.advance(resident, syncId);
1606
1592
  // Echo detection: if this delta carries a transaction id that matches
1607
1593
  // one already applied optimistically, the pool already reflects the
1608
1594
  // mutation, so the pool operation is skipped. The IndexedDB write in
@@ -1638,8 +1624,10 @@ export class SyncClient extends EventEmitter {
1638
1624
  const model = this.objectPool.createFromData(data, undefined, {
1639
1625
  deferObservability: true,
1640
1626
  });
1641
- if (model)
1627
+ if (model) {
1628
+ this.objectPool.watermarks.advance(model, syncId);
1642
1629
  modelsToAdd.push(model);
1630
+ }
1643
1631
  }
1644
1632
  break;
1645
1633
  }
@@ -1712,6 +1700,7 @@ export class SyncClient extends EventEmitter {
1712
1700
  if (!bootstrapData.models) {
1713
1701
  return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0 };
1714
1702
  }
1703
+ const snapshotPosition = bootstrapData.lastSyncId;
1715
1704
  const allModels = [];
1716
1705
  const serverIdsByType = new Map();
1717
1706
  let healedCount = 0;
@@ -1747,19 +1736,21 @@ export class SyncClient extends EventEmitter {
1747
1736
  // taken at a server watermark. If a concurrent live delta already
1748
1737
  // advanced this row past the snapshot, skip it. `createFromData`
1749
1738
  // mutates the pooled model in place to keep instances alive, so this
1750
- // version guard has to run before it; a guard at the upsert layer would
1751
- // be too late, because the row would already be clobbered.
1739
+ // guard has to run before it; a guard at the upsert layer would be too
1740
+ // late, because the row would already be clobbered.
1752
1741
  if (options?.scoped && recordId) {
1753
- const existing = this.objectPool.get(recordId);
1754
- if (existing && !rawRecordIsNewer(data, existing)) {
1742
+ const existing = this.objectPool.peek(recordId);
1743
+ if (existing && this.objectPool.watermarks.isAheadOf(existing, snapshotPosition)) {
1755
1744
  skippedCount++;
1756
1745
  continue;
1757
1746
  }
1758
1747
  }
1759
1748
  try {
1760
1749
  const model = this.objectPool.createFromData(data);
1761
- if (model)
1750
+ if (model) {
1751
+ this.objectPool.watermarks.advance(model, snapshotPosition);
1762
1752
  allModels.push(model);
1753
+ }
1763
1754
  }
1764
1755
  catch {
1765
1756
  skippedCount++;
@@ -62,6 +62,10 @@ export function createInternalComponents(input) {
62
62
  baseUrl: bootstrapBaseUrl,
63
63
  getAuthToken: auth?.getAuthToken,
64
64
  runtime,
65
+ // The one canonical log position; the loader reads its floor when a query
66
+ // leaves so a late answer cannot overwrite a row the pool already knows to
67
+ // be further along.
68
+ position: syncClient.position,
65
69
  });
66
70
  // Drop the lazy-lane hydration ledger on reconnect. While connected, the
67
71
  // WebSocket delta stream keeps hydrated rows fresh so repeat reads serve
@@ -19,6 +19,7 @@ import { toMs } from '@abloatai/transaction/utils/duration';
19
19
  import { LEASE_TTL_MS } from '@abloatai/transaction/wire/protocol';
20
20
  import { heartbeatCadenceMs, resolveHeartbeatOptions, resolveHeartbeatPlan, startClaimHeartbeatLoop, } from '@abloatai/transaction/coordination/claimHeartbeatLoop';
21
21
  import { assertWriteOptions } from '@abloatai/transaction/resources/writeOptionsSchema';
22
+ import { modelList } from '@abloatai/transaction/resources/httpResources';
22
23
  import { subTarget } from '@abloatai/transaction/coordination';
23
24
  // A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
24
25
  // .dependency-cruiser.cjs): the reactive proxy's self-claim targets are
@@ -75,7 +76,7 @@ hydration, collaboration, readSetContext) {
75
76
  'but no matching constructor was registered.', { code: 'model_not_registered' });
76
77
  }
77
78
  // The coordination plane must speak the same wire dialect as the commit
78
- // plane: the lowercased typename (`task`), not the schema key (`tasks`). The
79
+ // plane: the lowercased typename (`item`), not the schema key (`items`). The
79
80
  // server's commit-time claim guard probes the lease store with the commit
80
81
  // operation's model name, so a lease recorded under the schema key never
81
82
  // matches — which would silently disarm the guard for every model whose
@@ -115,6 +116,15 @@ hydration, collaboration, readSetContext) {
115
116
  };
116
117
  };
117
118
  const load = async (options) => {
119
+ if (options?.cursor !== undefined) {
120
+ // The live client hydrates a working set into the local graph rather than
121
+ // handing back pages, so there is no cursor for this read to resume from.
122
+ // Accepting the option and ignoring it would return page one every time
123
+ // while the caller believed it was advancing.
124
+ throw new AbloValidationError('`cursor` resumes a page of the stateless read. This client keeps a ' +
125
+ 'local graph and loads a working set instead of pages: narrow the ' +
126
+ '`where`, or construct the client with `transport: \'http\'` to page.', { code: 'invalid_options', param: 'cursor' });
127
+ }
118
128
  const rows = await hydration.fetch(schemaKey, options);
119
129
  return rows.map((row) => modelAsRow(row));
120
130
  };
@@ -649,8 +659,16 @@ hydration, collaboration, readSetContext) {
649
659
  const list = guard(async (options) => {
650
660
  const registry = readSetContext?.getStore();
651
661
  const rows = await load(options);
662
+ // This transport loads a working set rather than pages, so there is no
663
+ // cursor to hand back. `limit` can still cut the set short, and a full
664
+ // count is exactly the case where the caller cannot tell: report it rather
665
+ // than claim completeness this read cannot vouch for.
666
+ const page = modelList(rows, {
667
+ hasMore: options?.limit !== undefined && rows.length >= options.limit,
668
+ nextCursor: null,
669
+ });
652
670
  if (!registry)
653
- return rows;
671
+ return page;
654
672
  for (const row of rows) {
655
673
  const stamp = hydration.getReadEvidence?.(row);
656
674
  if (stamp === undefined) {
@@ -665,7 +683,7 @@ hydration, collaboration, readSetContext) {
665
683
  }
666
684
  capturePointRead(readSetContext, readSetClientIdentity, wireModel, id, row, stamp);
667
685
  }
668
- return rows;
686
+ return page;
669
687
  });
670
688
  const operations = {
671
689
  local,
@@ -253,13 +253,13 @@ export interface RuntimeConfig {
253
253
  * Fields to preserve when merging a partial update into the local store. A
254
254
  * change usually carries only the fields that changed; listing a model's
255
255
  * essential fields here keeps them from being dropped during that merge.
256
- * For example: `{ Task: ['title', 'projectId'], Section: ['reportId', 'order'] }`.
256
+ * For example: `{ Item: ['title', 'projectId'], Section: ['reportId', 'order'] }`.
257
257
  */
258
258
  essentialFields: Readonly<Record<string, readonly string[]>>;
259
259
  /**
260
260
  * A fallback map from class name to model name, used to resolve a model's name
261
261
  * when the usual lookup fails — for instance, when a bundler has minified the
262
- * class names. For example: `{ TaskModel: 'Task', ProjectModel: 'Project' }`.
262
+ * class names. For example: `{ ItemModel: 'Item', ProjectModel: 'Project' }`.
263
263
  */
264
264
  classNameFallbackMap: Readonly<Record<string, string>>;
265
265
  /**
@@ -283,7 +283,7 @@ export interface RuntimeConfig {
283
283
  expectedSourceSchemaHash?: string;
284
284
  /**
285
285
  * Per-model content hashes of the schema this client was built against,
286
- * keyed by schema key (`tasks` → hash of that model's serialized JSON). The
286
+ * keyed by schema key (`items` → hash of that model's serialized JSON). The
287
287
  * semantic layer of the drift check: on a whole-schema mismatch the client
288
288
  * compares only the models IT declares against the server's per-model
289
289
  * surface, so a purely additive server-side change (new models this build
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The log position each pooled row is known to reflect — the client-side
3
+ * companion of the server's per-row watermark (`ModelListEvidence.stamp`).
4
+ *
5
+ * A row's copy in the pool moves through four doors, and every one of them
6
+ * names the log position it delivers: the ordered delta stream (the delta's
7
+ * id), the acknowledgement of this client's own commit (`lastSyncId`), a
8
+ * bootstrap snapshot (its `lastSyncId`), and a server read (the row's evidence
9
+ * stamp). Recording that position per row is what lets a later snapshot be
10
+ * judged. A snapshot taken at position P cannot carry anything the log did not
11
+ * hold at P, so when the pooled copy already reflects a position beyond P the
12
+ * snapshot is stale for that row and is left unapplied. Deltas repair every
13
+ * peer change a skipped snapshot would have carried; nothing repairs a
14
+ * snapshot that regresses this client's own confirmed write, because own
15
+ * echoes are suppressed on apply — which is why the rule errs toward keeping
16
+ * the resident copy.
17
+ *
18
+ * The row's `updatedAt` is not consulted. It is an application field the
19
+ * server never stamps and the client fabricates when a row arrives without one,
20
+ * so it orders nothing; the log does.
21
+ *
22
+ * Positions are `sync_deltas` ids, the same space as {@link LogPosition}. Zero
23
+ * and `undefined` mean "no evidence" and never advance a row.
24
+ */
25
+ export declare class RowWatermarks {
26
+ #private;
27
+ /** Record that `row`'s pooled copy reflects the log at least through `position`. */
28
+ advance(row: object, position: number | undefined): void;
29
+ /** The highest log position `row` is known to reflect, if the client has any evidence. */
30
+ of(row: object): number | undefined;
31
+ /**
32
+ * Whether the pooled copy of `row` is known to be ahead of a snapshot that
33
+ * reflects the log through `snapshotPosition`. `snapshotPosition` is a lower
34
+ * bound: the position the snapshot provably includes (a row's evidence stamp,
35
+ * a bootstrap's `lastSyncId`, or the client's own read floor at the moment
36
+ * the read was issued — the server had at least that much when it answered).
37
+ * A snapshot with no known position is never judged stale.
38
+ */
39
+ isAheadOf(row: object, snapshotPosition: number | undefined): boolean;
40
+ }