@volter/twin-cohere 0.1.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.
- package/README.md +224 -0
- package/defaults/handlers.json +26 -0
- package/dist/defaults/handlers.json +26 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +31 -0
- package/dist/src/cohere-budget.d.ts +55 -0
- package/dist/src/cohere-budget.js +171 -0
- package/dist/src/cohere-capabilities.d.ts +14 -0
- package/dist/src/cohere-capabilities.js +1852 -0
- package/dist/src/cohere-conformance.d.ts +17 -0
- package/dist/src/cohere-conformance.js +464 -0
- package/dist/src/cohere-connector.d.ts +150 -0
- package/dist/src/cohere-connector.js +625 -0
- package/dist/src/cohere-models.d.ts +21 -0
- package/dist/src/cohere-models.js +73 -0
- package/dist/src/cohere-scenario.d.ts +57 -0
- package/dist/src/cohere-scenario.js +176 -0
- package/dist/src/cohere-server.d.ts +16 -0
- package/dist/src/cohere-server.js +184 -0
- package/dist/src/cohere-stub.d.ts +119 -0
- package/dist/src/cohere-stub.js +321 -0
- package/dist/src/cohere-twin.d.ts +82 -0
- package/dist/src/cohere-twin.js +1243 -0
- package/dist/src/cohere-types.d.ts +226 -0
- package/dist/src/cohere-types.js +40 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +84 -0
- package/package.json +71 -0
- package/src/cli.ts +30 -0
- package/src/cohere-budget.ts +197 -0
- package/src/cohere-capabilities.ts +1855 -0
- package/src/cohere-conformance.ts +489 -0
- package/src/cohere-connector.ts +709 -0
- package/src/cohere-models.ts +79 -0
- package/src/cohere-scenario.ts +194 -0
- package/src/cohere-server.ts +195 -0
- package/src/cohere-stub.ts +337 -0
- package/src/cohere-twin.ts +1290 -0
- package/src/cohere-types.ts +231 -0
- package/src/index.ts +159 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
// Cohere CONNECTOR — the live-vendor pull/push path that gives the Cohere twin the full
|
|
2
|
+
// "git for SaaS" lifecycle (pull real state → mirror; push local writes → real).
|
|
3
|
+
//
|
|
4
|
+
// PULL (real → twin): fetch real Datasets / Connectors / Embed jobs, map the vendor JSON →
|
|
5
|
+
// SyncResource[], fold into the event log via syncPull (shadow-diff dedup,
|
|
6
|
+
// so re-pulling identical state appends nothing).
|
|
7
|
+
// PUSH (twin → real): for every PENDING local action (create / update / cancel / delete), call
|
|
8
|
+
// the real Cohere REST API and confirmAction on success (records the
|
|
9
|
+
// confirmed fields as an observed event + suppresses the local projection —
|
|
10
|
+
// counted exactly once).
|
|
11
|
+
//
|
|
12
|
+
// The vendor I/O is an INJECTED executor (B3 auth boundary): the kernel + this pack hold NO Cohere
|
|
13
|
+
// key and import NO SDK at runtime. Tests pass a fake executor; live runs pass
|
|
14
|
+
// `liveCohereExecute(apiKey)`. Same code path either way — fully exercisable offline.
|
|
15
|
+
import { assertBudgetGuardIntact, confirmAction, pendingActions, projectResources, syncPull } from '@volter/world-core';
|
|
16
|
+
import type { SyncResource, TwinAction } from '@volter/world-core';
|
|
17
|
+
import { CohereBudget, CohereBudgetError, cohereCallWeight, type CohereBudgetOptions } from './cohere-budget.ts';
|
|
18
|
+
|
|
19
|
+
const SERVICE = 'cohere';
|
|
20
|
+
|
|
21
|
+
/** The real Cohere API base — `CohereEnvironment.Production` in cohere-ai@8.1.0's
|
|
22
|
+
* `environments.d.ts`, which is the ONLY host either SDK ships. */
|
|
23
|
+
export const COHERE_API_BASE = 'https://api.cohere.com';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The injected real-Cohere boundary. `request` issues ONE Cohere REST call:
|
|
27
|
+
* method — 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
|
28
|
+
* path — e.g. '/v1/datasets' or '/v1/embed-jobs/<id>/cancel'
|
|
29
|
+
* body — JSON body for POST/PATCH (omitted otherwise)
|
|
30
|
+
* Returns the parsed JSON: one of Cohere's per-collection list envelopes, a single object, or its
|
|
31
|
+
* `{ message }` error envelope.
|
|
32
|
+
*/
|
|
33
|
+
export type CohereExecute = (
|
|
34
|
+
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
|
35
|
+
path: string,
|
|
36
|
+
body?: Record<string, unknown>,
|
|
37
|
+
) => Promise<unknown>;
|
|
38
|
+
|
|
39
|
+
/** Construction options for the live executor. `budget` cannot be null and cannot be loosened. */
|
|
40
|
+
export type LiveCohereOptions = {
|
|
41
|
+
/** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
|
|
42
|
+
fetchImpl?: typeof fetch;
|
|
43
|
+
/** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
|
|
44
|
+
budget?: CohereBudget;
|
|
45
|
+
/** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
|
|
46
|
+
budgetOptions?: CohereBudgetOptions;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A live executor against the real Cohere REST API (the user's own API key). Sends
|
|
51
|
+
* `Authorization: Bearer <key>` — the scheme BOTH SDKs use (cohere-ai's `BearerAuthProvider`,
|
|
52
|
+
* which falls back to the `CO_API_KEY` env var, and @ai-sdk/cohere's provider, which reads
|
|
53
|
+
* `COHERE_API_KEY`). Never imported by the pack's own serving path — only constructed by a caller
|
|
54
|
+
* that opts into real I/O.
|
|
55
|
+
*
|
|
56
|
+
* THIS IS THE ONE PLACE this pack issues a live api.cohere.com request, and therefore the one
|
|
57
|
+
* place the rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE
|
|
58
|
+
* the request goes out (`checkBudget`, which THROWS `CohereBudgetError` instead of returning when
|
|
59
|
+
* the ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a
|
|
60
|
+
* `retry-after` / 429 signal becomes a persisted cooldown that makes every later call fail fast
|
|
61
|
+
* WITHOUT touching Cohere. There is deliberately no OPTION to disable the guard, and no value a
|
|
62
|
+
* caller can pass for `budget` that yields an unguarded client. What that does NOT claim is
|
|
63
|
+
* immunity from a caller who WANTS one: a fresh `budgetOptions.path` per construction, or an
|
|
64
|
+
* injected clock, restores the allowance, because the same seam tests need cannot be denied to a
|
|
65
|
+
* determined caller in the same process. See `cohere-budget.ts` and the kernel's `rateBudget.ts`
|
|
66
|
+
* header for the limits of the guarantee.
|
|
67
|
+
*/
|
|
68
|
+
export function liveCohereExecute(
|
|
69
|
+
apiKey: string,
|
|
70
|
+
base = COHERE_API_BASE,
|
|
71
|
+
opts: LiveCohereOptions = {},
|
|
72
|
+
): CohereExecute {
|
|
73
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
74
|
+
// ONE expression decides which budget is used, so there is no second, weaker test that could
|
|
75
|
+
// disagree with the first. `null`/`undefined` (or omitting it) build the default; anything else
|
|
76
|
+
// must be an UNMODIFIED CohereBudget — a duck-typed stand-in, a SUBCLASS overriding
|
|
77
|
+
// `checkBudget`, and a Proxy trapping it are ALL refused, because each is a one-liner that would
|
|
78
|
+
// otherwise hand back a client with no ceiling.
|
|
79
|
+
const budget = opts.budget !== undefined && opts.budget !== null
|
|
80
|
+
? assertBudgetGuardIntact(opts.budget, CohereBudget, 'liveCohereExecute')
|
|
81
|
+
: new CohereBudget({ token: apiKey, ...(opts.budgetOptions ?? {}) });
|
|
82
|
+
return async (method, path, body) => {
|
|
83
|
+
const headers: Record<string, string> = { authorization: `Bearer ${apiKey}`, accept: 'application/json' };
|
|
84
|
+
const init: { method: string; headers: Record<string, string>; body?: string } = { method, headers };
|
|
85
|
+
if (method === 'POST' || method === 'PATCH') {
|
|
86
|
+
headers['content-type'] = 'application/json';
|
|
87
|
+
init.body = JSON.stringify(body ?? {});
|
|
88
|
+
}
|
|
89
|
+
const weight = cohereCallWeight(method, path);
|
|
90
|
+
// THROWS instead of calling. Nothing below this line runs when the budget refuses.
|
|
91
|
+
const reservation = budget.checkBudget(weight);
|
|
92
|
+
const res = await doFetch(`${base}${path}`, init);
|
|
93
|
+
const resHeaders: Record<string, string> = {};
|
|
94
|
+
res.headers.forEach((v: string, k: string) => { resHeaders[k.toLowerCase()] = v; });
|
|
95
|
+
// READ AS TEXT, then parse only if there is something to parse. `res.json()` throws a
|
|
96
|
+
// SyntaxError on an empty body — and the vendor HAS body-less responses (`embedJobs.cancel`
|
|
97
|
+
// is declared `-> void`). That throw would land BEFORE `recordCall`, leaving the budget
|
|
98
|
+
// reservation unsettled and dropping a `retry-after` on that very response on the floor.
|
|
99
|
+
// Settlement must not depend on the body parsing.
|
|
100
|
+
const text = await res.text();
|
|
101
|
+
// Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
|
|
102
|
+
// `retry-after` beyond the cap is not something to sleep off) — the cooldown is persisted
|
|
103
|
+
// first either way, so the refusal survives the throw.
|
|
104
|
+
// recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
|
|
105
|
+
// call that louder refusal wins; an answer Cohere ACCEPTED is kept, so a write that landed is
|
|
106
|
+
// never recorded as failed and performed again on retry.
|
|
107
|
+
try {
|
|
108
|
+
budget.recordCall(weight, resHeaders, { status: res.status, reservation });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (!(error instanceof CohereBudgetError) || !res.ok) throw error;
|
|
111
|
+
}
|
|
112
|
+
if (text.trim() === '') return {};
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(text) as unknown;
|
|
115
|
+
} catch {
|
|
116
|
+
throw new Error(`cohere: ${method} ${path} answered ${res.status} with a body that is not JSON: ${text.slice(0, 200)}`);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A REFUSED pull is NOT an empty account. Cohere answers a failure with its BARE `{ message }`
|
|
123
|
+
* envelope; mapping that to "no resources" would fold an empty account OVER real observed state
|
|
124
|
+
* and silently delete the mirror. Throw instead.
|
|
125
|
+
*
|
|
126
|
+
* Note the envelope is what identifies a refusal, not the HTTP status — the executor has already
|
|
127
|
+
* consumed the status, and a caller supplying its own executor may not preserve it at all.
|
|
128
|
+
*/
|
|
129
|
+
function throwIfError(res: unknown, ctx: string): void {
|
|
130
|
+
const e = res as { message?: unknown } | null;
|
|
131
|
+
if (e && typeof e === 'object' && typeof e.message === 'string') {
|
|
132
|
+
throw new Error(`cohere ${ctx} failed: ${e.message}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Read one collection out of Cohere's per-collection list envelope.
|
|
138
|
+
*
|
|
139
|
+
* Cohere does NOT use a uniform `{ data: [...] }` wrapper: datasets answer `{ datasets }`,
|
|
140
|
+
* connectors `{ connectors, total_count }`, embed jobs `{ embed_jobs }`. Reading a shape the
|
|
141
|
+
* collection does not use would silently yield zero rows — which `syncPull` would then fold as an
|
|
142
|
+
* emptied account.
|
|
143
|
+
*
|
|
144
|
+
* The key must be PRESENT. Cohere's own schemas make each list key optional-and-nullable, so an
|
|
145
|
+
* explicit `null` genuinely means "no rows" and is accepted; a response with the key MISSING
|
|
146
|
+
* ENTIRELY is not an empty account, it is a response this connector does not understand, and it
|
|
147
|
+
* throws rather than reporting zero.
|
|
148
|
+
*/
|
|
149
|
+
function listOf(res: unknown, key: string, nullable: boolean, ctx: string): Array<Record<string, unknown>> {
|
|
150
|
+
if (!res || typeof res !== 'object' || Array.isArray(res)) {
|
|
151
|
+
throw new Error(`cohere ${ctx} failed: expected an object carrying "${key}", got ${JSON.stringify(res).slice(0, 120)}`);
|
|
152
|
+
}
|
|
153
|
+
if (!Object.hasOwn(res as object, key)) {
|
|
154
|
+
throw new Error(`cohere ${ctx} failed: the response has no "${key}" key — an unrecognized envelope is not an empty account`);
|
|
155
|
+
}
|
|
156
|
+
const v = (res as Record<string, unknown>)[key];
|
|
157
|
+
if (v === null || v === undefined) {
|
|
158
|
+
// NULLABILITY IS PER COLLECTION, read from the vendor's own schemas — not assumed uniform.
|
|
159
|
+
// `DatasetsListResponse.datasets` and `ListEmbedJobResponse.embed_jobs` are declared
|
|
160
|
+
// `?: Raw[] | null`, so an explicit null genuinely means "no rows". `ListConnectorsResponse`
|
|
161
|
+
// declares `connectors: Connector.Raw[]` REQUIRED and NON-nullable, so a null there is a
|
|
162
|
+
// response the vendor says cannot occur — accepting it as an empty account would fold an empty
|
|
163
|
+
// list over real observed connector state, which is the exact hazard this module exists to
|
|
164
|
+
// close (§9 round 1, connector m1: an earlier version waved all three through on a premise
|
|
165
|
+
// that was false for one of them).
|
|
166
|
+
if (nullable) return [];
|
|
167
|
+
throw new Error(`cohere ${ctx} failed: "${key}" is null, but the vendor declares it required and non-nullable — an unexpected null is not an empty account`);
|
|
168
|
+
}
|
|
169
|
+
if (!Array.isArray(v)) throw new Error(`cohere ${ctx} failed: "${key}" is not a list`);
|
|
170
|
+
return v as Array<Record<string, unknown>>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── PULL ────────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/** Map a real-Cohere Dataset → a twin sync resource. Every field the vendor's `Dataset` schema
|
|
176
|
+
* declares REQUIRED (`id`, `name`, `created_at`, `updated_at`, `dataset_type`,
|
|
177
|
+
* `validation_status`) is carried; a pulled row that lost one would be unparseable by the
|
|
178
|
+
* official SDK the moment the twin served it back. */
|
|
179
|
+
export function mapDataset(d: Record<string, unknown>): SyncResource {
|
|
180
|
+
return {
|
|
181
|
+
type: 'dataset',
|
|
182
|
+
id: String(d.id),
|
|
183
|
+
fields: {
|
|
184
|
+
name: (d.name as string) ?? null,
|
|
185
|
+
created_at: (d.created_at as string) ?? null,
|
|
186
|
+
updated_at: (d.updated_at as string) ?? (d.created_at as string) ?? null,
|
|
187
|
+
dataset_type: (d.dataset_type as string) ?? null,
|
|
188
|
+
validation_status: (d.validation_status as string) ?? null,
|
|
189
|
+
validation_error: (d.validation_error as string) ?? null,
|
|
190
|
+
validation_warnings: (d.validation_warnings as string[]) ?? [],
|
|
191
|
+
required_fields: (d.required_fields as string[]) ?? [],
|
|
192
|
+
preserve_fields: (d.preserve_fields as string[]) ?? [],
|
|
193
|
+
dataset_parts: (d.dataset_parts as unknown[]) ?? [],
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Map a real-Cohere Connector → a twin sync resource. */
|
|
199
|
+
export function mapConnector(c: Record<string, unknown>): SyncResource {
|
|
200
|
+
return {
|
|
201
|
+
type: 'connector',
|
|
202
|
+
id: String(c.id),
|
|
203
|
+
fields: {
|
|
204
|
+
organization_id: (c.organization_id as string) ?? null,
|
|
205
|
+
name: (c.name as string) ?? null,
|
|
206
|
+
description: (c.description as string) ?? null,
|
|
207
|
+
url: (c.url as string) ?? null,
|
|
208
|
+
created_at: (c.created_at as string) ?? null,
|
|
209
|
+
updated_at: (c.updated_at as string) ?? (c.created_at as string) ?? null,
|
|
210
|
+
excludes: (c.excludes as string[]) ?? [],
|
|
211
|
+
auth_type: (c.auth_type as string) ?? null,
|
|
212
|
+
oauth: (c.oauth as Record<string, unknown>) ?? null,
|
|
213
|
+
auth_status: (c.auth_status as string) ?? null,
|
|
214
|
+
active: c.active === undefined ? true : c.active === true,
|
|
215
|
+
continue_on_failure: c.continue_on_failure === true,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Map a real-Cohere EmbedJob → a twin sync resource.
|
|
222
|
+
*
|
|
223
|
+
* The vendor's key for an embed job is `job_id`, NOT `id` — there is no `id` on the schema at all.
|
|
224
|
+
* Subjecting the resource under `d.id` would make every pulled job land under the string
|
|
225
|
+
* "undefined" and collapse the whole collection onto one row.
|
|
226
|
+
*/
|
|
227
|
+
export function mapEmbedJob(j: Record<string, unknown>): SyncResource {
|
|
228
|
+
return {
|
|
229
|
+
type: 'embed_job',
|
|
230
|
+
id: String(j.job_id),
|
|
231
|
+
fields: {
|
|
232
|
+
job_id: String(j.job_id),
|
|
233
|
+
name: (j.name as string) ?? null,
|
|
234
|
+
status: (j.status as string) ?? null,
|
|
235
|
+
created_at: (j.created_at as string) ?? null,
|
|
236
|
+
input_dataset_id: (j.input_dataset_id as string) ?? null,
|
|
237
|
+
output_dataset_id: (j.output_dataset_id as string) ?? null,
|
|
238
|
+
model: (j.model as string) ?? null,
|
|
239
|
+
truncate: (j.truncate as string) ?? null,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** `nullable` mirrors each list key's own declaration in cohere-ai@8.1.0's serializers:
|
|
245
|
+
* `DatasetsListResponse.datasets?: Raw[] | null`, `ListEmbedJobResponse.embed_jobs?: Raw[] | null`,
|
|
246
|
+
* but `ListConnectorsResponse.connectors: Connector.Raw[]` — required and non-nullable. */
|
|
247
|
+
const COLLECTIONS: Array<{ path: string; key: string; nullable: boolean; map: (r: Record<string, unknown>) => SyncResource }> = [
|
|
248
|
+
{ path: '/v1/datasets', key: 'datasets', nullable: true, map: mapDataset },
|
|
249
|
+
{ path: '/v1/connectors', key: 'connectors', nullable: false, map: mapConnector },
|
|
250
|
+
{ path: '/v1/embed-jobs', key: 'embed_jobs', nullable: true, map: mapEmbedJob },
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
/** Pull all modeled real collections via the executor and map them to twin sync resources. */
|
|
254
|
+
export async function pullCohereState(execute: CohereExecute): Promise<SyncResource[]> {
|
|
255
|
+
const out: SyncResource[] = [];
|
|
256
|
+
for (const c of COLLECTIONS) {
|
|
257
|
+
const res = await execute('GET', c.path);
|
|
258
|
+
throwIfError(res, `pull ${c.path}`);
|
|
259
|
+
for (const item of listOf(res, c.key, c.nullable, `pull ${c.path}`)) out.push(c.map(item));
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A MOVING poll timestamp, forced strictly increasing within the process. NOT a pinned constant:
|
|
266
|
+
* the kernel hashes an observed event over (`occurredAt` + post-state), so under a fixed poll time
|
|
267
|
+
* a vendor value that REVERTS (a connector toggled active→inactive→active across polls) collides
|
|
268
|
+
* with its own earlier observation and `syncPull` reports `deltasAppended: 1` while nothing lands
|
|
269
|
+
* — a phantom delta, with the projection still serving the stale value.
|
|
270
|
+
*/
|
|
271
|
+
let lastPoll = 0;
|
|
272
|
+
export function pollTimestamp(): string {
|
|
273
|
+
const now = Math.max(Date.now(), lastPoll + 1);
|
|
274
|
+
lastPoll = now;
|
|
275
|
+
return new Date(now).toISOString();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Pull from real Cohere and fold into the twin (mirror seeding). syncPull's shadow-diff makes a
|
|
280
|
+
* re-pull of identical state a no-op.
|
|
281
|
+
*/
|
|
282
|
+
export async function syncCohereFromReal(
|
|
283
|
+
execute: CohereExecute,
|
|
284
|
+
opts: { root?: string; occurredAt?: string } = {},
|
|
285
|
+
): Promise<{ observed: number; deltasAppended: number }> {
|
|
286
|
+
const resources = await pullCohereState(execute);
|
|
287
|
+
const result = syncPull({
|
|
288
|
+
service: SERVICE,
|
|
289
|
+
resources,
|
|
290
|
+
occurredAt: opts.occurredAt ?? pollTimestamp(),
|
|
291
|
+
...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
292
|
+
});
|
|
293
|
+
return { observed: result.observed, deltasAppended: result.deltasAppended };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── PUSH ────────────────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
/** The twin operations this connector knows how to push. Anything not here must FAIL LOUDLY
|
|
299
|
+
* rather than be silently dropped — pushing an unrecognized op risks hitting the wrong endpoint
|
|
300
|
+
* or no-op'ing a real change. */
|
|
301
|
+
const PUSHABLE_VERBS = new Set(['create', 'update', 'cancel', 'delete']);
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Subject types NO verb of which can be pushed, keyed by TYPE rather than by operation.
|
|
305
|
+
*
|
|
306
|
+
* A type-wide rule is the only shape that cannot be out-flanked by a verb nobody thought of: an
|
|
307
|
+
* op-keyed list covering `token.observe` would let a hypothetical `token.delete` through, and the
|
|
308
|
+
* request builder's `/v1/${type}s` fallback would then fire `DELETE /v1/tokens/<id>` — an endpoint
|
|
309
|
+
* Cohere does not have — at a REAL account.
|
|
310
|
+
*/
|
|
311
|
+
const UNPUSHABLE_TYPES: Record<string, string> = {
|
|
312
|
+
token: "the tokenizer vocabulary is twin-local state with no vendor endpoint in either direction — Cohere's tokenizer is fixed server-side, so there is nothing to push and nothing to pull",
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Individual operations whose verb and type are otherwise pushable, but whose vendor request this
|
|
317
|
+
* connector cannot faithfully reproduce from stored state. Refusing them by name is the honest
|
|
318
|
+
* answer; pushing an approximation would write the wrong thing to a real account, which is worse
|
|
319
|
+
* than not pushing at all. Each is filed as a todo in the manifest.
|
|
320
|
+
*/
|
|
321
|
+
const UNPUSHABLE_OPS: Record<string, string> = {
|
|
322
|
+
'dataset.create': "the vendor's dataset create is a multipart/form-data upload whose `data` part is the dataset FILE, and whose name/type travel as query parameters — not the JSON body this executor sends, and the twin does not retain the original bytes; see cohere.connector.push_dataset_multipart",
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Why this op is a KNOWN, filed, un-pushable gap — or undefined if the connector can push it.
|
|
327
|
+
*
|
|
328
|
+
* This answers only for gaps that have been REASONED ABOUT. An operation the connector has simply
|
|
329
|
+
* never heard of is NOT this function's business: it is a coding gap, and `assertPushable` throws
|
|
330
|
+
* on it, loudly. Routing every unknown verb through the skip-and-count path would turn a genuine
|
|
331
|
+
* unhandled write into an anonymous integer that ticks up and tells nobody.
|
|
332
|
+
*/
|
|
333
|
+
export function unpushableReason(op: string, subjectType?: string): string | undefined {
|
|
334
|
+
if (subjectType !== undefined && UNPUSHABLE_TYPES[subjectType] !== undefined) return UNPUSHABLE_TYPES[subjectType];
|
|
335
|
+
return UNPUSHABLE_OPS[op];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Does this subject have a VENDOR identity, or only a twin-minted one?
|
|
340
|
+
*
|
|
341
|
+
* THE BUG THIS CLOSES (§9 round 1, connector B1 + M1 — one BLOCKER and one MAJOR, both the same
|
|
342
|
+
* root class). The twin mints UUID-SHAPED ids, so a twin id is indistinguishable from a real
|
|
343
|
+
* Cohere id in an operator's logs. Two ordinary sequences fired one at the REAL account:
|
|
344
|
+
*
|
|
345
|
+
* B1 create a connector, PATCH it, push. The create confirms under the vendor's id, but the
|
|
346
|
+
* QUEUED update still carries the twin-minted id, so `PATCH /v1/connectors/<twin-uuid>` went
|
|
347
|
+
* out. Real Cohere 404s, `throwIfError` aborts the batch mid-run, and the update stays
|
|
348
|
+
* PENDING — so every later push re-issues the same doomed request forever.
|
|
349
|
+
* M1 create a dataset, delete it, push. `dataset.create` is a FILED multipart gap, so it is
|
|
350
|
+
* skipped — and skipping it is exactly what made the delete reachable:
|
|
351
|
+
* `DELETE /v1/datasets/<twin-uuid>` went out. Aborting at the refused create would have
|
|
352
|
+
* stopped the run; the skip is what let it through. That is the "trace what a fix makes
|
|
353
|
+
* newly REACHABLE" class, and the fix's own reasoning had it backwards.
|
|
354
|
+
*
|
|
355
|
+
* The discriminator is the `_twin_minted` marker the handler stamps on every local create. A row
|
|
356
|
+
* that arrived through `syncPull` carries no marker and IS known to the vendor under its own id, so
|
|
357
|
+
* a mutation on it pushes normally — that path must keep working, which is why "does a local create
|
|
358
|
+
* action exist" is the question rather than "does the id look local".
|
|
359
|
+
*/
|
|
360
|
+
type Identity =
|
|
361
|
+
| { kind: 'vendor' } // the vendor knows this id; push it as-is
|
|
362
|
+
| { kind: 'superseded'; externalId: string } // the vendor knows it under a DIFFERENT id
|
|
363
|
+
| { kind: 'local' }; // the vendor has never seen it
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* What identity does this subject have AT THE VENDOR? Read from the durable projection, so the
|
|
367
|
+
* answer survives a restart, an aborted batch and a fresh process.
|
|
368
|
+
*
|
|
369
|
+
* §9 ROUND TWO, BLOCKER. Round one fixed B1 with an in-loop `Map` and M1 with a `_twin_minted`
|
|
370
|
+
* marker — and the two fixes DESTROYED each other on the same state. `confirmAction` suppresses the
|
|
371
|
+
* confirmed create's whole projection, and the supersede tombstone it writes carries only
|
|
372
|
+
* `{_deleted, _superseded_by}` — so `_twin_minted`, which lived on the now-suppressed create,
|
|
373
|
+
* VANISHES. The local id then looked vendor-known, and the per-batch map was the only thing left
|
|
374
|
+
* holding the line:
|
|
375
|
+
*
|
|
376
|
+
* create + PATCH queued → push #1 confirms the create, then the PATCH throws (a budget refusal
|
|
377
|
+
* mid-batch, a retry-after past the cap, a network error, any vendor `{message}`) → batch
|
|
378
|
+
* aborts, the PATCH stays pending → push #2 has an EMPTY map and a marker-less tombstone
|
|
379
|
+
* → `PATCH /v1/connectors/<twin-uuid>` at the REAL account, every run, forever.
|
|
380
|
+
*
|
|
381
|
+
* That is byte-for-byte the outcome B1 claimed to close, and neither the capability written to
|
|
382
|
+
* prove B1 nor its revert-matrix cell could see it — both are single-batch by construction.
|
|
383
|
+
*
|
|
384
|
+
* The tombstone the confirm already writes IS the durable mapping; reading it is what makes the
|
|
385
|
+
* resolution survive the batch. `mintedExternal` is now only a within-run cache for rows confirmed
|
|
386
|
+
* moments ago, whose tombstone this same call would find anyway.
|
|
387
|
+
*/
|
|
388
|
+
function vendorIdentity(subjectType: string, subjectId: string, root?: string): Identity {
|
|
389
|
+
const row = projectResources(SERVICE, root).find((r) => r.type === subjectType && r.id === subjectId);
|
|
390
|
+
// No row at all: nothing local ever claimed this id, so it can only have come from the vendor.
|
|
391
|
+
if (row === undefined) return { kind: 'vendor' };
|
|
392
|
+
// A supersede tombstone records exactly which vendor id this local id became.
|
|
393
|
+
if (typeof row._superseded_by === 'string' && row._superseded_by) return { kind: 'superseded', externalId: row._superseded_by };
|
|
394
|
+
if (row._twin_minted === true) return { kind: 'local' };
|
|
395
|
+
return { kind: 'vendor' };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Fields to CONFIRM with — the twin's own bookkeeping removed. `_twin_minted` must not ride along
|
|
399
|
+
* to the vendor-id row: that row IS vendor-known, and leaving the marker on it would make every
|
|
400
|
+
* later mutation of a converged resource refuse itself. */
|
|
401
|
+
function confirmFields(fields: Record<string, unknown> | undefined): Record<string, unknown> {
|
|
402
|
+
const out: Record<string, unknown> = { ...(fields ?? {}) };
|
|
403
|
+
delete out._twin_minted;
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Resolve every id a payload REFERENCES, by the same rule the subject gets.
|
|
410
|
+
*
|
|
411
|
+
* The only foreign key this twin pushes is an embed job's `input_dataset_id`. It matters because
|
|
412
|
+
* `dataset.create` is an un-pushable filed gap, so a locally created dataset is PERMANENTLY
|
|
413
|
+
* vendor-unknown — and the embed-job create would then have posted
|
|
414
|
+
* `{"dataset_id": "<twin-uuid>"}` to the real account (§9 round two, MAJOR). Worse, the capability
|
|
415
|
+
* written to pin the read-shape→write-shape rename ASSERTED that it did.
|
|
416
|
+
*/
|
|
417
|
+
function resolveForeignKeys<A extends Pick<TwinAction, 'operation' | 'subject' | 'fields'>>(
|
|
418
|
+
action: A,
|
|
419
|
+
cache: Map<string, string>,
|
|
420
|
+
root?: string,
|
|
421
|
+
): { action: A; refusal?: string } {
|
|
422
|
+
const op = action.operation ?? '';
|
|
423
|
+
if (op !== 'embed_job.create') return { action };
|
|
424
|
+
const datasetId = (action.fields ?? {}).input_dataset_id;
|
|
425
|
+
if (typeof datasetId !== 'string' || datasetId === '') return { action };
|
|
426
|
+
const cached = cache.get(`dataset:${datasetId}`);
|
|
427
|
+
const identity: Identity = cached !== undefined ? { kind: 'superseded', externalId: cached } : vendorIdentity('dataset', datasetId, root);
|
|
428
|
+
if (identity.kind === 'local') {
|
|
429
|
+
return {
|
|
430
|
+
action,
|
|
431
|
+
refusal: `the embed job references dataset '${datasetId}', which this twin minted and the vendor has never seen (dataset creates are an un-pushable multipart gap) — posting that id would ask the real account to embed a dataset that does not exist there`,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (identity.kind === 'superseded') {
|
|
435
|
+
return { action: { ...action, fields: { ...(action.fields ?? {}), input_dataset_id: identity.externalId } } };
|
|
436
|
+
}
|
|
437
|
+
return { action };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Throw if `op` is not a write operation this connector can faithfully push. */
|
|
441
|
+
function assertPushable(op: string, subjectType?: string): void {
|
|
442
|
+
// NAMED GAP FIRST, so a filed, reasoned gap always answers with ITS reason rather than the
|
|
443
|
+
// generic "unsupported operation" — otherwise a caller cannot tell a thought-about gap from a
|
|
444
|
+
// hole nobody has looked at, which is the whole distinction this pair encodes.
|
|
445
|
+
const why = unpushableReason(op, subjectType);
|
|
446
|
+
if (why !== undefined) throw new Error(`cohere push: cannot faithfully push '${op}' — ${why}`);
|
|
447
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
448
|
+
if (!PUSHABLE_VERBS.has(verb)) {
|
|
449
|
+
throw new Error(`cohere push: unsupported operation '${op}' — refusing to silently drop a local write`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Map a subject type → its REST collection path. */
|
|
454
|
+
const COLLECTION_PATH: Record<string, string> = {
|
|
455
|
+
dataset: '/v1/datasets',
|
|
456
|
+
connector: '/v1/connectors',
|
|
457
|
+
embed_job: '/v1/embed-jobs',
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Resolve the REST (method, path) for ONE pending action — faithful to the real Cohere REST
|
|
462
|
+
* surface for every write op the twin records:
|
|
463
|
+
* - <type>.create → POST <collection>
|
|
464
|
+
* - <type>.update → PATCH <collection>/:id
|
|
465
|
+
* - <type>.cancel → POST <collection>/:id/cancel
|
|
466
|
+
* - <type>.delete → DELETE <collection>/:id
|
|
467
|
+
*/
|
|
468
|
+
export function cohereRequestForAction(action: Pick<TwinAction, 'operation' | 'subject'>): { method: 'GET' | 'POST' | 'PATCH' | 'DELETE'; path: string } {
|
|
469
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
470
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
471
|
+
const collection = COLLECTION_PATH[action.subject.type] ?? `/v1/${action.subject.type}s`;
|
|
472
|
+
if (verb === 'create') return { method: 'POST', path: collection };
|
|
473
|
+
if (verb === 'update') return { method: 'PATCH', path: `${collection}/${action.subject.id}` };
|
|
474
|
+
if (verb === 'cancel') return { method: 'POST', path: `${collection}/${action.subject.id}/cancel` };
|
|
475
|
+
if (verb === 'delete') return { method: 'DELETE', path: `${collection}/${action.subject.id}` };
|
|
476
|
+
// assertPushable rejects anything else, so this is only reached for the pushable verbs above.
|
|
477
|
+
return { method: 'POST', path: collection };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** The vendor-accepted payload for a create/update. The stored fields include derived lifecycle
|
|
481
|
+
* data the endpoints do not accept, so the twin's own bookkeeping is stripped. */
|
|
482
|
+
function payloadFor(action: Pick<TwinAction, 'operation' | 'subject' | 'fields'>, verb: string): Record<string, unknown> | undefined {
|
|
483
|
+
const f = (action.fields ?? {}) as Record<string, unknown>;
|
|
484
|
+
if (verb === 'update') {
|
|
485
|
+
// `UpdateConnectorRequest` is a strict subset: name / url / excludes / active /
|
|
486
|
+
// continue_on_failure. Replaying `auth_status` or `created_at` would be sending the vendor
|
|
487
|
+
// fields its own PATCH schema does not declare.
|
|
488
|
+
const out: Record<string, unknown> = {};
|
|
489
|
+
for (const k of ['name', 'url', 'excludes', 'active', 'continue_on_failure'] as const) {
|
|
490
|
+
if (f[k] !== undefined) out[k] = f[k];
|
|
491
|
+
}
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
if (verb !== 'create') return undefined;
|
|
495
|
+
switch (action.subject.type) {
|
|
496
|
+
case 'connector':
|
|
497
|
+
// `CreateConnectorRequest` REQUIRES name + url; the rest are optional. `auth_type` and
|
|
498
|
+
// `auth_status` are DERIVED server-side and are not accepted on create.
|
|
499
|
+
return {
|
|
500
|
+
name: f.name,
|
|
501
|
+
url: f.url,
|
|
502
|
+
...(f.description ? { description: f.description } : {}),
|
|
503
|
+
...(Array.isArray(f.excludes) && f.excludes.length ? { excludes: f.excludes } : {}),
|
|
504
|
+
...(f.oauth ? { oauth: f.oauth } : {}),
|
|
505
|
+
...(f.active !== undefined ? { active: f.active } : {}),
|
|
506
|
+
...(f.continue_on_failure !== undefined ? { continue_on_failure: f.continue_on_failure } : {}),
|
|
507
|
+
};
|
|
508
|
+
case 'embed_job':
|
|
509
|
+
// `CreateEmbedJobRequest` REQUIRES model + dataset_id + input_type. The twin stores the
|
|
510
|
+
// dataset reference as `input_dataset_id` (the READ shape) and the create endpoint wants
|
|
511
|
+
// `dataset_id` (the WRITE shape) — the two names differ on the vendor's own schemas.
|
|
512
|
+
//
|
|
513
|
+
// `dataset_id` is resolved by the caller BEFORE this runs (§9 round two, MAJOR): the
|
|
514
|
+
// identity guard covers `subject.id`, and a FOREIGN KEY is just as capable of carrying a
|
|
515
|
+
// twin-minted UUID to the real account. See `resolveForeignKeys`.
|
|
516
|
+
return {
|
|
517
|
+
model: f.model,
|
|
518
|
+
dataset_id: f.input_dataset_id,
|
|
519
|
+
input_type: f.input_type ?? 'search_document',
|
|
520
|
+
...(f.name ? { name: f.name } : {}),
|
|
521
|
+
...(f.truncate ? { truncate: f.truncate } : {}),
|
|
522
|
+
};
|
|
523
|
+
// `dataset.create` is refused by `assertPushable` above (multipart), so it has no payload.
|
|
524
|
+
default:
|
|
525
|
+
return {};
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* The id the VENDOR minted, read out of the create response.
|
|
531
|
+
*
|
|
532
|
+
* Cohere's create responses do NOT share one envelope, and a generic `res.id` would silently fall
|
|
533
|
+
* back to the twin's local id on two of the three types — which is exactly the shape that makes a
|
|
534
|
+
* push-then-pull serve one account row TWICE:
|
|
535
|
+
* • dataset → `{ id }` (`DatasetsCreateResponse`)
|
|
536
|
+
* • connector → `{ connector: { id } }` (`CreateConnectorResponse` — NESTED)
|
|
537
|
+
* • embed job → `{ job_id }` (`CreateEmbedJobResponse` — a different key)
|
|
538
|
+
*/
|
|
539
|
+
function externalIdOf(subjectType: string, res: unknown): string {
|
|
540
|
+
const o = (res ?? {}) as Record<string, unknown>;
|
|
541
|
+
const found = subjectType === 'connector'
|
|
542
|
+
? (o.connector as { id?: unknown } | undefined)?.id
|
|
543
|
+
: subjectType === 'embed_job'
|
|
544
|
+
? o.job_id
|
|
545
|
+
: o.id;
|
|
546
|
+
if (typeof found === 'string' && found) return found;
|
|
547
|
+
// NO FALLBACK TO THE LOCAL ID (§9 round two, m-R2.1). Confirming under the twin's own UUID would
|
|
548
|
+
// MANUFACTURE a vendor identity: no tombstone is written, so `vendorIdentity` would afterwards
|
|
549
|
+
// report that id as vendor-known and every later mutation would push the twin's UUID at the real
|
|
550
|
+
// account. A create response that carries no id is a malformed response, not a successful create.
|
|
551
|
+
throw new Error(`cohere push: the ${subjectType} create response carried no vendor id — refusing to confirm under the twin's own id`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Push ONE pending action to REAL Cohere via the injected executor. Returns the real external id.
|
|
556
|
+
* WRITES TO THE REAL ACCOUNT.
|
|
557
|
+
*/
|
|
558
|
+
export async function pushCohereAction(
|
|
559
|
+
execute: CohereExecute,
|
|
560
|
+
action: Pick<TwinAction, 'operation' | 'subject' | 'fields'>,
|
|
561
|
+
): Promise<{ externalId: string }> {
|
|
562
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
563
|
+
assertPushable(op, action.subject.type);
|
|
564
|
+
const { method, path } = cohereRequestForAction(action);
|
|
565
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
566
|
+
const payload = payloadFor(action, verb);
|
|
567
|
+
const res = await execute(method, path, payload);
|
|
568
|
+
throwIfError(res, `push ${action.subject.type}`);
|
|
569
|
+
return { externalId: verb === 'create' ? externalIdOf(action.subject.type, res) : action.subject.id };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Push the twin's PENDING local actions to real Cohere and CONFIRM each: for every pending action,
|
|
574
|
+
* call the real API; on success, confirmAction records the confirmed fields as an observed event
|
|
575
|
+
* and maps action → event (suppressing the local projection — counted exactly once). Idempotency:
|
|
576
|
+
* a confirmed action is no longer pending, so a re-push enacts NOTHING.
|
|
577
|
+
*
|
|
578
|
+
* COMPOUND-ACTION AUDIT (the `confirmAction` hazard in ADDING_A_TWIN.md §5): every action this
|
|
579
|
+
* connector pushes is SINGLE-RESOURCE — a connector or embed-job create/update, an embed-job
|
|
580
|
+
* cancel, a dataset or connector delete — so `fields` alone is the complete post-state and no
|
|
581
|
+
* `additionalObservations` are needed for the payload. The one write this twin makes that touches
|
|
582
|
+
* several resources at once is a TOKENIZE (one `token.observe` per fresh segment) — and those are
|
|
583
|
+
* separate single-resource actions on a type that is refused push-wide, so the hazard cannot
|
|
584
|
+
* arise here.
|
|
585
|
+
*/
|
|
586
|
+
export async function pushPendingCohereActions(
|
|
587
|
+
execute: CohereExecute,
|
|
588
|
+
opts: { root?: string; occurredAt: string },
|
|
589
|
+
): Promise<{ pushed: number; confirmed: string[]; externalIds: Record<string, string>; refused: Array<{ actionId: string; operation: string; subjectId: string; reason: string }> }> {
|
|
590
|
+
const confirmed: string[] = [];
|
|
591
|
+
const externalIds: Record<string, string> = {};
|
|
592
|
+
const refused: Array<{ actionId: string; operation: string; subjectId: string; reason: string }> = [];
|
|
593
|
+
// Vendor ids minted DURING this run, keyed by the local subject they replaced. A create confirms
|
|
594
|
+
// under the vendor's id; every later action in the same batch that names the superseded local id
|
|
595
|
+
// must be rewritten through this map before its request is built.
|
|
596
|
+
const mintedExternal = new Map<string, string>();
|
|
597
|
+
const subjectKey = (type: string, id: string) => `${type}:${id}`;
|
|
598
|
+
|
|
599
|
+
for (const pending of pendingActions(SERVICE, opts.root)) {
|
|
600
|
+
const operation = pending.operation ?? `${pending.subject.type}.update`;
|
|
601
|
+
const verb = operation.includes('.') ? operation.slice(operation.indexOf('.') + 1) : operation;
|
|
602
|
+
// A KNOWN, filed gap is REPORTED BY NAME and skipped: throwing would abort the batch and
|
|
603
|
+
// leave the account half-pushed over a write we already decided not to send, while dropping
|
|
604
|
+
// it silently is what the doctrine forbids. Anything NOT on that list falls through to
|
|
605
|
+
// `pushCohereAction` → `assertPushable`, which THROWS — an operation nobody has reasoned about
|
|
606
|
+
// must stop the run, not become an anonymous tally.
|
|
607
|
+
const why = unpushableReason(operation, pending.subject.type);
|
|
608
|
+
if (why !== undefined) {
|
|
609
|
+
refused.push({ actionId: pending.id, operation, subjectId: pending.subject.id, reason: why });
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// IDENTITY RESOLUTION, before any request is built (§9 round 1 B1/M1, hardened in round two).
|
|
614
|
+
// A non-create acts on a subject that must already exist AT THE VENDOR:
|
|
615
|
+
// • superseded — a create already mapped this local id to a vendor id (this run's cache, or
|
|
616
|
+
// the DURABLE supersede tombstone from any earlier run): address the vendor's id;
|
|
617
|
+
// • local — the subject is twin-minted and was never successfully pushed, so there is NOTHING
|
|
618
|
+
// at the vendor to act on. Sending the twin's own UUID would mutate or delete whatever (if
|
|
619
|
+
// anything) carries that id in the real account. Refuse BY NAME, so it is visible every run;
|
|
620
|
+
// • vendor — the id came from a pull and is the vendor's own: push it as-is.
|
|
621
|
+
let action: Pick<TwinAction, 'id' | 'operation' | 'subject' | 'fields'> = pending;
|
|
622
|
+
if (verb !== 'create') {
|
|
623
|
+
const cached = mintedExternal.get(subjectKey(pending.subject.type, pending.subject.id));
|
|
624
|
+
const identity: Identity = cached !== undefined
|
|
625
|
+
? { kind: 'superseded', externalId: cached }
|
|
626
|
+
: vendorIdentity(pending.subject.type, pending.subject.id, opts.root);
|
|
627
|
+
if (identity.kind === 'local') {
|
|
628
|
+
refused.push({
|
|
629
|
+
actionId: pending.id,
|
|
630
|
+
operation,
|
|
631
|
+
subjectId: pending.subject.id,
|
|
632
|
+
reason: `the subject was minted by this twin and the vendor has never seen it (its create was refused or never pushed), so '${operation}' has nothing to act on — sending the twin's own id would address an unrelated resource in the real account`,
|
|
633
|
+
});
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (identity.kind === 'superseded') {
|
|
637
|
+
action = { ...pending, subject: { ...pending.subject, id: identity.externalId } };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// FOREIGN KEYS are resolved by the SAME rule as the subject (§9 round two, MAJOR). The subject
|
|
642
|
+
// guard above says nothing about the ids a payload REFERENCES, and an embed-job create carries
|
|
643
|
+
// its dataset's id in the body — so a locally-minted dataset id sailed straight to the real
|
|
644
|
+
// account, where the vendor rejects it and the batch wedges exactly as B1 did.
|
|
645
|
+
const fk = resolveForeignKeys(action, mintedExternal, opts.root);
|
|
646
|
+
if (fk.refusal !== undefined) {
|
|
647
|
+
refused.push({ actionId: pending.id, operation, subjectId: pending.subject.id, reason: fk.refusal });
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
action = fk.action;
|
|
651
|
+
|
|
652
|
+
const { externalId } = await pushCohereAction(execute, action);
|
|
653
|
+
// CONFIRM UNDER THE VENDOR'S ID, not the twin's locally-minted one. The local mint is a
|
|
654
|
+
// placeholder that only ever existed here; the account knows the row by the id its create
|
|
655
|
+
// response returned. Confirming under the local id would leave the local projection standing
|
|
656
|
+
// while the next `pullCohereState` folded the SAME resource in under its real id — one account
|
|
657
|
+
// row served as TWO. Re-subjecting the confirm suppresses the local action's projection and
|
|
658
|
+
// rebuilds the row at the real id, so push-then-pull converges.
|
|
659
|
+
const subject = { ...action.subject, id: externalId };
|
|
660
|
+
// TOMBSTONE THE SUPERSEDED LOCAL ID, IN THE SAME CONFIRM. Re-subjecting suppresses the local
|
|
661
|
+
// action's projection entirely, so without this the placeholder id would leave the row set with
|
|
662
|
+
// NO record that it ever existed or what it became — a client still holding it gets a bare 404
|
|
663
|
+
// and no way to follow the resource to its real id. The tombstone keeps that provenance
|
|
664
|
+
// (`_superseded_by`) auditable and the id visibly occupied.
|
|
665
|
+
//
|
|
666
|
+
// HONEST SCOPE (§9 round 1, m4, corrected by its own revert-matrix cell): this does NOT stop
|
|
667
|
+
// `nextId` re-issuing the id. That mint takes its ordinal from the row-set SIZE and only ever
|
|
668
|
+
// probes upward, so a superseded id is not a re-issue candidate with or without the tombstone.
|
|
669
|
+
// An earlier version of this note claimed otherwise, and the pin written to prove that claim
|
|
670
|
+
// came out hollow.
|
|
671
|
+
//
|
|
672
|
+
// It rides `additionalObservations` — the
|
|
673
|
+
// kernel's own seam for a compound confirm — rather than a second `applyTwinWrite`: a local
|
|
674
|
+
// write here would itself be a PENDING action, which the very next push would try to send to
|
|
675
|
+
// the vendor as `<type>.superseded`.
|
|
676
|
+
const tombstone = externalId !== action.subject.id
|
|
677
|
+
? [{ subject: action.subject, fields: { _deleted: true, _superseded_by: externalId } }]
|
|
678
|
+
: [];
|
|
679
|
+
if (externalId !== action.subject.id) mintedExternal.set(subjectKey(action.subject.type, action.subject.id), externalId);
|
|
680
|
+
confirmAction({
|
|
681
|
+
service: SERVICE, actionId: action.id, subject, fields: confirmFields(action.fields),
|
|
682
|
+
...(tombstone.length ? { additionalObservations: tombstone } : {}),
|
|
683
|
+
occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
684
|
+
});
|
|
685
|
+
confirmed.push(action.id);
|
|
686
|
+
externalIds[action.id] = externalId;
|
|
687
|
+
}
|
|
688
|
+
return { pushed: confirmed.length, confirmed, externalIds, refused };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ── FULL bi-directional sync ─────────────────────────────────────────────────
|
|
692
|
+
/**
|
|
693
|
+
* FULL bi-directional sync over the injected client: (1) PUSH every pending local action to real
|
|
694
|
+
* Cohere and confirm it, then (2) PULL all modeled collections back and fold them into the event
|
|
695
|
+
* log. Pushing first means the pull observes the twin's own writes as confirmed external state (no
|
|
696
|
+
* double-count). Re-running with no pending writes and identical real state is a no-op (push 0,
|
|
697
|
+
* deltasAppended 0). Same code path offline (fake executor) and live (real key).
|
|
698
|
+
*/
|
|
699
|
+
export async function fullSyncCohere(
|
|
700
|
+
execute: CohereExecute,
|
|
701
|
+
opts: { root?: string; occurredAt: string },
|
|
702
|
+
): Promise<{ pushed: number; refused: Array<{ actionId: string; operation: string; subjectId: string; reason: string }>; observed: number; deltasAppended: number; collections: number }> {
|
|
703
|
+
const push = await pushPendingCohereActions(execute, { occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
704
|
+
const resources = await pullCohereState(execute);
|
|
705
|
+
const pull = syncPull({ service: SERVICE, resources, occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
706
|
+
// The REFUSALS THEMSELVES, not a count: a bare integer that ticks up and never comes down is not
|
|
707
|
+
// a signal an operator can act on.
|
|
708
|
+
return { pushed: push.pushed, refused: push.refused, observed: pull.observed, deltasAppended: pull.deltasAppended, collections: COLLECTIONS.length };
|
|
709
|
+
}
|