@opencxh/domain 1.226.0 → 1.228.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.
@@ -0,0 +1,426 @@
1
+ import { SyncRecord } from './sync';
2
+ /** The provider role group, as a constant so a typo does not silently yield an empty list. */
3
+ export declare const CONNECTOR_PROVIDER_GROUP = "connector";
4
+ /**
5
+ * What a connector can do with a kind — read by the UI instead of branching on a connector id.
6
+ *
7
+ * An empty list is a valid answer: it says "declared, but nothing works yet". Same shape and same
8
+ * reason as `StorageCapability`, which already proves it — the browser there asks what a backend
9
+ * supports rather than knowing which backend it is talking to.
10
+ *
11
+ * A capability without a route is a promise the caller cannot collect on, so each arrived with
12
+ * its route. `get` is the one a connector only declares when its records carry detail the
13
+ * headline deliberately omits — a Jira issue has a description, comments and custom fields; a
14
+ * To Do task has nothing the headline leaves out, and declares none.
15
+ */
16
+ export type ConnectorCapability = "list" | "get" | "fields" | "write" | "comment" | "options";
17
+ /**
18
+ * Continuous or one-off — a real difference, not a label.
19
+ *
20
+ * `continuous` runs on a tick and so has to be able to do nothing cheaply (a cursor saying
21
+ * "nothing new"). `once` is started by a person, may be expensive, and has to be **exhaustive**: a
22
+ * migration that fetches 95% is not a migration. Not every connector can do both.
23
+ */
24
+ export type SyncMode = "continuous" | "once";
25
+ /**
26
+ * One setting of a connection, as the connector declares it.
27
+ *
28
+ * Deliberately no ui-kit types here: `packages/domain` has no UI dependencies, and the form maps
29
+ * this declaration onto its own fields itself.
30
+ */
31
+ export interface SyncSettingsField {
32
+ /** Key in the settings object the connector gets back on `list`. */
33
+ key: string;
34
+ label: string;
35
+ type: "text" | "number" | "boolean" | "select" | "multiselect";
36
+ required?: boolean;
37
+ /**
38
+ * Fixed choices, known when the connector was written.
39
+ *
40
+ * For anything the *account* decides — which projects, which boards, which folders — a static
41
+ * list cannot be honest, and that is what {@link SyncSettingsField.optionsFromConnector} is
42
+ * for. Only one of the two means anything per field.
43
+ */
44
+ options?: {
45
+ value: string;
46
+ label: string;
47
+ }[];
48
+ /**
49
+ * Ask the connector for this field's choices, once an account is picked.
50
+ *
51
+ * The alternative is what this replaces: a text field where a person types project keys from
52
+ * memory, and a typo becomes an empty sync round with no error anywhere. The connector is the
53
+ * only side that can list them, and it can only do so with a credential — hence "once an
54
+ * account is picked" rather than at declaration time.
55
+ */
56
+ optionsFromConnector?: boolean;
57
+ /** Explanation under the field. For "empty = ..." cases, which are guesswork otherwise. */
58
+ help?: string;
59
+ /**
60
+ * Hint that this value points at a resource in another app (`"work_project"`).
61
+ *
62
+ * Today the form renders it as a text field; the hint is here so a picker can be added later
63
+ * without the connector changing. A hint, not a promise.
64
+ */
65
+ resource?: string;
66
+ /**
67
+ * Rarely needed: the form hides it behind a collapsed "Advanced".
68
+ *
69
+ * Declared by the connector and not guessed by the form, because only the connector knows which
70
+ * of its settings are the everyday ones. A generic connector has fifteen settings of which nine are
71
+ * paging and delta plumbing, and showing those at the same level as "which tool" is what makes
72
+ * such a form unreadable.
73
+ */
74
+ advanced?: boolean;
75
+ }
76
+ export interface ConnectorDefinition {
77
+ /**
78
+ * Stable and app-namespaced: `microsoft.todo`, `atlassian.jira`.
79
+ *
80
+ * A **stored reference** — `SyncConnection.sourceId` keeps it — so renaming breaks existing
81
+ * connections. Dot as separator, like `MemoryKindId` for app-owned kinds.
82
+ */
83
+ id: string;
84
+ label: string;
85
+ description?: string;
86
+ /**
87
+ * The scope kinds this connector delivers: `work_item`, `interaction`, `company`, `contact`.
88
+ *
89
+ * The same vocabulary as `ScopeDescribe.kinds`, and not a cosmetic choice — the runtime routes a
90
+ * record to the owning app with `findScopeOwner(kind)`. A kind without a scope owner cannot be
91
+ * landed, and you notice that while registering instead of halfway a run.
92
+ */
93
+ kinds: string[];
94
+ /**
95
+ * What this connector can do. The UI reads this instead of branching on {@link
96
+ * ConnectorDefinition.id} — hardcoding per vendor breaks at vendor N+1.
97
+ */
98
+ capabilities: ConnectorCapability[];
99
+ /** Which modes this connector supports. Empty is pointless; at least one. */
100
+ modes: SyncMode[];
101
+ /**
102
+ * The `providerId` of the {@link ManagedAccount} this connector needs.
103
+ *
104
+ * Absent = no credential needed. Present means a connection does not work
105
+ * without a chosen account, and the UI can say so before the first run instead of showing a
106
+ * failed one.
107
+ */
108
+ accountProviderId?: string;
109
+ /**
110
+ * Settings that differ per connection and therefore do not belong in code.
111
+ *
112
+ * Declarative, so a connector app needs **no frontend** to be configurable: the Connections form
113
+ * renders these fields. That is what keeps a connector app free of frontend files.
114
+ *
115
+ * Kept flat on purpose. A nested schema would turn into a mapping DSL, which was rejected
116
+ * earlier: date formats and enums differ per vendor, and errors move from `tsc` to runtime.
117
+ */
118
+ settingsSchema?: SyncSettingsField[];
119
+ /**
120
+ * Recommended minimum time between two rounds, in ms. The organisation may override it.
121
+ *
122
+ * An **interval** and not a cron expression, because that is what is actually executable:
123
+ * `JobOptions.schedule` is fixed at registration, so a per-connection cron string would never be
124
+ * evaluated — a field promising what it does not do. The runtime ticks on a coarse grid and
125
+ * gates on this.
126
+ *
127
+ * On the connector and not in the runtime, because only it knows what its API tolerates: a
128
+ * delta feed may run every minute, a list endpoint returning everything every time may not.
129
+ */
130
+ defaultIntervalMs?: number;
131
+ /**
132
+ * How often the connector should ignore its cursor and read everything again (ms).
133
+ *
134
+ * The reconcile sweep from the Stripe/Shopify pattern, already proven in this codebase as
135
+ * `CHAT_FULL_SWEEP_INTERVAL_MS`: cursors rest on the assumption that the vendor bumps an
136
+ * `updatedAt` for everything that counts, which holds for new records but is unreliable for
137
+ * edits and deletions. Absent = never sweep (right for a delta feed that reports deletions).
138
+ */
139
+ fullSweepIntervalMs?: number;
140
+ }
141
+ /**
142
+ * Bare payload of `GET /provider/connector/describe` — **not** wrapped in `ResponseFactory`
143
+ * (template: `MemorySourceDescription` and `AnalyticsSourceDescription`).
144
+ */
145
+ export interface ConnectorDescribe {
146
+ /** The declaring app (== `manifest.name` == `req.source.app`). */
147
+ source: string;
148
+ connectors: ConnectorDefinition[];
149
+ }
150
+ /** `POST /provider/connector/list` — fetch one page. */
151
+ export interface ConnectorListRequest {
152
+ /** → {@link ConnectorDefinition.id} */
153
+ sourceId: string;
154
+ /** So the connector can log and find its own per-connection state, if it has any. */
155
+ connectionId: string;
156
+ /** The {@link ManagedAccount} to talk with. Absent when no credential is needed. */
157
+ accountId?: string;
158
+ /**
159
+ * The settings of this connection, per {@link ConnectorDefinition.settingsSchema}.
160
+ *
161
+ * An **object** here and a JSON string in storage — for the same reason as the cursor: one
162
+ * schema field that has to be able to carry any shape. Not because of key mangling; the client
163
+ * does not transform outgoing keys.
164
+ */
165
+ settings?: Record<string, unknown>;
166
+ /**
167
+ * Opaque, exactly as the connector returned it last time. `undefined` = from the start.
168
+ *
169
+ * **The runtime never looks inside.** A Graph `deltaLink`, a timestamp, a page token — it does
170
+ * not matter, and that is precisely why no column per sub-sync has to exist. That
171
+ * `microsoft_sync_state` today carries `todoDeltaLink` next to `chatCursors` next to
172
+ * `chatFullSweepAt` is exactly what an opaque field prevents.
173
+ */
174
+ cursor?: unknown;
175
+ /** Ignore the cursor and read everything again — the reconcile sweep. */
176
+ fullSweep?: boolean;
177
+ /** Maximum number of records in this answer. The connector may give fewer, never more. */
178
+ limit: number;
179
+ }
180
+ /** Answer to `POST /provider/connector/list`. This one *is* wrapped in `ResponseFactory`. */
181
+ export interface ConnectorListResponse {
182
+ records: SyncRecord[];
183
+ /**
184
+ * The cursor after this page. The runtime stores it **only once the records have landed**.
185
+ *
186
+ * That is the Singer/Airbyte rule and the reason crash recovery works: if things fall over
187
+ * between pull and land, the batch is repeated (idempotent, because upsert on `externalId`)
188
+ * instead of skipped. The other way round — cursor first — loses records silently, and silent is
189
+ * the problem here.
190
+ */
191
+ cursor?: unknown;
192
+ /** No more data after this page. The runtime then does not chain on. */
193
+ done: boolean;
194
+ /**
195
+ * The connector is rate-limited; wait at least this long before the next attempt.
196
+ *
197
+ * In milliseconds, like every other duration in this platform. A connector that fills this in
198
+ * instead of throwing keeps the run `partial` rather than `failed` — it is a delay, not an
199
+ * error.
200
+ */
201
+ retryAfterMs?: number;
202
+ }
203
+ /**
204
+ * `POST /provider/connector/write` — apply a change in the system that owns the record.
205
+ *
206
+ * **This is not two-way sync.** Two-way is hard because both sides hold truth: conflicts, echo
207
+ * loops, delete detection. A proxy row holds none — it is an index. So a change is one remote
208
+ * call, and whatever comes back replaces what we had. Nothing to merge.
209
+ */
210
+ export interface ConnectorWriteRequest {
211
+ /** → {@link ConnectorDefinition.id} */
212
+ sourceId: string;
213
+ connectionId: string;
214
+ /** The {@link ManagedAccount} to talk with. Absent when no credential is needed. */
215
+ accountId?: string;
216
+ settings?: Record<string, unknown>;
217
+ /** From {@link ConnectorDefinition.kinds}. */
218
+ kind: string;
219
+ /** The record to change, in the source system's own id space. */
220
+ externalId: string;
221
+ /**
222
+ * The change, in the **target app's** field names — the same vocabulary as
223
+ * {@link SyncRecord.data}, travelling the other way.
224
+ *
225
+ * The connector translates to vendor fields itself, exactly as it does on the way in. The
226
+ * runtime never learns a vendor field name; the moment it did it would stop being generic.
227
+ *
228
+ * `null` clears a field. Unlike the client-facing API there is no separate `clear` list here:
229
+ * this payload is assembled server-side, so a `null` survives the trip.
230
+ */
231
+ patch: Record<string, unknown>;
232
+ }
233
+ /** Answer to `POST /provider/connector/write`. Wrapped in `ResponseFactory`. */
234
+ export interface ConnectorWriteResponse {
235
+ externalId: string;
236
+ /**
237
+ * The record as it now stands **at the source**, in the target app's field names.
238
+ *
239
+ * The whole reason the write returns anything: a vendor may refuse a field, round a date,
240
+ * or move a status somewhere else than asked. Storing what we sent instead of what they kept
241
+ * would make our index quietly disagree with the system that owns the record — and disagree
242
+ * in the direction of looking successful.
243
+ *
244
+ * Fields the connector does not mention are left as they are here.
245
+ */
246
+ data: Record<string, unknown>;
247
+ }
248
+ /**
249
+ * One editable field of a **record**, as the connector describes it.
250
+ *
251
+ * Deliberately not {@link SyncSettingsField}, which looks almost identical and answers a
252
+ * different question: that one configures a *connection* once, this one describes what a person
253
+ * may change on *a row*, and it is fetched per record context rather than declared in code.
254
+ *
255
+ * `key` is in the **target app's** vocabulary — the same field names as {@link SyncRecord.data}
256
+ * and {@link ConnectorWriteRequest.patch}. The vendor's own names never reach the runtime.
257
+ *
258
+ * Needed in three places and not one: the edit form, the list columns and filters, and turning a
259
+ * stored option id back into a label. Without the third, a screen shows `10004` where `Done`
260
+ * belongs.
261
+ */
262
+ export interface FieldDescriptor {
263
+ key: string;
264
+ label: string;
265
+ type: "text" | "textarea" | "number" | "boolean" | "date" | "select";
266
+ /**
267
+ * The values this field accepts. Present means the UI shows a picker **and** can resolve an
268
+ * id to a label; absent means free input.
269
+ */
270
+ options?: {
271
+ value: string;
272
+ label: string;
273
+ }[];
274
+ /** `select` only: several at once — Jira's labels, a multi-select custom field. */
275
+ multiValued?: boolean;
276
+ /**
277
+ * What the record holds now, in the same vocabulary as {@link FieldDescriptor.options}.
278
+ *
279
+ * Here and not in {@link ConnectorGetResponse.extra} because this is the *editable* view: an
280
+ * editor has to start from the current value, and a display string cannot be edited back into
281
+ * an option id. A connector that only wants to show something uses `extra`.
282
+ */
283
+ value?: unknown;
284
+ required?: boolean;
285
+ /** Shown, never sent. A vendor field a person may read but not change. */
286
+ readOnly?: boolean;
287
+ help?: string;
288
+ }
289
+ /**
290
+ * `POST /provider/connector/fields` — what may be changed on a record like this one.
291
+ *
292
+ * Per context, not once per connector. Jira is the case that sets the shape: `/rest/api/3/field`
293
+ * is global, `createmeta` is per project and issue type, and `editmeta` is **per issue** — and
294
+ * that last one is the good answer for an existing record, because it also folds in the workflow
295
+ * and the reading user's permissions. A field a project admin may change is one a reporter may
296
+ * not, and a static list would promise fields the save then refuses.
297
+ *
298
+ * So all three of `externalId`, `containerExternalId` and `typeId` are optional and a connector
299
+ * uses what it has. `microsoft.todo` ignores all three and answers the same list every time.
300
+ */
301
+ export interface ConnectorFieldsRequest {
302
+ /** → {@link ConnectorDefinition.id} */
303
+ sourceId: string;
304
+ connectionId: string;
305
+ /** The {@link ManagedAccount} to talk with. Absent when no credential is needed. */
306
+ accountId?: string;
307
+ settings?: Record<string, unknown>;
308
+ /** From {@link ConnectorDefinition.kinds}. */
309
+ kind: string;
310
+ /**
311
+ * The record itself, when there is one.
312
+ *
313
+ * Absent means "what could be filled in for a *new* record here". A connector whose schema
314
+ * narrows per record — by workflow, by permission — has a much better answer with it than
315
+ * without, so it travels whenever the caller has it.
316
+ */
317
+ externalId?: string;
318
+ /** The container this record lives in (the project, the board, the list). */
319
+ containerExternalId?: string;
320
+ /** The record type, where the vendor has more than one. */
321
+ typeId?: string;
322
+ }
323
+ /** Answer to `POST /provider/connector/fields`. Wrapped in `ResponseFactory`. */
324
+ export interface ConnectorFieldsResponse {
325
+ fields: FieldDescriptor[];
326
+ }
327
+ /**
328
+ * `POST /provider/connector/get` — the detail a headline leaves out.
329
+ *
330
+ * Deliberately *not* the same shape as a {@link SyncRecord}: nothing here is stored. This is what
331
+ * a screen renders while it is open, and the reason the row underneath can stay thin.
332
+ */
333
+ export interface ConnectorGetRequest {
334
+ /** → {@link ConnectorDefinition.id} */
335
+ sourceId: string;
336
+ connectionId: string;
337
+ /** The {@link ManagedAccount} to talk with. Absent when no credential is needed. */
338
+ accountId?: string;
339
+ settings?: Record<string, unknown>;
340
+ /** From {@link ConnectorDefinition.kinds}. */
341
+ kind: string;
342
+ externalId: string;
343
+ }
344
+ /** One comment on a record, flattened to what a reader needs. */
345
+ export interface ConnectorComment {
346
+ id: string;
347
+ /** The author's display name. Never an account id — that says nothing to a reader. */
348
+ author: string;
349
+ /** Plain text. Vendor markup is flattened by the connector, which is the only side that knows it. */
350
+ body: string;
351
+ /** Epoch ms. */
352
+ createdAt?: number;
353
+ }
354
+ /** Answer to `POST /provider/connector/get`. Wrapped in `ResponseFactory`. */
355
+ export interface ConnectorGetResponse {
356
+ /** Plain text. Empty is a valid answer and means the record has none. */
357
+ description?: string;
358
+ comments?: ConnectorComment[];
359
+ /** A deep link into the source system, so a person can always go to the real thing. */
360
+ url?: string;
361
+ /**
362
+ * Anything else the connector wants to show, already labelled.
363
+ *
364
+ * An escape hatch on purpose: a vendor's custom fields have no place in our schema and no
365
+ * meaning to the runtime, but a person reading the issue expects to see them.
366
+ */
367
+ extra?: {
368
+ label: string;
369
+ value: string;
370
+ }[];
371
+ }
372
+ /**
373
+ * `POST /provider/connector/options` — the choices for one settings field.
374
+ *
375
+ * Separate from {@link ConnectorFieldsRequest}, which describes the fields of a *record*. This
376
+ * one describes the choices for a field of the *connection* — asked while somebody is still
377
+ * configuring it, so before there is a connection at all.
378
+ */
379
+ export interface ConnectorOptionsRequest {
380
+ /** → {@link ConnectorDefinition.id} */
381
+ sourceId: string;
382
+ /** The {@link ManagedAccount} to talk with. Absent when no credential is needed. */
383
+ accountId?: string;
384
+ /** The settings filled in so far: one field's choices may depend on another's value. */
385
+ settings?: Record<string, unknown>;
386
+ /** → {@link SyncSettingsField.key} */
387
+ field: string;
388
+ }
389
+ /** Answer to `POST /provider/connector/options`. Wrapped in `ResponseFactory`. */
390
+ export interface ConnectorOptionsResponse {
391
+ options: {
392
+ value: string;
393
+ label: string;
394
+ }[];
395
+ }
396
+ /**
397
+ * `POST /provider/connector/comment` — say something on the record, where it lives.
398
+ *
399
+ * Its own capability rather than a field on {@link ConnectorWriteRequest}, because a comment is
400
+ * not a field: it is appended, never replaced, and a vendor that lets you edit a summary may not
401
+ * let you comment at all. A connector that cannot do it simply does not declare it, and the
402
+ * screen keeps the comment local instead of pretending it travelled.
403
+ */
404
+ export interface ConnectorCommentRequest {
405
+ /** → {@link ConnectorDefinition.id} */
406
+ sourceId: string;
407
+ connectionId: string;
408
+ /** The {@link ManagedAccount} to talk with. Absent when no credential is needed. */
409
+ accountId?: string;
410
+ settings?: Record<string, unknown>;
411
+ /** From {@link ConnectorDefinition.kinds}. */
412
+ kind: string;
413
+ externalId: string;
414
+ /**
415
+ * Plain text.
416
+ *
417
+ * The connector converts to whatever its vendor wants — Jira needs a document, Slack needs
418
+ * its own blocks — because it is the only side that knows. The runtime never learns a markup.
419
+ */
420
+ body: string;
421
+ }
422
+ /** Answer to `POST /provider/connector/comment`. Wrapped in `ResponseFactory`. */
423
+ export interface ConnectorCommentResponse {
424
+ /** As the source stored it, so the screen shows what is really there. */
425
+ comment: ConnectorComment;
426
+ }
@@ -0,0 +1,225 @@
1
+ import { OwnerScope } from '../entities/scope/types';
2
+ import { SyncMode } from './connector';
3
+ /** The provider role group, as a constant so a typo does not silently yield an empty list. */
4
+ export declare const SYNC_TARGET_PROVIDER_GROUP = "sync-target";
5
+ /**
6
+ * One record from a source system, ready to be landed.
7
+ *
8
+ * The source does the mapping into the shape the target app expects. Deliberately so: the runtime
9
+ * knows no field of any vendor, and the moment it did it would stop being generic. See
10
+ * {@link SyncRecord.data}.
11
+ */
12
+ export interface SyncRecord {
13
+ /**
14
+ * The stable id in the source system, namespaced by the source itself (`asana_task:12345`,
15
+ * `graph-todo:AAMk…`).
16
+ *
17
+ * This is the dedupe axis: the target app upserts on it through its
18
+ * `upsert_by_external_provider_id` route, so running the same run twice writes one row.
19
+ * "Stable" is the whole requirement — an id that changes per page turns every sync into a
20
+ * duplicate factory.
21
+ */
22
+ externalId: string;
23
+ /**
24
+ * Ids pointing at the **container** and not at this row: the list, the project, the board.
25
+ *
26
+ * They are stored (outgoing dispatch needs them) but must never take part in the dedupe. This
27
+ * field exists because it went wrong once: next to the task id Microsoft Graph also sends
28
+ * `graph-todolist:<id>`, identical for every task in that list, and searching on it collapsed
29
+ * the whole list onto one row (`task/external-ids.ts`). Repaired there with a prefix list; here
30
+ * the source says it itself, so that list does not have to grow.
31
+ */
32
+ containerExternalIds?: string[];
33
+ /** The scope kind, from `ConnectorDefinition.kinds`. Decides which app this goes to. */
34
+ kind: string;
35
+ /**
36
+ * The fields for the target app.
37
+ *
38
+ * **Fixed field names, never a map keyed by an external id.** The target app has to be able to
39
+ * *type* the fields: `{ "asana_gid_123": {...} }` cannot be read by `upsertWorkItemFromExternal`
40
+ * without first knowing what the source put in there, and then every error moves from `tsc` to
41
+ * runtime.
42
+ */
43
+ data: Record<string, unknown>;
44
+ /**
45
+ * Who owns what lands here, stamped by the source from the connection settings.
46
+ *
47
+ * Envelope, not data, and stamped by `registerSyncTarget` next to `externalId`. It comes from
48
+ * the connection and not from the vendor, so no source field can ever produce it.
49
+ */
50
+ ownerScope?: OwnerScope;
51
+ /**
52
+ * This record was deleted at the source.
53
+ *
54
+ * Only meaningful when the source *can* know: a delta feed reports deletions, a list endpoint
55
+ * simply leaves them out. Absence from a list is **not** a deletion — not respecting that
56
+ * difference means a filtered or paginated response clears out half your administration.
57
+ */
58
+ deleted?: boolean;
59
+ }
60
+ /** `POST /provider/sync/land` — write these records. App callers only. */
61
+ export interface SyncLandRequest {
62
+ /** Where they came from, for `source`/attribution on the landed row. */
63
+ sourceId: string;
64
+ /**
65
+ * Which connection delivered them. The landing app stores it on a proxied row, because that
66
+ * is what tells a later edit *which* credential to write back with — two connections to the
67
+ * same product are two different answers.
68
+ */
69
+ connectionId?: string;
70
+ /**
71
+ * These records are a **headline index**, not a copy: their truth stays at the source.
72
+ *
73
+ * The landing app stores only the columns it was given and renders the rest live, and an edit
74
+ * is written back instead of applied locally. Per connection and not per record: "who owns
75
+ * this data" is a decision about the link, not something a row can differ on.
76
+ */
77
+ proxy?: boolean;
78
+ records: SyncRecord[];
79
+ }
80
+ /** What happened to one record. */
81
+ export interface SyncLandOutcome {
82
+ externalId: string;
83
+ /**
84
+ * `created` and `updated` are both "written"; `unchanged` exists separately because an
85
+ * idempotent resync that changed nothing must not make SSE noise and must not count as work
86
+ * (the pattern from `lib/upsert-dedup.ts`). `failed` is one row, not the batch.
87
+ */
88
+ result: "created" | "updated" | "unchanged" | "deleted" | "failed";
89
+ /** The scopeKey of the landed row, so the run log can link to the result. */
90
+ scopeKey?: string;
91
+ /** Only on `failed`. Short enough for a table row. */
92
+ error?: string;
93
+ }
94
+ /** Answer to `POST /provider/sync/land`. This one *is* wrapped in `ResponseFactory`. */
95
+ export interface SyncLandResponse {
96
+ outcomes: SyncLandOutcome[];
97
+ }
98
+ /**
99
+ * Bare payload of `GET /provider/sync/land-describe`: which kinds this app can land.
100
+ *
101
+ * Separate from `ScopeDescribe`, even though the kinds usually overlap: an app can authorize a
102
+ * kind without having an ingest route for it. Making that difference visible is cheaper than a run
103
+ * that finds a 404 halfway.
104
+ */
105
+ export interface SyncTargetDescribe {
106
+ source: string;
107
+ kinds: string[];
108
+ }
109
+ /** How healthy a connection is. What the list shows as a badge. */
110
+ export type SyncHealth =
111
+ /** Last run succeeded. */
112
+ "ok"
113
+ /** Last run partly succeeded, or was rate-limited. Still running, but not cleanly. */
114
+ | "degraded"
115
+ /** Last run failed — usually an expired credential. Needs a person. */
116
+ | "broken"
117
+ /** Never ran, or a required account is missing. Not an error yet. */
118
+ | "unconfigured";
119
+ /**
120
+ * One configured connection: this source, with this account, in this organisation.
121
+ *
122
+ * The credential is **not** in here — `accountId` points at the canonical
123
+ * {@link ManagedAccount} store. A second place where tokens live is a second place where they
124
+ * expire without anyone knowing.
125
+ */
126
+ export interface SyncConnection {
127
+ id: string;
128
+ organizationId: string;
129
+ /** → `ConnectorDefinition.id` */
130
+ sourceId: string;
131
+ /** → {@link ManagedAccount.id}. Absent for a source without a credential. */
132
+ accountId?: string;
133
+ /**
134
+ * The filled-in settings, parsed. In storage it is a JSON string, for the same reason as the
135
+ * cursor: one schema field that has to be able to carry any shape.
136
+ */
137
+ settings?: Record<string, unknown>;
138
+ /** Free-form name; absent = the source's label. */
139
+ label?: string;
140
+ mode: SyncMode;
141
+ enabled: boolean;
142
+ /**
143
+ * Minimum time between two rounds, in ms.
144
+ *
145
+ * Copied from `ConnectorDefinition.defaultIntervalMs` on creation, so the tick can read
146
+ * it without querying the source catalog per connection — and so the effective interval is
147
+ * visible and changeable instead of buried in code.
148
+ */
149
+ intervalMs?: number;
150
+ /**
151
+ * This connection is a proxy: keep the headline, render the rest live, write edits back.
152
+ *
153
+ * Absent or false = an ordinary import, where a landed row becomes ours. The two modes differ
154
+ * in who owns the truth, which is why it is one flag and not a spectrum.
155
+ */
156
+ proxy?: boolean;
157
+ /** Opaque, from the connector. See `ConnectorListRequest.cursor`. */
158
+ cursor?: unknown;
159
+ /** Epoch ms of the last full sweep. */
160
+ lastFullSweepAt?: number;
161
+ health: SyncHealth;
162
+ lastRunAt?: number;
163
+ /** The error of the last failed run, so the list can show it without reading a run. */
164
+ lastError?: string;
165
+ createdBy?: string;
166
+ createdAt?: number;
167
+ updatedAt?: number;
168
+ }
169
+ /** What started a run. */
170
+ export type SyncRunTrigger = "cron" | "manual" | "webhook";
171
+ /**
172
+ * `partial` exists next to `done` and `failed` because "12 of the 500 rows did not make it" is
173
+ * neither: the connection works, the cursor may advance, and there is still something to report.
174
+ * Without that third state it becomes either an error that halts the sync or a success that hides
175
+ * the failures.
176
+ */
177
+ export type SyncRunStatus = "running" | "done" | "partial" | "failed";
178
+ /**
179
+ * What happened during one round.
180
+ *
181
+ * Deliberately the same shape as `PlaybookRun`: it is the same question ("what did the system do
182
+ * unasked, and did it go well?"), so the same fields and the same screens.
183
+ */
184
+ export interface SyncRun {
185
+ id: string;
186
+ organizationId: string;
187
+ connectionId: string;
188
+ sourceId: string;
189
+ trigger: SyncRunTrigger;
190
+ status: SyncRunStatus;
191
+ startedAt: number;
192
+ finishedAt?: number;
193
+ /** How many pages this run read. Gives away a source that is not making progress. */
194
+ pages: number;
195
+ scanned: number;
196
+ /** `created` + `updated`. Explicitly not `unchanged`, or an empty resync looks like work. */
197
+ written: number;
198
+ /** `unchanged` — the healthy outcome of an idempotent resync. */
199
+ skipped: number;
200
+ failed: number;
201
+ /** Was this a sweep? Explains why `scanned` is suddenly much higher. */
202
+ fullSweep?: boolean;
203
+ /**
204
+ * A trial round: mapped and checked, but nothing written and no cursor moved.
205
+ *
206
+ * It reads **one page**, on purpose — a dry run is a sample that answers "does this source
207
+ * still deliver what we expect", not an import. `written` therefore stays 0 and the rows that
208
+ * would have landed are counted as `skipped`.
209
+ */
210
+ dryRun?: boolean;
211
+ /** The error that stopped the whole run. Empty on `partial` — see {@link SyncRun.errors}. */
212
+ error?: string;
213
+ /**
214
+ * The first N failed rows, with their `externalId`.
215
+ *
216
+ * Bounded and not complete: a source where every row fails would otherwise produce a run row of
217
+ * megabytes. The goal is debugging ("which row, and why"), not bookkeeping.
218
+ */
219
+ errors?: {
220
+ externalId: string;
221
+ message: string;
222
+ }[];
223
+ }
224
+ /** How many failed rows a {@link SyncRun} remembers. */
225
+ export declare const SYNC_RUN_MAX_ERRORS = 20;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencxh/domain",
3
- "version": "1.226.0",
3
+ "version": "1.228.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",