@opencxh/domain 1.224.0 → 1.225.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.
@@ -102,10 +102,21 @@ export interface FileUploadPayload {
102
102
  mimeType: string;
103
103
  accountId?: string;
104
104
  path?: string;
105
+ /**
106
+ * Write only if the object still carries this version marker.
107
+ *
108
+ * The floor is the caller's own pre-check; this closes the window between
109
+ * that check and the write. A backend that does not support conditional
110
+ * writes ignores the field, so the pre-check has to stay - see the save
111
+ * handler in `apps/storage`.
112
+ */
113
+ ifMatch?: string;
105
114
  }
106
115
  export interface FileUploadResponse {
107
116
  id: string;
108
117
  url: string;
118
+ /** The version marker the object now carries, when the backend reported one. */
119
+ revision?: string;
109
120
  }
110
121
  export interface FileGetPayload {
111
122
  accountId?: string;
@@ -54,17 +54,23 @@ export interface FilePointer {
54
54
  size: number;
55
55
  ownerId: string;
56
56
  createdAt?: number;
57
+ /** When the backend last saw it change. Only set on a listing the backend owns. */
58
+ modifiedAt?: number;
57
59
  tags?: string[];
58
60
  /**
59
- * Bumped on every write to the file's bytes. An editor reads it when it opens
60
- * the file and presents it back on save; the save is a compare-and-set on this
61
- * value, so a second writer is refused instead of silently overwriting.
61
+ * The version an editor read, handed back on save so a second writer is
62
+ * refused instead of silently overwriting.
63
+ *
64
+ * **Opaque to every reader.** It is a counter on a row the storage app owns
65
+ * and a version marker (an ETag) on a backend that owns its own tree, and no
66
+ * client does arithmetic on it - it is read when the file opens and presented
67
+ * back unchanged. Only the writer that minted it compares it.
62
68
  *
63
69
  * Optional because rows written before the field existed read back undefined,
64
70
  * and there is no `$exists` to find them. The first save on such a row is
65
71
  * unconditional and gives it one.
66
72
  */
67
- revision?: number;
73
+ revision?: string | number;
68
74
  }
69
75
  /**
70
76
  * A field a storage provider needs to connect an account. Providers declare
@@ -78,12 +84,276 @@ export interface StorageAccountField {
78
84
  required?: boolean;
79
85
  placeholder?: string;
80
86
  }
87
+ /**
88
+ * What a storage backend can actually do.
89
+ *
90
+ * Declared per provider rather than assumed, because the backends genuinely
91
+ * differ: a bucket has no rename (it is a copy plus a delete), a Graph drive
92
+ * has no notion of a base path, and FTP can do nothing at all in this runtime.
93
+ * The UI reads this instead of branching on a provider id, so a new backend
94
+ * needs no changes here.
95
+ *
96
+ * An empty array is a legitimate answer: "connected, but nothing works right
97
+ * now" is more useful than a button that fails on click.
98
+ */
99
+ export type StorageCapability =
100
+ /** Read one object's bytes. */
101
+ "get"
102
+ /** Write one object's bytes. */
103
+ | "upload" | "delete"
104
+ /** List one directory. Without it the backend cannot be browsed at all. */
105
+ | "list"
106
+ /** Report one entry: the size, type and version marker of a single key. */
107
+ | "stat"
108
+ /** Enumerate the locations an account can reach (a drive, a site, a bucket). */
109
+ | "roots"
110
+ /**
111
+ * Move a key to another key. Whether that is atomic is the backend's business:
112
+ * a drive does a real move, a bucket copies and deletes.
113
+ */
114
+ | "rename"
115
+ /** Materialise an empty directory. A bucket cannot; a drive can. */
116
+ | "create-folder"
117
+ /** Answer a free-text query the store itself resolves. */
118
+ | "search"
119
+ /** Give someone else access, at the backend, to a file we do not own the ACL of. */
120
+ | "share"
121
+ /** Say who has access right now. */
122
+ | "share-list"
123
+ /** Take an access away again. */
124
+ | "share-revoke"
125
+ /** Say which earlier versions of a file the backend still holds. */
126
+ | "version-list"
127
+ /** Read the bytes of one earlier version. */
128
+ | "version-get"
129
+ /** Make an earlier version the current one again. */
130
+ | "version-restore";
131
+ /** Who a share reaches. Deliberately no "anyone": see the plan for why. */
132
+ export type ShareAudience = "people" | "organization";
133
+ export type ShareRole = "read" | "write";
134
+ /**
135
+ * One access that exists at the backend right now.
136
+ *
137
+ * Read back from the provider on every question rather than mirrored here. The
138
+ * backend owns the tree and it owns the ACL with it; a table of our own beside
139
+ * it would be a second, staler account of who can open a file - and the moment
140
+ * somebody changes it in OneDrive itself, ours would be wrong and confident.
141
+ */
142
+ export interface ProviderShare {
143
+ /** The backend's own permission id. Opaque - only it knows what this means. */
144
+ id: string;
145
+ audience: ShareAudience;
146
+ role: ShareRole;
147
+ /** Who it reaches, as the backend names them. */
148
+ recipients: {
149
+ name?: string;
150
+ email?: string;
151
+ }[];
152
+ url?: string;
153
+ expiresAt?: number;
154
+ /**
155
+ * Granted on a folder further up, not on this file.
156
+ *
157
+ * You cannot take it back from here, so the UI shows it greyed and without a
158
+ * button instead of offering an action that 403s.
159
+ */
160
+ inherited?: boolean;
161
+ }
162
+ /** `POST /provider/storage/share` */
163
+ export interface ProviderSharePayload {
164
+ accountId?: string;
165
+ root?: string;
166
+ key: string;
167
+ audience: ShareAudience;
168
+ role: ShareRole;
169
+ /** Addresses, already resolved by the storage app. Only for `people`. */
170
+ recipients?: string[];
171
+ expiresAt?: number;
172
+ message?: string;
173
+ }
174
+ export interface ProviderShareResult {
175
+ /** Present for a link share; a person-share sends an invitation instead. */
176
+ url?: string;
177
+ grants: ProviderShare[];
178
+ }
179
+ /** `POST /provider/storage/shares` and `/unshare`. */
180
+ export interface ProviderSharesPayload {
181
+ accountId?: string;
182
+ root?: string;
183
+ key: string;
184
+ }
185
+ export interface ProviderUnsharePayload extends ProviderSharesPayload {
186
+ shareId: string;
187
+ }
188
+ /**
189
+ * One earlier state of a file.
190
+ *
191
+ * `id` is opaque and travels back to the provider verbatim: a drive mints a
192
+ * readable "3.0", a bucket an arbitrary token. Nobody else may parse it, and
193
+ * nobody may order on it - `modifiedAt` is the only orderable field.
194
+ *
195
+ * Everything past `modifiedAt` is optional because the two backends disagree on
196
+ * what a version even records: a drive knows who saved it, a bucket knows only
197
+ * that somebody did.
198
+ */
199
+ export interface ProviderVersion {
200
+ id: string;
201
+ modifiedAt: number;
202
+ /** Absent where the backend does not report a size per version. */
203
+ size?: number;
204
+ /** Absent at a bucket: S3 records an owner id, not a person. */
205
+ author?: {
206
+ name?: string;
207
+ email?: string;
208
+ };
209
+ /** The version that is the file right now. One of them, or none. */
210
+ current?: boolean;
211
+ /** A marker that hides the key rather than a state of it: no bytes to read. */
212
+ deleted?: boolean;
213
+ }
214
+ /** `POST /provider/storage/versions` */
215
+ export interface ProviderVersionsPayload {
216
+ accountId?: string;
217
+ root?: string;
218
+ key: string;
219
+ }
220
+ /** `POST /provider/storage/version-get` and `/provider/storage/version-restore`. */
221
+ export interface ProviderVersionPayload extends ProviderVersionsPayload {
222
+ versionId: string;
223
+ }
224
+ export interface ProviderVersionsResult {
225
+ versions: ProviderVersion[];
226
+ /**
227
+ * False only where the backend *could* keep history but it is switched off -
228
+ * a bucket without versioning. A drive that always keeps it answers true with
229
+ * a single entry, and "no history yet" is a different sentence on screen than
230
+ * "history is off here".
231
+ */
232
+ enabled: boolean;
233
+ }
81
234
  /** A connectable storage provider + the fields needed to connect an account. */
82
235
  export interface StorageProviderInfo {
83
236
  /** Storage-role provider id (e.g. base64("ftp:storage")). */
84
237
  id: string;
85
238
  displayName: string;
86
239
  accountFields: StorageAccountField[];
240
+ /**
241
+ * Empty when the provider still serves only the old, un-namespaced
242
+ * `/provider/describe` - that route predates capabilities and cannot report
243
+ * them, so "we do not know" and "nothing works" read the same here.
244
+ */
245
+ capabilities: StorageCapability[];
246
+ /** What a share may look like here; present only when `share` is declared. */
247
+ share?: StorageProviderDescription["share"];
248
+ }
249
+ /**
250
+ * The payload of `GET /provider/storage/describe`.
251
+ *
252
+ * A separate, namespaced route rather than a wider `/provider/describe`: an app
253
+ * serves exactly one bare describe, and an app that is also a communication
254
+ * provider has already spent it. This follows the convention already set by
255
+ * `/provider/sync/describe` and `/provider/scope/describe`.
256
+ */
257
+ export interface StorageProviderDescription {
258
+ displayName: string;
259
+ accountFields: StorageAccountField[];
260
+ capabilities: StorageCapability[];
261
+ /**
262
+ * What a share may look like at this backend. Present only when `share` is
263
+ * declared; the dialog is built from it, so a backend that shares differently
264
+ * needs no UI changes here.
265
+ */
266
+ share?: {
267
+ audiences: ShareAudience[];
268
+ roles: ShareRole[];
269
+ /** May a share carry an expiry date? */
270
+ expiry: boolean;
271
+ };
272
+ }
273
+ /**
274
+ * One location an account can reach: a OneDrive, a SharePoint site, a bucket.
275
+ *
276
+ * The point of asking the provider instead of having someone type it in: one
277
+ * connected Microsoft account can reach a personal drive plus every site the
278
+ * person is a member of, and making an admin create a row per site per user
279
+ * does not scale and goes stale the moment access is revoked.
280
+ */
281
+ export interface StorageRoot {
282
+ /**
283
+ * Opaque to the storage app - it travels back to the provider verbatim on
284
+ * every `list`. A provider is free to put a drive id, a site id, or nothing
285
+ * meaningful in it.
286
+ */
287
+ key: string;
288
+ name: string;
289
+ kind: "drive" | "site" | "library" | "bucket" | "shared";
290
+ }
291
+ /** `POST /provider/storage/roots` */
292
+ export interface ProviderRootsPayload {
293
+ accountId?: string;
294
+ }
295
+ /** `POST /provider/storage/list` - one directory at the backend. */
296
+ export interface ProviderListPayload {
297
+ accountId?: string;
298
+ /** A {@link StorageRoot.key}; empty means the account's own default root. */
299
+ root?: string;
300
+ /** Directory inside that root: leading slash, no trailing one. */
301
+ path?: string;
302
+ /** Opaque continuation token from a previous answer. */
303
+ cursor?: string;
304
+ /**
305
+ * Return the whole subtree instead of one directory.
306
+ *
307
+ * Only for the cross-location views ("Recent"), which ask a question no single
308
+ * directory answers. A provider that cannot do it is free to ignore the flag
309
+ * and return its normal listing - the caller then simply sees less, which is
310
+ * better than a second route that half the backends refuse.
311
+ */
312
+ recursive?: boolean;
313
+ }
314
+ /** `POST /provider/storage/rename` - move a key, returning the key it now has. */
315
+ export interface ProviderRenamePayload {
316
+ accountId?: string;
317
+ root?: string;
318
+ key: string;
319
+ /** The full key it should have; the storage app builds it from path + name. */
320
+ newKey: string;
321
+ }
322
+ /** `POST /provider/storage/stat` - one entry, or null when it is not there. */
323
+ export interface ProviderStatPayload {
324
+ accountId?: string;
325
+ root?: string;
326
+ /** The backend's own key, as handed out by `list`. */
327
+ key: string;
328
+ }
329
+ /**
330
+ * One entry in a directory, as the *backend* sees it.
331
+ *
332
+ * Deliberately not a {@link FilePointer}: a provider knows nothing about mounts,
333
+ * owners or the storage app's ids, and inventing them there would be the
334
+ * provider claiming things it cannot know. The storage app maps this into a
335
+ * `FilePointer` on the way out, which is where the id is minted.
336
+ */
337
+ export interface ProviderEntry {
338
+ name: string;
339
+ type: "file" | "folder";
340
+ /** The backend's own key for this entry. Opaque to the storage app. */
341
+ key: string;
342
+ size: number;
343
+ /** Epoch millis; 0 when the backend did not say. */
344
+ modifiedAt: number;
345
+ /** Set only when the backend actually knows; the storage app guesses otherwise. */
346
+ mimeType?: string;
347
+ /**
348
+ * Version marker for a compare-and-set write (an S3 ETag, a Graph eTag).
349
+ * Absent means the backend offers no such guarantee.
350
+ */
351
+ revision?: string;
352
+ }
353
+ export interface ProviderListResult {
354
+ entries: ProviderEntry[];
355
+ /** Present when the directory was truncated; hand it back to continue. */
356
+ cursor?: string;
87
357
  }
88
358
  /**
89
359
  * A linked storage backend account (credentials to an S3 bucket, an FTP host, …).
@@ -93,6 +363,13 @@ export interface StorageProviderInfo {
93
363
  export interface StorageAccount {
94
364
  id: string;
95
365
  organizationId: string;
366
+ /**
367
+ * Whose credential this is. Absent means the organisation's, set means one
368
+ * person's - the single ownership axis on a credential, as
369
+ * `platform/account.ts` lays down. A location backed by a personal account
370
+ * can only ever be personal: sharing it would be lending out the token.
371
+ */
372
+ userId?: string;
96
373
  /** Account-provider id, e.g. base64("s3:account"). */
97
374
  providerId: string;
98
375
  displayName: string;
@@ -107,6 +107,16 @@ export interface SyncSourceDefinition {
107
107
  * failed one.
108
108
  */
109
109
  accountProviderId?: string;
110
+ /**
111
+ * The settings key that decides which kind this connection lands.
112
+ *
113
+ * Most sources know that at build time and list it in {@link SyncSourceDefinition.kinds}. A
114
+ * generic source does not: one MCP tool answers with one kind of thing, but *which* kind is a
115
+ * choice made per connection. Declaring the key here — rather than letting the UI guess that a
116
+ * setting called `kind` is special — keeps the mapping step showing the fields of the one kind
117
+ * this connection will actually produce, instead of every kind the source could ever land.
118
+ */
119
+ kindSetting?: string;
110
120
  /**
111
121
  * Settings that differ per connection and therefore do not belong in code.
112
122
  *
@@ -303,6 +313,24 @@ export interface SyncLandOutcome {
303
313
  export interface SyncLandResponse {
304
314
  outcomes: SyncLandOutcome[];
305
315
  }
316
+ /**
317
+ * One field a target app accepts for a kind — the right-hand side of a column mapping.
318
+ *
319
+ * Deliberately **flat and scalar only**. A field that carries structure (a contact's endpoint
320
+ * list, a work item's assignees) cannot come out of a CSV column or a tool answer anyway, and
321
+ * declaring it would promise a mapping UI something it cannot render. An app that wants such a
322
+ * value mappable declares a scalar alias instead (`email`) and assembles the structure itself —
323
+ * that conversion belongs with the app that owns the shape, not in the pipeline.
324
+ */
325
+ export interface SyncTargetField {
326
+ key: string;
327
+ label: string;
328
+ /** Missing after mapping ⇒ that one row fails, not the batch. */
329
+ required?: boolean;
330
+ type?: "text" | "number" | "boolean" | "date";
331
+ /** Explanation under the field in the mapping UI. */
332
+ help?: string;
333
+ }
306
334
  /**
307
335
  * Bare payload of `GET /provider/sync/land-describe`: which kinds this app can land.
308
336
  *
@@ -313,7 +341,19 @@ export interface SyncLandResponse {
313
341
  export interface SyncTargetDescribe {
314
342
  source: string;
315
343
  kinds: string[];
344
+ /**
345
+ * Per kind, the fields a mapping may fill. Absent = this app does not support column mapping,
346
+ * and only a source that maps in code (`transport: "native"`) can feed it.
347
+ */
348
+ fields?: Record<string, SyncTargetField[]>;
316
349
  }
350
+ /**
351
+ * The target field key that always has to be mapped, next to whatever the app declares.
352
+ *
353
+ * It is not in {@link SyncTargetDescribe.fields} because no app owns it: it is the dedupe axis of
354
+ * the pipeline itself. A mapping without it would land every row as a new one, every round.
355
+ */
356
+ export declare const SYNC_MAPPING_EXTERNAL_ID = "externalId";
317
357
  /** How healthy a connection is. What the list shows as a badge. */
318
358
  export type SyncHealth =
319
359
  /** Last run succeeded. */
@@ -352,6 +392,30 @@ export interface SyncConnection {
352
392
  * cursor: one schema field that has to be able to carry any shape.
353
393
  */
354
394
  settings?: Record<string, unknown>;
395
+ /**
396
+ * The field names this connection's source actually delivers — the left-hand side of the
397
+ * mapping UI.
398
+ *
399
+ * On the **connection** and not on the source definition, because that is where the truth is:
400
+ * for a CSV they are the column headers of *this* file, for a tool the keys of *this* answer.
401
+ * A static declaration on the source would have to lie about both.
402
+ */
403
+ rawFields?: string[];
404
+ /**
405
+ * Target field key → raw field name. Absent = the source maps in code and the runtime passes
406
+ * records through untouched, which is what every `native` source does.
407
+ */
408
+ mapping?: Record<string, string>;
409
+ /**
410
+ * Namespace stamped in front of every mapped external id (`hubspot` ⇒ `hubspot:5591`).
411
+ *
412
+ * A source that maps in code namespaces its own ids, and must: a bare `12345` from two systems
413
+ * is the same row as far as the dedupe index is concerned, so without this two connections
414
+ * would merge each other's records. A mapped source cannot do it itself — the mapping runs
415
+ * after the pull and is the thing that decides which field is the id — so the runtime does it,
416
+ * and the connection carries the namespace. Required as soon as a mapping exists.
417
+ */
418
+ externalIdPrefix?: string;
355
419
  /** Free-form name; absent = the source's label. */
356
420
  label?: string;
357
421
  mode: SyncMode;
@@ -410,6 +474,14 @@ export interface SyncRun {
410
474
  failed: number;
411
475
  /** Was this a sweep? Explains why `scanned` is suddenly much higher. */
412
476
  fullSweep?: boolean;
477
+ /**
478
+ * A trial round: mapped and checked, but nothing written and no cursor moved.
479
+ *
480
+ * It reads **one page**, on purpose — a dry run is a sample that answers "does this mapping
481
+ * hold up", not an import. `written` therefore stays 0 and the rows that would have landed are
482
+ * counted as `skipped`; only rows the mapping could not produce count as `failed`.
483
+ */
484
+ dryRun?: boolean;
413
485
  /** The error that stopped the whole run. Empty on `partial` — see {@link SyncRun.errors}. */
414
486
  error?: string;
415
487
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencxh/domain",
3
- "version": "1.224.0",
3
+ "version": "1.225.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",