@remit/config-transfer 0.0.10 → 0.0.12
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/binder.ts +219 -40
- package/src/export.test.ts +8 -38
- package/src/export.ts +2 -25
- package/src/fixtures.ts +2 -20
- package/src/import-repositories.ts +0 -29
- package/src/import.test.ts +587 -160
- package/src/import.ts +71 -139
- package/src/index.ts +2 -2
- package/src/repositories.ts +0 -2
package/src/import.ts
CHANGED
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
ConfigImportRefKind,
|
|
27
27
|
ConfigImportState,
|
|
28
28
|
ConnectionState,
|
|
29
|
+
FilterDisabledReason,
|
|
30
|
+
FilterState,
|
|
29
31
|
} from "@remit/domain-enums";
|
|
30
32
|
import { carriesUserFlag } from "./export.js";
|
|
31
33
|
import type { ConfigImportDeps } from "./import-repositories.js";
|
|
@@ -37,17 +39,6 @@ import {
|
|
|
37
39
|
ConfigImportVerdict,
|
|
38
40
|
} from "./report.js";
|
|
39
41
|
|
|
40
|
-
/**
|
|
41
|
-
* The `<modelId>@<dimensions>` stamp an anchor carries when this deployment
|
|
42
|
-
* could not embed its source text at import time. It matches no real embedder,
|
|
43
|
-
* which is what makes the existing lazy repair (`refreshAnchorForEmbedder`)
|
|
44
|
-
* treat the anchor as needing a fresh vector the first time it is matched —
|
|
45
|
-
* the same route a model migration takes. Deliberately the same literal as
|
|
46
|
-
* `UNKNOWN_CHUNK_EMBEDDING_ID` in `@remit/search-service`, which config
|
|
47
|
-
* transfer cannot import without dragging the vector stack into the CLI.
|
|
48
|
-
*/
|
|
49
|
-
export const ANCHOR_EMBEDDING_PENDING = "unknown";
|
|
50
|
-
|
|
51
42
|
/** The named sentinel a Filter carries for "no action of this kind". */
|
|
52
43
|
const NO_ACTION = "None";
|
|
53
44
|
|
|
@@ -62,6 +53,15 @@ export interface ImportConfigInput {
|
|
|
62
53
|
|
|
63
54
|
const normalize = (value: string): string => value.trim().toLowerCase();
|
|
64
55
|
|
|
56
|
+
const filterFolderPendingMessage = (name: string, folderPath: string): string =>
|
|
57
|
+
`Filter "${name}" files mail into "${folderPath}", which this account does not hold yet. The folder is created on the mail server when the account next syncs, and the filter stays off until the server confirms it.`;
|
|
58
|
+
|
|
59
|
+
const folderPendingMessage = (folderPath: string): string =>
|
|
60
|
+
`"${folderPath}" is not a folder this account holds yet. It is created on the mail server when the account next syncs, and the setting is bound once the server confirms it.`;
|
|
61
|
+
|
|
62
|
+
const awaitingFolderReason = (folderPath: string): string =>
|
|
63
|
+
`Off until "${folderPath}" exists on the mail server.`;
|
|
64
|
+
|
|
65
65
|
/**
|
|
66
66
|
* The version a document claims, read straight off the raw input. The parse
|
|
67
67
|
* failed, so this is the only honest answer the report can give — and a report
|
|
@@ -259,9 +259,9 @@ const landingState = (
|
|
|
259
259
|
|
|
260
260
|
/**
|
|
261
261
|
* What the apply has decided so far. Owned by `importConfig` rather than by
|
|
262
|
-
* `applyDocument` so a write that fails part-way still has it:
|
|
263
|
-
*
|
|
264
|
-
*
|
|
262
|
+
* `applyDocument` so a write that fails part-way still has it: those items are
|
|
263
|
+
* what actually landed, and a report that threw them away would name a stopping
|
|
264
|
+
* point without naming the state.
|
|
265
265
|
*/
|
|
266
266
|
interface ApplyProgress {
|
|
267
267
|
items: ConfigImportItemReport[];
|
|
@@ -277,56 +277,6 @@ const emptyProgress = (): ApplyProgress => ({
|
|
|
277
277
|
unresolvedRefs: [],
|
|
278
278
|
});
|
|
279
279
|
|
|
280
|
-
interface AnchorVector {
|
|
281
|
-
embedding: number[];
|
|
282
|
-
embeddingId: string;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Every anchor's vector, computed before the transaction opens.
|
|
287
|
-
*
|
|
288
|
-
* Two reasons it happens here and not at the point of write. An embedder is a
|
|
289
|
-
* network round trip and the sqlite write queue is process-wide, so embedding
|
|
290
|
-
* inside the transaction holds that queue against every other writer for the
|
|
291
|
-
* length of the call. And a failed embed is not a failed import: it degrades
|
|
292
|
-
* exactly the way a deployment that ships no embedder does, landing the anchor
|
|
293
|
-
* stamped `ANCHOR_EMBEDDING_PENDING` for the lazy repair to rebuild the first
|
|
294
|
-
* time the filter runs. A 503 from the embedder must not cost a person their
|
|
295
|
-
* configuration.
|
|
296
|
-
*/
|
|
297
|
-
const embedAnchors = async (
|
|
298
|
-
deps: ConfigImportDeps,
|
|
299
|
-
document: ReaderConfigDocument,
|
|
300
|
-
warnings: ConfigImportProblem[],
|
|
301
|
-
): Promise<Map<string, AnchorVector>> => {
|
|
302
|
-
const vectors = new Map<string, AnchorVector>();
|
|
303
|
-
const { embedAnchor } = deps;
|
|
304
|
-
|
|
305
|
-
for (const filter of document.filters) {
|
|
306
|
-
const { anchor } = filter;
|
|
307
|
-
if (anchor === null) continue;
|
|
308
|
-
|
|
309
|
-
const embedded = embedAnchor
|
|
310
|
-
? await embedAnchor(anchor.sourceText).catch(() => undefined)
|
|
311
|
-
: undefined;
|
|
312
|
-
|
|
313
|
-
if (embedded) {
|
|
314
|
-
vectors.set(normalize(filter.name), embedded);
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
warnings.push(
|
|
319
|
-
problem(
|
|
320
|
-
"anchor_not_embedded",
|
|
321
|
-
`The example behind filter "${filter.name}" is carried as text; its vector is rebuilt the first time the filter runs.`,
|
|
322
|
-
{ filter: filter.name },
|
|
323
|
-
),
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return vectors;
|
|
328
|
-
};
|
|
329
|
-
|
|
330
280
|
/**
|
|
331
281
|
* The id this instance will hold each account in the file under: the one it
|
|
332
282
|
* already has for that id or email, or the file's own, which `create` preserves
|
|
@@ -367,8 +317,7 @@ const foldersFor = (
|
|
|
367
317
|
|
|
368
318
|
/**
|
|
369
319
|
* A write that failed after validation. It carries the item it stopped on so
|
|
370
|
-
* the report can name it
|
|
371
|
-
* stopped is the only fact worth recovering from.
|
|
320
|
+
* the report can name it beside what landed before it.
|
|
372
321
|
*/
|
|
373
322
|
class ImportWriteError extends Error {
|
|
374
323
|
constructor(
|
|
@@ -388,7 +337,6 @@ const applyDocument = async (
|
|
|
388
337
|
input: ImportConfigInput,
|
|
389
338
|
document: ReaderConfigDocument,
|
|
390
339
|
existing: ExistingConfig,
|
|
391
|
-
anchorVectors: ReadonlyMap<string, AnchorVector>,
|
|
392
340
|
progress: ApplyProgress,
|
|
393
341
|
): Promise<string> => {
|
|
394
342
|
const { repositories } = deps;
|
|
@@ -474,8 +422,7 @@ const applyDocument = async (
|
|
|
474
422
|
account.email,
|
|
475
423
|
() =>
|
|
476
424
|
repositories.account.create({
|
|
477
|
-
// Verbatim: a resynced message id hashes from it
|
|
478
|
-
// what keeps every filter anchor's provenance valid across the drop.
|
|
425
|
+
// Verbatim: a resynced message id hashes from it.
|
|
479
426
|
accountId: account.accountId,
|
|
480
427
|
accountConfigId,
|
|
481
428
|
...endpoints,
|
|
@@ -534,7 +481,6 @@ const applyDocument = async (
|
|
|
534
481
|
const existingFilterByName = new Map(
|
|
535
482
|
existing.filters.map((filter) => [normalize(filter.name), filter] as const),
|
|
536
483
|
);
|
|
537
|
-
const filterIdByName = new Map<string, string>();
|
|
538
484
|
|
|
539
485
|
const resolveFilterFolder = (
|
|
540
486
|
filter: ConfigFilter,
|
|
@@ -563,7 +509,12 @@ const applyDocument = async (
|
|
|
563
509
|
? NO_ACTION
|
|
564
510
|
: (labelIdByName.get(normalize(filter.actionLabelName)) ?? NO_ACTION),
|
|
565
511
|
actionMailboxId: folder.actionMailboxId,
|
|
566
|
-
|
|
512
|
+
...(folder.pending
|
|
513
|
+
? {
|
|
514
|
+
state: FilterState.Disabled,
|
|
515
|
+
disabledReason: FilterDisabledReason.AwaitingFolder,
|
|
516
|
+
}
|
|
517
|
+
: {}),
|
|
567
518
|
};
|
|
568
519
|
|
|
569
520
|
const held = existingFilterByName.get(key);
|
|
@@ -586,12 +537,14 @@ const applyDocument = async (
|
|
|
586
537
|
)
|
|
587
538
|
).filterId;
|
|
588
539
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
540
|
+
items.push({
|
|
541
|
+
section: ConfigImportSection.Filters,
|
|
542
|
+
key: filter.name,
|
|
543
|
+
verdict: held ? ConfigImportVerdict.Updated : ConfigImportVerdict.Created,
|
|
544
|
+
...(folder.pending && filter.actionFolder
|
|
545
|
+
? { reason: awaitingFolderReason(filter.actionFolder.folderPath) }
|
|
546
|
+
: {}),
|
|
547
|
+
});
|
|
595
548
|
|
|
596
549
|
if (folder.pending && filter.actionFolder) {
|
|
597
550
|
unresolvedRefs.push({
|
|
@@ -601,45 +554,21 @@ const applyDocument = async (
|
|
|
601
554
|
filter.actionFolder.accountId,
|
|
602
555
|
folderPath: filter.actionFolder.folderPath,
|
|
603
556
|
target: filterId,
|
|
557
|
+
mailboxId: NO_ACTION,
|
|
604
558
|
});
|
|
605
559
|
warnings.push(
|
|
606
560
|
problem(
|
|
607
561
|
"folder_not_found_yet",
|
|
608
|
-
|
|
562
|
+
filterFolderPendingMessage(
|
|
563
|
+
filter.name,
|
|
564
|
+
filter.actionFolder.folderPath,
|
|
565
|
+
),
|
|
609
566
|
{ filter: filter.name, folderPath: filter.actionFolder.folderPath },
|
|
610
567
|
),
|
|
611
568
|
);
|
|
612
569
|
}
|
|
613
570
|
}
|
|
614
571
|
|
|
615
|
-
// ---- anchors --------------------------------------------------------
|
|
616
|
-
// Vectors were computed before the transaction opened; a missing one is an
|
|
617
|
-
// anchor carried as text, already warned about there.
|
|
618
|
-
for (const filter of document.filters) {
|
|
619
|
-
const { anchor } = filter;
|
|
620
|
-
if (anchor === null) continue;
|
|
621
|
-
const key = normalize(filter.name);
|
|
622
|
-
const filterId = filterIdByName.get(key);
|
|
623
|
-
if (filterId === undefined) {
|
|
624
|
-
throw new Error(
|
|
625
|
-
`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.`,
|
|
626
|
-
);
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
const embedded = anchorVectors.get(key);
|
|
630
|
-
|
|
631
|
-
await write(ConfigImportSection.Filters, filter.name, () =>
|
|
632
|
-
repositories.filterAnchor.put({
|
|
633
|
-
accountConfigId,
|
|
634
|
-
filterId,
|
|
635
|
-
anchorEmbedding: embedded?.embedding ?? [],
|
|
636
|
-
anchorEmbeddingId: embedded?.embeddingId ?? ANCHOR_EMBEDDING_PENDING,
|
|
637
|
-
anchorSourceText: anchor.sourceText,
|
|
638
|
-
anchorMessageId: anchor.sourceMessageId,
|
|
639
|
-
}),
|
|
640
|
-
);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
572
|
// ---- address flags --------------------------------------------------
|
|
644
573
|
for (const entry of document.addressFlags) {
|
|
645
574
|
await write(ConfigImportSection.AddressFlags, entry.normalizedEmail, () =>
|
|
@@ -699,11 +628,10 @@ const applyDocument = async (
|
|
|
699
628
|
for (const ref of unresolvedRefs) {
|
|
700
629
|
if (ref.kind === ConfigImportRefKind.FilterAction) continue;
|
|
701
630
|
warnings.push(
|
|
702
|
-
problem(
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
),
|
|
631
|
+
problem("folder_not_found_yet", folderPendingMessage(ref.folderPath), {
|
|
632
|
+
folderPath: ref.folderPath,
|
|
633
|
+
accountId: ref.accountId,
|
|
634
|
+
}),
|
|
707
635
|
);
|
|
708
636
|
}
|
|
709
637
|
|
|
@@ -861,6 +789,7 @@ class SettingWriter {
|
|
|
861
789
|
accountId,
|
|
862
790
|
folderPath: role.folderPath,
|
|
863
791
|
target: role.role,
|
|
792
|
+
mailboxId: NO_ACTION,
|
|
864
793
|
});
|
|
865
794
|
continue;
|
|
866
795
|
}
|
|
@@ -881,6 +810,7 @@ class SettingWriter {
|
|
|
881
810
|
accountId,
|
|
882
811
|
folderPath: override.folderPath,
|
|
883
812
|
target: override.folderPath,
|
|
813
|
+
mailboxId: NO_ACTION,
|
|
884
814
|
});
|
|
885
815
|
continue;
|
|
886
816
|
}
|
|
@@ -914,8 +844,8 @@ class SettingWriter {
|
|
|
914
844
|
*
|
|
915
845
|
* The order is fixed: parse and migrate, check every reference and every
|
|
916
846
|
* natural key against what this configuration already holds, and only then
|
|
917
|
-
* write —
|
|
918
|
-
*
|
|
847
|
+
* write — accounts before labels before filters before
|
|
848
|
+
* addresses before settings, because each of those reads
|
|
919
849
|
* an id the one before it minted.
|
|
920
850
|
*
|
|
921
851
|
* A document this reader will not import is the operation's expected outcome,
|
|
@@ -979,19 +909,16 @@ export const importConfig = async (
|
|
|
979
909
|
};
|
|
980
910
|
}
|
|
981
911
|
|
|
982
|
-
// Before the transaction: an embed is a network round trip, and the write
|
|
983
|
-
// queue it would otherwise hold is process-wide.
|
|
984
|
-
const embedWarnings: ConfigImportProblem[] = [];
|
|
985
|
-
const anchorVectors = await embedAnchors(deps, document, embedWarnings);
|
|
986
|
-
|
|
987
|
-
const { transaction } = deps;
|
|
988
|
-
const atomic = transaction !== undefined;
|
|
989
912
|
const progress = emptyProgress();
|
|
990
|
-
const apply = (): Promise<string> =>
|
|
991
|
-
applyDocument(deps, input, document, existing, anchorVectors, progress);
|
|
992
913
|
|
|
993
914
|
try {
|
|
994
|
-
const importId = await (
|
|
915
|
+
const importId = await applyDocument(
|
|
916
|
+
deps,
|
|
917
|
+
input,
|
|
918
|
+
document,
|
|
919
|
+
existing,
|
|
920
|
+
progress,
|
|
921
|
+
);
|
|
995
922
|
return {
|
|
996
923
|
outcome: "report",
|
|
997
924
|
report: {
|
|
@@ -1001,16 +928,17 @@ export const importConfig = async (
|
|
|
1001
928
|
applied: true,
|
|
1002
929
|
items: progress.items,
|
|
1003
930
|
errors: [],
|
|
1004
|
-
warnings:
|
|
931
|
+
warnings: progress.warnings,
|
|
1005
932
|
accountsNeedingCredentials: progress.accountsNeedingCredentials,
|
|
1006
933
|
},
|
|
1007
934
|
};
|
|
1008
935
|
} catch (error) {
|
|
1009
936
|
if (!(error instanceof ImportWriteError)) throw error;
|
|
1010
937
|
// What the report owes the reader is where it stopped and what is now in
|
|
1011
|
-
// the store
|
|
1012
|
-
//
|
|
1013
|
-
|
|
938
|
+
// the store: everything recorded up to the failure. Every write is keyed on
|
|
939
|
+
// a natural key, so importing the same file again with merge folds the
|
|
940
|
+
// rest in over what landed.
|
|
941
|
+
const landed = progress;
|
|
1014
942
|
return {
|
|
1015
943
|
outcome: "report",
|
|
1016
944
|
report: {
|
|
@@ -1029,15 +957,11 @@ export const importConfig = async (
|
|
|
1029
957
|
errors: [
|
|
1030
958
|
problem(
|
|
1031
959
|
"import_write_failed",
|
|
1032
|
-
`${error.message}
|
|
1033
|
-
atomic
|
|
1034
|
-
? "Nothing was written; the import stopped here."
|
|
1035
|
-
: "The items above it were written and remain; the import stopped here."
|
|
1036
|
-
}`,
|
|
960
|
+
`${error.message} The items above it were written and remain; import the same file again with merge to finish.`,
|
|
1037
961
|
{ section: error.section, key: error.key },
|
|
1038
962
|
),
|
|
1039
963
|
],
|
|
1040
|
-
warnings:
|
|
964
|
+
warnings: landed.warnings,
|
|
1041
965
|
accountsNeedingCredentials: landed.accountsNeedingCredentials,
|
|
1042
966
|
},
|
|
1043
967
|
};
|
|
@@ -1102,10 +1026,19 @@ const plan = (
|
|
|
1102
1026
|
});
|
|
1103
1027
|
}
|
|
1104
1028
|
for (const filter of document.filters) {
|
|
1029
|
+
const { actionFolder } = filter;
|
|
1030
|
+
const awaiting =
|
|
1031
|
+
actionFolder !== null &&
|
|
1032
|
+
!foldersFor(existing, liveAccountId, actionFolder.accountId).has(
|
|
1033
|
+
actionFolder.folderPath,
|
|
1034
|
+
);
|
|
1105
1035
|
items.push({
|
|
1106
1036
|
section: ConfigImportSection.Filters,
|
|
1107
1037
|
key: filter.name,
|
|
1108
1038
|
verdict: verdict(heldFilters.has(normalize(filter.name))),
|
|
1039
|
+
...(awaiting && actionFolder
|
|
1040
|
+
? { reason: awaitingFolderReason(actionFolder.folderPath) }
|
|
1041
|
+
: {}),
|
|
1109
1042
|
});
|
|
1110
1043
|
}
|
|
1111
1044
|
for (const entry of document.addressFlags) {
|
|
@@ -1139,7 +1072,7 @@ const plan = (
|
|
|
1139
1072
|
warnings.push(
|
|
1140
1073
|
problem(
|
|
1141
1074
|
"folder_not_found_yet",
|
|
1142
|
-
|
|
1075
|
+
filterFolderPendingMessage(filter.name, actionFolder.folderPath),
|
|
1143
1076
|
{ filter: filter.name, folderPath: actionFolder.folderPath },
|
|
1144
1077
|
),
|
|
1145
1078
|
);
|
|
@@ -1155,11 +1088,10 @@ const plan = (
|
|
|
1155
1088
|
for (const folderPath of paths) {
|
|
1156
1089
|
if (folders.has(folderPath)) continue;
|
|
1157
1090
|
warnings.push(
|
|
1158
|
-
problem(
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
),
|
|
1091
|
+
problem("folder_not_found_yet", folderPendingMessage(folderPath), {
|
|
1092
|
+
folderPath,
|
|
1093
|
+
accountId,
|
|
1094
|
+
}),
|
|
1163
1095
|
);
|
|
1164
1096
|
}
|
|
1165
1097
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,8 @@ export {
|
|
|
3
3
|
bindImportedFolders,
|
|
4
4
|
type ConfigBinderDeps,
|
|
5
5
|
type ConfigBinderRepositories,
|
|
6
|
+
type CreateImportedFolder,
|
|
7
|
+
disableFiltersMissingFolders,
|
|
6
8
|
pendingImportOf,
|
|
7
9
|
} from "./binder.js";
|
|
8
10
|
export {
|
|
@@ -11,7 +13,6 @@ export {
|
|
|
11
13
|
readConfigForExport,
|
|
12
14
|
} from "./export.js";
|
|
13
15
|
export {
|
|
14
|
-
ANCHOR_EMBEDDING_PENDING,
|
|
15
16
|
type ImportConfigInput,
|
|
16
17
|
importConfig,
|
|
17
18
|
} from "./import.js";
|
|
@@ -19,7 +20,6 @@ export type {
|
|
|
19
20
|
AppointFolderRole,
|
|
20
21
|
ConfigImportDeps,
|
|
21
22
|
ConfigImportRepositories,
|
|
22
|
-
EmbedAnchor,
|
|
23
23
|
} from "./import-repositories.js";
|
|
24
24
|
export {
|
|
25
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
|