@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/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: on a backend
263
- * without a transaction those items are what actually landed, and a report that
264
- * threw them away would name a stopping point without naming the state.
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: the import is atomic, so nothing landed, and where 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, so preserving it is
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
- hasAnchor: filter.anchor !== null,
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
- filterIdByName.set(key, filterId);
590
- record(
591
- ConfigImportSection.Filters,
592
- filter.name,
593
- held ? ConfigImportVerdict.Updated : ConfigImportVerdict.Created,
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
- `Filter "${filter.name}" files mail into "${filter.actionFolder.folderPath}", which this account does not hold yet. It is bound once the folder list has been read.`,
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
- "folder_not_found_yet",
704
- `"${ref.folderPath}" is not a folder this account holds yet. It is bound once the folder list has been read.`,
705
- { folderPath: ref.folderPath, accountId: ref.accountId },
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 — inside one transaction, accounts before labels before filters before
918
- * their anchors before addresses before settings, because each of those reads
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 (transaction ? transaction(apply) : apply());
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: [...embedWarnings, ...progress.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. Under a transaction the answer to the second is "nothing";
1012
- // without one it is everything recorded up to the failure.
1013
- const landed = atomic ? emptyProgress() : progress;
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: [...embedWarnings, ...landed.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
- `Filter "${filter.name}" files mail into "${actionFolder.folderPath}", which this account does not hold yet. It is bound once the folder list has been read.`,
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
- "folder_not_found_yet",
1160
- `"${folderPath}" is not a folder this account holds yet. It is bound once the folder list has been read.`,
1161
- { folderPath, accountId },
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,
@@ -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