@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.
@@ -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,
@@ -17,7 +16,11 @@ import {
17
16
  composeFolderRoleAppointmentName,
18
17
  } from "@remit/data-ports/folder-role";
19
18
  import { deriveAddressId } from "@remit/data-ports/id";
20
- import { bindImportedFolders, pendingImportOf } from "./binder.js";
19
+ import {
20
+ bindImportedFolders,
21
+ disableFiltersMissingFolders,
22
+ pendingImportOf,
23
+ } from "./binder.js";
21
24
  import { readConfigForExport } from "./export.js";
22
25
  import {
23
26
  ACCOUNT_CONFIG_ID,
@@ -26,7 +29,6 @@ import {
26
29
  makeAccount,
27
30
  makeAccountConfig,
28
31
  makeAddress,
29
- makeAnchor,
30
32
  makeFilter,
31
33
  makeJunkOnlyFlaggedAddress,
32
34
  makeLabel,
@@ -37,7 +39,7 @@ import {
37
39
  PASSWORD_ACCOUNT_ID,
38
40
  suggestable,
39
41
  } from "./fixtures.js";
40
- import { ANCHOR_EMBEDDING_PENDING, importConfig } from "./import.js";
42
+ import { importConfig } from "./import.js";
41
43
  import type { ConfigImportDeps } from "./import-repositories.js";
42
44
 
43
45
  const IDENTITY = {
@@ -49,7 +51,6 @@ const IDENTITY = {
49
51
 
50
52
  const TARGET_CONFIG_ID = "1a2b3c4d5e6f7a8b9c0d1e2f3";
51
53
  const TARGET_USER_ID = "9f8e7d6c-5b4a-4938-8271-605f4e3d2c1b";
52
- const EMBEDDING_ID = "amazon.titan-embed-text-v2:0@1024";
53
54
 
54
55
  const INBOX_ID = "mbx-inbox";
55
56
  const SENT_ID = "mbx-sent";
@@ -70,7 +71,6 @@ interface Store {
70
71
  mailboxes: MailboxItem[];
71
72
  labels: LabelItem[];
72
73
  filters: FilterItem[];
73
- anchors: FilterAnchorItem[];
74
74
  addresses: AddressItem[];
75
75
  imports: ConfigImportItem[];
76
76
  /** Set to make the next write of this kind fail, for the abort path. */
@@ -88,7 +88,6 @@ const emptyStore = (): Store => ({
88
88
  mailboxes: [],
89
89
  labels: [],
90
90
  filters: [],
91
- anchors: [],
92
91
  addresses: [],
93
92
  imports: [],
94
93
  });
@@ -104,7 +103,6 @@ const storeFromFixture = (fixture: ConfigFixture): Store => ({
104
103
  mailboxes: [...fixture.mailboxes],
105
104
  labels: [...fixture.labels],
106
105
  filters: [...fixture.filters],
107
- anchors: [...fixture.anchors],
108
106
  addresses: [...fixture.addresses],
109
107
  imports: [],
110
108
  });
@@ -185,6 +183,9 @@ const repositoriesOf = (store: Store, accountConfigId: string): any => {
185
183
  mailbox: {
186
184
  listAllByAccount: async (accountId: string) =>
187
185
  store.mailboxes.filter((mailbox) => mailbox.accountId === accountId),
186
+ resolveAccountId: async (mailboxId: string) =>
187
+ store.mailboxes.find((mailbox) => mailbox.mailboxId === mailboxId)
188
+ ?.accountId ?? null,
188
189
  },
189
190
  label: {
190
191
  listByAccountConfig: async () => [...store.labels],
@@ -237,19 +238,6 @@ const repositoriesOf = (store: Store, accountConfigId: string): any => {
237
238
  return next;
238
239
  },
239
240
  },
240
- filterAnchor: {
241
- put: async (input: Partial<FilterAnchorItem> & { filterId: string }) => {
242
- const row = makeAnchor({ ...input, accountConfigId });
243
- store.anchors = [
244
- ...store.anchors.filter(
245
- (anchor) => anchor.filterId !== input.filterId,
246
- ),
247
- row,
248
- ];
249
- return row;
250
- },
251
- listByAccountConfig: async () => [...store.anchors],
252
- },
253
241
  address: {
254
242
  // Both reads, so the stub can tell them apart: the suggest listing
255
243
  // hides a junk-only row, and the import must not be reading it (#1029).
@@ -353,50 +341,47 @@ const appointFolderRoleInto =
353
341
  );
354
342
  };
355
343
 
356
- /**
357
- * A transaction that actually rolls back. The backend's `writeSet` discards
358
- * every write on a throw, so a fake that merely called through would let an
359
- * atomicity test pass over a store that had kept half a document — which is the
360
- * one thing these tests exist to catch. Each collection is restored to the
361
- * instance it had on entry.
362
- */
363
- const transactionOver =
344
+ const createFolderInto =
364
345
  (store: Store) =>
365
- async <T>(run: () => Promise<T>): Promise<T> => {
366
- const before = {
367
- accountConfigs: new Map(store.accountConfigs),
368
- accounts: [...store.accounts],
369
- settings: new Map(store.settings),
370
- mailboxes: [...store.mailboxes],
371
- labels: [...store.labels],
372
- filters: [...store.filters],
373
- anchors: [...store.anchors],
374
- addresses: [...store.addresses],
375
- imports: [...store.imports],
376
- };
377
- return run().catch((error: unknown) => {
378
- Object.assign(store, before);
379
- throw error;
346
+ async (
347
+ accountId: string,
348
+ fullPath: string,
349
+ ): Promise<{ outcome: "Created"; mailbox: MailboxItem }> => {
350
+ const row = makeMailbox({
351
+ mailboxId: nextId("mbx"),
352
+ accountId,
353
+ fullPath,
354
+ syncStatus: "pending",
380
355
  });
356
+ store.mailboxes.push(row);
357
+ return { outcome: "Created", mailbox: row };
381
358
  };
382
359
 
383
- const depsOf = (
360
+ const settle = (
384
361
  store: Store,
385
- accountConfigId: string,
386
- embed = true,
387
- ): ConfigImportDeps => ({
362
+ fullPath: string,
363
+ syncStatus: MailboxItem["syncStatus"],
364
+ confirmedPath: string = fullPath,
365
+ ): MailboxItem => {
366
+ const at = store.mailboxes.findIndex(
367
+ (mailbox) =>
368
+ mailbox.fullPath === fullPath && mailbox.syncStatus === "pending",
369
+ );
370
+ assert.notEqual(at, -1, `no pending create for ${fullPath}`);
371
+ const next = { ...store.mailboxes[at], syncStatus, fullPath: confirmedPath };
372
+ store.mailboxes[at] = next;
373
+ return next;
374
+ };
375
+
376
+ const depsOf = (store: Store, accountConfigId: string): ConfigImportDeps => ({
388
377
  repositories: repositoriesOf(store, accountConfigId),
389
378
  appointFolderRole: appointFolderRoleInto(store),
390
- transaction: transactionOver(store),
391
- embedAnchor: embed
392
- ? async () => ({ embedding: [0.11, 0.22, 0.33], embeddingId: EMBEDDING_ID })
393
- : undefined,
394
379
  now: () => NOW,
395
380
  });
396
381
 
397
382
  // ---------------------------------------------------------------------------
398
383
  // A configuration worth carrying across: two accounts, both auth types, folder
399
- // roles, per-folder overrides, labels, a literal and a semantic filter, and two
384
+ // roles, per-folder overrides, labels, two filters, and two
400
385
  // flagged senders.
401
386
  // ---------------------------------------------------------------------------
402
387
 
@@ -504,14 +489,12 @@ const sourceFixture = (): ConfigFixture => ({
504
489
  actionMailboxId: INVOICES_ID,
505
490
  }),
506
491
  makeFilter({
507
- filterId: "flt-semantic",
508
- name: "Anything like this release note",
492
+ filterId: "flt-release",
493
+ name: "Release notes",
509
494
  scope: "Temporary",
510
495
  expiresAt: "2026-12-31T23:59:59+01:00",
511
- hasAnchor: true,
512
496
  }),
513
497
  ],
514
- anchors: [makeAnchor({ filterId: "flt-semantic" })],
515
498
  addresses: [
516
499
  makeAddress({
517
500
  addressId: "adr-bank",
@@ -602,6 +585,7 @@ test("a configuration survives an export, an import and a discovery unchanged",
602
585
  const binder = {
603
586
  repositories: repositoriesOf(store, TARGET_CONFIG_ID),
604
587
  appointFolderRole: appointFolderRoleInto(store),
588
+ createFolder: createFolderInto(store),
605
589
  now: () => NOW,
606
590
  };
607
591
  await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
@@ -828,7 +812,7 @@ test("a flag that arrives at the sentinel is stored at the sentinel", async () =
828
812
  assert.equal(legacy?.flags?.category?.setAt, 0);
829
813
  });
830
814
 
831
- test("a folder no mailbox answers to yet is kept, then bound when discovery finds it", async () => {
815
+ test("a folder discovery did not produce is created, and bound once the server confirms it", async () => {
832
816
  const document = await exportSource();
833
817
  const store = emptyStore();
834
818
 
@@ -849,6 +833,7 @@ test("a folder no mailbox answers to yet is kept, then bound when discovery find
849
833
  const binder = {
850
834
  repositories: repositoriesOf(store, TARGET_CONFIG_ID),
851
835
  appointFolderRole: appointFolderRoleInto(store),
836
+ createFolder: createFolderInto(store),
852
837
  now: () => NOW,
853
838
  };
854
839
  const first = await bindImportedFolders(
@@ -858,6 +843,7 @@ test("a folder no mailbox answers to yet is kept, then bound when discovery find
858
843
  );
859
844
 
860
845
  assert.equal(first.bound > 0, true);
846
+ assert.equal(first.created, 2);
861
847
  assert.equal(first.stillPending > 0, true);
862
848
  const invoices = store.mailboxes.find(
863
849
  (mailbox) => mailbox.fullPath === "INBOX.Facturen",
@@ -868,16 +854,28 @@ test("a folder no mailbox answers to yet is kept, then bound when discovery find
868
854
  invoices?.mailboxId,
869
855
  );
870
856
 
871
- // Replayable: a second discovery over the same folders changes nothing.
857
+ // Replayable: a second discovery over the same folders creates nothing again.
872
858
  const again = await bindImportedFolders(
873
859
  binder,
874
860
  TARGET_CONFIG_ID,
875
861
  PASSWORD_ACCOUNT_ID,
876
862
  );
877
863
  assert.equal(again.bound, 0);
864
+ assert.equal(again.created, 0);
865
+ assert.equal(
866
+ store.mailboxes.filter((mailbox) => mailbox.fullPath === "INBOX.Sent")
867
+ .length,
868
+ 1,
869
+ );
878
870
  assert.equal(store.imports[0].state, "Pending");
879
871
 
880
- discover(store, PASSWORD_ACCOUNT_ID, ["INBOX.Sent", "INBOX.Lists.dev-null"]);
872
+ settle(store, "INBOX.Sent", "synced");
873
+ const lists = settle(
874
+ store,
875
+ "INBOX.Lists.dev-null",
876
+ "synced",
877
+ "INBOX.Lists.dev-null-normalized",
878
+ );
881
879
  discover(store, OAUTH_ACCOUNT_ID, ["INBOX"]);
882
880
  await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
883
881
 
@@ -890,15 +888,499 @@ test("a folder no mailbox answers to yet is kept, then bound when discovery find
890
888
  )?.value.kind,
891
889
  "String",
892
890
  );
893
- const lists = store.mailboxes.find(
894
- (mailbox) => mailbox.fullPath === "INBOX.Lists.dev-null",
895
- );
896
891
  assert.equal(
897
- store.settings.get(`MailboxDisplayName#${lists?.mailboxId}`)?.value.value,
892
+ store.settings.get(`MailboxDisplayName#${lists.mailboxId}`)?.value.value,
898
893
  "dev/null",
899
894
  );
900
895
  });
901
896
 
897
+ test("a filter whose folder is missing lands disabled, awaiting the folder, and the report says so", async () => {
898
+ const document = await exportSource();
899
+ const store = emptyStore();
900
+
901
+ const report = reportOf(await apply(store, document));
902
+
903
+ const invoices = store.filters.find(
904
+ (filter) => filter.name === "Invoices to Facturen",
905
+ );
906
+ assert.equal(invoices?.state, "Disabled");
907
+ assert.equal(invoices?.disabledReason, "AwaitingFolder");
908
+ assert.match(
909
+ report.items.find((item) => item.key === "Invoices to Facturen")?.reason ??
910
+ "",
911
+ /Off until "INBOX\.Facturen" exists/,
912
+ );
913
+ });
914
+
915
+ test("the dry run names the filters an apply would leave off", async () => {
916
+ const document = await exportSource();
917
+ const store = emptyStore();
918
+
919
+ const outcome = await importConfig(depsOf(store, TARGET_CONFIG_ID), {
920
+ accountConfigId: TARGET_CONFIG_ID,
921
+ userId: TARGET_USER_ID,
922
+ document,
923
+ mode: "validate",
924
+ onExisting: "abort",
925
+ });
926
+
927
+ assert.match(
928
+ reportOf(outcome).items.find((item) => item.key === "Invoices to Facturen")
929
+ ?.reason ?? "",
930
+ /Off until "INBOX\.Facturen" exists/,
931
+ );
932
+ });
933
+
934
+ test("an imported filter turns on once the folder the binder created settles, under the path the server chose", async () => {
935
+ const document = await exportSource();
936
+ const store = emptyStore();
937
+ await apply(store, document);
938
+
939
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX", "INBOX.Sent"]);
940
+ const binder = {
941
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
942
+ appointFolderRole: appointFolderRoleInto(store),
943
+ createFolder: createFolderInto(store),
944
+ now: () => NOW,
945
+ };
946
+ await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
947
+
948
+ const waiting = store.filters.find(
949
+ (filter) => filter.name === "Invoices to Facturen",
950
+ );
951
+ assert.equal(waiting?.state, "Disabled");
952
+ assert.equal(waiting?.disabledReason, "AwaitingFolder");
953
+ assert.equal(waiting?.actionMailboxId, "None");
954
+
955
+ const created = settle(store, "INBOX.Facturen", "synced", "INBOX/Facturen");
956
+ await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
957
+
958
+ const bound = store.filters.find(
959
+ (filter) => filter.name === "Invoices to Facturen",
960
+ );
961
+ assert.equal(bound?.state, "Active");
962
+ assert.equal(bound?.disabledReason, "None");
963
+ assert.equal(bound?.actionMailboxId, created.mailboxId);
964
+ });
965
+
966
+ test("a folder the server refused to create turns its filter off with FolderCreateFailed", async () => {
967
+ const document = await exportSource();
968
+ const store = emptyStore();
969
+ await apply(store, document);
970
+
971
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX", "INBOX.Sent"]);
972
+ const binder = {
973
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
974
+ appointFolderRole: appointFolderRoleInto(store),
975
+ createFolder: createFolderInto(store),
976
+ now: () => NOW,
977
+ };
978
+ await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
979
+ settle(store, "INBOX.Facturen", "failed");
980
+
981
+ const result = await bindImportedFolders(
982
+ binder,
983
+ TARGET_CONFIG_ID,
984
+ PASSWORD_ACCOUNT_ID,
985
+ );
986
+
987
+ assert.equal(result.disabled, 1);
988
+ const refused = store.filters.find(
989
+ (filter) => filter.name === "Invoices to Facturen",
990
+ );
991
+ assert.equal(refused?.state, "Disabled");
992
+ assert.equal(refused?.disabledReason, "FolderCreateFailed");
993
+ assert.equal(
994
+ pendingImportOf(store.imports)?.folderPaths.includes("INBOX.Facturen") ??
995
+ false,
996
+ false,
997
+ );
998
+ });
999
+
1000
+ test("a created folder that disappears before it settles means the create never landed", async () => {
1001
+ const document = await exportSource();
1002
+ const store = emptyStore();
1003
+ await apply(store, document);
1004
+
1005
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX", "INBOX.Sent"]);
1006
+ const binder = {
1007
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1008
+ appointFolderRole: appointFolderRoleInto(store),
1009
+ createFolder: createFolderInto(store),
1010
+ now: () => NOW,
1011
+ };
1012
+ await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
1013
+ store.mailboxes = store.mailboxes.filter(
1014
+ (mailbox) => mailbox.fullPath !== "INBOX.Facturen",
1015
+ );
1016
+
1017
+ const result = await bindImportedFolders(
1018
+ binder,
1019
+ TARGET_CONFIG_ID,
1020
+ PASSWORD_ACCOUNT_ID,
1021
+ );
1022
+
1023
+ assert.equal(result.disabled, 1);
1024
+ const refused = store.filters.find(
1025
+ (filter) => filter.name === "Invoices to Facturen",
1026
+ );
1027
+ assert.equal(refused?.state, "Disabled");
1028
+ assert.equal(refused?.disabledReason, "FolderCreateFailed");
1029
+ });
1030
+
1031
+ test("a create that finds its path already taken binds the existing folder on the next sync", async () => {
1032
+ const document = await exportSource();
1033
+ const store = emptyStore();
1034
+ await apply(store, document);
1035
+
1036
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX", "INBOX.Sent"]);
1037
+ const binder = {
1038
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1039
+ appointFolderRole: appointFolderRoleInto(store),
1040
+ createFolder: async () => ({ outcome: "PathTaken" as const }),
1041
+ now: () => NOW,
1042
+ };
1043
+ const first = await bindImportedFolders(
1044
+ binder,
1045
+ TARGET_CONFIG_ID,
1046
+ PASSWORD_ACCOUNT_ID,
1047
+ );
1048
+
1049
+ assert.equal(first.created, 0);
1050
+ assert.equal(
1051
+ store.imports[0].unresolvedRefs.find(
1052
+ (ref) => ref.folderPath === "INBOX.Facturen",
1053
+ )?.mailboxId,
1054
+ "None",
1055
+ );
1056
+
1057
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX.Facturen"]);
1058
+ await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
1059
+
1060
+ const existing = store.mailboxes.find(
1061
+ (mailbox) => mailbox.fullPath === "INBOX.Facturen",
1062
+ );
1063
+ const bound = store.filters.find(
1064
+ (filter) => filter.name === "Invoices to Facturen",
1065
+ );
1066
+ assert.equal(bound?.state, "Active");
1067
+ assert.equal(bound?.actionMailboxId, existing?.mailboxId);
1068
+ });
1069
+
1070
+ const withUnprefixedPaths = async () => {
1071
+ const document = (await exportSource()) as unknown as {
1072
+ accounts: {
1073
+ folderRoles: { role: string; folderPath: string }[];
1074
+ }[];
1075
+ filters: { actionFolder: { folderPath: string } | null }[];
1076
+ };
1077
+ for (const filter of document.filters) {
1078
+ if (filter.actionFolder?.folderPath === "INBOX.Facturen") {
1079
+ filter.actionFolder.folderPath = "Facturen";
1080
+ }
1081
+ }
1082
+ for (const account of document.accounts) {
1083
+ for (const role of account.folderRoles) {
1084
+ if (role.folderPath === "INBOX.Sent") role.folderPath = "Sent";
1085
+ }
1086
+ }
1087
+ return document;
1088
+ };
1089
+
1090
+ const discoverUnderInbox = (store: Store, paths: readonly string[]): void => {
1091
+ for (const fullPath of ["INBOX", ...paths]) {
1092
+ store.mailboxes.push(
1093
+ makeMailbox({
1094
+ mailboxId: nextId("mbx"),
1095
+ accountId: PASSWORD_ACCOUNT_ID,
1096
+ fullPath,
1097
+ namespacePrefix: "INBOX.",
1098
+ }),
1099
+ );
1100
+ }
1101
+ };
1102
+
1103
+ test("a folder the account already holds under its namespace is bound, not created", async () => {
1104
+ const store = emptyStore();
1105
+ await apply(store, await withUnprefixedPaths());
1106
+
1107
+ discoverUnderInbox(store, ["INBOX.Facturen", "INBOX.Sent"]);
1108
+ const result = await bindImportedFolders(
1109
+ {
1110
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1111
+ appointFolderRole: appointFolderRoleInto(store),
1112
+ createFolder: createFolderInto(store),
1113
+ now: () => NOW,
1114
+ },
1115
+ TARGET_CONFIG_ID,
1116
+ PASSWORD_ACCOUNT_ID,
1117
+ );
1118
+
1119
+ const facturen = store.mailboxes.find(
1120
+ (mailbox) => mailbox.fullPath === "INBOX.Facturen",
1121
+ );
1122
+ const sent = store.mailboxes.find(
1123
+ (mailbox) => mailbox.fullPath === "INBOX.Sent",
1124
+ );
1125
+ assert.equal(
1126
+ store.mailboxes.some((mailbox) => mailbox.fullPath === "Facturen"),
1127
+ false,
1128
+ );
1129
+ assert.equal(result.disabled, 0);
1130
+ const bound = store.filters.find(
1131
+ (filter) => filter.name === "Invoices to Facturen",
1132
+ );
1133
+ assert.equal(bound?.state, "Active");
1134
+ assert.equal(bound?.actionMailboxId, facturen?.mailboxId);
1135
+ assert.equal(
1136
+ store.settings.get(
1137
+ composeFolderRoleAppointmentName(PASSWORD_ACCOUNT_ID, "Sent"),
1138
+ )?.value.value,
1139
+ sent?.mailboxId,
1140
+ );
1141
+ });
1142
+
1143
+ test("a created folder the worker folds into one the server already held binds to that one", async () => {
1144
+ const store = emptyStore();
1145
+ await apply(store, await withUnprefixedPaths());
1146
+
1147
+ discoverUnderInbox(store, ["INBOX.Sent"]);
1148
+ const binder = {
1149
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1150
+ appointFolderRole: appointFolderRoleInto(store),
1151
+ createFolder: createFolderInto(store),
1152
+ now: () => NOW,
1153
+ };
1154
+ const first = await bindImportedFolders(
1155
+ binder,
1156
+ TARGET_CONFIG_ID,
1157
+ PASSWORD_ACCOUNT_ID,
1158
+ );
1159
+ assert.equal(first.created > 0, true);
1160
+
1161
+ store.mailboxes = store.mailboxes.filter(
1162
+ (mailbox) => mailbox.fullPath !== "Facturen",
1163
+ );
1164
+ const holder = makeMailbox({
1165
+ mailboxId: nextId("mbx"),
1166
+ accountId: PASSWORD_ACCOUNT_ID,
1167
+ fullPath: "INBOX.Facturen",
1168
+ namespacePrefix: "INBOX.",
1169
+ });
1170
+ store.mailboxes.push(holder);
1171
+
1172
+ const second = await bindImportedFolders(
1173
+ binder,
1174
+ TARGET_CONFIG_ID,
1175
+ PASSWORD_ACCOUNT_ID,
1176
+ );
1177
+
1178
+ assert.equal(second.disabled, 0);
1179
+ const bound = store.filters.find(
1180
+ (filter) => filter.name === "Invoices to Facturen",
1181
+ );
1182
+ assert.equal(bound?.state, "Active");
1183
+ assert.equal(bound?.disabledReason, "None");
1184
+ assert.equal(bound?.actionMailboxId, holder.mailboxId);
1185
+ });
1186
+
1187
+ test("a filter the user pointed at a folder keeps it when the imported folder appears", async () => {
1188
+ const document = await exportSource();
1189
+ const store = emptyStore();
1190
+ await apply(store, document);
1191
+
1192
+ const invoices = store.filters.find(
1193
+ (filter) => filter.name === "Invoices to Facturen",
1194
+ );
1195
+ assert.ok(invoices);
1196
+ invoices.actionMailboxId = "mbx-picked";
1197
+
1198
+ discover(store, PASSWORD_ACCOUNT_ID, [
1199
+ "INBOX",
1200
+ "INBOX.Sent",
1201
+ "INBOX.Facturen",
1202
+ ]);
1203
+ await bindImportedFolders(
1204
+ {
1205
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1206
+ appointFolderRole: appointFolderRoleInto(store),
1207
+ createFolder: createFolderInto(store),
1208
+ now: () => NOW,
1209
+ },
1210
+ TARGET_CONFIG_ID,
1211
+ PASSWORD_ACCOUNT_ID,
1212
+ );
1213
+
1214
+ const picked = store.filters.find(
1215
+ (filter) => filter.name === "Invoices to Facturen",
1216
+ );
1217
+ assert.equal(picked?.actionMailboxId, "mbx-picked");
1218
+ assert.equal(picked?.state, "Disabled");
1219
+ assert.equal(picked?.disabledReason, "UserDisabled");
1220
+ assert.equal(
1221
+ store.imports[0].unresolvedRefs.some(
1222
+ (ref) => ref.target === invoices.filterId,
1223
+ ),
1224
+ false,
1225
+ );
1226
+ });
1227
+
1228
+ test("a filter the user already turned on with their own folder survives the import's failed create", async () => {
1229
+ const document = await exportSource();
1230
+ const store = emptyStore();
1231
+ await apply(store, document);
1232
+
1233
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX", "INBOX.Sent"]);
1234
+ const binder = {
1235
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1236
+ appointFolderRole: appointFolderRoleInto(store),
1237
+ createFolder: createFolderInto(store),
1238
+ now: () => NOW,
1239
+ };
1240
+ await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
1241
+
1242
+ const invoices = store.filters.find(
1243
+ (filter) => filter.name === "Invoices to Facturen",
1244
+ );
1245
+ assert.ok(invoices);
1246
+ Object.assign(invoices, {
1247
+ actionMailboxId: "mbx-picked",
1248
+ state: "Active",
1249
+ disabledReason: "None",
1250
+ });
1251
+ settle(store, "INBOX.Facturen", "failed");
1252
+
1253
+ const result = await bindImportedFolders(
1254
+ binder,
1255
+ TARGET_CONFIG_ID,
1256
+ PASSWORD_ACCOUNT_ID,
1257
+ );
1258
+
1259
+ assert.equal(result.disabled, 0);
1260
+ const kept = store.filters.find(
1261
+ (filter) => filter.name === "Invoices to Facturen",
1262
+ );
1263
+ assert.equal(kept?.state, "Active");
1264
+ assert.equal(kept?.actionMailboxId, "mbx-picked");
1265
+ });
1266
+
1267
+ test("a failed rename of the created folder is not read as a failed create", async () => {
1268
+ const document = await exportSource();
1269
+ const store = emptyStore();
1270
+ await apply(store, document);
1271
+
1272
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX", "INBOX.Sent"]);
1273
+ const binder = {
1274
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1275
+ appointFolderRole: appointFolderRoleInto(store),
1276
+ createFolder: createFolderInto(store),
1277
+ now: () => NOW,
1278
+ };
1279
+ await bindImportedFolders(binder, TARGET_CONFIG_ID, PASSWORD_ACCOUNT_ID);
1280
+ const created = settle(store, "INBOX.Facturen", "failed");
1281
+ Object.assign(created, { pendingPath: "INBOX.Invoices" });
1282
+
1283
+ const result = await bindImportedFolders(
1284
+ binder,
1285
+ TARGET_CONFIG_ID,
1286
+ PASSWORD_ACCOUNT_ID,
1287
+ );
1288
+
1289
+ assert.equal(result.disabled, 0);
1290
+ const waiting = store.filters.find(
1291
+ (filter) => filter.name === "Invoices to Facturen",
1292
+ );
1293
+ assert.equal(waiting?.disabledReason, "AwaitingFolder");
1294
+ assert.equal(
1295
+ store.imports[0].unresolvedRefs.some(
1296
+ (ref) => ref.mailboxId === created.mailboxId,
1297
+ ),
1298
+ true,
1299
+ );
1300
+ });
1301
+
1302
+ test("a folder still pending on the server is waited for, never bound", async () => {
1303
+ const document = await exportSource();
1304
+ const store = emptyStore();
1305
+ await apply(store, document);
1306
+
1307
+ store.mailboxes.push(
1308
+ makeMailbox({
1309
+ mailboxId: nextId("mbx"),
1310
+ accountId: PASSWORD_ACCOUNT_ID,
1311
+ fullPath: "INBOX.Facturen",
1312
+ syncStatus: "pending",
1313
+ }),
1314
+ );
1315
+ discover(store, PASSWORD_ACCOUNT_ID, ["INBOX", "INBOX.Sent"]);
1316
+ const result = await bindImportedFolders(
1317
+ {
1318
+ repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1319
+ appointFolderRole: appointFolderRoleInto(store),
1320
+ createFolder: createFolderInto(store),
1321
+ now: () => NOW,
1322
+ },
1323
+ TARGET_CONFIG_ID,
1324
+ PASSWORD_ACCOUNT_ID,
1325
+ );
1326
+
1327
+ assert.equal(
1328
+ store.mailboxes.filter((mailbox) => mailbox.fullPath === "INBOX.Facturen")
1329
+ .length,
1330
+ 1,
1331
+ );
1332
+ assert.equal(result.stillPending > 0, true);
1333
+ assert.equal(
1334
+ store.filters.find((filter) => filter.name === "Invoices to Facturen")
1335
+ ?.actionMailboxId,
1336
+ "None",
1337
+ );
1338
+ });
1339
+
1340
+ test("a running filter whose folder another client deleted turns off with FolderMissing", async () => {
1341
+ const store = emptyStore();
1342
+ store.mailboxes.push(
1343
+ makeMailbox({
1344
+ mailboxId: INVOICES_ID,
1345
+ accountId: PASSWORD_ACCOUNT_ID,
1346
+ fullPath: "INBOX.Facturen",
1347
+ }),
1348
+ );
1349
+ store.filters.push(
1350
+ makeFilter({
1351
+ filterId: "flt-live",
1352
+ name: "Live",
1353
+ actionMailboxId: INVOICES_ID,
1354
+ }),
1355
+ makeFilter({
1356
+ filterId: "flt-gone",
1357
+ name: "Gone",
1358
+ actionMailboxId: LISTS_ID,
1359
+ }),
1360
+ makeFilter({
1361
+ filterId: "flt-off",
1362
+ name: "Off",
1363
+ state: "Disabled",
1364
+ disabledReason: "UserDisabled",
1365
+ actionMailboxId: SENT_ID,
1366
+ }),
1367
+ );
1368
+
1369
+ const disabled = await disableFiltersMissingFolders(
1370
+ repositoriesOf(store, TARGET_CONFIG_ID),
1371
+ TARGET_CONFIG_ID,
1372
+ );
1373
+
1374
+ assert.equal(disabled, 1);
1375
+ const byId = new Map(
1376
+ store.filters.map((filter) => [filter.filterId, filter]),
1377
+ );
1378
+ assert.equal(byId.get("flt-live")?.state, "Active");
1379
+ assert.equal(byId.get("flt-gone")?.state, "Disabled");
1380
+ assert.equal(byId.get("flt-gone")?.disabledReason, "FolderMissing");
1381
+ assert.equal(byId.get("flt-off")?.disabledReason, "UserDisabled");
1382
+ });
1383
+
902
1384
  test("an import with no folders to wait for is complete the moment it lands", async () => {
903
1385
  const document = await exportSource();
904
1386
  const store = emptyStore();
@@ -1032,67 +1514,6 @@ test("a filter filing into an account the file does not carry is refused", async
1032
1514
  assert.equal(report.errors[0]?.code, "unknown_account");
1033
1515
  });
1034
1516
 
1035
- test("an anchor this deployment cannot embed is stored as text for the repair to pick up", async () => {
1036
- const document = await exportSource();
1037
- const store = emptyStore();
1038
-
1039
- const outcome = await importConfig(depsOf(store, TARGET_CONFIG_ID, false), {
1040
- accountConfigId: TARGET_CONFIG_ID,
1041
- userId: TARGET_USER_ID,
1042
- document,
1043
- mode: "apply",
1044
- onExisting: "abort",
1045
- });
1046
- const report = reportOf(outcome);
1047
-
1048
- const anchor = store.anchors[0];
1049
- assert.equal(anchor.anchorEmbeddingId, ANCHOR_EMBEDDING_PENDING);
1050
- assert.deepEqual(anchor.anchorEmbedding, []);
1051
- assert.equal(
1052
- anchor.anchorSourceText,
1053
- "the release note this filter was drawn from",
1054
- );
1055
- assert.equal(
1056
- report.warnings.some((warning) => warning.code === "anchor_not_embedded"),
1057
- true,
1058
- );
1059
- });
1060
-
1061
- test("an embedder that fails costs the anchor its vector, not the import", async () => {
1062
- const document = await exportSource();
1063
- const store = emptyStore();
1064
- const deps = depsOf(store, TARGET_CONFIG_ID);
1065
-
1066
- const report = reportOf(
1067
- await importConfig(
1068
- {
1069
- ...deps,
1070
- embedAnchor: () => Promise.reject(new Error("embedder unavailable")),
1071
- },
1072
- {
1073
- accountConfigId: TARGET_CONFIG_ID,
1074
- userId: TARGET_USER_ID,
1075
- document,
1076
- mode: "apply",
1077
- onExisting: "abort",
1078
- },
1079
- ),
1080
- );
1081
-
1082
- assert.equal(report.applied, true);
1083
- assert.deepEqual(report.errors, []);
1084
- assert.equal(
1085
- report.warnings.some((warning) => warning.code === "anchor_not_embedded"),
1086
- true,
1087
- );
1088
- assert.equal(store.anchors[0]?.anchorEmbeddingId, ANCHOR_EMBEDDING_PENDING);
1089
- assert.equal(
1090
- store.anchors[0]?.anchorSourceText,
1091
- "the release note this filter was drawn from",
1092
- );
1093
- assert.equal(store.accounts.length > 0, true);
1094
- });
1095
-
1096
1517
  test("the dry run names the same folders and credentials the apply does", async () => {
1097
1518
  const document = await exportSource();
1098
1519
  const planStore = emptyStore();
@@ -1121,7 +1542,7 @@ test("the dry run names the same folders and credentials the apply does", async
1121
1542
  assert.deepEqual(planStore.accounts, []);
1122
1543
  });
1123
1544
 
1124
- test("a write that fails after validation leaves nothing behind and names where it stopped", async () => {
1545
+ test("a write that fails part-way reports what it did leave behind and names where it stopped", async () => {
1125
1546
  const document = await exportSource();
1126
1547
  const store = emptyStore();
1127
1548
  store.failOn = "filter";
@@ -1129,40 +1550,8 @@ test("a write that fails after validation leaves nothing behind and names where
1129
1550
  const report = reportOf(await apply(store, document));
1130
1551
 
1131
1552
  assert.equal(report.valid, true);
1132
- assert.equal(report.applied, false);
1133
1553
  assert.equal(report.errors[0]?.code, "import_write_failed");
1134
1554
  assert.equal(report.errors[0]?.details?.section, "filters");
1135
- assert.match(report.errors[0]?.message ?? "", /Nothing was written/);
1136
- assert.equal(report.items[0]?.verdict, "rejected");
1137
-
1138
- // Filters fail after accounts and labels have been written, so these are the
1139
- // assertions that make the claim: the rollback undid them too.
1140
- assert.deepEqual(store.imports, []);
1141
- assert.deepEqual(store.accounts, []);
1142
- assert.deepEqual(store.labels, []);
1143
- assert.deepEqual(store.filters, []);
1144
- assert.equal(store.accountConfigs.size, 0);
1145
- });
1146
-
1147
- test("a write that fails without a transaction reports what it did leave behind", async () => {
1148
- const document = await exportSource();
1149
- const store = emptyStore();
1150
- store.failOn = "filter";
1151
-
1152
- const deps = depsOf(store, TARGET_CONFIG_ID);
1153
- const report = reportOf(
1154
- await importConfig(
1155
- { ...deps, transaction: undefined },
1156
- {
1157
- accountConfigId: TARGET_CONFIG_ID,
1158
- userId: TARGET_USER_ID,
1159
- document,
1160
- mode: "apply",
1161
- onExisting: "abort",
1162
- },
1163
- ),
1164
- );
1165
-
1166
1555
  assert.equal(report.applied, true);
1167
1556
  assert.match(report.errors[0]?.message ?? "", /were written and remain/);
1168
1557
  assert.equal(
@@ -1174,6 +1563,29 @@ test("a write that fails without a transaction reports what it did leave behind"
1174
1563
  assert.equal(store.accounts.length > 0, true);
1175
1564
  });
1176
1565
 
1566
+ test("a stopped import imported again with merge reaches what one clean import holds", async () => {
1567
+ const document = await exportSource();
1568
+ const clean = emptyStore();
1569
+ await apply(clean, document);
1570
+
1571
+ const store = emptyStore();
1572
+ store.failOn = "filter";
1573
+ await apply(store, document);
1574
+ store.failOn = undefined;
1575
+ const report = reportOf(await apply(store, document, "merge"));
1576
+
1577
+ assert.deepEqual(report.errors, []);
1578
+ const held = (target: Store) => ({
1579
+ accounts: target.accounts.map((account) => account.email).sort(),
1580
+ labels: target.labels.map((label) => label.name).sort(),
1581
+ filters: target.filters.length,
1582
+ addresses: target.addresses.length,
1583
+ settings: [...target.settings.keys()].sort(),
1584
+ });
1585
+ assert.deepEqual(held(store), held(clean));
1586
+ assert.ok(held(clean).filters > 0, "the file carries filters to land");
1587
+ });
1588
+
1177
1589
  test("the configuration row is created when the database holds none, and named from the file", async () => {
1178
1590
  const document = await exportSource();
1179
1591
  const store = emptyStore();
@@ -1226,12 +1638,19 @@ test("a binder run on a configuration with no import does nothing", async () =>
1226
1638
  {
1227
1639
  repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1228
1640
  appointFolderRole: appointFolderRoleInto(store),
1641
+ createFolder: createFolderInto(store),
1229
1642
  now: () => NOW,
1230
1643
  },
1231
1644
  TARGET_CONFIG_ID,
1232
1645
  PASSWORD_ACCOUNT_ID,
1233
1646
  );
1234
- assert.deepEqual(result, { bound: 0, dropped: 0, stillPending: 0 });
1647
+ assert.deepEqual(result, {
1648
+ bound: 0,
1649
+ dropped: 0,
1650
+ created: 0,
1651
+ disabled: 0,
1652
+ stillPending: 0,
1653
+ });
1235
1654
  assert.equal(pendingImportOf([]), undefined);
1236
1655
  });
1237
1656
 
@@ -1258,6 +1677,7 @@ test("a filter deleted before its folder is discovered drops its reference inste
1258
1677
  const binder = {
1259
1678
  repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1260
1679
  appointFolderRole: appointFolderRoleInto(store),
1680
+ createFolder: createFolderInto(store),
1261
1681
  now: () => NOW,
1262
1682
  };
1263
1683
 
@@ -1282,7 +1702,13 @@ test("a filter deleted before its folder is discovered drops its reference inste
1282
1702
  TARGET_CONFIG_ID,
1283
1703
  PASSWORD_ACCOUNT_ID,
1284
1704
  );
1285
- assert.deepEqual(again, { bound: 0, dropped: 0, stillPending: 0 });
1705
+ assert.deepEqual(again, {
1706
+ bound: 0,
1707
+ dropped: 0,
1708
+ created: 0,
1709
+ disabled: 0,
1710
+ stillPending: 0,
1711
+ });
1286
1712
  });
1287
1713
 
1288
1714
  test("a deleted filter's reference is dropped even when its folder never appears", async () => {
@@ -1303,6 +1729,7 @@ test("a deleted filter's reference is dropped even when its folder never appears
1303
1729
  {
1304
1730
  repositories: repositoriesOf(store, TARGET_CONFIG_ID),
1305
1731
  appointFolderRole: appointFolderRoleInto(store),
1732
+ createFolder: createFolderInto(store),
1306
1733
  now: () => NOW,
1307
1734
  },
1308
1735
  TARGET_CONFIG_ID,