@remit/config-transfer 0.0.11 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/config-transfer",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
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 = {
@@ -53,7 +51,6 @@ const IDENTITY = {
53
51
 
54
52
  const TARGET_CONFIG_ID = "1a2b3c4d5e6f7a8b9c0d1e2f3";
55
53
  const TARGET_USER_ID = "9f8e7d6c-5b4a-4938-8271-605f4e3d2c1b";
56
- const EMBEDDING_ID = "amazon.titan-embed-text-v2:0@1024";
57
54
 
58
55
  const INBOX_ID = "mbx-inbox";
59
56
  const SENT_ID = "mbx-sent";
@@ -74,7 +71,6 @@ interface Store {
74
71
  mailboxes: MailboxItem[];
75
72
  labels: LabelItem[];
76
73
  filters: FilterItem[];
77
- anchors: FilterAnchorItem[];
78
74
  addresses: AddressItem[];
79
75
  imports: ConfigImportItem[];
80
76
  /** Set to make the next write of this kind fail, for the abort path. */
@@ -92,7 +88,6 @@ const emptyStore = (): Store => ({
92
88
  mailboxes: [],
93
89
  labels: [],
94
90
  filters: [],
95
- anchors: [],
96
91
  addresses: [],
97
92
  imports: [],
98
93
  });
@@ -108,7 +103,6 @@ const storeFromFixture = (fixture: ConfigFixture): Store => ({
108
103
  mailboxes: [...fixture.mailboxes],
109
104
  labels: [...fixture.labels],
110
105
  filters: [...fixture.filters],
111
- anchors: [...fixture.anchors],
112
106
  addresses: [...fixture.addresses],
113
107
  imports: [],
114
108
  });
@@ -244,19 +238,6 @@ const repositoriesOf = (store: Store, accountConfigId: string): any => {
244
238
  return next;
245
239
  },
246
240
  },
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
241
  address: {
261
242
  // Both reads, so the stub can tell them apart: the suggest listing
262
243
  // hides a junk-only row, and the import must not be reading it (#1029).
@@ -392,50 +373,15 @@ const settle = (
392
373
  return next;
393
374
  };
394
375
 
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 => ({
376
+ const depsOf = (store: Store, accountConfigId: string): ConfigImportDeps => ({
427
377
  repositories: repositoriesOf(store, accountConfigId),
428
378
  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
379
  now: () => NOW,
434
380
  });
435
381
 
436
382
  // ---------------------------------------------------------------------------
437
383
  // 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
384
+ // roles, per-folder overrides, labels, two filters, and two
439
385
  // flagged senders.
440
386
  // ---------------------------------------------------------------------------
441
387
 
@@ -543,14 +489,12 @@ const sourceFixture = (): ConfigFixture => ({
543
489
  actionMailboxId: INVOICES_ID,
544
490
  }),
545
491
  makeFilter({
546
- filterId: "flt-semantic",
547
- name: "Anything like this release note",
492
+ filterId: "flt-release",
493
+ name: "Release notes",
548
494
  scope: "Temporary",
549
495
  expiresAt: "2026-12-31T23:59:59+01:00",
550
- hasAnchor: true,
551
496
  }),
552
497
  ],
553
- anchors: [makeAnchor({ filterId: "flt-semantic" })],
554
498
  addresses: [
555
499
  makeAddress({
556
500
  addressId: "adr-bank",
@@ -1570,67 +1514,6 @@ test("a filter filing into an account the file does not carry is refused", async
1570
1514
  assert.equal(report.errors[0]?.code, "unknown_account");
1571
1515
  });
1572
1516
 
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
1517
  test("the dry run names the same folders and credentials the apply does", async () => {
1635
1518
  const document = await exportSource();
1636
1519
  const planStore = emptyStore();
@@ -1659,7 +1542,7 @@ test("the dry run names the same folders and credentials the apply does", async
1659
1542
  assert.deepEqual(planStore.accounts, []);
1660
1543
  });
1661
1544
 
1662
- 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 () => {
1663
1546
  const document = await exportSource();
1664
1547
  const store = emptyStore();
1665
1548
  store.failOn = "filter";
@@ -1667,40 +1550,8 @@ test("a write that fails after validation leaves nothing behind and names where
1667
1550
  const report = reportOf(await apply(store, document));
1668
1551
 
1669
1552
  assert.equal(report.valid, true);
1670
- assert.equal(report.applied, false);
1671
1553
  assert.equal(report.errors[0]?.code, "import_write_failed");
1672
1554
  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
1555
  assert.equal(report.applied, true);
1705
1556
  assert.match(report.errors[0]?.message ?? "", /were written and remain/);
1706
1557
  assert.equal(
@@ -1712,6 +1563,29 @@ test("a write that fails without a transaction reports what it did leave behind"
1712
1563
  assert.equal(store.accounts.length > 0, true);
1713
1564
  });
1714
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
+
1715
1589
  test("the configuration row is created when the database holds none, and named from the file", async () => {
1716
1590
  const document = await exportSource();
1717
1591
  const store = emptyStore();
package/src/import.ts CHANGED
@@ -39,17 +39,6 @@ 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
 
@@ -270,9 +259,9 @@ const landingState = (
270
259
 
271
260
  /**
272
261
  * 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.
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.
276
265
  */
277
266
  interface ApplyProgress {
278
267
  items: ConfigImportItemReport[];
@@ -288,56 +277,6 @@ const emptyProgress = (): ApplyProgress => ({
288
277
  unresolvedRefs: [],
289
278
  });
290
279
 
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
280
  /**
342
281
  * The id this instance will hold each account in the file under: the one it
343
282
  * already has for that id or email, or the file's own, which `create` preserves
@@ -378,8 +317,7 @@ const foldersFor = (
378
317
 
379
318
  /**
380
319
  * 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.
320
+ * the report can name it beside what landed before it.
383
321
  */
384
322
  class ImportWriteError extends Error {
385
323
  constructor(
@@ -399,7 +337,6 @@ const applyDocument = async (
399
337
  input: ImportConfigInput,
400
338
  document: ReaderConfigDocument,
401
339
  existing: ExistingConfig,
402
- anchorVectors: ReadonlyMap<string, AnchorVector>,
403
340
  progress: ApplyProgress,
404
341
  ): Promise<string> => {
405
342
  const { repositories } = deps;
@@ -485,8 +422,7 @@ const applyDocument = async (
485
422
  account.email,
486
423
  () =>
487
424
  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.
425
+ // Verbatim: a resynced message id hashes from it.
490
426
  accountId: account.accountId,
491
427
  accountConfigId,
492
428
  ...endpoints,
@@ -545,7 +481,6 @@ const applyDocument = async (
545
481
  const existingFilterByName = new Map(
546
482
  existing.filters.map((filter) => [normalize(filter.name), filter] as const),
547
483
  );
548
- const filterIdByName = new Map<string, string>();
549
484
 
550
485
  const resolveFilterFolder = (
551
486
  filter: ConfigFilter,
@@ -574,7 +509,6 @@ const applyDocument = async (
574
509
  ? NO_ACTION
575
510
  : (labelIdByName.get(normalize(filter.actionLabelName)) ?? NO_ACTION),
576
511
  actionMailboxId: folder.actionMailboxId,
577
- hasAnchor: filter.anchor !== null,
578
512
  ...(folder.pending
579
513
  ? {
580
514
  state: FilterState.Disabled,
@@ -603,7 +537,6 @@ const applyDocument = async (
603
537
  )
604
538
  ).filterId;
605
539
 
606
- filterIdByName.set(key, filterId);
607
540
  items.push({
608
541
  section: ConfigImportSection.Filters,
609
542
  key: filter.name,
@@ -636,34 +569,6 @@ const applyDocument = async (
636
569
  }
637
570
  }
638
571
 
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
572
  // ---- address flags --------------------------------------------------
668
573
  for (const entry of document.addressFlags) {
669
574
  await write(ConfigImportSection.AddressFlags, entry.normalizedEmail, () =>
@@ -939,8 +844,8 @@ class SettingWriter {
939
844
  *
940
845
  * The order is fixed: parse and migrate, check every reference and every
941
846
  * 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
847
+ * write — accounts before labels before filters before
848
+ * addresses before settings, because each of those reads
944
849
  * an id the one before it minted.
945
850
  *
946
851
  * A document this reader will not import is the operation's expected outcome,
@@ -1004,19 +909,16 @@ export const importConfig = async (
1004
909
  };
1005
910
  }
1006
911
 
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
912
  const progress = emptyProgress();
1015
- const apply = (): Promise<string> =>
1016
- applyDocument(deps, input, document, existing, anchorVectors, progress);
1017
913
 
1018
914
  try {
1019
- const importId = await (transaction ? transaction(apply) : apply());
915
+ const importId = await applyDocument(
916
+ deps,
917
+ input,
918
+ document,
919
+ existing,
920
+ progress,
921
+ );
1020
922
  return {
1021
923
  outcome: "report",
1022
924
  report: {
@@ -1026,16 +928,17 @@ export const importConfig = async (
1026
928
  applied: true,
1027
929
  items: progress.items,
1028
930
  errors: [],
1029
- warnings: [...embedWarnings, ...progress.warnings],
931
+ warnings: progress.warnings,
1030
932
  accountsNeedingCredentials: progress.accountsNeedingCredentials,
1031
933
  },
1032
934
  };
1033
935
  } catch (error) {
1034
936
  if (!(error instanceof ImportWriteError)) throw error;
1035
937
  // 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;
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;
1039
942
  return {
1040
943
  outcome: "report",
1041
944
  report: {
@@ -1054,15 +957,11 @@ export const importConfig = async (
1054
957
  errors: [
1055
958
  problem(
1056
959
  "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
- }`,
960
+ `${error.message} The items above it were written and remain; import the same file again with merge to finish.`,
1062
961
  { section: error.section, key: error.key },
1063
962
  ),
1064
963
  ],
1065
- warnings: [...embedWarnings, ...landed.warnings],
964
+ warnings: landed.warnings,
1066
965
  accountsNeedingCredentials: landed.accountsNeedingCredentials,
1067
966
  },
1068
967
  };
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