@abloatai/humans 0.60.0 → 0.62.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 (99) hide show
  1. package/dist/Ablo.d.ts +4 -4
  2. package/dist/Ablo.js +2 -1
  3. package/dist/client.d.ts +7 -12
  4. package/dist/humans.d.ts +2 -2
  5. package/dist/humans.js +2 -6
  6. package/dist/local/BaseSyncedStore.d.ts +2 -4
  7. package/dist/local/BaseSyncedStore.js +5 -8
  8. package/dist/local/Model.js +46 -56
  9. package/dist/local/NetworkMonitor.js +2 -0
  10. package/dist/local/RuntimeContext.js +2 -0
  11. package/dist/local/SyncClient.d.ts +8 -32
  12. package/dist/local/SyncClient.js +26 -99
  13. package/dist/local/client/clientPrelude.d.ts +2 -0
  14. package/dist/local/client/clientPrelude.js +13 -1
  15. package/dist/local/client/createInternalComponents.js +2 -0
  16. package/dist/local/client/createModelOperations.d.ts +5 -0
  17. package/dist/local/client/createModelOperations.js +7 -4
  18. package/dist/local/client/reactiveEngine.d.ts +2 -2
  19. package/dist/local/client/reactiveEngine.js +9 -11
  20. package/dist/local/fileUploads.d.ts +27 -0
  21. package/dist/local/fileUploads.js +55 -0
  22. package/dist/local/query/client.d.ts +3 -0
  23. package/dist/local/query/client.js +1 -1
  24. package/dist/local/stores/syncAction.d.ts +1 -1
  25. package/dist/local/sync/BootstrapFetcher.d.ts +2 -0
  26. package/dist/local/sync/BootstrapFetcher.js +4 -4
  27. package/dist/local/sync/OnDemandLoader.d.ts +2 -0
  28. package/dist/local/sync/OnDemandLoader.js +1 -0
  29. package/dist/local/sync/SyncWebSocket.d.ts +2 -15
  30. package/dist/local/sync/SyncWebSocket.js +1 -36
  31. package/dist/local/sync/contextOnChange.js +1 -1
  32. package/dist/local/sync/createClaimStream.d.ts +9 -19
  33. package/dist/local/sync/createClaimStream.js +41 -56
  34. package/dist/local/sync/deltaPipeline.js +12 -6
  35. package/dist/local/sync/schemas.d.ts +2 -2
  36. package/dist/local/sync/socketEventWiring.d.ts +1 -2
  37. package/dist/local/sync/socketEventWiring.js +1 -5
  38. package/dist/local/transactions/localMutation.js +3 -3
  39. package/dist/local/transactions/mutations/MutationQueue.d.ts +4 -5
  40. package/dist/local/transactions/mutations/MutationQueue.js +25 -51
  41. package/dist/local/transactions/mutations/batchProcessing.js +23 -10
  42. package/dist/local/transactions/mutations/commitPayload.d.ts +8 -1
  43. package/dist/local/transactions/mutations/commitTransport.js +3 -1
  44. package/dist/local/transactions/mutations/executionSelection.d.ts +0 -1
  45. package/dist/local/transactions/mutations/executionSelection.js +9 -17
  46. package/dist/local/transactions/mutations/failureHandling.js +9 -0
  47. package/dist/local/transactions/mutations/localMutation.js +3 -3
  48. package/dist/local/transactions/mutations/queueCoalescing.js +8 -0
  49. package/dist/local/transactions/mutations/replayValidation.d.ts +4 -4
  50. package/dist/presence/index.d.ts +15 -0
  51. package/dist/presence/index.js +49 -0
  52. package/dist/react/AbloProvider.d.ts +3 -3
  53. package/dist/react/AbloProvider.js +4 -3
  54. package/dist/react/useErrorListener.js +1 -1
  55. package/dist/react/useMutationFailureListener.js +1 -1
  56. package/dist/surface.d.ts +1 -1
  57. package/dist/surface.js +1 -0
  58. package/package.json +3 -4
  59. package/src/Ablo.ts +15 -6
  60. package/src/client.ts +8 -13
  61. package/src/humans.ts +3 -10
  62. package/src/local/BaseSyncedStore.ts +5 -11
  63. package/src/local/Model.ts +45 -55
  64. package/src/local/NetworkMonitor.ts +2 -0
  65. package/src/local/RuntimeContext.ts +2 -0
  66. package/src/local/SyncClient.ts +33 -127
  67. package/src/local/client/clientPrelude.ts +20 -0
  68. package/src/local/client/createInternalComponents.ts +2 -0
  69. package/src/local/client/createModelOperations.ts +17 -6
  70. package/src/local/client/reactiveEngine.ts +19 -14
  71. package/src/local/fileUploads.ts +97 -0
  72. package/src/local/query/client.ts +4 -0
  73. package/src/local/sync/BootstrapFetcher.ts +13 -4
  74. package/src/local/sync/OnDemandLoader.ts +3 -0
  75. package/src/local/sync/SyncWebSocket.ts +1 -42
  76. package/src/local/sync/contextOnChange.ts +1 -1
  77. package/src/local/sync/createClaimStream.ts +52 -72
  78. package/src/local/sync/deltaPipeline.ts +10 -6
  79. package/src/local/sync/socketEventWiring.ts +1 -8
  80. package/src/local/transactions/localMutation.ts +3 -3
  81. package/src/local/transactions/mutations/MutationQueue.ts +24 -53
  82. package/src/local/transactions/mutations/batchProcessing.ts +25 -10
  83. package/src/local/transactions/mutations/commitPayload.ts +11 -1
  84. package/src/local/transactions/mutations/commitTransport.ts +2 -2
  85. package/src/local/transactions/mutations/executionSelection.ts +9 -15
  86. package/src/local/transactions/mutations/failureHandling.ts +10 -0
  87. package/src/local/transactions/mutations/localMutation.ts +3 -3
  88. package/src/local/transactions/mutations/queueCoalescing.ts +6 -0
  89. package/src/presence/index.ts +72 -0
  90. package/src/react/AbloProvider.tsx +14 -9
  91. package/src/react/useErrorListener.ts +1 -1
  92. package/src/react/useMutationFailureListener.ts +1 -1
  93. package/src/surface.ts +1 -0
  94. package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
  95. package/dist/local/transactions/mutations/pendingDrain.js +0 -117
  96. package/dist/presenceStream.d.ts +0 -69
  97. package/dist/presenceStream.js +0 -200
  98. package/src/local/transactions/mutations/pendingDrain.ts +0 -169
  99. package/src/presenceStream.ts +0 -279
@@ -18,7 +18,7 @@ import { deepEqual, snapshotJsonValue } from '@abloatai/transaction/utils/json';
18
18
  import { LoadStrategy } from '@abloatai/transaction/types';
19
19
  import { globalRuntime } from './context.js';
20
20
  import type { RuntimeContext } from './RuntimeContext.js';
21
- import { AbloAuthenticationError, AbloError, AbloValidationError } from '@abloatai/transaction/errors';
21
+ import { AbloError, AbloValidationError } from '@abloatai/transaction/errors';
22
22
  import { EventEmitter } from 'events';
23
23
  import { NetworkMonitor } from './NetworkMonitor.js';
24
24
  import {
@@ -51,9 +51,16 @@ import {
51
51
  type SyncState,
52
52
  } from './syncClientTypes.js';
53
53
  import type { BootstrapSnapshot } from './syncClientTypes.js';
54
+ import {
55
+ batchUploadFiles,
56
+ uploadFile,
57
+ type BatchFileUploadOptions,
58
+ type FileUploadContext,
59
+ type FileUploadOptions,
60
+ } from './fileUploads.js';
54
61
 
55
62
  export type { BootstrapSnapshot, RehydrationStats } from './syncClientTypes.js';
56
-
63
+ const ignoreSeparatelyObservedMutationFailure = (): undefined => undefined;
57
64
  export class SyncClient extends EventEmitter {
58
65
  private objectPool: InstanceCache;
59
66
  private database: Database;
@@ -1057,7 +1064,7 @@ export class SyncClient extends EventEmitter {
1057
1064
  if (capturedChanges === undefined) return;
1058
1065
 
1059
1066
  this.objectPool.upsert(model, ModelScope.live);
1060
- this.stageMutation('update', model, capturedChanges);
1067
+ void this.stageMutation('update', model, capturedChanges);
1061
1068
  this.notifyObservers({
1062
1069
  type: 'update',
1063
1070
  modelType: model.getModelName(),
@@ -1080,128 +1087,30 @@ export class SyncClient extends EventEmitter {
1080
1087
  return this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
1081
1088
  }
1082
1089
 
1083
- /**
1084
- * Upload a file and create its attachment record. The upload runs through
1085
- * the {@link MutationQueue}, and a model is built from the server's
1086
- * response and added to the pool.
1087
- */
1088
- async uploadFile(
1089
- file: File,
1090
- options: {
1091
- id: string;
1092
- attachableType: string;
1093
- attachableId: string;
1094
- metadata?: Record<string, unknown>;
1095
- }
1096
- ): Promise<Model | null> {
1097
- if (!this.userId || !this.organizationId) {
1098
- throw new AbloAuthenticationError('Authentication required for file uploads', {
1099
- code: 'file_upload_auth_required',
1100
- });
1101
- }
1102
-
1103
- try {
1104
- // Use MutationQueue to handle the upload mutation
1105
- const result = await this.mutationQueue.uploadAttachment(
1106
- file,
1107
- {
1108
- id: options.id,
1109
- attachableType: options.attachableType,
1110
- attachableId: options.attachableId,
1111
- metadata: options.metadata,
1112
- },
1113
- {
1114
- userId: this.userId,
1115
- organizationId: this.organizationId,
1116
- }
1117
- );
1118
-
1119
- if (result) {
1120
- // Create model from response using ModelRegistry (generic — no concrete class import)
1121
- const model = this.objectPool.createFromData({
1122
- id: options.id,
1123
- ...result,
1124
- });
1125
-
1126
- if (model) {
1127
- this.objectPool.add(model, ModelScope.live);
1128
- this.notifyObservers({
1129
- type: 'create',
1130
- modelType: model.getModelName(),
1131
- model,
1132
- });
1133
- return model;
1134
- }
1135
- }
1136
-
1137
- return null;
1138
- } catch (error) {
1139
- this.runtime.observability.captureMutationFailure({
1140
- context: 'file-upload',
1141
- error: error instanceof Error ? error : new Error(String(error)),
1142
- });
1143
- throw error;
1144
- }
1145
- }
1146
-
1147
- /**
1148
- * Batch upload files — single GraphQL call + parallel S3 PUTs.
1149
- *
1150
- * Returns the raw `Model[]` built by the object pool (typename is
1151
- * determined by the payload the server returns — currently always
1152
- * `Attachment`). The SDK has no knowledge of app-specific model classes,
1153
- * so it cannot honestly claim a narrower return type; consumers that
1154
- * need an `Attachment[]` project through their own typed accessor
1155
- * (e.g. `store.query.attachments.findMany({ where: { id: IN ids } })`)
1156
- * after the upload resolves.
1157
- */
1158
- async batchUploadFiles(
1159
- files: File[],
1160
- options: {
1161
- ids: string[];
1162
- attachableType: string;
1163
- attachableId: string;
1164
- metadata?: Record<string, unknown>;
1165
- }
1166
- ): Promise<Model[]> {
1167
- if (!this.userId || !this.organizationId) {
1168
- throw new AbloAuthenticationError('Authentication required for file uploads', {
1169
- code: 'file_upload_auth_required',
1170
- });
1171
- }
1172
-
1173
- const items = options.ids.map((id) => ({
1174
- id,
1175
- attachableType: options.attachableType,
1176
- attachableId: options.attachableId,
1177
- metadata: options.metadata,
1178
- }));
1179
-
1180
- const results = await this.mutationQueue.batchUploadAttachments(files, items, {
1090
+ private fileUploadContext(): FileUploadContext {
1091
+ return {
1181
1092
  userId: this.userId,
1182
1093
  organizationId: this.organizationId,
1183
- });
1094
+ mutationQueue: this.mutationQueue,
1095
+ objectPool: this.objectPool,
1096
+ observability: this.runtime.observability,
1097
+ notifyCreated: (model) => {
1098
+ this.notifyObservers({ type: 'create', modelType: model.getModelName(), model });
1099
+ },
1100
+ };
1101
+ }
1184
1102
 
1185
- const models: Model[] = [];
1186
- for (const result of results) {
1187
- const model = this.objectPool.createFromData({ ...result });
1188
- if (model) {
1189
- this.objectPool.add(model, ModelScope.live);
1190
- this.notifyObservers({
1191
- type: 'create',
1192
- modelType: model.getModelName(),
1193
- model,
1194
- });
1195
- models.push(model);
1196
- }
1197
- }
1103
+ uploadFile(file: File, options: FileUploadOptions): Promise<Model | null> {
1104
+ return uploadFile(this.fileUploadContext(), file, options);
1105
+ }
1198
1106
 
1199
- return models;
1107
+ batchUploadFiles(files: File[], options: BatchFileUploadOptions): Promise<Model[]> {
1108
+ return batchUploadFiles(this.fileUploadContext(), files, options);
1200
1109
  }
1201
1110
 
1202
1111
  /** Archive model (ARCHIVE) - works offline */
1203
- archive(model: Model): void {
1204
- this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
1112
+ archive(model: Model): Promise<void> | undefined {
1113
+ return this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
1205
1114
  }
1206
1115
 
1207
1116
  /**
@@ -1243,7 +1152,7 @@ export class SyncClient extends EventEmitter {
1243
1152
  // Most internal callers intentionally use fire-and-forget writes. Observe
1244
1153
  // their rejection without replacing the exact promise returned to model
1245
1154
  // operations that need authoritative per-transaction confirmation.
1246
- void confirmation.catch(() => undefined);
1155
+ void confirmation.catch(ignoreSeparatelyObservedMutationFailure);
1247
1156
  const pending = staging.then(() => undefined).catch((error: Error) => {
1248
1157
  this.runtime.observability.captureMutationFailure({
1249
1158
  context: `stage-mutation-${type}`,
@@ -1495,14 +1404,11 @@ export class SyncClient extends EventEmitter {
1495
1404
  markConnected(): void {
1496
1405
  this.setConnectionState('connected');
1497
1406
  // Browser online state may have marked the client connected before the
1498
- // WebSocket itself was ready. Always kick both durable lanes on the real
1499
- // socket event, even when the high-level state did not change.
1500
- void this.drainPendingConfirmations().catch((error: unknown) => {
1501
- this.runtime.observability.captureMutationFailure({
1502
- context: 'restore-commit-outbox',
1503
- error: error instanceof Error ? error : new Error(String(error)),
1504
- });
1505
- });
1407
+ // WebSocket itself was ready. Kick the durable lanes through the staging
1408
+ // barrier: a model mutation enters the in-memory store before its journal
1409
+ // row finishes saving, so a direct reconnect drain can otherwise try to
1410
+ // seal a source record that does not exist yet. The pending drain also
1411
+ // starts the atomic commit lane, so one ordered entry point covers both.
1506
1412
  void this.processPendingMutations();
1507
1413
  }
1508
1414
 
@@ -13,12 +13,17 @@
13
13
  */
14
14
 
15
15
  import type { ParticipantKind } from '@abloatai/transaction/types/participant';
16
+ import { AbloValidationError } from '@abloatai/transaction/errors';
16
17
  import type { Logger } from '@abloatai/transaction/logger';
17
18
  import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
18
19
  import {
19
20
  createAuthCredentialSource,
20
21
  type AuthCredentialSource,
21
22
  } from '@abloatai/transaction/auth/credentialSource';
23
+ import {
24
+ createPresenceSessionSource,
25
+ type PresenceSessionSource,
26
+ } from '@abloatai/transaction/presence';
22
27
  import {
23
28
  assertBrowserSafety,
24
29
  readProcessEnv,
@@ -47,6 +52,7 @@ export interface ClientPrelude<S extends SchemaRecord> {
47
52
  */
48
53
  readonly credentialResolver: CredentialProvider | null;
49
54
  readonly authCredentials: AuthCredentialSource;
55
+ readonly presenceSession: PresenceSessionSource;
50
56
  readonly logger: Logger;
51
57
  readonly url: string;
52
58
  /**
@@ -69,6 +75,17 @@ export interface ClientPrelude<S extends SchemaRecord> {
69
75
  export function resolveClientPrelude<S extends SchemaRecord>(
70
76
  options: AbloOptions<S>,
71
77
  ): ClientPrelude<S> {
78
+ // This package owns the reactive materialiser, not transport selection.
79
+ // TypeScript can miss excess properties on generic calls and object spreads,
80
+ // so reject a misplaced selector before resolving any credentials or
81
+ // constructing local state. The core `Ablo` client owns `transport`.
82
+ if ('transport' in options) {
83
+ throw new AbloValidationError(
84
+ "The reactive client does not accept `transport`. Import `Ablo` from " +
85
+ "'@abloatai/ablo' when selecting HTTP or WebSocket transport.",
86
+ { code: 'invalid_options', param: 'transport' },
87
+ );
88
+ }
72
89
  const env = readProcessEnv();
73
90
  const internalOptions = {
74
91
  ...options,
@@ -79,9 +96,11 @@ export function resolveClientPrelude<S extends SchemaRecord>(
79
96
  const configuredApiKey = resolveApiKey(authInput);
80
97
  const configuredAuthToken = resolveAuthToken(authInput);
81
98
  const credentialResolver = resolveCredentialResolver(configuredApiKey);
99
+ const presenceSession = createPresenceSessionSource();
82
100
  const authCredentials = createAuthCredentialSource(
83
101
  // eslint-disable-next-line @typescript-eslint/no-deprecated -- load-bearing on the self-hosted path; server-internal cap-mint (Phase 3) not shipped
84
102
  internalOptions.capabilityToken ?? configuredAuthToken,
103
+ presenceSession,
85
104
  );
86
105
  rejectRemovedDatabaseUrlOption(options);
87
106
  assertBrowserSafety({
@@ -108,6 +127,7 @@ export function resolveClientPrelude<S extends SchemaRecord>(
108
127
  configuredAuthToken,
109
128
  credentialResolver,
110
129
  authCredentials,
130
+ presenceSession,
111
131
  logger,
112
132
  url: resolveBaseURL(authInput),
113
133
  participantId,
@@ -90,6 +90,7 @@ export function createInternalComponents<S extends SchemaRecord>(
90
90
  syncGroups: options.syncGroups,
91
91
  instantModels: deriveInstantModels(schema),
92
92
  getAuthToken: auth?.getAuthToken,
93
+ presenceSession: auth?.presenceSession,
93
94
  runtime,
94
95
  });
95
96
 
@@ -120,6 +121,7 @@ export function createInternalComponents<S extends SchemaRecord>(
120
121
  schema,
121
122
  baseUrl: bootstrapBaseUrl,
122
123
  getAuthToken: auth?.getAuthToken,
124
+ presenceSession: auth?.presenceSession,
123
125
  runtime,
124
126
  // The one canonical log position; the loader reads its floor when a query
125
127
  // leaves so a late answer cannot overwrite a row the pool already knows to
@@ -143,12 +143,16 @@ import type {
143
143
  HttpModelClient,
144
144
  } from '@abloatai/transaction/transport/http';
145
145
  import type { ParticipantKind } from '@abloatai/transaction/types/participant';
146
+ import type { PresenceSession } from '@abloatai/transaction/presence';
146
147
  import {
147
148
  capturePointRead,
148
149
  prepareReadSet,
149
150
  type ReadSetContext,
150
151
  } from '@abloatai/transaction/internal/read-set';
151
152
 
153
+ const ignoreSeparatelyObservedMutationFailure = (): undefined => undefined;
154
+ const ignoreBestEffortClaimReleaseFailure = (): undefined => undefined;
155
+
152
156
  export interface ModelClientMeta {
153
157
  readonly key: string;
154
158
  readonly typename: string;
@@ -174,6 +178,8 @@ type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
174
178
  // `ModelCollaboration<Item>` and `ModelCollaboration<Invoice>` the same type
175
179
  // while reading as though they differed.
176
180
  export interface ModelCollaboration {
181
+ /** Session projections already held by this client's one presence store. */
182
+ presence(model: string, recordId?: string): readonly PresenceSession[];
177
183
  /** Exact point evidence from the HTTP read boundary (stamp captured before data). */
178
184
  readPoint(model: string, id: string): Promise<{ data: unknown; stamp: number }>;
179
185
  /**
@@ -333,6 +339,9 @@ interface ReactiveModelSurface<T, Fields = T> {
333
339
  /** The synchronous local-graph reads. */
334
340
  local: LocalReads<T>;
335
341
 
342
+ /** Sessions currently active on this model, optionally narrowed to one record. */
343
+ presence(recordId?: string): readonly PresenceSession[];
344
+
336
345
  /**
337
346
  * Claim a row so other writers wait or are rejected until you're done, and
338
347
  * inspect or manage that coordination through the same namespace. Call it to
@@ -490,7 +499,7 @@ export function createModelOperations<T, C>(
490
499
  // await does not create an unhandled-rejection process error. Returning
491
500
  // the original promise preserves normal rejection for callers that do
492
501
  // await or attach their own catch handler.
493
- void confirmation.catch(() => undefined);
502
+ void confirmation.catch(ignoreSeparatelyObservedMutationFailure);
494
503
  return confirmation;
495
504
  };
496
505
  };
@@ -660,9 +669,9 @@ export function createModelOperations<T, C>(
660
669
  // This runs after authoritative confirmation. A best-effort abandon frame
661
670
  // cannot turn a committed write into an apparent failure; the server has
662
671
  // already fulfilled the participant's claims as part of that commit.
663
- await releaseClaimsForEntity(entityId).catch(() => undefined);
672
+ await releaseClaimsForEntity(entityId).catch(ignoreBestEffortClaimReleaseFailure);
664
673
  if (explicit && !explicitWasLocal) {
665
- await explicit.release?.().catch(() => undefined);
674
+ await explicit.release?.().catch(ignoreBestEffortClaimReleaseFailure);
666
675
  }
667
676
  };
668
677
 
@@ -1376,7 +1385,7 @@ export function createModelOperations<T, C>(
1376
1385
  await waitForMutation(model, confirmation);
1377
1386
  return modelAsRow<T>(model);
1378
1387
  } finally {
1379
- await autoLease?.release?.().catch(() => {});
1388
+ await autoLease?.release?.().catch(ignoreBestEffortClaimReleaseFailure);
1380
1389
  }
1381
1390
  });
1382
1391
 
@@ -1400,6 +1409,8 @@ export function createModelOperations<T, C>(
1400
1409
  const operations: ModelOperations<T, C> = {
1401
1410
  local,
1402
1411
 
1412
+ presence: (recordId?: string) => collaboration?.presence(registeredModelName, recordId) ?? [],
1413
+
1403
1414
  get,
1404
1415
  read,
1405
1416
 
@@ -1501,7 +1512,7 @@ export function createModelOperations<T, C>(
1501
1512
  const confirmation = syncClient.update(
1502
1513
  model,
1503
1514
  effective,
1504
- patch as Record<string, unknown>,
1515
+ patch,
1505
1516
  );
1506
1517
  await waitForMutation(model, confirmation);
1507
1518
  return modelAsRow<T>(model);
@@ -1562,7 +1573,7 @@ export function createModelOperations<T, C>(
1562
1573
  const confirmation = syncClient.update(
1563
1574
  model,
1564
1575
  effective,
1565
- params.data as Record<string, unknown>,
1576
+ params.data,
1566
1577
  );
1567
1578
  await waitForMutation(model, confirmation);
1568
1579
  const updated = modelAsRow<T>(model);
@@ -39,7 +39,10 @@ import {
39
39
  bindClaimLifetime,
40
40
  claimLifetimeOf,
41
41
  } from '@abloatai/transaction/claims/lifetime';
42
- import type { AttachablePresenceStream } from '../../presenceStream.js';
42
+ import {
43
+ attachPresenceToClient,
44
+ type AttachablePresence,
45
+ } from '../../presence/index.js';
43
46
  import type { ClaimWaitOptions } from '@abloatai/transaction/types/streams';
44
47
  import type { Claim } from '@abloatai/transaction/types/streams';
45
48
  import { resolveApiKeyValue, resolveBootstrapBaseUrl } from '@abloatai/transaction/auth/apiKey';
@@ -96,7 +99,7 @@ export interface ReactiveEngineInputs<S extends SchemaRecord> extends ClientPrel
96
99
  transport: SyncWebSocket;
97
100
  /** The humans() plugin's contribution — built by its `init`, already
98
101
  * attached to the connection the context carried. */
99
- presence: AttachablePresenceStream;
102
+ presence: AttachablePresence;
100
103
  /**
101
104
  * The store cluster `humans().init` constructed from the widened context:
102
105
  * this client's runtime, the component graph, and the store. The engine
@@ -183,7 +186,11 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
183
186
  // filter own echoes by participant id, seeded in `ready()` alongside the
184
187
  // locals above.
185
188
  const presenceStream = presence;
186
- const claimStream = createClaimStream({ participantId, logger }, transport);
189
+ const claimStream = createClaimStream(
190
+ { logger },
191
+ transport,
192
+ presenceStream,
193
+ );
187
194
 
188
195
  // 6. Validate options up front — fail loudly on obviously wrong inputs so
189
196
  // strangers don't get silent empty results. Validation errors are written
@@ -239,17 +246,11 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
239
246
  kind,
240
247
  logger,
241
248
  validationError: _validationError,
242
- onIdentityResolved: ({ userId, participantKind, accountScope, syncGroups, authority }) => {
249
+ onIdentityResolved: ({ userId, participantKind, accountScope, authority }) => {
243
250
  selfParticipantId = userId;
244
251
  selfParticipantKind = participantKind;
245
252
  _resolvedOrganizationId = accountScope;
246
253
  _resolvedIdentity = authority;
247
- presenceStream.setParticipant({
248
- id: userId,
249
- kind: participantKind,
250
- syncGroups: [...syncGroups],
251
- });
252
- claimStream.setParticipant({ id: userId });
253
254
  },
254
255
  });
255
256
  const ready = lifecycle.ready;
@@ -573,6 +574,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
573
574
  modelRegistry,
574
575
  hydration,
575
576
  {
577
+ presence: (model, recordId) => presenceStream.forModel(model, recordId),
576
578
  createClaim: (claimOptions) => publicClaims.create(claimOptions),
577
579
  // Lazily referenced: `commits` is declared below this loop, and this
578
580
  // only runs when someone actually writes a batch.
@@ -831,6 +833,11 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
831
833
  return store.syncStatus;
832
834
  },
833
835
 
836
+ // The humans capability owns the connection-backed presence projection.
837
+ // Keep it on the base client as well as in the plugin surface so the
838
+ // concrete AbloClient contract and runtime object agree before layering.
839
+ presence: presenceStream,
840
+
834
841
  schema,
835
842
 
836
843
  // ── Internal accessors for framework integration ─────────────────
@@ -848,10 +855,6 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
848
855
  /** The SyncWebSocket — for collaboration events (selection, cursors). */
849
856
  get _ws() { return store.getSyncWebSocket(); },
850
857
 
851
- /** Presence livestream — same socket as entity sync, no second
852
- * connection. Stable reference across the engine's lifetime. */
853
- presence: presenceStream,
854
-
855
858
  /** Claim livestream — same socket. Stable reference. */
856
859
  claims: publicClaims,
857
860
 
@@ -859,6 +862,8 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
859
862
 
860
863
  } as Ablo<S>;
861
864
 
865
+ attachPresenceToClient(engine, presenceStream);
866
+
862
867
  Object.defineProperty(engine, kReadEvidence, {
863
868
  value: {
864
869
  context: cluster.readSetContext,
@@ -0,0 +1,97 @@
1
+ /** File-upload behavior owned beneath the SyncClient boundary. */
2
+
3
+ import { AbloAuthenticationError } from '@abloatai/transaction/errors';
4
+ import type { RuntimeContext } from './RuntimeContext.js';
5
+ import { Model } from './Model.js';
6
+ import { InstanceCache, ModelScope } from './InstanceCache.js';
7
+ import type { MutationQueue } from './transactions/mutations/MutationQueue.js';
8
+
9
+ export interface FileUploadOptions {
10
+ readonly id: string;
11
+ readonly attachableType: string;
12
+ readonly attachableId: string;
13
+ readonly metadata?: Record<string, unknown>;
14
+ }
15
+
16
+ export interface BatchFileUploadOptions {
17
+ readonly ids: string[];
18
+ readonly attachableType: string;
19
+ readonly attachableId: string;
20
+ readonly metadata?: Record<string, unknown>;
21
+ }
22
+
23
+ export interface FileUploadContext {
24
+ readonly userId: string | null;
25
+ readonly organizationId: string | null;
26
+ readonly mutationQueue: MutationQueue;
27
+ readonly objectPool: InstanceCache;
28
+ readonly observability: RuntimeContext['observability'];
29
+ readonly notifyCreated: (model: Model) => void;
30
+ }
31
+
32
+ function authenticatedContext(context: FileUploadContext): {
33
+ readonly userId: string;
34
+ readonly organizationId: string;
35
+ } {
36
+ if (!context.userId || !context.organizationId) {
37
+ throw new AbloAuthenticationError('Authentication required for file uploads', {
38
+ code: 'file_upload_auth_required',
39
+ });
40
+ }
41
+ return { userId: context.userId, organizationId: context.organizationId };
42
+ }
43
+
44
+ function acceptUploadedModel(
45
+ context: FileUploadContext,
46
+ data: Record<string, unknown>,
47
+ ): Model | null {
48
+ const model = context.objectPool.createFromData(data);
49
+ if (!model) return null;
50
+ context.objectPool.add(model, ModelScope.live);
51
+ context.notifyCreated(model);
52
+ return model;
53
+ }
54
+
55
+ export async function uploadFile(
56
+ context: FileUploadContext,
57
+ file: File,
58
+ options: FileUploadOptions,
59
+ ): Promise<Model | null> {
60
+ const identity = authenticatedContext(context);
61
+ try {
62
+ const result = await context.mutationQueue.uploadAttachment(file, {
63
+ id: options.id,
64
+ attachableType: options.attachableType,
65
+ attachableId: options.attachableId,
66
+ metadata: options.metadata,
67
+ }, identity);
68
+ return result
69
+ ? acceptUploadedModel(context, { id: options.id, ...result })
70
+ : null;
71
+ } catch (error) {
72
+ context.observability.captureMutationFailure({
73
+ context: 'file-upload',
74
+ error: error instanceof Error ? error : new Error(String(error)),
75
+ });
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ export async function batchUploadFiles(
81
+ context: FileUploadContext,
82
+ files: File[],
83
+ options: BatchFileUploadOptions,
84
+ ): Promise<Model[]> {
85
+ const identity = authenticatedContext(context);
86
+ const items = options.ids.map((id) => ({
87
+ id,
88
+ attachableType: options.attachableType,
89
+ attachableId: options.attachableId,
90
+ metadata: options.metadata,
91
+ }));
92
+ const results = await context.mutationQueue.batchUploadAttachments(files, items, identity);
93
+ return results.flatMap((result) => {
94
+ const model = acceptUploadedModel(context, { ...result });
95
+ return model ? [model] : [];
96
+ });
97
+ }
@@ -20,6 +20,7 @@ import { classifyRecovery, type RecoveryClass } from '@abloatai/transaction/erro
20
20
  import { withAuthHeaders, type AuthTokenGetter } from '@abloatai/transaction/auth/credentialSource';
21
21
  import { globalRuntime } from '../context.js';
22
22
  import type { RuntimeContext } from '../RuntimeContext.js';
23
+ import type { PresenceSessionSource } from '@abloatai/transaction/presence';
23
24
 
24
25
  // ── Response validation ─────────────────────────────────────────────────
25
26
  //
@@ -78,6 +79,8 @@ export interface PostQueryOptions {
78
79
  * effect without rebuilding the client.
79
80
  */
80
81
  capabilityToken?: string;
82
+ /** Server-bound attribution shared with the owning WebSocket. */
83
+ presenceSession?: PresenceSessionSource;
81
84
 
82
85
  /**
83
86
  * An optional hook that tries to recover from a rejected credential. When a
@@ -128,6 +131,7 @@ export async function postQuery(
128
131
  options.getAuthToken,
129
132
  { 'Content-Type': 'application/json' },
130
133
  options.capabilityToken,
134
+ options.presenceSession,
131
135
  );
132
136
  const response = await fetch(url, {
133
137
  method: 'POST',
@@ -97,6 +97,8 @@ export interface BootstrapOptions {
97
97
  * {@link BootstrapFetcher.setAuthToken}.
98
98
  */
99
99
  getAuthToken?: AuthTokenGetter;
100
+ /** Server-bound attribution shared with the owning WebSocket. */
101
+ presenceSession?: import('@abloatai/transaction/presence').PresenceSessionSource;
100
102
  /** The owning client's runtime. Defaults to the module-global bridge. */
101
103
  runtime?: RuntimeContext;
102
104
  }
@@ -186,13 +188,14 @@ function classifyRequestFailure(
186
188
  }
187
189
 
188
190
  export class BootstrapFetcher {
189
- private options: Required<Omit<BootstrapOptions, 'baseUrl' | 'instantModels' | 'organizationId' | 'cacheScope' | 'getAuthToken' | 'runtime'>> & {
191
+ private options: Required<Omit<BootstrapOptions, 'baseUrl' | 'instantModels' | 'organizationId' | 'cacheScope' | 'getAuthToken' | 'presenceSession' | 'runtime'>> & {
190
192
  baseUrl: string;
191
193
  instantModels?: string[];
192
194
  cacheScope: string | null;
193
195
  organizationId?: string;
194
196
  authToken?: string;
195
197
  getAuthToken?: AuthTokenGetter;
198
+ presenceSession?: import('@abloatai/transaction/presence').PresenceSessionSource;
196
199
  runtime?: RuntimeContext;
197
200
  };
198
201
 
@@ -311,7 +314,12 @@ export class BootstrapFetcher {
311
314
  try {
312
315
  const res = await fetch(`${this.options.baseUrl}/schema`, {
313
316
  method: 'GET',
314
- headers: withAuthHeaders(this.options.getAuthToken, {}, this.options.authToken),
317
+ headers: withAuthHeaders(
318
+ this.options.getAuthToken,
319
+ {},
320
+ this.options.authToken,
321
+ this.options.presenceSession,
322
+ ),
315
323
  });
316
324
  if (!res.ok) throw new Error(`schema read-back ${res.status}`);
317
325
  const body = (await res.json()) as { models?: unknown };
@@ -768,6 +776,7 @@ export class BootstrapFetcher {
768
776
  this.options.getAuthToken,
769
777
  { 'Content-Type': 'application/json' },
770
778
  this.options.authToken,
779
+ this.options.presenceSession,
771
780
  );
772
781
 
773
782
  const controller = new AbortController();
@@ -977,7 +986,7 @@ export class BootstrapFetcher {
977
986
  'Content-Type': 'application/json',
978
987
  'Cache-Control': 'no-cache, no-store, must-revalidate',
979
988
  Pragma: 'no-cache',
980
- }, this.options.authToken),
989
+ }, this.options.authToken, this.options.presenceSession),
981
990
  signal: controller.signal,
982
991
  cache: 'no-store', // Force browser to not cache
983
992
  });
@@ -1049,7 +1058,7 @@ export class BootstrapFetcher {
1049
1058
  method: 'GET',
1050
1059
  headers: withAuthHeaders(this.options.getAuthToken, {
1051
1060
  'Content-Type': 'application/json',
1052
- }, this.options.authToken),
1061
+ }, this.options.authToken, this.options.presenceSession),
1053
1062
  signal: controller.signal,
1054
1063
  });
1055
1064
  } catch (error) {
@@ -41,6 +41,7 @@ import type { LoadWhere, Query, WhereClause, WhereOp } from '../query/types.js';
41
41
  import { normalizeWhere } from '@abloatai/transaction/client/resources/where';
42
42
  import type { Schema } from '@abloatai/transaction/schema/schema';
43
43
  import type { LogPositionPort } from '../logPosition.js';
44
+ import type { PresenceSessionSource } from '@abloatai/transaction/presence';
44
45
 
45
46
  export interface OnDemandLoaderOptions {
46
47
  readonly objectPool: InstanceCache;
@@ -58,6 +59,7 @@ export interface OnDemandLoaderOptions {
58
59
  * propagate without re-instantiating the coordinator.
59
60
  */
60
61
  readonly getAuthToken?: () => string | null;
62
+ readonly presenceSession?: PresenceSessionSource;
61
63
  /** @deprecated Use `getAuthToken`. */
62
64
  readonly getCapabilityToken?: () => string | null;
63
65
  /** The owning client's runtime. Defaults to the module-global bridge. */
@@ -647,6 +649,7 @@ export class OnDemandLoader {
647
649
  getAuthToken: this.authTokenProvider ?? undefined,
648
650
  recoverCredential: this.credentialRecovery ?? undefined,
649
651
  runtime: this.opts.runtime,
652
+ presenceSession: this.opts.presenceSession,
650
653
  },
651
654
  { queries: [query] },
652
655
  );