@remit/config-transfer 0.0.11 → 0.0.13
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/package.json +1 -1
- package/src/export.test.ts +8 -38
- package/src/export.ts +2 -25
- package/src/fixtures.ts +1 -20
- package/src/import-repositories.ts +0 -29
- package/src/import.test.ts +30 -163
- package/src/import.ts +27 -145
- package/src/index.ts +0 -2
- package/src/repositories.ts +0 -2
package/package.json
CHANGED
package/src/export.test.ts
CHANGED
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
makeAccount,
|
|
17
17
|
makeAccountConfig,
|
|
18
18
|
makeAddress,
|
|
19
|
-
makeAnchor,
|
|
20
19
|
makeFilter,
|
|
21
20
|
makeJunkOnlyFlaggedAddress,
|
|
22
21
|
makeLabel,
|
|
@@ -158,14 +157,12 @@ const fullFixture = (): ConfigFixture => ({
|
|
|
158
157
|
actionMailboxId: INVOICES_ID,
|
|
159
158
|
}),
|
|
160
159
|
makeFilter({
|
|
161
|
-
filterId: "flt-
|
|
162
|
-
name: "
|
|
160
|
+
filterId: "flt-release",
|
|
161
|
+
name: "Release notes",
|
|
163
162
|
scope: "Temporary",
|
|
164
163
|
expiresAt: "2026-12-31T23:59:59+01:00",
|
|
165
|
-
hasAnchor: true,
|
|
166
164
|
}),
|
|
167
165
|
],
|
|
168
|
-
anchors: [makeAnchor({ filterId: "flt-semantic" })],
|
|
169
166
|
addresses: [
|
|
170
167
|
makeAddress({
|
|
171
168
|
addressId: "adr-bank",
|
|
@@ -195,12 +192,12 @@ const fullFixture = (): ConfigFixture => ({
|
|
|
195
192
|
const exportFixture = (fixture: ConfigFixture) =>
|
|
196
193
|
readConfigForExport(asRepositories(fixture), ACCOUNT_CONFIG_ID, IDENTITY);
|
|
197
194
|
|
|
198
|
-
test("a configuration exports as a document the
|
|
195
|
+
test("a configuration exports as a document the current schema accepts", async () => {
|
|
199
196
|
const document = await exportFixture(fullFixture());
|
|
200
197
|
|
|
201
198
|
assert.equal(ReaderConfigDocumentSchema.safeParse(document).success, true);
|
|
202
199
|
assert.equal(document.kind, "reader.config");
|
|
203
|
-
assert.equal(document.schemaVersion,
|
|
200
|
+
assert.equal(document.schemaVersion, 2);
|
|
204
201
|
assert.deepEqual(document.provenance, {
|
|
205
202
|
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
206
203
|
instance: "reader.ischen.nl",
|
|
@@ -379,7 +376,7 @@ test("an override on a folder that no longer exists is dropped", async () => {
|
|
|
379
376
|
|
|
380
377
|
test("a filter names its label and its folder, never their ids", async () => {
|
|
381
378
|
const document = await exportFixture(fullFixture());
|
|
382
|
-
const [invoices,
|
|
379
|
+
const [invoices, release] = document.filters;
|
|
383
380
|
|
|
384
381
|
assert.equal(invoices?.actionLabelName, "Facturen");
|
|
385
382
|
assert.equal(invoices?.matchOperator, "Or");
|
|
@@ -388,11 +385,10 @@ test("a filter names its label and its folder, never their ids", async () => {
|
|
|
388
385
|
{ field: "FromDomain", value: "moneybird.com" },
|
|
389
386
|
]);
|
|
390
387
|
assert.equal(invoices?.expiresAt, null);
|
|
391
|
-
assert.equal(invoices?.anchor, null);
|
|
392
388
|
|
|
393
|
-
assert.equal(
|
|
394
|
-
assert.equal(
|
|
395
|
-
assert.equal(
|
|
389
|
+
assert.equal(release?.actionLabelName, null);
|
|
390
|
+
assert.equal(release?.actionFolder, null);
|
|
391
|
+
assert.equal(release?.expiresAt, "2026-12-31T23:59:59+01:00");
|
|
396
392
|
});
|
|
397
393
|
|
|
398
394
|
test("a filter action naming something that is gone exports as no action", async () => {
|
|
@@ -408,31 +404,6 @@ test("a filter action naming something that is gone exports as no action", async
|
|
|
408
404
|
assert.equal(document.filters[0]?.actionFolder, null);
|
|
409
405
|
});
|
|
410
406
|
|
|
411
|
-
test("an anchor exports its text and its provenance, never its vector", async () => {
|
|
412
|
-
const document = await exportFixture(fullFixture());
|
|
413
|
-
|
|
414
|
-
assert.deepEqual(document.filters[1]?.anchor, {
|
|
415
|
-
sourceText: "the release note this filter was drawn from",
|
|
416
|
-
embeddingId: "amazon.titan-embed-text-v2:0@1024",
|
|
417
|
-
sourceMessageId: "2c9e4b117d5a4f609a315b8c0",
|
|
418
|
-
});
|
|
419
|
-
assert.equal(JSON.stringify(document).includes("anchorEmbedding"), false);
|
|
420
|
-
assert.equal(JSON.stringify(document).includes("0.22"), false);
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
test("a filter claiming an anchor it has no row for exports without one", async () => {
|
|
424
|
-
// hasAnchor is a cache of whether the sibling row exists, and a write that
|
|
425
|
-
// failed after the filter landed leaves it saying yes over nothing. The
|
|
426
|
-
// export answers with the rows it can read, never with the flag.
|
|
427
|
-
const fixture = fullFixture();
|
|
428
|
-
fixture.anchors = [];
|
|
429
|
-
|
|
430
|
-
const document = await exportFixture(fixture);
|
|
431
|
-
|
|
432
|
-
assert.equal(document.filters[1]?.anchor, null);
|
|
433
|
-
assert.equal(ReaderConfigDocumentSchema.safeParse(document).success, true);
|
|
434
|
-
});
|
|
435
|
-
|
|
436
407
|
test("labels export by name and colour", async () => {
|
|
437
408
|
const document = await exportFixture(fullFixture());
|
|
438
409
|
|
|
@@ -625,7 +596,6 @@ test("a configuration with nothing in it still exports a valid document", async
|
|
|
625
596
|
mailboxes: [],
|
|
626
597
|
labels: [],
|
|
627
598
|
filters: [],
|
|
628
|
-
anchors: [],
|
|
629
599
|
addresses: [],
|
|
630
600
|
});
|
|
631
601
|
|
package/src/export.ts
CHANGED
|
@@ -14,7 +14,6 @@ import type {
|
|
|
14
14
|
AccountItem,
|
|
15
15
|
AddressFlags,
|
|
16
16
|
AddressItem,
|
|
17
|
-
FilterAnchorItem,
|
|
18
17
|
FilterItem,
|
|
19
18
|
LabelItem,
|
|
20
19
|
MailboxItem,
|
|
@@ -245,7 +244,6 @@ const toConfigLabel = (label: LabelItem): ConfigLabel => ({
|
|
|
245
244
|
|
|
246
245
|
const toConfigFilter = (
|
|
247
246
|
filter: FilterItem,
|
|
248
|
-
anchor: FilterAnchorItem | undefined,
|
|
249
247
|
labelNames: ReadonlyMap<string, string>,
|
|
250
248
|
index: MailboxIndex,
|
|
251
249
|
): ConfigFilter => {
|
|
@@ -274,16 +272,6 @@ const toConfigFilter = (
|
|
|
274
272
|
actionAccountId === undefined || actionPath === undefined
|
|
275
273
|
? null
|
|
276
274
|
: { accountId: actionAccountId, folderPath: actionPath },
|
|
277
|
-
// The stored vector is a function of the model that produced it, so it
|
|
278
|
-
// means nothing on an instance running another one. The source text is
|
|
279
|
-
// what travels; import re-embeds from it.
|
|
280
|
-
anchor: anchor
|
|
281
|
-
? {
|
|
282
|
-
sourceText: anchor.anchorSourceText,
|
|
283
|
-
embeddingId: anchor.anchorEmbeddingId,
|
|
284
|
-
sourceMessageId: anchor.anchorMessageId,
|
|
285
|
-
}
|
|
286
|
-
: null,
|
|
287
275
|
};
|
|
288
276
|
};
|
|
289
277
|
|
|
@@ -378,7 +366,7 @@ export const readConfigForExport = async (
|
|
|
378
366
|
accountConfigId: string,
|
|
379
367
|
identity: ConfigExportIdentity,
|
|
380
368
|
): Promise<ReaderConfigDocument> => {
|
|
381
|
-
const [accountConfig, accounts, settingRows, labels, filters
|
|
369
|
+
const [accountConfig, accounts, settingRows, labels, filters] =
|
|
382
370
|
await Promise.all([
|
|
383
371
|
// A configuration nobody has added an account to yet holds no row. That
|
|
384
372
|
// is an empty export, not a failure: the file is the migration safety
|
|
@@ -393,7 +381,6 @@ export const readConfigForExport = async (
|
|
|
393
381
|
repositories.accountSetting.listByAccountConfig(accountConfigId),
|
|
394
382
|
repositories.label.listByAccountConfig(accountConfigId),
|
|
395
383
|
repositories.filter.listByAccountConfig(accountConfigId),
|
|
396
|
-
repositories.filterAnchor.listByAccountConfig(accountConfigId),
|
|
397
384
|
]);
|
|
398
385
|
|
|
399
386
|
const liveAccounts = accounts.filter(
|
|
@@ -415,9 +402,6 @@ export const readConfigForExport = async (
|
|
|
415
402
|
const labelNames = new Map(
|
|
416
403
|
labels.map((label) => [label.labelId, label.name] as const),
|
|
417
404
|
);
|
|
418
|
-
const anchorsByFilter = new Map(
|
|
419
|
-
anchors.map((anchor) => [anchor.filterId, anchor] as const),
|
|
420
|
-
);
|
|
421
405
|
const addresses = await readAddresses(repositories, accountConfigId);
|
|
422
406
|
|
|
423
407
|
const document: ReaderConfigDocument = {
|
|
@@ -448,14 +432,7 @@ export const readConfigForExport = async (
|
|
|
448
432
|
),
|
|
449
433
|
),
|
|
450
434
|
labels: labels.map(toConfigLabel),
|
|
451
|
-
filters: filters.map((filter) =>
|
|
452
|
-
toConfigFilter(
|
|
453
|
-
filter,
|
|
454
|
-
anchorsByFilter.get(filter.filterId),
|
|
455
|
-
labelNames,
|
|
456
|
-
index,
|
|
457
|
-
),
|
|
458
|
-
),
|
|
435
|
+
filters: filters.map((filter) => toConfigFilter(filter, labelNames, index)),
|
|
459
436
|
addressFlags: addresses
|
|
460
437
|
.filter((address) => carriesUserFlag(address.flags))
|
|
461
438
|
.map(toConfigAddressFlags),
|
package/src/fixtures.ts
CHANGED
|
@@ -3,7 +3,6 @@ import type {
|
|
|
3
3
|
AccountItem,
|
|
4
4
|
AccountSettingItem,
|
|
5
5
|
AddressItem,
|
|
6
|
-
FilterAnchorItem,
|
|
7
6
|
FilterItem,
|
|
8
7
|
IAddressRepository,
|
|
9
8
|
LabelItem,
|
|
@@ -24,7 +23,6 @@ export interface ConfigFixture {
|
|
|
24
23
|
mailboxes: MailboxItem[];
|
|
25
24
|
labels: LabelItem[];
|
|
26
25
|
filters: FilterItem[];
|
|
27
|
-
anchors: FilterAnchorItem[];
|
|
28
26
|
addresses: AddressItem[];
|
|
29
27
|
/** Page size for the address listing, so paging is exercised. */
|
|
30
28
|
addressPageSize?: number;
|
|
@@ -130,11 +128,10 @@ export const makeFilter = (
|
|
|
130
128
|
scope: "Standing",
|
|
131
129
|
state: "Active",
|
|
132
130
|
disabledReason: "None",
|
|
133
|
-
hasAnchor: false,
|
|
134
131
|
ruleChangedAt: NOW,
|
|
135
132
|
actionChangedAt: NOW,
|
|
136
133
|
matchOperator: "And",
|
|
137
|
-
literalClauses: [],
|
|
134
|
+
literalClauses: [{ field: "Subject", value: "release notes" }],
|
|
138
135
|
actionLabelId: "None",
|
|
139
136
|
actionMailboxId: "None",
|
|
140
137
|
createdAt: NOW,
|
|
@@ -142,19 +139,6 @@ export const makeFilter = (
|
|
|
142
139
|
...overrides,
|
|
143
140
|
});
|
|
144
141
|
|
|
145
|
-
export const makeAnchor = (
|
|
146
|
-
overrides: Partial<FilterAnchorItem> & Pick<FilterAnchorItem, "filterId">,
|
|
147
|
-
): FilterAnchorItem => ({
|
|
148
|
-
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
149
|
-
anchorEmbedding: [0.11, 0.22, 0.33],
|
|
150
|
-
anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
|
|
151
|
-
anchorSourceText: "the release note this filter was drawn from",
|
|
152
|
-
anchorMessageId: "2c9e4b117d5a4f609a315b8c0",
|
|
153
|
-
createdAt: NOW,
|
|
154
|
-
updatedAt: NOW,
|
|
155
|
-
...overrides,
|
|
156
|
-
});
|
|
157
|
-
|
|
158
142
|
export const makeAddress = (
|
|
159
143
|
overrides: Partial<AddressItem> &
|
|
160
144
|
Pick<AddressItem, "addressId" | "normalizedEmail">,
|
|
@@ -287,8 +271,5 @@ export const asRepositories = (
|
|
|
287
271
|
filter: {
|
|
288
272
|
listByAccountConfig: async () => fixture.filters,
|
|
289
273
|
},
|
|
290
|
-
filterAnchor: {
|
|
291
|
-
listByAccountConfig: async () => fixture.anchors,
|
|
292
|
-
},
|
|
293
274
|
address: asAddressRepository(fixture),
|
|
294
275
|
});
|
|
@@ -5,7 +5,6 @@ import type {
|
|
|
5
5
|
IAccountSettingRepository,
|
|
6
6
|
IAddressRepository,
|
|
7
7
|
IConfigImportRepository,
|
|
8
|
-
IFilterAnchorRepository,
|
|
9
8
|
IFilterRepository,
|
|
10
9
|
ILabelRepository,
|
|
11
10
|
IMailboxRepository,
|
|
@@ -38,7 +37,6 @@ export interface ConfigImportRepositories {
|
|
|
38
37
|
mailbox: Pick<IMailboxRepository, "listAllByAccount">;
|
|
39
38
|
label: Pick<ILabelRepository, "listByAccountConfig" | "create" | "update">;
|
|
40
39
|
filter: Pick<IFilterRepository, "listByAccountConfig" | "create" | "update">;
|
|
41
|
-
filterAnchor: Pick<IFilterAnchorRepository, "put">;
|
|
42
40
|
/**
|
|
43
41
|
* Not the suggest listing, for the reason the export refuses it too: what a
|
|
44
42
|
* configuration already holds is decided over every stored row, junk-only
|
|
@@ -54,21 +52,6 @@ export interface ConfigImportRepositories {
|
|
|
54
52
|
>;
|
|
55
53
|
}
|
|
56
54
|
|
|
57
|
-
/**
|
|
58
|
-
* A filter anchor, re-embedded from the source text the file carries. The
|
|
59
|
-
* stored vector is a function of the model that produced it, so it never
|
|
60
|
-
* travels; this is the one thing an import has to compute rather than copy.
|
|
61
|
-
*
|
|
62
|
-
* Optional, and free to answer `undefined`, because a configuration file has to
|
|
63
|
-
* import on a deployment that ships no embedding model at all. Either way the
|
|
64
|
-
* anchor lands stamped `ANCHOR_EMBEDDING_PENDING`, which the existing lazy
|
|
65
|
-
* repair (`refreshAnchorForEmbedder`) treats as needing a fresh vector the
|
|
66
|
-
* first time the filter is matched.
|
|
67
|
-
*/
|
|
68
|
-
export type EmbedAnchor = (
|
|
69
|
-
sourceText: string,
|
|
70
|
-
) => Promise<{ embedding: number[]; embeddingId: string } | undefined>;
|
|
71
|
-
|
|
72
55
|
/**
|
|
73
56
|
* Persist one role's folder appointment. Injected rather than composed here:
|
|
74
57
|
* an appointment row means a person decided, and it keeps that meaning only
|
|
@@ -86,17 +69,5 @@ export type AppointFolderRole = (
|
|
|
86
69
|
export interface ConfigImportDeps {
|
|
87
70
|
repositories: ConfigImportRepositories;
|
|
88
71
|
appointFolderRole: AppointFolderRole;
|
|
89
|
-
/**
|
|
90
|
-
* Runs the whole apply as one write set, rolling back on any throw.
|
|
91
|
-
*
|
|
92
|
-
* Optional, and its presence is the fact the report words itself from: a
|
|
93
|
-
* backend that supplies this rolls a failed apply back to nothing, and one
|
|
94
|
-
* that does not leaves behind whatever landed before the failure. Only the
|
|
95
|
-
* caller knows which, so it says so by passing this or omitting it rather
|
|
96
|
-
* than by handing over a pass-through that would claim atomicity it has not
|
|
97
|
-
* got.
|
|
98
|
-
*/
|
|
99
|
-
transaction?: <T>(run: () => Promise<T>) => Promise<T>;
|
|
100
|
-
embedAnchor?: EmbedAnchor;
|
|
101
72
|
now?: () => number;
|
|
102
73
|
}
|
package/src/import.test.ts
CHANGED
|
@@ -6,7 +6,6 @@ import type {
|
|
|
6
6
|
AccountSettingItem,
|
|
7
7
|
AddressItem,
|
|
8
8
|
ConfigImportItem,
|
|
9
|
-
FilterAnchorItem,
|
|
10
9
|
FilterItem,
|
|
11
10
|
LabelItem,
|
|
12
11
|
MailboxItem,
|
|
@@ -30,7 +29,6 @@ import {
|
|
|
30
29
|
makeAccount,
|
|
31
30
|
makeAccountConfig,
|
|
32
31
|
makeAddress,
|
|
33
|
-
makeAnchor,
|
|
34
32
|
makeFilter,
|
|
35
33
|
makeJunkOnlyFlaggedAddress,
|
|
36
34
|
makeLabel,
|
|
@@ -41,7 +39,7 @@ import {
|
|
|
41
39
|
PASSWORD_ACCOUNT_ID,
|
|
42
40
|
suggestable,
|
|
43
41
|
} from "./fixtures.js";
|
|
44
|
-
import {
|
|
42
|
+
import { importConfig } from "./import.js";
|
|
45
43
|
import type { ConfigImportDeps } from "./import-repositories.js";
|
|
46
44
|
|
|
47
45
|
const IDENTITY = {
|
|
@@ -52,8 +50,6 @@ const IDENTITY = {
|
|
|
52
50
|
};
|
|
53
51
|
|
|
54
52
|
const TARGET_CONFIG_ID = "1a2b3c4d5e6f7a8b9c0d1e2f3";
|
|
55
|
-
const TARGET_USER_ID = "9f8e7d6c-5b4a-4938-8271-605f4e3d2c1b";
|
|
56
|
-
const EMBEDDING_ID = "amazon.titan-embed-text-v2:0@1024";
|
|
57
53
|
|
|
58
54
|
const INBOX_ID = "mbx-inbox";
|
|
59
55
|
const SENT_ID = "mbx-sent";
|
|
@@ -74,7 +70,6 @@ interface Store {
|
|
|
74
70
|
mailboxes: MailboxItem[];
|
|
75
71
|
labels: LabelItem[];
|
|
76
72
|
filters: FilterItem[];
|
|
77
|
-
anchors: FilterAnchorItem[];
|
|
78
73
|
addresses: AddressItem[];
|
|
79
74
|
imports: ConfigImportItem[];
|
|
80
75
|
/** Set to make the next write of this kind fail, for the abort path. */
|
|
@@ -92,7 +87,6 @@ const emptyStore = (): Store => ({
|
|
|
92
87
|
mailboxes: [],
|
|
93
88
|
labels: [],
|
|
94
89
|
filters: [],
|
|
95
|
-
anchors: [],
|
|
96
90
|
addresses: [],
|
|
97
91
|
imports: [],
|
|
98
92
|
});
|
|
@@ -108,7 +102,6 @@ const storeFromFixture = (fixture: ConfigFixture): Store => ({
|
|
|
108
102
|
mailboxes: [...fixture.mailboxes],
|
|
109
103
|
labels: [...fixture.labels],
|
|
110
104
|
filters: [...fixture.filters],
|
|
111
|
-
anchors: [...fixture.anchors],
|
|
112
105
|
addresses: [...fixture.addresses],
|
|
113
106
|
imports: [],
|
|
114
107
|
});
|
|
@@ -244,19 +237,6 @@ const repositoriesOf = (store: Store, accountConfigId: string): any => {
|
|
|
244
237
|
return next;
|
|
245
238
|
},
|
|
246
239
|
},
|
|
247
|
-
filterAnchor: {
|
|
248
|
-
put: async (input: Partial<FilterAnchorItem> & { filterId: string }) => {
|
|
249
|
-
const row = makeAnchor({ ...input, accountConfigId });
|
|
250
|
-
store.anchors = [
|
|
251
|
-
...store.anchors.filter(
|
|
252
|
-
(anchor) => anchor.filterId !== input.filterId,
|
|
253
|
-
),
|
|
254
|
-
row,
|
|
255
|
-
];
|
|
256
|
-
return row;
|
|
257
|
-
},
|
|
258
|
-
listByAccountConfig: async () => [...store.anchors],
|
|
259
|
-
},
|
|
260
240
|
address: {
|
|
261
241
|
// Both reads, so the stub can tell them apart: the suggest listing
|
|
262
242
|
// hides a junk-only row, and the import must not be reading it (#1029).
|
|
@@ -392,50 +372,15 @@ const settle = (
|
|
|
392
372
|
return next;
|
|
393
373
|
};
|
|
394
374
|
|
|
395
|
-
|
|
396
|
-
* A transaction that actually rolls back. The backend's `writeSet` discards
|
|
397
|
-
* every write on a throw, so a fake that merely called through would let an
|
|
398
|
-
* atomicity test pass over a store that had kept half a document — which is the
|
|
399
|
-
* one thing these tests exist to catch. Each collection is restored to the
|
|
400
|
-
* instance it had on entry.
|
|
401
|
-
*/
|
|
402
|
-
const transactionOver =
|
|
403
|
-
(store: Store) =>
|
|
404
|
-
async <T>(run: () => Promise<T>): Promise<T> => {
|
|
405
|
-
const before = {
|
|
406
|
-
accountConfigs: new Map(store.accountConfigs),
|
|
407
|
-
accounts: [...store.accounts],
|
|
408
|
-
settings: new Map(store.settings),
|
|
409
|
-
mailboxes: [...store.mailboxes],
|
|
410
|
-
labels: [...store.labels],
|
|
411
|
-
filters: [...store.filters],
|
|
412
|
-
anchors: [...store.anchors],
|
|
413
|
-
addresses: [...store.addresses],
|
|
414
|
-
imports: [...store.imports],
|
|
415
|
-
};
|
|
416
|
-
return run().catch((error: unknown) => {
|
|
417
|
-
Object.assign(store, before);
|
|
418
|
-
throw error;
|
|
419
|
-
});
|
|
420
|
-
};
|
|
421
|
-
|
|
422
|
-
const depsOf = (
|
|
423
|
-
store: Store,
|
|
424
|
-
accountConfigId: string,
|
|
425
|
-
embed = true,
|
|
426
|
-
): ConfigImportDeps => ({
|
|
375
|
+
const depsOf = (store: Store, accountConfigId: string): ConfigImportDeps => ({
|
|
427
376
|
repositories: repositoriesOf(store, accountConfigId),
|
|
428
377
|
appointFolderRole: appointFolderRoleInto(store),
|
|
429
|
-
transaction: transactionOver(store),
|
|
430
|
-
embedAnchor: embed
|
|
431
|
-
? async () => ({ embedding: [0.11, 0.22, 0.33], embeddingId: EMBEDDING_ID })
|
|
432
|
-
: undefined,
|
|
433
378
|
now: () => NOW,
|
|
434
379
|
});
|
|
435
380
|
|
|
436
381
|
// ---------------------------------------------------------------------------
|
|
437
382
|
// A configuration worth carrying across: two accounts, both auth types, folder
|
|
438
|
-
// roles, per-folder overrides, labels,
|
|
383
|
+
// roles, per-folder overrides, labels, two filters, and two
|
|
439
384
|
// flagged senders.
|
|
440
385
|
// ---------------------------------------------------------------------------
|
|
441
386
|
|
|
@@ -543,14 +488,12 @@ const sourceFixture = (): ConfigFixture => ({
|
|
|
543
488
|
actionMailboxId: INVOICES_ID,
|
|
544
489
|
}),
|
|
545
490
|
makeFilter({
|
|
546
|
-
filterId: "flt-
|
|
547
|
-
name: "
|
|
491
|
+
filterId: "flt-release",
|
|
492
|
+
name: "Release notes",
|
|
548
493
|
scope: "Temporary",
|
|
549
494
|
expiresAt: "2026-12-31T23:59:59+01:00",
|
|
550
|
-
hasAnchor: true,
|
|
551
495
|
}),
|
|
552
496
|
],
|
|
553
|
-
anchors: [makeAnchor({ filterId: "flt-semantic" })],
|
|
554
497
|
addresses: [
|
|
555
498
|
makeAddress({
|
|
556
499
|
addressId: "adr-bank",
|
|
@@ -597,7 +540,6 @@ const apply = (
|
|
|
597
540
|
) =>
|
|
598
541
|
importConfig(depsOf(store, TARGET_CONFIG_ID), {
|
|
599
542
|
accountConfigId: TARGET_CONFIG_ID,
|
|
600
|
-
userId: TARGET_USER_ID,
|
|
601
543
|
document,
|
|
602
544
|
mode: "apply",
|
|
603
545
|
onExisting,
|
|
@@ -703,7 +645,6 @@ test("validate writes nothing and still says what an apply would do", async () =
|
|
|
703
645
|
|
|
704
646
|
const outcome = await importConfig(depsOf(store, TARGET_CONFIG_ID), {
|
|
705
647
|
accountConfigId: TARGET_CONFIG_ID,
|
|
706
|
-
userId: TARGET_USER_ID,
|
|
707
648
|
document,
|
|
708
649
|
mode: "validate",
|
|
709
650
|
onExisting: "abort",
|
|
@@ -731,7 +672,6 @@ test("a configuration that already holds something refuses the default import",
|
|
|
731
672
|
|
|
732
673
|
const outcome = await importConfig(depsOf(store, ACCOUNT_CONFIG_ID), {
|
|
733
674
|
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
734
|
-
userId: TARGET_USER_ID,
|
|
735
675
|
document,
|
|
736
676
|
mode: "apply",
|
|
737
677
|
onExisting: "abort",
|
|
@@ -758,7 +698,6 @@ test("a junk-only sender the user decided about still holds the configuration",
|
|
|
758
698
|
|
|
759
699
|
const outcome = await importConfig(depsOf(store, ACCOUNT_CONFIG_ID), {
|
|
760
700
|
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
761
|
-
userId: TARGET_USER_ID,
|
|
762
701
|
document,
|
|
763
702
|
mode: "apply",
|
|
764
703
|
onExisting: "abort",
|
|
@@ -974,7 +913,6 @@ test("the dry run names the filters an apply would leave off", async () => {
|
|
|
974
913
|
|
|
975
914
|
const outcome = await importConfig(depsOf(store, TARGET_CONFIG_ID), {
|
|
976
915
|
accountConfigId: TARGET_CONFIG_ID,
|
|
977
|
-
userId: TARGET_USER_ID,
|
|
978
916
|
document,
|
|
979
917
|
mode: "validate",
|
|
980
918
|
onExisting: "abort",
|
|
@@ -1570,67 +1508,6 @@ test("a filter filing into an account the file does not carry is refused", async
|
|
|
1570
1508
|
assert.equal(report.errors[0]?.code, "unknown_account");
|
|
1571
1509
|
});
|
|
1572
1510
|
|
|
1573
|
-
test("an anchor this deployment cannot embed is stored as text for the repair to pick up", async () => {
|
|
1574
|
-
const document = await exportSource();
|
|
1575
|
-
const store = emptyStore();
|
|
1576
|
-
|
|
1577
|
-
const outcome = await importConfig(depsOf(store, TARGET_CONFIG_ID, false), {
|
|
1578
|
-
accountConfigId: TARGET_CONFIG_ID,
|
|
1579
|
-
userId: TARGET_USER_ID,
|
|
1580
|
-
document,
|
|
1581
|
-
mode: "apply",
|
|
1582
|
-
onExisting: "abort",
|
|
1583
|
-
});
|
|
1584
|
-
const report = reportOf(outcome);
|
|
1585
|
-
|
|
1586
|
-
const anchor = store.anchors[0];
|
|
1587
|
-
assert.equal(anchor.anchorEmbeddingId, ANCHOR_EMBEDDING_PENDING);
|
|
1588
|
-
assert.deepEqual(anchor.anchorEmbedding, []);
|
|
1589
|
-
assert.equal(
|
|
1590
|
-
anchor.anchorSourceText,
|
|
1591
|
-
"the release note this filter was drawn from",
|
|
1592
|
-
);
|
|
1593
|
-
assert.equal(
|
|
1594
|
-
report.warnings.some((warning) => warning.code === "anchor_not_embedded"),
|
|
1595
|
-
true,
|
|
1596
|
-
);
|
|
1597
|
-
});
|
|
1598
|
-
|
|
1599
|
-
test("an embedder that fails costs the anchor its vector, not the import", async () => {
|
|
1600
|
-
const document = await exportSource();
|
|
1601
|
-
const store = emptyStore();
|
|
1602
|
-
const deps = depsOf(store, TARGET_CONFIG_ID);
|
|
1603
|
-
|
|
1604
|
-
const report = reportOf(
|
|
1605
|
-
await importConfig(
|
|
1606
|
-
{
|
|
1607
|
-
...deps,
|
|
1608
|
-
embedAnchor: () => Promise.reject(new Error("embedder unavailable")),
|
|
1609
|
-
},
|
|
1610
|
-
{
|
|
1611
|
-
accountConfigId: TARGET_CONFIG_ID,
|
|
1612
|
-
userId: TARGET_USER_ID,
|
|
1613
|
-
document,
|
|
1614
|
-
mode: "apply",
|
|
1615
|
-
onExisting: "abort",
|
|
1616
|
-
},
|
|
1617
|
-
),
|
|
1618
|
-
);
|
|
1619
|
-
|
|
1620
|
-
assert.equal(report.applied, true);
|
|
1621
|
-
assert.deepEqual(report.errors, []);
|
|
1622
|
-
assert.equal(
|
|
1623
|
-
report.warnings.some((warning) => warning.code === "anchor_not_embedded"),
|
|
1624
|
-
true,
|
|
1625
|
-
);
|
|
1626
|
-
assert.equal(store.anchors[0]?.anchorEmbeddingId, ANCHOR_EMBEDDING_PENDING);
|
|
1627
|
-
assert.equal(
|
|
1628
|
-
store.anchors[0]?.anchorSourceText,
|
|
1629
|
-
"the release note this filter was drawn from",
|
|
1630
|
-
);
|
|
1631
|
-
assert.equal(store.accounts.length > 0, true);
|
|
1632
|
-
});
|
|
1633
|
-
|
|
1634
1511
|
test("the dry run names the same folders and credentials the apply does", async () => {
|
|
1635
1512
|
const document = await exportSource();
|
|
1636
1513
|
const planStore = emptyStore();
|
|
@@ -1638,7 +1515,6 @@ test("the dry run names the same folders and credentials the apply does", async
|
|
|
1638
1515
|
const planned = reportOf(
|
|
1639
1516
|
await importConfig(depsOf(planStore, TARGET_CONFIG_ID), {
|
|
1640
1517
|
accountConfigId: TARGET_CONFIG_ID,
|
|
1641
|
-
userId: TARGET_USER_ID,
|
|
1642
1518
|
document,
|
|
1643
1519
|
mode: "validate",
|
|
1644
1520
|
onExisting: "abort",
|
|
@@ -1659,7 +1535,7 @@ test("the dry run names the same folders and credentials the apply does", async
|
|
|
1659
1535
|
assert.deepEqual(planStore.accounts, []);
|
|
1660
1536
|
});
|
|
1661
1537
|
|
|
1662
|
-
test("a write that fails
|
|
1538
|
+
test("a write that fails part-way reports what it did leave behind and names where it stopped", async () => {
|
|
1663
1539
|
const document = await exportSource();
|
|
1664
1540
|
const store = emptyStore();
|
|
1665
1541
|
store.failOn = "filter";
|
|
@@ -1667,40 +1543,8 @@ test("a write that fails after validation leaves nothing behind and names where
|
|
|
1667
1543
|
const report = reportOf(await apply(store, document));
|
|
1668
1544
|
|
|
1669
1545
|
assert.equal(report.valid, true);
|
|
1670
|
-
assert.equal(report.applied, false);
|
|
1671
1546
|
assert.equal(report.errors[0]?.code, "import_write_failed");
|
|
1672
1547
|
assert.equal(report.errors[0]?.details?.section, "filters");
|
|
1673
|
-
assert.match(report.errors[0]?.message ?? "", /Nothing was written/);
|
|
1674
|
-
assert.equal(report.items[0]?.verdict, "rejected");
|
|
1675
|
-
|
|
1676
|
-
// Filters fail after accounts and labels have been written, so these are the
|
|
1677
|
-
// assertions that make the claim: the rollback undid them too.
|
|
1678
|
-
assert.deepEqual(store.imports, []);
|
|
1679
|
-
assert.deepEqual(store.accounts, []);
|
|
1680
|
-
assert.deepEqual(store.labels, []);
|
|
1681
|
-
assert.deepEqual(store.filters, []);
|
|
1682
|
-
assert.equal(store.accountConfigs.size, 0);
|
|
1683
|
-
});
|
|
1684
|
-
|
|
1685
|
-
test("a write that fails without a transaction reports what it did leave behind", async () => {
|
|
1686
|
-
const document = await exportSource();
|
|
1687
|
-
const store = emptyStore();
|
|
1688
|
-
store.failOn = "filter";
|
|
1689
|
-
|
|
1690
|
-
const deps = depsOf(store, TARGET_CONFIG_ID);
|
|
1691
|
-
const report = reportOf(
|
|
1692
|
-
await importConfig(
|
|
1693
|
-
{ ...deps, transaction: undefined },
|
|
1694
|
-
{
|
|
1695
|
-
accountConfigId: TARGET_CONFIG_ID,
|
|
1696
|
-
userId: TARGET_USER_ID,
|
|
1697
|
-
document,
|
|
1698
|
-
mode: "apply",
|
|
1699
|
-
onExisting: "abort",
|
|
1700
|
-
},
|
|
1701
|
-
),
|
|
1702
|
-
);
|
|
1703
|
-
|
|
1704
1548
|
assert.equal(report.applied, true);
|
|
1705
1549
|
assert.match(report.errors[0]?.message ?? "", /were written and remain/);
|
|
1706
1550
|
assert.equal(
|
|
@@ -1712,6 +1556,29 @@ test("a write that fails without a transaction reports what it did leave behind"
|
|
|
1712
1556
|
assert.equal(store.accounts.length > 0, true);
|
|
1713
1557
|
});
|
|
1714
1558
|
|
|
1559
|
+
test("a stopped import imported again with merge reaches what one clean import holds", async () => {
|
|
1560
|
+
const document = await exportSource();
|
|
1561
|
+
const clean = emptyStore();
|
|
1562
|
+
await apply(clean, document);
|
|
1563
|
+
|
|
1564
|
+
const store = emptyStore();
|
|
1565
|
+
store.failOn = "filter";
|
|
1566
|
+
await apply(store, document);
|
|
1567
|
+
store.failOn = undefined;
|
|
1568
|
+
const report = reportOf(await apply(store, document, "merge"));
|
|
1569
|
+
|
|
1570
|
+
assert.deepEqual(report.errors, []);
|
|
1571
|
+
const held = (target: Store) => ({
|
|
1572
|
+
accounts: target.accounts.map((account) => account.email).sort(),
|
|
1573
|
+
labels: target.labels.map((label) => label.name).sort(),
|
|
1574
|
+
filters: target.filters.length,
|
|
1575
|
+
addresses: target.addresses.length,
|
|
1576
|
+
settings: [...target.settings.keys()].sort(),
|
|
1577
|
+
});
|
|
1578
|
+
assert.deepEqual(held(store), held(clean));
|
|
1579
|
+
assert.ok(held(clean).filters > 0, "the file carries filters to land");
|
|
1580
|
+
});
|
|
1581
|
+
|
|
1715
1582
|
test("the configuration row is created when the database holds none, and named from the file", async () => {
|
|
1716
1583
|
const document = await exportSource();
|
|
1717
1584
|
const store = emptyStore();
|
|
@@ -1721,7 +1588,7 @@ test("the configuration row is created when the database holds none, and named f
|
|
|
1721
1588
|
assert.equal(store.accountConfigs.get(TARGET_CONFIG_ID)?.name, "Matthijs");
|
|
1722
1589
|
assert.equal(
|
|
1723
1590
|
store.accountConfigs.get(TARGET_CONFIG_ID)?.userId,
|
|
1724
|
-
|
|
1591
|
+
TARGET_CONFIG_ID,
|
|
1725
1592
|
);
|
|
1726
1593
|
assert.equal(
|
|
1727
1594
|
store.settings.get("DefaultComposerFormat")?.value.value,
|
package/src/import.ts
CHANGED
|
@@ -14,11 +14,11 @@ import {
|
|
|
14
14
|
type FilterItem,
|
|
15
15
|
type LabelItem,
|
|
16
16
|
} from "@remit/data-ports";
|
|
17
|
+
import { ensureAccountConfig } from "@remit/data-ports/account-config";
|
|
17
18
|
import {
|
|
18
19
|
type AccountSettingNameValue,
|
|
19
20
|
composeSettingName,
|
|
20
21
|
} from "@remit/data-ports/account-settings";
|
|
21
|
-
import { NotFoundError } from "@remit/data-ports/errors";
|
|
22
22
|
import type { CanonicalMailboxRoleValue } from "@remit/data-ports/folder-role";
|
|
23
23
|
import { deriveAddressId } from "@remit/data-ports/id";
|
|
24
24
|
import {
|
|
@@ -39,24 +39,11 @@ import {
|
|
|
39
39
|
ConfigImportVerdict,
|
|
40
40
|
} from "./report.js";
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
* The `<modelId>@<dimensions>` stamp an anchor carries when this deployment
|
|
44
|
-
* could not embed its source text at import time. It matches no real embedder,
|
|
45
|
-
* which is what makes the existing lazy repair (`refreshAnchorForEmbedder`)
|
|
46
|
-
* treat the anchor as needing a fresh vector the first time it is matched —
|
|
47
|
-
* the same route a model migration takes. Deliberately the same literal as
|
|
48
|
-
* `UNKNOWN_CHUNK_EMBEDDING_ID` in `@remit/search-service`, which config
|
|
49
|
-
* transfer cannot import without dragging the vector stack into the CLI.
|
|
50
|
-
*/
|
|
51
|
-
export const ANCHOR_EMBEDDING_PENDING = "unknown";
|
|
52
|
-
|
|
53
42
|
/** The named sentinel a Filter carries for "no action of this kind". */
|
|
54
43
|
const NO_ACTION = "None";
|
|
55
44
|
|
|
56
45
|
export interface ImportConfigInput {
|
|
57
46
|
accountConfigId: string;
|
|
58
|
-
/** Owner of a configuration this import may have to create the row for. */
|
|
59
|
-
userId: string;
|
|
60
47
|
document: unknown;
|
|
61
48
|
mode: "validate" | "apply";
|
|
62
49
|
onExisting: "abort" | "merge";
|
|
@@ -270,9 +257,9 @@ const landingState = (
|
|
|
270
257
|
|
|
271
258
|
/**
|
|
272
259
|
* What the apply has decided so far. Owned by `importConfig` rather than by
|
|
273
|
-
* `applyDocument` so a write that fails part-way still has it:
|
|
274
|
-
*
|
|
275
|
-
*
|
|
260
|
+
* `applyDocument` so a write that fails part-way still has it: those items are
|
|
261
|
+
* what actually landed, and a report that threw them away would name a stopping
|
|
262
|
+
* point without naming the state.
|
|
276
263
|
*/
|
|
277
264
|
interface ApplyProgress {
|
|
278
265
|
items: ConfigImportItemReport[];
|
|
@@ -288,56 +275,6 @@ const emptyProgress = (): ApplyProgress => ({
|
|
|
288
275
|
unresolvedRefs: [],
|
|
289
276
|
});
|
|
290
277
|
|
|
291
|
-
interface AnchorVector {
|
|
292
|
-
embedding: number[];
|
|
293
|
-
embeddingId: string;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Every anchor's vector, computed before the transaction opens.
|
|
298
|
-
*
|
|
299
|
-
* Two reasons it happens here and not at the point of write. An embedder is a
|
|
300
|
-
* network round trip and the sqlite write queue is process-wide, so embedding
|
|
301
|
-
* inside the transaction holds that queue against every other writer for the
|
|
302
|
-
* length of the call. And a failed embed is not a failed import: it degrades
|
|
303
|
-
* exactly the way a deployment that ships no embedder does, landing the anchor
|
|
304
|
-
* stamped `ANCHOR_EMBEDDING_PENDING` for the lazy repair to rebuild the first
|
|
305
|
-
* time the filter runs. A 503 from the embedder must not cost a person their
|
|
306
|
-
* configuration.
|
|
307
|
-
*/
|
|
308
|
-
const embedAnchors = async (
|
|
309
|
-
deps: ConfigImportDeps,
|
|
310
|
-
document: ReaderConfigDocument,
|
|
311
|
-
warnings: ConfigImportProblem[],
|
|
312
|
-
): Promise<Map<string, AnchorVector>> => {
|
|
313
|
-
const vectors = new Map<string, AnchorVector>();
|
|
314
|
-
const { embedAnchor } = deps;
|
|
315
|
-
|
|
316
|
-
for (const filter of document.filters) {
|
|
317
|
-
const { anchor } = filter;
|
|
318
|
-
if (anchor === null) continue;
|
|
319
|
-
|
|
320
|
-
const embedded = embedAnchor
|
|
321
|
-
? await embedAnchor(anchor.sourceText).catch(() => undefined)
|
|
322
|
-
: undefined;
|
|
323
|
-
|
|
324
|
-
if (embedded) {
|
|
325
|
-
vectors.set(normalize(filter.name), embedded);
|
|
326
|
-
continue;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
warnings.push(
|
|
330
|
-
problem(
|
|
331
|
-
"anchor_not_embedded",
|
|
332
|
-
`The example behind filter "${filter.name}" is carried as text; its vector is rebuilt the first time the filter runs.`,
|
|
333
|
-
{ filter: filter.name },
|
|
334
|
-
),
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
return vectors;
|
|
339
|
-
};
|
|
340
|
-
|
|
341
278
|
/**
|
|
342
279
|
* The id this instance will hold each account in the file under: the one it
|
|
343
280
|
* already has for that id or email, or the file's own, which `create` preserves
|
|
@@ -378,8 +315,7 @@ const foldersFor = (
|
|
|
378
315
|
|
|
379
316
|
/**
|
|
380
317
|
* A write that failed after validation. It carries the item it stopped on so
|
|
381
|
-
* the report can name it
|
|
382
|
-
* stopped is the only fact worth recovering from.
|
|
318
|
+
* the report can name it beside what landed before it.
|
|
383
319
|
*/
|
|
384
320
|
class ImportWriteError extends Error {
|
|
385
321
|
constructor(
|
|
@@ -399,7 +335,6 @@ const applyDocument = async (
|
|
|
399
335
|
input: ImportConfigInput,
|
|
400
336
|
document: ReaderConfigDocument,
|
|
401
337
|
existing: ExistingConfig,
|
|
402
|
-
anchorVectors: ReadonlyMap<string, AnchorVector>,
|
|
403
338
|
progress: ApplyProgress,
|
|
404
339
|
): Promise<string> => {
|
|
405
340
|
const { repositories } = deps;
|
|
@@ -430,7 +365,7 @@ const applyDocument = async (
|
|
|
430
365
|
// First: everything below hangs off it, and on a fresh instance there is no
|
|
431
366
|
// row for an account to reference until this has run.
|
|
432
367
|
await write(ConfigImportSection.Settings, "accountConfig", () =>
|
|
433
|
-
|
|
368
|
+
nameAccountConfig(deps, input, document),
|
|
434
369
|
);
|
|
435
370
|
|
|
436
371
|
// ---- accounts -------------------------------------------------------
|
|
@@ -485,8 +420,7 @@ const applyDocument = async (
|
|
|
485
420
|
account.email,
|
|
486
421
|
() =>
|
|
487
422
|
repositories.account.create({
|
|
488
|
-
// Verbatim: a resynced message id hashes from it
|
|
489
|
-
// what keeps every filter anchor's provenance valid across the drop.
|
|
423
|
+
// Verbatim: a resynced message id hashes from it.
|
|
490
424
|
accountId: account.accountId,
|
|
491
425
|
accountConfigId,
|
|
492
426
|
...endpoints,
|
|
@@ -545,7 +479,6 @@ const applyDocument = async (
|
|
|
545
479
|
const existingFilterByName = new Map(
|
|
546
480
|
existing.filters.map((filter) => [normalize(filter.name), filter] as const),
|
|
547
481
|
);
|
|
548
|
-
const filterIdByName = new Map<string, string>();
|
|
549
482
|
|
|
550
483
|
const resolveFilterFolder = (
|
|
551
484
|
filter: ConfigFilter,
|
|
@@ -574,7 +507,6 @@ const applyDocument = async (
|
|
|
574
507
|
? NO_ACTION
|
|
575
508
|
: (labelIdByName.get(normalize(filter.actionLabelName)) ?? NO_ACTION),
|
|
576
509
|
actionMailboxId: folder.actionMailboxId,
|
|
577
|
-
hasAnchor: filter.anchor !== null,
|
|
578
510
|
...(folder.pending
|
|
579
511
|
? {
|
|
580
512
|
state: FilterState.Disabled,
|
|
@@ -603,7 +535,6 @@ const applyDocument = async (
|
|
|
603
535
|
)
|
|
604
536
|
).filterId;
|
|
605
537
|
|
|
606
|
-
filterIdByName.set(key, filterId);
|
|
607
538
|
items.push({
|
|
608
539
|
section: ConfigImportSection.Filters,
|
|
609
540
|
key: filter.name,
|
|
@@ -636,34 +567,6 @@ const applyDocument = async (
|
|
|
636
567
|
}
|
|
637
568
|
}
|
|
638
569
|
|
|
639
|
-
// ---- anchors --------------------------------------------------------
|
|
640
|
-
// Vectors were computed before the transaction opened; a missing one is an
|
|
641
|
-
// anchor carried as text, already warned about there.
|
|
642
|
-
for (const filter of document.filters) {
|
|
643
|
-
const { anchor } = filter;
|
|
644
|
-
if (anchor === null) continue;
|
|
645
|
-
const key = normalize(filter.name);
|
|
646
|
-
const filterId = filterIdByName.get(key);
|
|
647
|
-
if (filterId === undefined) {
|
|
648
|
-
throw new Error(
|
|
649
|
-
`Filter "${filter.name}" carries an anchor but the filter loop minted no id for it. The two loops read the same documents.filters, so this cannot happen without one of them changing.`,
|
|
650
|
-
);
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
const embedded = anchorVectors.get(key);
|
|
654
|
-
|
|
655
|
-
await write(ConfigImportSection.Filters, filter.name, () =>
|
|
656
|
-
repositories.filterAnchor.put({
|
|
657
|
-
accountConfigId,
|
|
658
|
-
filterId,
|
|
659
|
-
anchorEmbedding: embedded?.embedding ?? [],
|
|
660
|
-
anchorEmbeddingId: embedded?.embeddingId ?? ANCHOR_EMBEDDING_PENDING,
|
|
661
|
-
anchorSourceText: anchor.sourceText,
|
|
662
|
-
anchorMessageId: anchor.sourceMessageId,
|
|
663
|
-
}),
|
|
664
|
-
);
|
|
665
|
-
}
|
|
666
|
-
|
|
667
570
|
// ---- address flags --------------------------------------------------
|
|
668
571
|
for (const entry of document.addressFlags) {
|
|
669
572
|
await write(ConfigImportSection.AddressFlags, entry.normalizedEmail, () =>
|
|
@@ -782,32 +685,17 @@ const writeAddressFlags = async (
|
|
|
782
685
|
/**
|
|
783
686
|
* The configuration row itself. A fresh instance holds none — `GET /config`
|
|
784
687
|
* synthesizes an empty answer rather than writing one — so an import into a
|
|
785
|
-
* dropped database has to materialize it
|
|
688
|
+
* dropped database has to materialize it, the same way every other first write
|
|
689
|
+
* does, before anything can hang off it.
|
|
786
690
|
*/
|
|
787
|
-
const
|
|
691
|
+
const nameAccountConfig = async (
|
|
788
692
|
deps: ConfigImportDeps,
|
|
789
693
|
input: ImportConfigInput,
|
|
790
694
|
document: ReaderConfigDocument,
|
|
791
695
|
): Promise<void> => {
|
|
792
696
|
const { accountConfig } = deps.repositories;
|
|
793
|
-
|
|
794
|
-
// means "create it"; anything else is a store fault and belongs to the caller.
|
|
795
|
-
const held = await accountConfig
|
|
796
|
-
.get(input.accountConfigId)
|
|
797
|
-
.catch((error: unknown) => {
|
|
798
|
-
if (error instanceof NotFoundError) return undefined;
|
|
799
|
-
throw error;
|
|
800
|
-
});
|
|
697
|
+
const held = await ensureAccountConfig(accountConfig, input.accountConfigId);
|
|
801
698
|
const name = document.accountConfig.name;
|
|
802
|
-
|
|
803
|
-
if (!held) {
|
|
804
|
-
await accountConfig.create({
|
|
805
|
-
accountConfigId: input.accountConfigId,
|
|
806
|
-
userId: input.userId,
|
|
807
|
-
name,
|
|
808
|
-
});
|
|
809
|
-
return;
|
|
810
|
-
}
|
|
811
699
|
if (name !== "" && name !== held.name) {
|
|
812
700
|
await accountConfig.update(input.accountConfigId, { name });
|
|
813
701
|
}
|
|
@@ -939,8 +827,8 @@ class SettingWriter {
|
|
|
939
827
|
*
|
|
940
828
|
* The order is fixed: parse and migrate, check every reference and every
|
|
941
829
|
* natural key against what this configuration already holds, and only then
|
|
942
|
-
* write —
|
|
943
|
-
*
|
|
830
|
+
* write — accounts before labels before filters before
|
|
831
|
+
* addresses before settings, because each of those reads
|
|
944
832
|
* an id the one before it minted.
|
|
945
833
|
*
|
|
946
834
|
* A document this reader will not import is the operation's expected outcome,
|
|
@@ -1004,19 +892,16 @@ export const importConfig = async (
|
|
|
1004
892
|
};
|
|
1005
893
|
}
|
|
1006
894
|
|
|
1007
|
-
// Before the transaction: an embed is a network round trip, and the write
|
|
1008
|
-
// queue it would otherwise hold is process-wide.
|
|
1009
|
-
const embedWarnings: ConfigImportProblem[] = [];
|
|
1010
|
-
const anchorVectors = await embedAnchors(deps, document, embedWarnings);
|
|
1011
|
-
|
|
1012
|
-
const { transaction } = deps;
|
|
1013
|
-
const atomic = transaction !== undefined;
|
|
1014
895
|
const progress = emptyProgress();
|
|
1015
|
-
const apply = (): Promise<string> =>
|
|
1016
|
-
applyDocument(deps, input, document, existing, anchorVectors, progress);
|
|
1017
896
|
|
|
1018
897
|
try {
|
|
1019
|
-
const importId = await (
|
|
898
|
+
const importId = await applyDocument(
|
|
899
|
+
deps,
|
|
900
|
+
input,
|
|
901
|
+
document,
|
|
902
|
+
existing,
|
|
903
|
+
progress,
|
|
904
|
+
);
|
|
1020
905
|
return {
|
|
1021
906
|
outcome: "report",
|
|
1022
907
|
report: {
|
|
@@ -1026,16 +911,17 @@ export const importConfig = async (
|
|
|
1026
911
|
applied: true,
|
|
1027
912
|
items: progress.items,
|
|
1028
913
|
errors: [],
|
|
1029
|
-
warnings:
|
|
914
|
+
warnings: progress.warnings,
|
|
1030
915
|
accountsNeedingCredentials: progress.accountsNeedingCredentials,
|
|
1031
916
|
},
|
|
1032
917
|
};
|
|
1033
918
|
} catch (error) {
|
|
1034
919
|
if (!(error instanceof ImportWriteError)) throw error;
|
|
1035
920
|
// What the report owes the reader is where it stopped and what is now in
|
|
1036
|
-
// the store
|
|
1037
|
-
//
|
|
1038
|
-
|
|
921
|
+
// the store: everything recorded up to the failure. Every write is keyed on
|
|
922
|
+
// a natural key, so importing the same file again with merge folds the
|
|
923
|
+
// rest in over what landed.
|
|
924
|
+
const landed = progress;
|
|
1039
925
|
return {
|
|
1040
926
|
outcome: "report",
|
|
1041
927
|
report: {
|
|
@@ -1054,15 +940,11 @@ export const importConfig = async (
|
|
|
1054
940
|
errors: [
|
|
1055
941
|
problem(
|
|
1056
942
|
"import_write_failed",
|
|
1057
|
-
`${error.message}
|
|
1058
|
-
atomic
|
|
1059
|
-
? "Nothing was written; the import stopped here."
|
|
1060
|
-
: "The items above it were written and remain; the import stopped here."
|
|
1061
|
-
}`,
|
|
943
|
+
`${error.message} The items above it were written and remain; import the same file again with merge to finish.`,
|
|
1062
944
|
{ section: error.section, key: error.key },
|
|
1063
945
|
),
|
|
1064
946
|
],
|
|
1065
|
-
warnings:
|
|
947
|
+
warnings: landed.warnings,
|
|
1066
948
|
accountsNeedingCredentials: landed.accountsNeedingCredentials,
|
|
1067
949
|
},
|
|
1068
950
|
};
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,6 @@ export {
|
|
|
13
13
|
readConfigForExport,
|
|
14
14
|
} from "./export.js";
|
|
15
15
|
export {
|
|
16
|
-
ANCHOR_EMBEDDING_PENDING,
|
|
17
16
|
type ImportConfigInput,
|
|
18
17
|
importConfig,
|
|
19
18
|
} from "./import.js";
|
|
@@ -21,7 +20,6 @@ export type {
|
|
|
21
20
|
AppointFolderRole,
|
|
22
21
|
ConfigImportDeps,
|
|
23
22
|
ConfigImportRepositories,
|
|
24
|
-
EmbedAnchor,
|
|
25
23
|
} from "./import-repositories.js";
|
|
26
24
|
export {
|
|
27
25
|
type ConfigImportConflict,
|
package/src/repositories.ts
CHANGED
|
@@ -3,7 +3,6 @@ import type {
|
|
|
3
3
|
IAccountRepository,
|
|
4
4
|
IAccountSettingRepository,
|
|
5
5
|
IAddressRepository,
|
|
6
|
-
IFilterAnchorRepository,
|
|
7
6
|
IFilterRepository,
|
|
8
7
|
ILabelRepository,
|
|
9
8
|
IMailboxRepository,
|
|
@@ -24,7 +23,6 @@ export interface ConfigExportRepositories {
|
|
|
24
23
|
mailbox: Pick<IMailboxRepository, "listAllByAccount">;
|
|
25
24
|
label: Pick<ILabelRepository, "listByAccountConfig">;
|
|
26
25
|
filter: Pick<IFilterRepository, "listByAccountConfig">;
|
|
27
|
-
filterAnchor: Pick<IFilterAnchorRepository, "listByAccountConfig">;
|
|
28
26
|
/**
|
|
29
27
|
* Not the suggest listing: that one hides an address the machine marked
|
|
30
28
|
* `junkOnly` unless the account has corresponded with it, which is right for
|