@opencxh/domain 1.226.0 → 1.227.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.
@@ -1,517 +0,0 @@
1
- /**
2
- * Pulling outside data in, settled once.
3
- *
4
- * The platform ingests in four independent ways today — a cron sync per provider app, webhooks, an
5
- * event-driven enrichment job and loose backfills — and each of the four rebuilds the same six
6
- * things: cursor, batching, locking, idempotency, error handling and observability. It shows:
7
- * `cursorKey()` sits in the repo three times, `microsoft_sync_state` grows a column per sub-sync,
8
- * and a sync that stalls is a `console.error` nobody sees.
9
- *
10
- * The split that fixes that is the industry standard (Singer/Airbyte, Nango, Fivetran): **a narrow
11
- * source contract with a thick generic runtime.** The source knows how to fetch one page from
12
- * Microsoft Graph or Asana; the runtime knows how to checkpoint cursors, chain batches, back off
13
- * and keep a run log. This file is that narrow contract.
14
- *
15
- * Three endpoints, shaped like `memory-source` and `analytics-source` — so a provider role and not
16
- * a service-bus key, because this runs server-side with no user present:
17
- *
18
- * | Role | Endpoint | Answer |
19
- * |---|---|---|
20
- * | `sync-source` | `GET /provider/sync/describe` | {@link SyncSourceDescribe} |
21
- * | `sync-source` | `POST /provider/sync/pull` | {@link SyncPullResponse} |
22
- * | `sync-target` | `POST /provider/sync/land` | {@link SyncLandResponse} |
23
- *
24
- * Source and target are **separate roles**: `apps/microsoft` delivers records and lands none,
25
- * `apps/work` lands records and delivers none. An app may be both, but need not be.
26
- */
27
- /** The provider role groups, as constants so a typo does not silently yield an empty list. */
28
- export declare const SYNC_SOURCE_PROVIDER_GROUP = "sync-source";
29
- export declare const SYNC_TARGET_PROVIDER_GROUP = "sync-target";
30
- /**
31
- * Continuous or one-off — a real difference, not a label.
32
- *
33
- * `continuous` runs on a tick and so has to be able to do nothing cheaply (a cursor saying
34
- * "nothing new"). `once` is started by a person, may be expensive, and has to be **exhaustive**: a
35
- * migration that fetches 95% is not a migration. That requirement is exactly why not every source
36
- * can do both — see {@link SyncSourceDefinition.transport}.
37
- */
38
- export type SyncMode = "continuous" | "once";
39
- /**
40
- * How the records actually arrive.
41
- *
42
- * This sits in the contract because it sets the expectations the runtime and the UI may have, not
43
- * as documentation:
44
- *
45
- * - **`native`** — the app talks to the vendor's API itself. Knows delta tokens and pagination,
46
- * so suitable for `continuous` as well as `once`.
47
- * - **`tool`** — the app goes through the AI tool/MCP layer. Fine for the long tail of systems
48
- * where a dedicated connector does not pay off, but an MCP search tool answers "top-N relevant"
49
- * and not "everything since X", and the protocol has no change token. A `tool` source claiming
50
- * `once` promises completeness it cannot deliver.
51
- * - **`file`** — an uploaded file (CSV). `once` by definition, and complete by definition.
52
- */
53
- export type SyncTransport = "native" | "tool" | "file";
54
- /**
55
- * One setting of a connection, as the source declares it.
56
- *
57
- * Deliberately no ui-kit types here: `packages/domain` has no UI dependencies, and the form maps
58
- * this declaration onto its own fields itself.
59
- */
60
- export interface SyncSettingsField {
61
- /** Key in the settings object the source gets back on `pull`. */
62
- key: string;
63
- label: string;
64
- type: "text" | "number" | "boolean" | "select";
65
- required?: boolean;
66
- /** Only with `type: "select"`. */
67
- options?: {
68
- value: string;
69
- label: string;
70
- }[];
71
- /** Explanation under the field. For "empty = ..." cases, which are guesswork otherwise. */
72
- help?: string;
73
- /**
74
- * Hint that this value points at a resource in another app (`"work_project"`).
75
- *
76
- * Today the form renders it as a text field; the hint is here so a picker can be added later
77
- * without the source changing. A hint, not a promise.
78
- */
79
- resource?: string;
80
- /**
81
- * Rarely needed: the form hides it behind a collapsed "Advanced".
82
- *
83
- * Declared by the source and not guessed by the form, because only the source knows which of
84
- * its settings are the everyday ones. A generic source has fifteen settings of which nine are
85
- * paging and delta plumbing, and showing those at the same level as "which tool" is what makes
86
- * such a form unreadable.
87
- */
88
- advanced?: boolean;
89
- /**
90
- * This setting changes **what a trial call answers with**, so it belongs with that call.
91
- *
92
- * Two things at once: the form puts the field next to the trial-run button instead of among
93
- * the rest, and the trial call sends it along. Without it the form would have to know which
94
- * keys of which source are probe input — vendor knowledge in exactly the place that must not
95
- * have any.
96
- */
97
- probe?: boolean;
98
- }
99
- export interface SyncSourceDefinition {
100
- /**
101
- * Stable and app-namespaced: `microsoft.todo`, `asana.tasks`, `csv.contacts`.
102
- *
103
- * A **stored reference** — `SyncConnection.sourceId` keeps it — so renaming breaks existing
104
- * connections. Dot as separator, like `MemoryKindId` for app-owned kinds.
105
- */
106
- id: string;
107
- label: string;
108
- description?: string;
109
- /**
110
- * The scope kinds this source lands: `work_item`, `interaction`, `company`, `contact`.
111
- *
112
- * The same vocabulary as `ScopeDescribe.kinds`, and not a cosmetic choice — the runtime routes a
113
- * record to the owning app with `findScopeOwner(kind)`. A kind without a scope owner cannot be
114
- * landed, and you notice that while registering instead of halfway a run.
115
- */
116
- kinds: string[];
117
- /** Which modes this source supports. Empty is pointless; at least one. */
118
- modes: SyncMode[];
119
- transport: SyncTransport;
120
- /**
121
- * The `providerId` of the {@link ManagedAccount} this source needs.
122
- *
123
- * Absent = no credential needed (a file upload). Present means a connection does not work
124
- * without a chosen account, and the UI can say so before the first run instead of showing a
125
- * failed one.
126
- */
127
- accountProviderId?: string;
128
- /**
129
- * The settings key that decides which kind this connection lands.
130
- *
131
- * Most sources know that at build time and list it in {@link SyncSourceDefinition.kinds}. A
132
- * generic source does not: one MCP tool answers with one kind of thing, but *which* kind is a
133
- * choice made per connection. Declaring the key here — rather than letting the UI guess that a
134
- * setting called `kind` is special — keeps the mapping step showing the fields of the one kind
135
- * this connection will actually produce, instead of every kind the source could ever land.
136
- */
137
- kindSetting?: string;
138
- /**
139
- * Settings that differ per connection and therefore do not belong in code.
140
- *
141
- * Declarative, so a connector app needs **no frontend** to be configurable: the Connections form
142
- * renders these fields. That is what keeps `apps/asana` (zero frontend files) possible.
143
- *
144
- * Kept flat on purpose. A nested schema would turn into a mapping DSL, which was rejected
145
- * earlier: date formats and enums differ per source, and errors then move from `tsc` to runtime.
146
- */
147
- settingsSchema?: SyncSettingsField[];
148
- /**
149
- * Only with `transport: "tool"`: the catalog key the connector picker pre-filters on
150
- * (`"asana"`, `"linear"`).
151
- *
152
- * A **hint**, not a resolution: it decides which of the organisation's MCP connections are
153
- * offered as candidates. What the connection stores afterwards is the connector **id**
154
- * ({@link SyncConnection.connectorId}). Without a hint the user simply gets the full list.
155
- */
156
- connectorHint?: string;
157
- /**
158
- * Only with `transport: "tool"`: this source can only run on a shared organisation credential.
159
- *
160
- * Why this exists: an unattended round (cron, sync) has no acting user, so a connector with
161
- * `credentialScope: "per-user"` yields zero tools there by definition — which used to show up
162
- * only as "the sync does nothing". With this flag, saving a `continuous` connection is refused
163
- * right away, with an explanation.
164
- *
165
- * Only for `continuous`: a `once` connection is started by a person, and then there *is* an
166
- * acting user whose token can be used.
167
- */
168
- requiresCredentialScope?: "shared";
169
- /**
170
- * Recommended minimum time between two rounds, in ms. The organisation may override it.
171
- *
172
- * An **interval** and not a cron expression, because that is what is actually executable:
173
- * `JobOptions.schedule` is fixed at registration, so a per-connection cron string would never be
174
- * evaluated — a field promising what it does not do. The runtime ticks on a coarse grid and
175
- * gates on this.
176
- *
177
- * On the source and not in the runtime, because only the source knows what its API tolerates: a
178
- * delta feed may run every minute, a list endpoint returning everything every time may not.
179
- */
180
- defaultIntervalMs?: number;
181
- /**
182
- * How often the source should ignore its cursor and read everything again (ms).
183
- *
184
- * The reconcile sweep from the Stripe/Shopify pattern, already proven in this codebase as
185
- * `CHAT_FULL_SWEEP_INTERVAL_MS`: cursors rest on the assumption that the vendor bumps an
186
- * `updatedAt` for everything that counts, which holds for new records but is unreliable for
187
- * edits and deletions. Absent = never sweep (right for a delta feed that reports deletions).
188
- */
189
- fullSweepIntervalMs?: number;
190
- }
191
- /**
192
- * Bare payload of `GET /provider/sync/describe` — **not** wrapped in `ResponseFactory` (template:
193
- * {@link MemorySourceDescription} and `AnalyticsSourceDescription`).
194
- */
195
- export interface SyncSourceDescribe {
196
- /** The declaring app (== `manifest.name` == `req.source.app`). */
197
- source: string;
198
- sources: SyncSourceDefinition[];
199
- }
200
- /**
201
- * One record from a source system, ready to be landed.
202
- *
203
- * The source does the mapping into the shape the target app expects. Deliberately so: the runtime
204
- * knows no field of any vendor, and the moment it did it would stop being generic. See
205
- * {@link SyncRecord.data}.
206
- */
207
- export interface SyncRecord {
208
- /**
209
- * The stable id in the source system, namespaced by the source itself (`asana_task:12345`,
210
- * `graph-todo:AAMk…`).
211
- *
212
- * This is the dedupe axis: the target app upserts on it through its
213
- * `upsert_by_external_provider_id` route, so running the same run twice writes one row.
214
- * "Stable" is the whole requirement — an id that changes per page turns every sync into a
215
- * duplicate factory.
216
- */
217
- externalId: string;
218
- /**
219
- * Ids pointing at the **container** and not at this row: the list, the project, the board.
220
- *
221
- * They are stored (outgoing dispatch needs them) but must never take part in the dedupe. This
222
- * field exists because it went wrong once: next to the task id Microsoft Graph also sends
223
- * `graph-todolist:<id>`, identical for every task in that list, and searching on it collapsed
224
- * the whole list onto one row (`task/external-ids.ts`). Repaired there with a prefix list; here
225
- * the source says it itself, so that list does not have to grow.
226
- */
227
- containerExternalIds?: string[];
228
- /** The scope kind, from {@link SyncSourceDefinition.kinds}. Decides which app this goes to. */
229
- kind: string;
230
- /**
231
- * The fields for the target app.
232
- *
233
- * **Fixed field names, never a map keyed by an external id.** The target app has to be able to
234
- * *type* the fields: `{ "asana_gid_123": {...} }` cannot be read by `upsertWorkItemFromExternal`
235
- * without first knowing what the source put in there, and then every error moves from `tsc` to
236
- * runtime.
237
- */
238
- data: Record<string, unknown>;
239
- /**
240
- * This record was deleted at the source.
241
- *
242
- * Only meaningful when the source *can* know: a delta feed reports deletions, a list endpoint
243
- * simply leaves them out. Absence from a list is **not** a deletion — not respecting that
244
- * difference means a filtered or paginated response clears out half your administration.
245
- */
246
- deleted?: boolean;
247
- }
248
- /** `POST /provider/sync/pull` — fetch one page. */
249
- export interface SyncPullRequest {
250
- sourceId: string;
251
- /** So the source can log and find its own per-connection state, if it has any. */
252
- connectionId: string;
253
- /** The {@link ManagedAccount} to talk with. Absent with `transport: "file"`. */
254
- accountId?: string;
255
- /**
256
- * With `transport: "tool"`: the id of the MCP connector this connection may use, chosen when the
257
- * connection was configured.
258
- *
259
- * This makes the tool name a pure string — `mcpToolPrefix(connectorId) + tool name` — with no
260
- * resolution RPC per page. A source used to look its connector up by catalog key, which could
261
- * not pick a winner with two candidates (an org row next to a personal one, or two users each
262
- * with their own); an app could even end up with the prefix of somebody's personal connector.
263
- */
264
- connectorId?: string;
265
- /**
266
- * The settings of this connection, per {@link SyncSourceDefinition.settingsSchema}.
267
- *
268
- * An **object** here and a JSON string in storage — for the same reason as the cursor: one
269
- * schema field that has to be able to carry any shape. Not because of key mangling; the client
270
- * does not transform outgoing keys.
271
- */
272
- settings?: Record<string, unknown>;
273
- /**
274
- * Opaque, exactly as the source returned it last time. `undefined` = from the start.
275
- *
276
- * **The runtime never looks inside.** A Graph `deltaLink`, a timestamp, a page token — it does
277
- * not matter, and that is precisely why no column per sub-sync has to exist. That
278
- * `microsoft_sync_state` today carries `todoDeltaLink` next to `chatCursors` next to
279
- * `chatFullSweepAt` is exactly what an opaque field prevents.
280
- */
281
- cursor?: unknown;
282
- /** Ignore the cursor and read everything again — the reconcile sweep. */
283
- fullSweep?: boolean;
284
- /** Maximum number of records in this answer. The source may give fewer, never more. */
285
- limit: number;
286
- }
287
- /** Answer to `POST /provider/sync/pull`. This one *is* wrapped in `ResponseFactory`. */
288
- export interface SyncPullResponse {
289
- records: SyncRecord[];
290
- /**
291
- * The cursor after this page. The runtime stores it **only once the records have landed**.
292
- *
293
- * That is the Singer/Airbyte rule and the reason crash recovery works: if things fall over
294
- * between pull and land, the batch is repeated (idempotent, because upsert on `externalId`)
295
- * instead of skipped. The other way round — cursor first — loses records silently, and silent is
296
- * the problem here.
297
- */
298
- cursor?: unknown;
299
- /** No more data after this page. The runtime then does not chain on. */
300
- done: boolean;
301
- /**
302
- * The source is rate-limited; wait at least this long before the next attempt.
303
- *
304
- * In milliseconds, like every other duration in this platform. A source that fills this in
305
- * instead of throwing keeps the run `partial` rather than `failed` — it is a delay, not an
306
- * error.
307
- */
308
- retryAfterMs?: number;
309
- }
310
- /** `POST /provider/sync/land` — write these records. App callers only. */
311
- export interface SyncLandRequest {
312
- /** Where they came from, for `source`/attribution on the landed row. */
313
- sourceId: string;
314
- records: SyncRecord[];
315
- }
316
- /** What happened to one record. */
317
- export interface SyncLandOutcome {
318
- externalId: string;
319
- /**
320
- * `created` and `updated` are both "written"; `unchanged` exists separately because an
321
- * idempotent resync that changed nothing must not make SSE noise and must not count as work
322
- * (the pattern from `lib/upsert-dedup.ts`). `failed` is one row, not the batch.
323
- */
324
- result: "created" | "updated" | "unchanged" | "deleted" | "failed";
325
- /** The scopeKey of the landed row, so the run log can link to the result. */
326
- scopeKey?: string;
327
- /** Only on `failed`. Short enough for a table row. */
328
- error?: string;
329
- }
330
- /** Answer to `POST /provider/sync/land`. This one *is* wrapped in `ResponseFactory`. */
331
- export interface SyncLandResponse {
332
- outcomes: SyncLandOutcome[];
333
- }
334
- /**
335
- * One field a target app accepts for a kind — the right-hand side of a column mapping.
336
- *
337
- * Deliberately **flat and scalar only**. A field that carries structure (a contact's endpoint
338
- * list, a work item's assignees) cannot come out of a CSV column or a tool answer anyway, and
339
- * declaring it would promise a mapping UI something it cannot render. An app that wants such a
340
- * value mappable declares a scalar alias instead (`email`) and assembles the structure itself —
341
- * that conversion belongs with the app that owns the shape, not in the pipeline.
342
- */
343
- export interface SyncTargetField {
344
- key: string;
345
- label: string;
346
- /** Missing after mapping ⇒ that one row fails, not the batch. */
347
- required?: boolean;
348
- type?: "text" | "number" | "boolean" | "date";
349
- /** Explanation under the field in the mapping UI. */
350
- help?: string;
351
- }
352
- /**
353
- * Bare payload of `GET /provider/sync/land-describe`: which kinds this app can land.
354
- *
355
- * Separate from `ScopeDescribe`, even though the kinds usually overlap: an app can authorize a
356
- * kind without having an ingest route for it. Making that difference visible is cheaper than a run
357
- * that finds a 404 halfway.
358
- */
359
- export interface SyncTargetDescribe {
360
- source: string;
361
- kinds: string[];
362
- /**
363
- * Per kind, the fields a mapping may fill. Absent = this app does not support column mapping,
364
- * and only a source that maps in code (`transport: "native"`) can feed it.
365
- */
366
- fields?: Record<string, SyncTargetField[]>;
367
- }
368
- /**
369
- * The target field key that always has to be mapped, next to whatever the app declares.
370
- *
371
- * It is not in {@link SyncTargetDescribe.fields} because no app owns it: it is the dedupe axis of
372
- * the pipeline itself. A mapping without it would land every row as a new one, every round.
373
- */
374
- export declare const SYNC_MAPPING_EXTERNAL_ID = "externalId";
375
- /** How healthy a connection is. What the list shows as a badge. */
376
- export type SyncHealth =
377
- /** Last run succeeded. */
378
- "ok"
379
- /** Last run partly succeeded, or was rate-limited. Still running, but not cleanly. */
380
- | "degraded"
381
- /** Last run failed — usually an expired credential. Needs a person. */
382
- | "broken"
383
- /** Never ran, or a required account is missing. Not an error yet. */
384
- | "unconfigured";
385
- /**
386
- * One configured connection: this source, with this account, in this organisation.
387
- *
388
- * The credential is **not** in here — `accountId` points at the canonical
389
- * {@link ManagedAccount} store. A second place where tokens live is a second place where they
390
- * expire without anyone knowing.
391
- */
392
- export interface SyncConnection {
393
- id: string;
394
- organizationId: string;
395
- /** → {@link SyncSourceDefinition.id} */
396
- sourceId: string;
397
- /** → {@link ManagedAccount.id}. Absent for a source without a credential. */
398
- accountId?: string;
399
- /**
400
- * With `transport: "tool"`: the chosen MCP connector. Explicit, not guessed at run time — so the
401
- * UI shows which connection this one uses, and the answer does not change because somebody else
402
- * adds the same catalog entry.
403
- *
404
- * If the row is gone or disabled, the connection goes to `health: "unconfigured"` and the run
405
- * fails loudly — never a silent empty page the cursor advances over.
406
- */
407
- connectorId?: string;
408
- /**
409
- * The filled-in settings, parsed. In storage it is a JSON string, for the same reason as the
410
- * cursor: one schema field that has to be able to carry any shape.
411
- */
412
- settings?: Record<string, unknown>;
413
- /**
414
- * The field names this connection's source actually delivers — the left-hand side of the
415
- * mapping UI.
416
- *
417
- * On the **connection** and not on the source definition, because that is where the truth is:
418
- * for a CSV they are the column headers of *this* file, for a tool the keys of *this* answer.
419
- * A static declaration on the source would have to lie about both.
420
- */
421
- rawFields?: string[];
422
- /**
423
- * Target field key → raw field name. Absent = the source maps in code and the runtime passes
424
- * records through untouched, which is what every `native` source does.
425
- */
426
- mapping?: Record<string, string>;
427
- /**
428
- * Namespace stamped in front of every mapped external id (`hubspot` ⇒ `hubspot:5591`).
429
- *
430
- * A source that maps in code namespaces its own ids, and must: a bare `12345` from two systems
431
- * is the same row as far as the dedupe index is concerned, so without this two connections
432
- * would merge each other's records. A mapped source cannot do it itself — the mapping runs
433
- * after the pull and is the thing that decides which field is the id — so the runtime does it,
434
- * and the connection carries the namespace. Required as soon as a mapping exists.
435
- */
436
- externalIdPrefix?: string;
437
- /** Free-form name; absent = the source's label. */
438
- label?: string;
439
- mode: SyncMode;
440
- enabled: boolean;
441
- /**
442
- * Minimum time between two rounds, in ms.
443
- *
444
- * Copied from {@link SyncSourceDefinition.defaultIntervalMs} on creation, so the tick can read
445
- * it without querying the source catalog per connection — and so the effective interval is
446
- * visible and changeable instead of buried in code.
447
- */
448
- intervalMs?: number;
449
- /** Opaque, from the source. See {@link SyncPullRequest.cursor}. */
450
- cursor?: unknown;
451
- /** Epoch ms of the last full sweep. */
452
- lastFullSweepAt?: number;
453
- health: SyncHealth;
454
- lastRunAt?: number;
455
- /** The error of the last failed run, so the list can show it without reading a run. */
456
- lastError?: string;
457
- createdBy?: string;
458
- createdAt?: number;
459
- updatedAt?: number;
460
- }
461
- /** What started a run. */
462
- export type SyncRunTrigger = "cron" | "manual" | "webhook";
463
- /**
464
- * `partial` exists next to `done` and `failed` because "12 of the 500 rows did not make it" is
465
- * neither: the connection works, the cursor may advance, and there is still something to report.
466
- * Without that third state it becomes either an error that halts the sync or a success that hides
467
- * the failures.
468
- */
469
- export type SyncRunStatus = "running" | "done" | "partial" | "failed";
470
- /**
471
- * What happened during one round.
472
- *
473
- * Deliberately the same shape as `PlaybookRun`: it is the same question ("what did the system do
474
- * unasked, and did it go well?"), so the same fields and the same screens.
475
- */
476
- export interface SyncRun {
477
- id: string;
478
- organizationId: string;
479
- connectionId: string;
480
- sourceId: string;
481
- trigger: SyncRunTrigger;
482
- status: SyncRunStatus;
483
- startedAt: number;
484
- finishedAt?: number;
485
- /** How many pages this run read. Gives away a source that is not making progress. */
486
- pages: number;
487
- scanned: number;
488
- /** `created` + `updated`. Explicitly not `unchanged`, or an empty resync looks like work. */
489
- written: number;
490
- /** `unchanged` — the healthy outcome of an idempotent resync. */
491
- skipped: number;
492
- failed: number;
493
- /** Was this a sweep? Explains why `scanned` is suddenly much higher. */
494
- fullSweep?: boolean;
495
- /**
496
- * A trial round: mapped and checked, but nothing written and no cursor moved.
497
- *
498
- * It reads **one page**, on purpose — a dry run is a sample that answers "does this mapping
499
- * hold up", not an import. `written` therefore stays 0 and the rows that would have landed are
500
- * counted as `skipped`; only rows the mapping could not produce count as `failed`.
501
- */
502
- dryRun?: boolean;
503
- /** The error that stopped the whole run. Empty on `partial` — see {@link SyncRun.errors}. */
504
- error?: string;
505
- /**
506
- * The first N failed rows, with their `externalId`.
507
- *
508
- * Bounded and not complete: a source where every row fails would otherwise produce a run row of
509
- * megabytes. The goal is debugging ("which row, and why"), not bookkeeping.
510
- */
511
- errors?: {
512
- externalId: string;
513
- message: string;
514
- }[];
515
- }
516
- /** How many failed rows a {@link SyncRun} remembers. */
517
- export declare const SYNC_RUN_MAX_ERRORS = 20;