@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/config-transfer",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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-semantic",
162
- name: "Anything like this release note",
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 v1 schema accepts", async () => {
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, 1);
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, semantic] = document.filters;
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(semantic?.actionLabelName, null);
394
- assert.equal(semantic?.actionFolder, null);
395
- assert.equal(semantic?.expiresAt, "2026-12-31T23:59:59+01:00");
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, anchors] =
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
  }
@@ -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 { ANCHOR_EMBEDDING_PENDING, importConfig } from "./import.js";
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, a literal and a semantic filter, and two
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-semantic",
547
- name: "Anything like this release note",
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 after validation leaves nothing behind and names where it stopped", async () => {
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
- TARGET_USER_ID,
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: on a backend
274
- * without a transaction those items are what actually landed, and a report that
275
- * threw them away would name a stopping point without naming the state.
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: the import is atomic, so nothing landed, and where 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
- ensureAccountConfig(deps, input, document),
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, so preserving it is
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 before anything can hang off 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 ensureAccountConfig = async (
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
- // Absence is the expected answer on a fresh instance and the only one that
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 — inside one transaction, accounts before labels before filters before
943
- * their anchors before addresses before settings, because each of those reads
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 (transaction ? transaction(apply) : apply());
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: [...embedWarnings, ...progress.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. Under a transaction the answer to the second is "nothing";
1037
- // without one it is everything recorded up to the failure.
1038
- const landed = atomic ? emptyProgress() : progress;
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: [...embedWarnings, ...landed.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,
@@ -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