@wirecat/cli-core 0.558.0 → 0.559.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.
@@ -932,5 +932,15 @@ export const GENERATED = [
932
932
  "CREATE TRIGGER facts_no_payment_bu BEFORE UPDATE OF type ON facts WHEN new.type = 'payment' BEGIN\n SELECT RAISE(ABORT, 'facts: a payment is a payments row in store.finance, not a fact; upgrade the CLI');\nEND;",
933
933
  "INSERT OR REPLACE INTO store_settings (key, value, at) VALUES ('payment_facts_to_move', '1', unixepoch() * 1000);"
934
934
  ]
935
+ },
936
+ {
937
+ "name": "20261102192959_version-47-records",
938
+ "statements": [
939
+ "CREATE TABLE `record_relations` (\n\t`id` integer PRIMARY KEY,\n\t`record_id` integer NOT NULL,\n\t`role` text NOT NULL,\n\t`target_type` text NOT NULL,\n\t`target_external_id` text NOT NULL,\n\t`target_id` integer,\n\t`created_at` integer NOT NULL,\n\tCONSTRAINT `fk_record_relations_record_id_records_id_fk` FOREIGN KEY (`record_id`) REFERENCES `records`(`id`),\n\tCONSTRAINT `fk_record_relations_target_id_records_id_fk` FOREIGN KEY (`target_id`) REFERENCES `records`(`id`),\n\tCONSTRAINT `record_relations_record_id_role_target_type_target_external_id_unique` UNIQUE(`record_id`,`role`,`target_type`,`target_external_id`)\n);",
940
+ "CREATE TABLE `records` (\n\t`id` integer PRIMARY KEY,\n\t`account_id` integer NOT NULL,\n\t`type` text NOT NULL,\n\t`external_id` text NOT NULL,\n\t`fields` text NOT NULL,\n\t`revision` integer,\n\t`fingerprint` text NOT NULL,\n\t`entity_type` text,\n\t`entity_id` integer,\n\t`projected_fingerprint` text,\n\t`batch_id` text NOT NULL,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL,\n\t`deleted_at` integer,\n\tCONSTRAINT `fk_records_account_id_accounts_id_fk` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`),\n\tCONSTRAINT `records_account_id_type_external_id_unique` UNIQUE(`account_id`,`type`,`external_id`)\n);",
941
+ "CREATE INDEX `record_relations_by_target_type_target_external_id` ON `record_relations` (`target_type`,`target_external_id`);",
942
+ "CREATE INDEX `record_relations_by_target_id` ON `record_relations` (`target_id`);",
943
+ "CREATE INDEX `records_by_entity_type_entity_id` ON `records` (`entity_type`,`entity_id`);"
944
+ ]
935
945
  }
936
946
  ];
@@ -0,0 +1,81 @@
1
+ import { type EnvelopeRecord, type EnvelopeSource, type IngestError } from "../../domain/records.js";
2
+ import type { Author } from "./memories.js";
3
+ import type { StoreContext } from "./open.js";
4
+ export interface RecordKey {
5
+ provider: string;
6
+ account: string;
7
+ type: string;
8
+ externalId: string;
9
+ }
10
+ export interface SourceRecord extends RecordKey {
11
+ fields: Record<string, unknown>;
12
+ revision: number | null;
13
+ fingerprint: string;
14
+ relations: {
15
+ role: string;
16
+ type: string;
17
+ externalId: string;
18
+ resolved: boolean;
19
+ }[];
20
+ /** The entity made from it or mapped to it by the owner; `null` while it waits or for a type without a table. */
21
+ entity: string | null;
22
+ /** The source changed or deleted it after its entity was made: the owner should look at the entity. */
23
+ flagged: boolean;
24
+ batchId: string;
25
+ createdAt: string;
26
+ updatedAt: string;
27
+ deletedAt: string | null;
28
+ }
29
+ export interface RecordOutcome {
30
+ /** The record's place in the envelope's `records`. */
31
+ index: number;
32
+ key: string;
33
+ status: "created" | "updated" | "unchanged" | "deleted" | "refused";
34
+ error?: IngestError;
35
+ }
36
+ export interface RecordsWritten {
37
+ outcomes: RecordOutcome[];
38
+ /** `invoice:` and `payment:` refs this import proposed, also for records of earlier imports it completed. */
39
+ proposed: string[];
40
+ /** Kept, with no entity yet: the roles whose target is not imported or has no entity. */
41
+ waiting: {
42
+ index: number;
43
+ key: string;
44
+ missing: string[];
45
+ }[];
46
+ /** Changed or deleted at the source after its entity was made. */
47
+ flagged: {
48
+ index: number;
49
+ key: string;
50
+ entity: string;
51
+ }[];
52
+ /** A record of an earlier import this one completed, whose entity was still refused. */
53
+ laterErrors: IngestError[];
54
+ }
55
+ export interface RecordStore {
56
+ /** Writes validated records of one source, per chunk in one transaction; a dry run rolls everything back. */
57
+ write(source: EnvelopeSource, records: {
58
+ index: number;
59
+ record: EnvelopeRecord;
60
+ }[], options?: {
61
+ dryRun?: boolean;
62
+ chunk?: number;
63
+ }): Promise<RecordsWritten>;
64
+ get(key: RecordKey): Promise<SourceRecord | undefined>;
65
+ list(filter: {
66
+ provider: string;
67
+ account: string;
68
+ type?: string;
69
+ /** Only records whose entity waits for a reference. */
70
+ waiting?: boolean;
71
+ flagged?: boolean;
72
+ /** 1–500, 100 by default. */
73
+ limit?: number;
74
+ }): Promise<SourceRecord[]>;
75
+ /**
76
+ * The owner says which entity a record is: `person:4` for a payer a sheet names. Never done by name. Records that
77
+ * waited for it get their entity, as proposals.
78
+ */
79
+ map(key: RecordKey, entity: string, by: Author): Promise<SourceRecord>;
80
+ }
81
+ export declare const recordStoreOver: (context: StoreContext) => RecordStore;
@@ -0,0 +1,372 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { formatDecimal, minorUnits, scaleOf } from "../../domain/money.js";
3
+ import { pointerOf, recordFingerprint, } from "../../domain/records.js";
4
+ import { accountPk, findAccountPk } from "./accounts.js";
5
+ import { financeOperationsOver } from "./finance.js";
6
+ import { requiredThing } from "./notes.js";
7
+ import { referenceOfThing, storedThing } from "./things.js";
8
+ import { read, write } from "./transaction.js";
9
+ const PROJECTED = {
10
+ invoice: { roles: ["issuer", "counterparty", "project"], many: [] },
11
+ payment: { roles: ["payer", "payee", "project", "settles"], many: ["settles"] },
12
+ };
13
+ const isProjected = (type) => type === "invoice" || type === "payment";
14
+ const IMPORTER = { bot: "import" };
15
+ const REFERENCE_FIELDS = ["issuer", "counterparty", "project", "payer", "payee"];
16
+ /** `at` names the record's member at fault; a refused entity is the fields' fault. */
17
+ const refusal = (message, code, at) => {
18
+ throw new CliError("validation_error", message, { reason: code, ...(at === undefined ? {} : { at }) });
19
+ };
20
+ const codeOf = (error) => {
21
+ const reason = error.details?.reason;
22
+ if (typeof reason === "string")
23
+ return reason;
24
+ return error.code === "not_found" ? "record.reference-not-found" : "record.entity";
25
+ };
26
+ const iso = (value) => new Date(Number(value)).toISOString();
27
+ const stringField = (fields, name) => {
28
+ const value = fields[name];
29
+ if (value === undefined || value === null)
30
+ return undefined;
31
+ if (typeof value !== "string")
32
+ return refusal(`fields.${name} is text`, "record.invalid");
33
+ return value;
34
+ };
35
+ const invoiceOf = (fields, related, source) => {
36
+ if (!Array.isArray(fields.lines) || fields.lines.some((line) => typeof line !== "object" || line === null))
37
+ return refusal("an invoice's fields.lines is a list of lines", "record.invalid");
38
+ const one = (role) => related.get(role)?.[0] ?? stringField(fields, role);
39
+ return {
40
+ ...written(source),
41
+ direction: fields.direction,
42
+ number: stringField(fields, "number"),
43
+ issuer: one("issuer"),
44
+ counterparty: one("counterparty"),
45
+ project: one("project"),
46
+ currency: fields.currency,
47
+ issueDate: stringField(fields, "issue_date"),
48
+ dueDate: stringField(fields, "due_date"),
49
+ total: fields.total,
50
+ lines: fields.lines.map((line) => ({
51
+ description: line.description,
52
+ quantity: line.quantity,
53
+ unitPrice: line.unit_price,
54
+ amount: line.amount,
55
+ })),
56
+ };
57
+ };
58
+ const paymentOf = (fields, related, source) => {
59
+ const one = (role) => related.get(role)?.[0] ?? stringField(fields, role);
60
+ return {
61
+ ...written(source),
62
+ direction: fields.direction,
63
+ payer: one("payer"),
64
+ payee: one("payee"),
65
+ project: one("project"),
66
+ amount: fields.amount,
67
+ currency: fields.currency,
68
+ date: fields.date,
69
+ reference: stringField(fields, "reference"),
70
+ };
71
+ };
72
+ const written = (source) => ({
73
+ by: IMPORTER,
74
+ source: `import:${source.provider}`,
75
+ reason: `imported from ${source.provider} account ${source.account}, batch ${source.batch_id}`,
76
+ });
77
+ export const recordStoreOver = (context) => {
78
+ const { database, now } = context;
79
+ const finance = financeOperationsOver(context);
80
+ const statements = new Map();
81
+ const sql = (text) => {
82
+ let statement = statements.get(text);
83
+ if (!statement) {
84
+ statement = database.prepare(text);
85
+ statements.set(text, statement);
86
+ }
87
+ return statement;
88
+ };
89
+ const entityRef = (type, id) => type == null ? null : (referenceOfThing(database, storedThing(type, id)) ?? `${String(type)}:${String(id)}`);
90
+ /** Roles a projection needs whose target has no entity yet, and the entities of the rest. */
91
+ const relatedOf = (recordId, type) => {
92
+ const { roles, many } = PROJECTED[type];
93
+ const rows = sql("SELECT rr.role, t.entity_type, t.entity_id FROM record_relations rr " +
94
+ "LEFT JOIN records t ON t.id = rr.target_id AND t.deleted_at IS NULL WHERE rr.record_id = ?").all(recordId);
95
+ const related = new Map();
96
+ const missing = new Set();
97
+ for (const row of rows) {
98
+ const role = String(row.role);
99
+ if (!roles.includes(role))
100
+ continue;
101
+ const ref = entityRef(row.entity_type, row.entity_id);
102
+ if (ref === null)
103
+ missing.add(role);
104
+ else
105
+ related.set(role, [...(related.get(role) ?? []), ref]);
106
+ }
107
+ for (const [role, refs] of related)
108
+ if (refs.length > 1 && !many.includes(role))
109
+ refusal(`a record has one ${role}`, "record.relation-ambiguous");
110
+ return { related, missing: [...missing] };
111
+ };
112
+ /** Makes the proposal a waiting invoice or payment record stands for, once every reference it names has an entity. */
113
+ const project = (row, source) => {
114
+ const type = row.type;
115
+ const { related, missing } = relatedOf(Number(row.id), type);
116
+ if (missing.length > 0)
117
+ return { missing };
118
+ const fields = JSON.parse(String(row.fields));
119
+ for (const name of REFERENCE_FIELDS)
120
+ stringField(fields, name);
121
+ const ref = type === "invoice" ? finance.addInvoice(invoiceOf(fields, related, source)).ref : settle(fields, related, source);
122
+ const entity = requiredThing(database, ref);
123
+ sql("UPDATE records SET entity_type = ?, entity_id = ?, projected_fingerprint = fingerprint WHERE id = ?").run(entity.type, entity.id, Number(row.id));
124
+ return { proposed: ref, missing: [] };
125
+ };
126
+ /** A payment settles each invoice it names as far as both allow, in the order named. */
127
+ const settle = (fields, related, source) => {
128
+ const made = finance.addPayment(paymentOf(fields, related, source));
129
+ const units = (amount) => minorUnits(amount, made.currency);
130
+ let left = units(made.unallocated);
131
+ for (const invoice of related.get("settles") ?? []) {
132
+ const share = Math.min(left, units(finance.invoice(invoice).open));
133
+ if (share <= 0)
134
+ continue;
135
+ const amount = formatDecimal(share, scaleOf(made.currency));
136
+ finance.allocate({ payment: made.ref, invoice, amount, by: IMPORTER });
137
+ left -= share;
138
+ }
139
+ return made.ref;
140
+ };
141
+ /** Records that waited on `target` and may now have their entity, and theirs in turn. */
142
+ const completeDependents = (target, source, proposed, laterErrors) => {
143
+ const queue = [target];
144
+ while (queue.length > 0) {
145
+ const rows = sql("SELECT DISTINCT r.* FROM record_relations rr JOIN records r ON r.id = rr.record_id WHERE rr.target_id = ? " +
146
+ "AND r.entity_id IS NULL AND r.deleted_at IS NULL AND r.type IN ('invoice', 'payment')").all(queue.shift());
147
+ for (const row of rows) {
148
+ try {
149
+ const done = write(database, () => project(row, source));
150
+ if (done.proposed) {
151
+ proposed.push(done.proposed);
152
+ queue.push(Number(row.id));
153
+ }
154
+ }
155
+ catch (error) {
156
+ if (!(error instanceof CliError))
157
+ throw error;
158
+ laterErrors.push({
159
+ pointer: "",
160
+ code: codeOf(error),
161
+ message: `${String(row.type)} ${String(row.external_id)} waits: ${error.message}`,
162
+ });
163
+ }
164
+ }
165
+ }
166
+ };
167
+ const relate = (recordId, accountId, relations, at) => {
168
+ sql("DELETE FROM record_relations WHERE record_id = ?").run(recordId);
169
+ for (const relation of relations)
170
+ sql("INSERT OR IGNORE INTO record_relations (record_id, role, target_type, target_external_id, target_id, created_at) " +
171
+ "VALUES (?, ?, ?, ?, (SELECT id FROM records WHERE account_id = ? AND type = ? AND external_id = ?), ?)").run(recordId, relation.role, relation.type, relation.external_id, accountId, relation.type, relation.external_id, at);
172
+ };
173
+ const writeOne = (accountId, source, { record }, proposed) => {
174
+ const stored = sql("SELECT * FROM records WHERE account_id = ? AND type = ? AND external_id = ?").get(accountId, record.type, record.external_id);
175
+ const relations = record.relations ?? [];
176
+ const fingerprint = record.operation === "upsert" ? recordFingerprint(record.fields ?? {}, relations) : null;
177
+ const stale = (why) => refusal(why, "record.stale-revision", "revision");
178
+ if (stored && stored.revision != null) {
179
+ const held = Number(stored.revision);
180
+ if (record.revision === undefined)
181
+ stale(`the store holds revision ${held}: send the record's revision`);
182
+ else if (record.revision < held)
183
+ stale(`revision ${record.revision} is older than the stored ${held}`);
184
+ else if (record.revision === held && fingerprint !== null && fingerprint !== stored.fingerprint)
185
+ stale(`revision ${held} is stored with other content: a change needs a higher revision`);
186
+ }
187
+ const time = now();
188
+ const revision = record.revision ?? (stored?.revision == null ? null : Number(stored.revision));
189
+ if (record.operation === "delete") {
190
+ if (!stored)
191
+ return refusal(`no ${record.type} ${record.external_id} to delete`, "record.not-found", "external_id");
192
+ if (stored.deleted_at != null)
193
+ return { status: "unchanged", id: Number(stored.id) };
194
+ sql("UPDATE records SET deleted_at = ?, updated_at = ?, revision = ?, batch_id = ? WHERE id = ?").run(time, time, revision, source.batch_id, Number(stored.id));
195
+ return { status: "deleted", id: Number(stored.id), flagged: stored.entity_id != null };
196
+ }
197
+ const fields = JSON.stringify(record.fields);
198
+ if (stored && stored.fingerprint === fingerprint && stored.deleted_at == null)
199
+ return { status: "unchanged", id: Number(stored.id) };
200
+ const id = stored
201
+ ? Number(stored.id)
202
+ : Number(sql("INSERT INTO records (account_id, type, external_id, fields, revision, fingerprint, batch_id, created_at, " +
203
+ "updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id").get(accountId, record.type, record.external_id, fields, revision, fingerprint, source.batch_id, time, time)
204
+ ?.id);
205
+ if (stored)
206
+ sql("UPDATE records SET fields = ?, revision = ?, fingerprint = ?, batch_id = ?, updated_at = ?, deleted_at = NULL " +
207
+ "WHERE id = ?").run(fields, revision, fingerprint, source.batch_id, time, id);
208
+ else
209
+ sql("UPDATE record_relations SET target_id = ? WHERE target_id IS NULL AND target_type = ? AND target_external_id = ? " +
210
+ "AND record_id IN (SELECT id FROM records WHERE account_id = ?)").run(id, record.type, record.external_id, accountId);
211
+ relate(id, accountId, relations, time);
212
+ let projected = false;
213
+ if (isProjected(record.type) && stored?.entity_id == null) {
214
+ const done = project({ id, type: record.type, fields, account_id: accountId }, source);
215
+ if (done.proposed)
216
+ proposed.push(done.proposed);
217
+ projected = done.proposed !== undefined;
218
+ }
219
+ return {
220
+ status: stored ? "updated" : "created",
221
+ id,
222
+ flagged: stored?.entity_id != null && stored.projected_fingerprint !== fingerprint,
223
+ // Records waiting on this one move only when it has an entity: made now, or back from a delete.
224
+ complete: projected || (stored?.deleted_at != null && stored.entity_id != null),
225
+ };
226
+ };
227
+ const missingOf = (id) => {
228
+ const row = sql("SELECT type, entity_id, deleted_at FROM records WHERE id = ?").get(id);
229
+ if (!row || row.entity_id != null || row.deleted_at != null || !isProjected(row.type))
230
+ return null;
231
+ const { missing } = relatedOf(id, row.type);
232
+ return missing.length > 0 ? missing : null;
233
+ };
234
+ const ingest = (source, records, chunk) => {
235
+ const outcomes = [];
236
+ const proposed = [];
237
+ const flagged = [];
238
+ const laterErrors = [];
239
+ const kept = [];
240
+ for (let start = 0; start < records.length; start += chunk) {
241
+ write(database, () => {
242
+ const accountId = accountPk(context, { provider: source.provider, account: source.account });
243
+ const complete = [];
244
+ for (const item of records.slice(start, start + chunk)) {
245
+ try {
246
+ const done = isProjected(item.record.type)
247
+ ? write(database, () => writeOne(accountId, source, item, proposed))
248
+ : writeOne(accountId, source, item, proposed);
249
+ outcomes.push({ index: item.index, key: item.record.key, status: done.status });
250
+ if (done.id === undefined)
251
+ continue;
252
+ kept.push({ index: item.index, key: item.record.key, id: done.id });
253
+ if (done.flagged) {
254
+ const row = sql("SELECT entity_type, entity_id FROM records WHERE id = ?").get(done.id);
255
+ flagged.push({
256
+ index: item.index,
257
+ key: item.record.key,
258
+ entity: entityRef(row?.entity_type, row?.entity_id),
259
+ });
260
+ }
261
+ if (done.complete)
262
+ complete.push(done.id);
263
+ }
264
+ catch (error) {
265
+ if (!(error instanceof CliError))
266
+ throw error;
267
+ const at = error.details?.at ?? "fields";
268
+ const pointer = pointerOf("records", item.index, at);
269
+ outcomes.push({
270
+ index: item.index,
271
+ key: item.record.key,
272
+ status: "refused",
273
+ error: { pointer, code: codeOf(error), message: error.message, key: item.record.key },
274
+ });
275
+ }
276
+ }
277
+ for (const id of complete)
278
+ completeDependents(id, source, proposed, laterErrors);
279
+ });
280
+ }
281
+ const waiting = read(database, () => kept.flatMap(({ index, key, id }) => {
282
+ const missing = missingOf(id);
283
+ return missing === null ? [] : [{ index, key, missing }];
284
+ }));
285
+ return { outcomes, proposed: [...new Set(proposed)], waiting, flagged, laterErrors };
286
+ };
287
+ const sourceRecordOf = (row) => ({
288
+ provider: String(row.provider),
289
+ account: String(row.account),
290
+ type: String(row.type),
291
+ externalId: String(row.external_id),
292
+ fields: JSON.parse(String(row.fields)),
293
+ revision: row.revision == null ? null : Number(row.revision),
294
+ fingerprint: String(row.fingerprint),
295
+ relations: sql("SELECT role, target_type, target_external_id, target_id FROM record_relations WHERE record_id = ? ORDER BY id")
296
+ .all(Number(row.id))
297
+ .map((relation) => ({
298
+ role: String(relation.role),
299
+ type: String(relation.target_type),
300
+ externalId: String(relation.target_external_id),
301
+ resolved: relation.target_id != null,
302
+ })),
303
+ entity: entityRef(row.entity_type, row.entity_id),
304
+ flagged: row.entity_id != null && (row.deleted_at != null || row.projected_fingerprint !== row.fingerprint),
305
+ batchId: String(row.batch_id),
306
+ createdAt: iso(row.created_at),
307
+ updatedAt: iso(row.updated_at),
308
+ deletedAt: row.deleted_at == null ? null : iso(row.deleted_at),
309
+ });
310
+ const SELECT = "SELECT r.*, a.provider, a.external_id AS account FROM records r JOIN accounts a ON a.id = r.account_id ";
311
+ const rowOf = (key) => sql(`${SELECT}WHERE a.provider = ? AND a.external_id = ? AND r.type = ? AND r.external_id = ?`).get(key.provider, key.account, key.type, key.externalId);
312
+ return {
313
+ write: async (source, records, { dryRun = false, chunk = 1000 } = {}) => {
314
+ if (!dryRun)
315
+ return ingest(source, records, chunk);
316
+ let result;
317
+ const rollback = new Error("dry run");
318
+ try {
319
+ write(database, () => {
320
+ result = ingest(source, records, chunk);
321
+ throw rollback;
322
+ });
323
+ }
324
+ catch (error) {
325
+ if (error !== rollback)
326
+ throw error;
327
+ }
328
+ return result;
329
+ },
330
+ get: async (key) => read(database, () => {
331
+ const row = rowOf(key);
332
+ return row ? sourceRecordOf(row) : undefined;
333
+ }),
334
+ list: async ({ provider, account, type, waiting, flagged, limit = 100 }) => {
335
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500)
336
+ throw new CliError("validation_error", "a record list takes 1–500 items");
337
+ return read(database, () => {
338
+ if (findAccountPk(context, { provider, account }) === undefined)
339
+ return [];
340
+ const rows = database
341
+ .prepare(`${SELECT}WHERE a.provider = ? AND a.external_id = ? AND (? IS NULL OR r.type = ?) ` +
342
+ "AND (? IS NULL OR (r.entity_id IS NULL AND r.deleted_at IS NULL AND r.type IN ('invoice', 'payment'))) " +
343
+ "AND (? IS NULL OR (r.entity_id IS NOT NULL AND (r.deleted_at IS NOT NULL " +
344
+ "OR r.projected_fingerprint IS NOT r.fingerprint))) ORDER BY r.updated_at DESC, r.id DESC LIMIT ?")
345
+ .all(provider, account, type ?? null, type ?? null, waiting ? 1 : null, flagged ? 1 : null, limit);
346
+ return rows.map(sourceRecordOf);
347
+ });
348
+ },
349
+ map: async (key, entity, by) => {
350
+ if (by !== "owner")
351
+ throw new CliError("permission_error", "only the owner says which entity a record is", {
352
+ reason: "record.map-owner-only",
353
+ });
354
+ const proposed = [];
355
+ const laterErrors = [];
356
+ write(database, () => {
357
+ const row = rowOf(key);
358
+ if (!row || row.deleted_at != null)
359
+ throw new CliError("not_found", `no ${key.type} ${key.externalId} from ${key.provider} ${key.account}`);
360
+ const thing = requiredThing(database, entity, "the entity");
361
+ if (thing.type !== key.type)
362
+ refusal(`a ${key.type} record maps to a ${key.type}:, not ${entity}`, "record.map-type");
363
+ if (row.entity_id != null && !(row.entity_type === thing.type && Number(row.entity_id) === thing.id))
364
+ refusal(`${key.type} ${key.externalId} is already ${entityRef(row.entity_type, row.entity_id)}`, "record.mapped");
365
+ sql("UPDATE records SET entity_type = ?, entity_id = ?, projected_fingerprint = fingerprint WHERE id = ?").run(thing.type, thing.id, Number(row.id));
366
+ const source = { provider: key.provider, account: key.account, batch_id: String(row.batch_id) };
367
+ completeDependents(Number(row.id), source, proposed, laterErrors);
368
+ });
369
+ return read(database, () => sourceRecordOf(rowOf(key)));
370
+ },
371
+ };
372
+ };