@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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.559.0 — 02.11.2026
11
+
12
+ ### Added
13
+
14
+ - **Imports through one envelope (store version 47).** `ingest(store, envelope, { account, dryRun })` in
15
+ `./services` takes the v1 records envelope (`schema_version`, `source { provider, account, batch_id }`, `records[]`
16
+ with `key`, `type`, `external_id`, `operation` `upsert` or `delete`, `fields`, `revision`, `relations[]`), or a JSONL
17
+ file through `envelopeOfJsonl`. Its JSON Schema (Draft 2020-12) is `RECORDS_ENVELOPE_SCHEMA` and
18
+ `@wirecat/cli-core/schemas/records-envelope.v1.schema.json`. Each record is kept in the new `records` table, unique
19
+ per account, type and external id: the same import twice writes nothing, a lower revision is refused, a delete is
20
+ soft, and a field that looks like a secret is refused. The receipt counts created, updated, unchanged, deleted and
21
+ refused records, and each error carries a JSON pointer and a stable code.
22
+ - **An import never writes confirmed money.** An `invoice` or `payment` record becomes a proposal by the bot `import`,
23
+ decided in the proposal inbox. A reference to a record not yet imported waits for it, and a person or organization
24
+ record is linked only by the owner (`store.records.map`), never by name. `store.records` lists and reads records,
25
+ waiting and flagged ones too.
26
+
10
27
  ## 0.558.0 — 02.11.2026
11
28
 
12
29
  ### Changed — may break callers
@@ -0,0 +1,161 @@
1
+ export declare const RECORDS_SCHEMA_VERSION = 1;
2
+ export declare const RECORD_OPERATIONS: readonly ["upsert", "delete"];
3
+ /** Types with a table of their own; anything else a source sends is `custom:<name>` and stays a record only. */
4
+ export declare const RECORD_ENTITY_TYPES: readonly ["invoice", "payment", "person", "organization", "project"];
5
+ export type RecordOperation = (typeof RECORD_OPERATIONS)[number];
6
+ export type RecordEntityType = (typeof RECORD_ENTITY_TYPES)[number];
7
+ export interface RecordRelation {
8
+ /** What the target is to this record: `counterparty`, `payer`, `settles`, or a source's own word. */
9
+ role: string;
10
+ type: string;
11
+ external_id: string;
12
+ }
13
+ export interface EnvelopeRecord {
14
+ /** The caller's name for this line, unique in the envelope; the receipt cites it. */
15
+ key: string;
16
+ type: string;
17
+ external_id: string;
18
+ operation: RecordOperation;
19
+ fields?: Record<string, unknown>;
20
+ /** Goes up with each change at the source; a lower one than the store holds is refused. */
21
+ revision?: number;
22
+ relations?: RecordRelation[];
23
+ }
24
+ export interface EnvelopeSource {
25
+ provider: string;
26
+ account: string;
27
+ batch_id: string;
28
+ }
29
+ export interface RecordsEnvelope {
30
+ schema_version: 1;
31
+ source: EnvelopeSource;
32
+ records: EnvelopeRecord[];
33
+ }
34
+ /** A refusal: `pointer` is an RFC 6901 JSON pointer into the envelope, `code` a stable word callers may branch on. */
35
+ export interface IngestError {
36
+ pointer: string;
37
+ code: string;
38
+ message: string;
39
+ key?: string;
40
+ }
41
+ /** The v1 envelope as JSON Schema (Draft 2020-12); `schemas/records-envelope.v1.schema.json` is this, written out. */
42
+ export declare const RECORDS_ENVELOPE_SCHEMA: {
43
+ readonly $schema: "https://json-schema.org/draft/2020-12/schema";
44
+ readonly title: "WireCat records envelope, version 1";
45
+ readonly description: string;
46
+ readonly type: "object";
47
+ readonly required: readonly ["schema_version", "source", "records"];
48
+ readonly additionalProperties: false;
49
+ readonly properties: {
50
+ readonly schema_version: {
51
+ readonly const: 1;
52
+ };
53
+ readonly source: {
54
+ readonly $ref: "#/$defs/source";
55
+ };
56
+ readonly records: {
57
+ readonly type: "array";
58
+ readonly items: {
59
+ readonly $ref: "#/$defs/record";
60
+ };
61
+ };
62
+ };
63
+ readonly $defs: {
64
+ readonly source: {
65
+ readonly type: "object";
66
+ readonly required: readonly ["provider", "account", "batch_id"];
67
+ readonly additionalProperties: false;
68
+ readonly properties: {
69
+ readonly provider: {
70
+ readonly type: "string";
71
+ readonly pattern: "^[a-z0-9][a-z0-9_-]{0,62}$";
72
+ readonly description: "The system the records come from";
73
+ };
74
+ readonly account: {
75
+ readonly type: "string";
76
+ readonly minLength: 1;
77
+ readonly maxLength: 512;
78
+ readonly description: "The account in that system";
79
+ };
80
+ readonly batch_id: {
81
+ readonly type: "string";
82
+ readonly minLength: 1;
83
+ readonly maxLength: 512;
84
+ readonly description: "This import, for the receipt";
85
+ };
86
+ };
87
+ };
88
+ readonly record: {
89
+ readonly type: "object";
90
+ readonly required: readonly ["key", "type", "external_id", "operation"];
91
+ readonly additionalProperties: false;
92
+ readonly properties: {
93
+ readonly key: {
94
+ readonly type: "string";
95
+ readonly minLength: 1;
96
+ readonly maxLength: 512;
97
+ };
98
+ readonly type: {
99
+ readonly type: "string";
100
+ readonly pattern: "^(invoice|payment|person|organization|project|custom:[a-z0-9][a-z0-9_-]{0,62})$";
101
+ };
102
+ readonly external_id: {
103
+ readonly type: "string";
104
+ readonly minLength: 1;
105
+ readonly maxLength: 512;
106
+ };
107
+ readonly operation: {
108
+ readonly enum: readonly ["upsert", "delete"];
109
+ };
110
+ readonly fields: {
111
+ readonly type: "object";
112
+ readonly description: "What the source says, as data: never secrets, never instructions";
113
+ };
114
+ readonly revision: {
115
+ readonly type: "integer";
116
+ readonly minimum: 0;
117
+ readonly maximum: number;
118
+ };
119
+ readonly relations: {
120
+ readonly type: "array";
121
+ readonly maxItems: 100;
122
+ readonly items: {
123
+ readonly $ref: "#/$defs/relation";
124
+ };
125
+ };
126
+ };
127
+ };
128
+ readonly relation: {
129
+ readonly type: "object";
130
+ readonly required: readonly ["role", "type", "external_id"];
131
+ readonly additionalProperties: false;
132
+ readonly properties: {
133
+ readonly role: {
134
+ readonly type: "string";
135
+ readonly pattern: "^[a-z0-9][a-z0-9_-]{0,62}$";
136
+ };
137
+ readonly type: {
138
+ readonly type: "string";
139
+ readonly pattern: "^(invoice|payment|person|organization|project|custom:[a-z0-9][a-z0-9_-]{0,62})$";
140
+ };
141
+ readonly external_id: {
142
+ readonly type: "string";
143
+ readonly minLength: 1;
144
+ readonly maxLength: 512;
145
+ };
146
+ };
147
+ };
148
+ };
149
+ };
150
+ export declare const pointerOf: (...segments: (string | number)[]) => string;
151
+ /** Why one record cannot be taken, or nothing; `index` is its place in `records`. */
152
+ export declare const recordErrors: (record: unknown, index: number) => IngestError[];
153
+ /** The envelope's own errors (not its records'): when there is one, no record is taken. */
154
+ export declare const envelopeErrors: (envelope: unknown, account: string) => IngestError[];
155
+ /** The same fields and relations in any key or relation order give the same fingerprint. */
156
+ export declare const recordFingerprint: (fields: Record<string, unknown>, relations: RecordRelation[]) => string;
157
+ /**
158
+ * A JSONL import as the envelope it stands for: the first line holds `schema_version` and `source`, each further
159
+ * non-empty line one record. A line that is not JSON comes back as a record the envelope check refuses, at its index.
160
+ */
161
+ export declare const envelopeOfJsonl: (text: string) => unknown;
@@ -0,0 +1,234 @@
1
+ import { createHash } from "node:crypto";
2
+ import { holdsCredential, secretNamed } from "../runs/secrets.js";
3
+ export const RECORDS_SCHEMA_VERSION = 1;
4
+ export const RECORD_OPERATIONS = ["upsert", "delete"];
5
+ /** Types with a table of their own; anything else a source sends is `custom:<name>` and stays a record only. */
6
+ export const RECORD_ENTITY_TYPES = ["invoice", "payment", "person", "organization", "project"];
7
+ const WORD = "^[a-z0-9][a-z0-9_-]{0,62}$";
8
+ const TYPE = "^(invoice|payment|person|organization|project|custom:[a-z0-9][a-z0-9_-]{0,62})$";
9
+ const MAX_ID = 512;
10
+ const MAX_FIELDS_BYTES = 65_536;
11
+ const MAX_RELATIONS = 100;
12
+ /** The v1 envelope as JSON Schema (Draft 2020-12); `schemas/records-envelope.v1.schema.json` is this, written out. */
13
+ export const RECORDS_ENVELOPE_SCHEMA = {
14
+ $schema: "https://json-schema.org/draft/2020-12/schema",
15
+ title: "WireCat records envelope, version 1",
16
+ description: "Records one source sends to the store. JSONL is the same model: the first line holds schema_version and " +
17
+ "source, each further line one record.",
18
+ type: "object",
19
+ required: ["schema_version", "source", "records"],
20
+ additionalProperties: false,
21
+ properties: {
22
+ schema_version: { const: RECORDS_SCHEMA_VERSION },
23
+ source: { $ref: "#/$defs/source" },
24
+ records: { type: "array", items: { $ref: "#/$defs/record" } },
25
+ },
26
+ $defs: {
27
+ source: {
28
+ type: "object",
29
+ required: ["provider", "account", "batch_id"],
30
+ additionalProperties: false,
31
+ properties: {
32
+ provider: { type: "string", pattern: WORD, description: "The system the records come from" },
33
+ account: { type: "string", minLength: 1, maxLength: MAX_ID, description: "The account in that system" },
34
+ batch_id: { type: "string", minLength: 1, maxLength: MAX_ID, description: "This import, for the receipt" },
35
+ },
36
+ },
37
+ record: {
38
+ type: "object",
39
+ required: ["key", "type", "external_id", "operation"],
40
+ additionalProperties: false,
41
+ properties: {
42
+ key: { type: "string", minLength: 1, maxLength: MAX_ID },
43
+ type: { type: "string", pattern: TYPE },
44
+ external_id: { type: "string", minLength: 1, maxLength: MAX_ID },
45
+ operation: { enum: [...RECORD_OPERATIONS] },
46
+ fields: {
47
+ type: "object",
48
+ description: "What the source says, as data: never secrets, never instructions",
49
+ },
50
+ revision: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
51
+ relations: { type: "array", maxItems: MAX_RELATIONS, items: { $ref: "#/$defs/relation" } },
52
+ },
53
+ },
54
+ relation: {
55
+ type: "object",
56
+ required: ["role", "type", "external_id"],
57
+ additionalProperties: false,
58
+ properties: {
59
+ role: { type: "string", pattern: WORD },
60
+ type: { type: "string", pattern: TYPE },
61
+ external_id: { type: "string", minLength: 1, maxLength: MAX_ID },
62
+ },
63
+ },
64
+ },
65
+ };
66
+ const wordPattern = new RegExp(WORD);
67
+ const typePattern = new RegExp(TYPE);
68
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
69
+ const escapePointer = (segment) => String(segment).replaceAll("~", "~0").replaceAll("/", "~1");
70
+ export const pointerOf = (...segments) => segments.map((s) => `/${escapePointer(s)}`).join("");
71
+ const idText = (value) => typeof value === "string" && value.trim() !== "" && value.length <= MAX_ID && !value.includes("\0");
72
+ const extraKeys = (value, allowed) => Object.keys(value).filter((key) => !allowed.includes(key));
73
+ /** The first secret in `fields`, as a pointer below it: a secret-named key, or a value in a known credential shape. */
74
+ const secretIn = (value, at) => {
75
+ if (typeof value === "string")
76
+ return holdsCredential(value) ? at : undefined;
77
+ if (Array.isArray(value)) {
78
+ for (const [index, item] of value.entries()) {
79
+ const found = secretIn(item, [...at, index]);
80
+ if (found)
81
+ return found;
82
+ }
83
+ return undefined;
84
+ }
85
+ if (!isObject(value))
86
+ return undefined;
87
+ for (const [key, item] of Object.entries(value)) {
88
+ if (secretNamed(key))
89
+ return [...at, key];
90
+ const found = secretIn(item, [...at, key]);
91
+ if (found)
92
+ return found;
93
+ }
94
+ return undefined;
95
+ };
96
+ const relationErrors = (relations, at) => {
97
+ if (relations === undefined)
98
+ return [];
99
+ if (!Array.isArray(relations) || relations.length > MAX_RELATIONS)
100
+ return [invalid(at, `relations is a list of at most ${MAX_RELATIONS}`)];
101
+ return relations.flatMap((relation, index) => {
102
+ const here = [...at, index];
103
+ if (!isObject(relation))
104
+ return [invalid(here, "a relation is an object")];
105
+ const extra = extraKeys(relation, ["role", "type", "external_id"]);
106
+ if (extra.length > 0)
107
+ return [invalid([...here, extra[0]], "a relation holds role, type and external_id")];
108
+ if (typeof relation.role !== "string" || !wordPattern.test(relation.role))
109
+ return [invalid([...here, "role"], "a role is lowercase letters, digits, - and _")];
110
+ if (typeof relation.type !== "string" || !typePattern.test(relation.type))
111
+ return [invalid([...here, "type"], `a type is one of ${RECORD_ENTITY_TYPES.join(", ")} or custom:<name>`)];
112
+ if (!idText(relation.external_id))
113
+ return [invalid([...here, "external_id"], `an external id takes 1–${MAX_ID} characters`)];
114
+ return [];
115
+ });
116
+ };
117
+ const invalid = (at, message, code = "record.invalid") => ({
118
+ pointer: pointerOf(...at),
119
+ code,
120
+ message,
121
+ });
122
+ /** Why one record cannot be taken, or nothing; `index` is its place in `records`. */
123
+ export const recordErrors = (record, index) => {
124
+ const at = ["records", index];
125
+ if (!isObject(record))
126
+ return [invalid(at, "a record is an object")];
127
+ const key = typeof record.key === "string" ? record.key : undefined;
128
+ const withKey = (errors) => errors.map((error) => (key === undefined ? error : { ...error, key }));
129
+ const extra = extraKeys(record, ["key", "type", "external_id", "operation", "fields", "revision", "relations"]);
130
+ if (extra.length > 0)
131
+ return withKey([invalid([...at, extra[0]], `a record has no ${extra[0]}`)]);
132
+ if (!idText(record.key))
133
+ return [invalid([...at, "key"], `a key takes 1–${MAX_ID} characters`)];
134
+ if (typeof record.type !== "string" || !typePattern.test(record.type))
135
+ return withKey([
136
+ invalid([...at, "type"], `a type is one of ${RECORD_ENTITY_TYPES.join(", ")} or custom:<name>`, "record.type"),
137
+ ]);
138
+ if (!idText(record.external_id))
139
+ return withKey([invalid([...at, "external_id"], `an external id takes 1–${MAX_ID} characters`)]);
140
+ if (!RECORD_OPERATIONS.includes(record.operation))
141
+ return withKey([invalid([...at, "operation"], "an operation is upsert or delete")]);
142
+ if (record.revision !== undefined &&
143
+ (typeof record.revision !== "number" || !Number.isSafeInteger(record.revision) || record.revision < 0))
144
+ return withKey([invalid([...at, "revision"], "a revision is a whole number, 0 or more")]);
145
+ if (record.operation === "upsert" && !isObject(record.fields))
146
+ return withKey([invalid([...at, "fields"], "an upsert carries its fields as an object")]);
147
+ if (record.fields !== undefined && !isObject(record.fields))
148
+ return withKey([invalid([...at, "fields"], "fields is an object")]);
149
+ if (record.fields !== undefined && Buffer.byteLength(JSON.stringify(record.fields)) > MAX_FIELDS_BYTES)
150
+ return withKey([invalid([...at, "fields"], `fields take at most ${MAX_FIELDS_BYTES} bytes`, "record.too-large")]);
151
+ const secret = record.fields === undefined ? undefined : secretIn(record.fields, [...at, "fields"]);
152
+ if (secret)
153
+ return withKey([
154
+ invalid(secret, "a secret never goes into the store: drop this field at the source", "record.secret-field"),
155
+ ]);
156
+ return withKey(relationErrors(record.relations, [...at, "relations"]));
157
+ };
158
+ /** The envelope's own errors (not its records'): when there is one, no record is taken. */
159
+ export const envelopeErrors = (envelope, account) => {
160
+ if (!isObject(envelope))
161
+ return [invalid([], "an envelope is an object", "envelope.invalid")];
162
+ const extra = extraKeys(envelope, ["schema_version", "source", "records"]);
163
+ if (extra.length > 0)
164
+ return [invalid([extra[0]], `an envelope has no ${extra[0]}`, "envelope.invalid")];
165
+ if (envelope.schema_version !== RECORDS_SCHEMA_VERSION)
166
+ return [
167
+ invalid(["schema_version"], `this build reads schema_version ${RECORDS_SCHEMA_VERSION}, not ${JSON.stringify(envelope.schema_version)}`, "envelope.schema-version"),
168
+ ];
169
+ const { source } = envelope;
170
+ if (!isObject(source))
171
+ return [invalid(["source"], "source is an object", "envelope.invalid")];
172
+ const sourceExtra = extraKeys(source, ["provider", "account", "batch_id"]);
173
+ if (sourceExtra.length > 0)
174
+ return [
175
+ invalid(["source", sourceExtra[0]], "source holds provider, account and batch_id", "envelope.invalid"),
176
+ ];
177
+ if (typeof source.provider !== "string" || !wordPattern.test(source.provider))
178
+ return [invalid(["source", "provider"], "a provider is lowercase letters, digits, - and _", "envelope.invalid")];
179
+ if (!idText(source.account))
180
+ return [invalid(["source", "account"], `an account takes 1–${MAX_ID} characters`, "envelope.invalid")];
181
+ if (!idText(source.batch_id))
182
+ return [invalid(["source", "batch_id"], `a batch id takes 1–${MAX_ID} characters`, "envelope.invalid")];
183
+ if (source.account !== account)
184
+ return [
185
+ invalid(["source", "account"], `the envelope is for account ${JSON.stringify(source.account)}, the import for ${JSON.stringify(account)}`, "envelope.account-mismatch"),
186
+ ];
187
+ if (!Array.isArray(envelope.records))
188
+ return [invalid(["records"], "records is a list", "envelope.invalid")];
189
+ return [];
190
+ };
191
+ const canonical = (value) => {
192
+ if (Array.isArray(value))
193
+ return value.map(canonical);
194
+ if (!isObject(value))
195
+ return value;
196
+ return Object.fromEntries(Object.keys(value)
197
+ .sort()
198
+ .map((key) => [key, canonical(value[key])]));
199
+ };
200
+ /** The same fields and relations in any key or relation order give the same fingerprint. */
201
+ export const recordFingerprint = (fields, relations) => createHash("sha256")
202
+ .update(JSON.stringify(canonical({
203
+ fields,
204
+ relations: relations
205
+ .map((relation) => [relation.role, relation.type, relation.external_id])
206
+ .sort((a, b) => a.join("\0").localeCompare(b.join("\0"))),
207
+ })))
208
+ .digest("hex");
209
+ /**
210
+ * A JSONL import as the envelope it stands for: the first line holds `schema_version` and `source`, each further
211
+ * non-empty line one record. A line that is not JSON comes back as a record the envelope check refuses, at its index.
212
+ */
213
+ export const envelopeOfJsonl = (text) => {
214
+ const lines = text.split(/\r?\n/);
215
+ const first = lines.findIndex((line) => line.trim() !== "");
216
+ if (first < 0)
217
+ return {};
218
+ const parse = (line) => {
219
+ try {
220
+ return JSON.parse(line);
221
+ }
222
+ catch {
223
+ return line;
224
+ }
225
+ };
226
+ const header = parse(lines[first]);
227
+ const records = [];
228
+ for (let at = first + 1; at < lines.length; at++) {
229
+ const line = lines[at];
230
+ if (line.trim() !== "")
231
+ records.push(parse(line));
232
+ }
233
+ return isObject(header) ? { ...header, records } : header;
234
+ };
@@ -151,6 +151,15 @@ const OTHERS = [
151
151
  validation("finance.ref", "the reference names no invoice or payment"),
152
152
  validation("finance.status", "the invoice's status does not allow this"),
153
153
  validation("finance.total", "the invoice's total is not the sum of its lines"),
154
+ validation("record.entity", "the invoice or payment the record stands for was refused"),
155
+ ["record.map-owner-only", "permission_error", "only the owner says which entity a source record is"],
156
+ validation("record.map-type", "a record maps only to an entity of its own type"),
157
+ validation("record.mapped", "the record already stands for another entity"),
158
+ validation("record.not-found", "the store holds no such record to delete"),
159
+ validation("record.invalid", "a field of the record is not the kind the entity needs"),
160
+ validation("record.reference-not-found", "a reference in the record's fields is not in the store"),
161
+ validation("record.relation-ambiguous", "the record names more than one target for a role that takes one"),
162
+ validation("record.stale-revision", "the store holds a newer revision of the record"),
154
163
  validation("memory.evidence-required", "a memory needs its evidence"),
155
164
  validation("memory.scope-required", "a memory needs its scope"),
156
165
  validation("money.amount", "the amount is not a decimal string in the currency's scale"),
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export { FOLDER_PROVIDERS, isFolderProvider, isKind, KINDS, type Kind, kindIn, k
14
14
  export type { RankingGraphEvidence, RankingGraphLink } from "./domain/rankings-graph.js";
15
15
  export type { RankingComponent, RankingInput, RankingMeasure, RankingOptions, RankingTarget, RankingWeights, ScorePreset, } from "./domain/rankings-options.js";
16
16
  export { rankingOptions } from "./domain/rankings-options.js";
17
+ export { type EnvelopeRecord, type EnvelopeSource, envelopeOfJsonl, type IngestError, RECORD_ENTITY_TYPES, RECORD_OPERATIONS, RECORDS_ENVELOPE_SCHEMA, RECORDS_SCHEMA_VERSION, type RecordEntityType, type RecordOperation, type RecordRelation, type RecordsEnvelope, } from "./domain/records.js";
17
18
  export { canonicalReference, formatReference, looksLikeReference, parseReference, REF_KINDS, type Reference, type RefKindName, type RefKindSpec, } from "./domain/references.js";
18
19
  export { normalizeTag, TAG_TYPES, type TagType } from "./domain/tags.js";
19
20
  export { type AppErrors, DETAILS as CORE_DETAILS, failureContext, REASONS as CORE_REASONS } from "./errors/reasons.js";
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export { CURRENCY_SCALES, currencyOf, minorUnits, moneyLabel, moneyOf, } from ".
9
9
  export { countedPoll } from "./domain/polls.js";
10
10
  export { FOLDER_PROVIDERS, isFolderProvider, isKind, KINDS, kindIn, kindOf, PROVIDERS, providerNamed, providers, UNKNOWN_PROVIDER, useProviders, } from "./domain/providers.js";
11
11
  export { rankingOptions } from "./domain/rankings-options.js";
12
+ export { envelopeOfJsonl, RECORD_ENTITY_TYPES, RECORD_OPERATIONS, RECORDS_ENVELOPE_SCHEMA, RECORDS_SCHEMA_VERSION, } from "./domain/records.js";
12
13
  export { canonicalReference, formatReference, looksLikeReference, parseReference, REF_KINDS, } from "./domain/references.js";
13
14
  export { normalizeTag, TAG_TYPES } from "./domain/tags.js";
14
15
  export { DETAILS as CORE_DETAILS, failureContext, REASONS as CORE_REASONS } from "./errors/reasons.js";
@@ -1,4 +1,6 @@
1
1
  export declare const secretNamed: (name: string) => boolean;
2
+ /** A credential in a shape vendors publish so scanners find it: a known token prefix, a private key, a `Bearer` value. */
3
+ export declare const holdsCredential: (text: string) => boolean;
2
4
  /**
3
5
  * Whether a run of token characters is a random string rather than words: long, mixed case, high Shannon entropy,
4
6
  * cut into short pieces (`Kz9vQ2mX`) where a name is cut into words (`InlineQueryResultMpeg4Gif`).
@@ -54,6 +54,8 @@ const PRIVATE_KEY = /-----BEGIN[A-Z ]* PRIVATE KEY-----[\s\S]*?(?:-----END[A-Z ]
54
54
  const SCHEME = /(?<![A-Za-z])(Bearer|Basic)(\s+)(?![A-Z]?[a-z]+(?![A-Za-z0-9]))[A-Za-z0-9._~+/=-]{8,}/g;
55
55
  // After a bare space only a value that is not a plain word: `pin 1234` is a secret, `pin this message` is not.
56
56
  const NAMED_PAIR = /(?<![\p{L}\p{N}])([\p{L}\p{N}_.-]*(?:token|pass|pwd|pin|otp|2fa|secret|credential|cookie|authorization|key|contrase|clave|пароль|пин|код)[\p{L}\p{N}_.-]*)(["']?\s*(?:=>|[:=])\s*["']?|\s+(?![\p{L}]+(?![\p{L}\p{N}])))((?:Bearer\s+|Basic\s+)?)([^\s"'&,;]+)/giu;
57
+ /** A credential in a shape vendors publish so scanners find it: a known token prefix, a private key, a `Bearer` value. */
58
+ export const holdsCredential = (text) => text.search(PRIVATE_KEY) >= 0 || text.search(KNOWN_TOKENS) >= 0 || text.search(SCHEME) >= 0;
57
59
  /**
58
60
  * Whether a run of token characters is a random string rather than words: long, mixed case, high Shannon entropy,
59
61
  * cut into short pieces (`Kz9vQ2mX`) where a name is cut into words (`InlineQueryResultMpeg4Gif`).
@@ -6,6 +6,7 @@ export { type ChatCoverage, chatCoverage } from "./chat-coverage.js";
6
6
  export type { ContextEvidenceItem, ContextEvidencePage, ContextEvidenceReader, ContextEvidenceRequest, ContextEvidenceSender, ContextIndex, ContextReadLimits, ContextReadUsage, ContextRecordReader, ContextSource, ScopedContextEvidenceReader, } from "./context-evidence.js";
7
7
  export { contextEvidenceFingerprint, createContextEvidenceReader } from "./context-evidence.js";
8
8
  export { excerpt } from "./excerpt.js";
9
+ export { type IngestOptions, type IngestReceipt, ingest } from "./ingest.js";
9
10
  export { aliasHits, chatSenders, chatTitle, combinedScore, DEFAULT_LIMIT as DEFAULT_LINK_SCAN_LIMIT, DEFAULT_THRESHOLD as DEFAULT_LINK_SCAN_THRESHOLD, emailDomain, type LinkCandidate, type LinkScorer, mailThread, memberChats, memberDialog, PERSON_SCORERS, PERSON_THRESHOLD as PERSON_LINK_SCAN_THRESHOLD, SCAN_BOT, SCORERS, type ScanInput as LinkScanInput, type ScannedProject, type ScannedProposal, type ScanResult as LinkScanResult, type ScorerInput, type Signal as LinkSignal, scanProjectLinks, } from "./link-scorers/index.js";
10
11
  export type { MediaArchiveOptions, MediaArchivePorts } from "./media-archive.js";
11
12
  export { archiveMedia, checkMediaArchive } from "./media-archive.js";
@@ -5,6 +5,7 @@ export { AGENDA_DEFAULTS } from "./agenda.js";
5
5
  export { chatCoverage } from "./chat-coverage.js";
6
6
  export { contextEvidenceFingerprint, createContextEvidenceReader } from "./context-evidence.js";
7
7
  export { excerpt } from "./excerpt.js";
8
+ export { ingest } from "./ingest.js";
8
9
  export { aliasHits, chatSenders, chatTitle, combinedScore, DEFAULT_LIMIT as DEFAULT_LINK_SCAN_LIMIT, DEFAULT_THRESHOLD as DEFAULT_LINK_SCAN_THRESHOLD, emailDomain, mailThread, memberChats, memberDialog, PERSON_SCORERS, PERSON_THRESHOLD as PERSON_LINK_SCAN_THRESHOLD, SCAN_BOT, SCORERS, scanProjectLinks, } from "./link-scorers/index.js";
9
10
  export { archiveMedia, checkMediaArchive } from "./media-archive.js";
10
11
  export { moveMediaArchive } from "./media-archive-move.js";
@@ -0,0 +1,45 @@
1
+ import { type IngestError } from "../domain/records.js";
2
+ import type { MessageStore } from "../store/store.js";
3
+ export interface IngestOptions {
4
+ /** The account the caller means; the envelope's `source.account` must be this one. */
5
+ account: string;
6
+ /** Checks and plans everything, then writes nothing. */
7
+ dryRun?: boolean;
8
+ /** Records per transaction, 1000 by default. */
9
+ chunk?: number;
10
+ }
11
+ export interface IngestReceipt {
12
+ dryRun: boolean;
13
+ source: {
14
+ provider: string;
15
+ account: string;
16
+ batchId: string;
17
+ } | null;
18
+ created: number;
19
+ updated: number;
20
+ unchanged: number;
21
+ deleted: number;
22
+ refused: number;
23
+ /** `invoice:` and `payment:` proposals this import made; the owner confirms them in review. */
24
+ proposed: string[];
25
+ /** Kept, with no entity yet: each named role points at a record not imported, or one with no entity. */
26
+ waiting: {
27
+ key: string;
28
+ pointer: string;
29
+ missing: string[];
30
+ }[];
31
+ /** Changed or deleted at the source after the entity was made from it: the entity was not touched. */
32
+ flagged: {
33
+ key: string;
34
+ pointer: string;
35
+ entity: string;
36
+ }[];
37
+ errors: IngestError[];
38
+ }
39
+ /**
40
+ * Takes a v1 records envelope (or `envelopeOfJsonl` of a JSONL file) into the store: checks it, then writes per
41
+ * chunk in one transaction. The same import twice writes nothing new. A record with a lower revision than the stored
42
+ * one is refused, a delete is soft, a reference to a record not yet imported waits for it, and nothing is linked by
43
+ * name. An invoice or a payment becomes a proposal: an import never writes confirmed money.
44
+ */
45
+ export declare const ingest: (store: Pick<MessageStore, "records">, envelope: unknown, { account, dryRun, chunk }: IngestOptions) => Promise<IngestReceipt>;
@@ -0,0 +1,80 @@
1
+ import { envelopeErrors, pointerOf, recordErrors, } from "../domain/records.js";
2
+ const emptyReceipt = (dryRun) => ({
3
+ dryRun,
4
+ source: null,
5
+ created: 0,
6
+ updated: 0,
7
+ unchanged: 0,
8
+ deleted: 0,
9
+ refused: 0,
10
+ proposed: [],
11
+ waiting: [],
12
+ flagged: [],
13
+ errors: [],
14
+ });
15
+ /**
16
+ * Takes a v1 records envelope (or `envelopeOfJsonl` of a JSONL file) into the store: checks it, then writes per
17
+ * chunk in one transaction. The same import twice writes nothing new. A record with a lower revision than the stored
18
+ * one is refused, a delete is soft, a reference to a record not yet imported waits for it, and nothing is linked by
19
+ * name. An invoice or a payment becomes a proposal: an import never writes confirmed money.
20
+ */
21
+ export const ingest = async (store, envelope, { account, dryRun = false, chunk = 1000 }) => {
22
+ const receipt = emptyReceipt(dryRun);
23
+ const broken = envelopeErrors(envelope, account);
24
+ if (broken.length > 0)
25
+ return { ...receipt, errors: broken };
26
+ const { source, records } = envelope;
27
+ receipt.source = { provider: source.provider, account: source.account, batchId: source.batch_id };
28
+ const taken = [];
29
+ const keys = new Set();
30
+ const identities = new Set();
31
+ for (const [index, record] of records.entries()) {
32
+ const errors = recordErrors(record, index);
33
+ const valid = record;
34
+ if (errors.length === 0 && keys.has(valid.key))
35
+ errors.push({
36
+ pointer: pointerOf("records", index, "key"),
37
+ code: "record.duplicate-key",
38
+ message: "another record of this envelope has this key",
39
+ key: valid.key,
40
+ });
41
+ const identity = errors.length === 0 ? `${valid.type}\0${valid.external_id}` : "";
42
+ if (errors.length === 0 && identities.has(identity))
43
+ errors.push({
44
+ pointer: pointerOf("records", index, "external_id"),
45
+ code: "record.duplicate",
46
+ message: `another record of this envelope is ${valid.type} ${valid.external_id}`,
47
+ key: valid.key,
48
+ });
49
+ if (errors.length > 0) {
50
+ receipt.refused += 1;
51
+ for (const error of errors)
52
+ receipt.errors.push(error);
53
+ continue;
54
+ }
55
+ keys.add(valid.key);
56
+ identities.add(identity);
57
+ taken.push({ index, record: valid });
58
+ }
59
+ const written = await store.records.write(source, taken, { dryRun, chunk });
60
+ for (const outcome of written.outcomes) {
61
+ receipt[outcome.status] += 1;
62
+ if (outcome.error)
63
+ receipt.errors.push(outcome.error);
64
+ }
65
+ for (const error of written.laterErrors)
66
+ receipt.errors.push(error);
67
+ receipt.errors.sort((a, b) => a.pointer.localeCompare(b.pointer, "en", { numeric: true }));
68
+ receipt.proposed = written.proposed;
69
+ receipt.waiting = written.waiting.map(({ index, key, missing }) => ({
70
+ key,
71
+ pointer: pointerOf("records", index, "relations"),
72
+ missing,
73
+ }));
74
+ receipt.flagged = written.flagged.map(({ index, key, entity }) => ({
75
+ key,
76
+ pointer: pointerOf("records", index),
77
+ entity,
78
+ }));
79
+ return receipt;
80
+ };
@@ -54,6 +54,7 @@ export { type NearestNote, NOTE_FIELDS, type NoteChunkToEmbed, type NoteHit, typ
54
54
  export type { FileNoteInput, Link, LinkInput, LinkListFilter, Note, NoteFolder, NotesStore, NoteTag, } from "./sqlite/notes.js";
55
55
  export type { PrivateContact, PrivateContactNote } from "./sqlite/private-people.js";
56
56
  export { PROPOSAL_STATUSES, type ProposalInput, type ProposedAction, type ProposedActionsStore, proposerOfAction, } from "./sqlite/proposed-actions.js";
57
+ export type { RecordKey, RecordOutcome, RecordStore, RecordsWritten, SourceRecord, } from "./sqlite/records.js";
57
58
  export type { LinkDecision, RelationUpdate } from "./sqlite/relation-proposals.js";
58
59
  export { fillSearchIndex, resetSearchIndex, type SearchIndexFill, type SearchIndexState, searchIndexState, } from "./sqlite/search-index.js";
59
60
  export type { SyncStateSave } from "./sqlite/sync.js";
@@ -114,6 +114,10 @@ export const purgeAccount = ({ orm, database }, accountKey) => {
114
114
  // The inbox project and its tasks are the owner's; only the tie to the account goes.
115
115
  orm.update(projects).set({ accountId: null }).where(eq(projects.accountId, accountKey)).run();
116
116
  orm.run(sql `DELETE FROM files WHERE account_id = ${accountKey}`);
117
+ // An entity made from a record is the owner's and stays; only what the source said goes.
118
+ const recordsOf = sql `(SELECT id FROM records WHERE account_id = ${accountKey})`;
119
+ orm.run(sql `DELETE FROM record_relations WHERE record_id IN ${recordsOf} OR target_id IN ${recordsOf}`);
120
+ orm.run(sql `DELETE FROM records WHERE account_id = ${accountKey}`);
117
121
  orm.delete(accounts).where(eq(accounts.id, accountKey)).run();
118
122
  dropOrphanPeople(database, unseenBefore);
119
123
  dropUnusedVectors(database, hashes);
@@ -62,6 +62,7 @@ export const MANIFEST = [
62
62
  { name: "20261102173102_version-45-finance", version: 45, minCompatible: 34 },
63
63
  { name: "20261102173113_version-45-finance-rules", version: 45, minCompatible: 34 },
64
64
  { name: "20261102185954_version-46-payment-facts", version: 46, minCompatible: 34 },
65
+ { name: "20261102192959_version-47-records", version: 47, minCompatible: 34 },
65
66
  ];
66
67
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
67
68
  const migrations = [];