@remit/config-transfer 0.0.2 → 0.0.3

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.2",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -7,7 +7,7 @@ import {
7
7
  composeFolderRoleAppointmentLabelName,
8
8
  composeFolderRoleAppointmentName,
9
9
  } from "@remit/data-ports/folder-role";
10
- import { readConfigForExport } from "./export.js";
10
+ import { ConfigExportRefusedError, readConfigForExport } from "./export.js";
11
11
  import {
12
12
  ACCOUNT_CONFIG_ID,
13
13
  asRepositories,
@@ -18,6 +18,7 @@ import {
18
18
  makeAnchor,
19
19
  makeFilter,
20
20
  makeLabel,
21
+ makeLegacyFlaggedAddress,
21
22
  makeMailbox,
22
23
  makeSetting,
23
24
  OAUTH_ACCOUNT_ID,
@@ -491,6 +492,55 @@ test("an exported address carries its whole flag payload, and no derived flag",
491
492
  });
492
493
  });
493
494
 
495
+ test("a flag set before the timestamp was recorded exports at the sentinel", async () => {
496
+ const fixture = fullFixture();
497
+ fixture.addresses = [makeLegacyFlaggedAddress()];
498
+
499
+ const document = await exportFixture(fixture);
500
+
501
+ assert.equal(ReaderConfigDocumentSchema.safeParse(document).success, true);
502
+ assert.deepEqual(document.addressFlags, [
503
+ {
504
+ normalizedEmail: "old@legacy.example",
505
+ displayName: "Legacy",
506
+ flags: {
507
+ vip: { value: true, setAt: 0, setBy: "web-client" },
508
+ category: { value: "newsletter", setAt: 0 },
509
+ },
510
+ },
511
+ ]);
512
+ });
513
+
514
+ test("a flag the format cannot carry is refused by address and by flag", async () => {
515
+ const fixture = fullFixture();
516
+ fixture.addresses = [
517
+ ...fixture.addresses,
518
+ makeAddress({
519
+ addressId: "adr-broken",
520
+ normalizedEmail: "broken@example.com",
521
+ flags: {
522
+ category: { value: "Nonsense", setAt: 1750000000000 },
523
+ } as unknown as AddressItem["flags"],
524
+ }),
525
+ ];
526
+
527
+ await assert.rejects(exportFixture(fixture), (error: unknown) => {
528
+ assert.ok(error instanceof ConfigExportRefusedError);
529
+ assert.match(
530
+ error.message,
531
+ /addressFlags\[broken@example\.com\]\.flags\.category\.value/,
532
+ );
533
+ return true;
534
+ });
535
+ });
536
+
537
+ test("a refusal outside the address list keeps the path the schema gave it", async () => {
538
+ const fixture = fullFixture();
539
+ fixture.labels = [makeLabel({ labelId: "lbl-blank", name: "" })];
540
+
541
+ await assert.rejects(exportFixture(fixture), /labels\[0\]\.name/);
542
+ });
543
+
494
544
  test("a configuration that holds no row at all still exports a valid document", async () => {
495
545
  const repositories = asRepositories(fullFixture());
496
546
  const document = await readConfigForExport(
package/src/export.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  type ReaderConfigDocument,
10
10
  ReaderConfigDocumentSchema,
11
11
  } from "@remit/config-format";
12
+ import { formatPath } from "@remit/config-format/errors";
12
13
  import type {
13
14
  AccountItem,
14
15
  AddressFlags,
@@ -39,12 +40,72 @@ import {
39
40
  */
40
41
  const NO_ACTION = "None";
41
42
 
43
+ /**
44
+ * The instance's own data does not fit the document format. Nothing is written:
45
+ * a file the import would refuse is worse than no file. Every problem names the
46
+ * row it came from, because the operator reading this has to go and fix one.
47
+ */
48
+ export class ConfigExportRefusedError extends Error {
49
+ readonly problems: readonly string[];
50
+
51
+ constructor(problems: readonly string[]) {
52
+ super(
53
+ `This configuration cannot be exported as a v${CURRENT_SCHEMA_VERSION} document: ${problems.join("; ")}`,
54
+ );
55
+ this.name = "ConfigExportRefusedError";
56
+ this.problems = problems;
57
+ }
58
+ }
59
+
42
60
  /** Which credential the account needs before it can sync again. */
43
61
  const credentialsFor = (account: AccountItem): ConfigAccount["credentials"] =>
44
62
  account.authType === AccountAuthType.OauthMicrosoft
45
63
  ? { required: "oauth", provider: "microsoft" }
46
64
  : { required: "password" };
47
65
 
66
+ /**
67
+ * A flag as the rows actually hold it. `setAt` arrived after the first flags
68
+ * were written, so rows from before it carry none; the stored type says
69
+ * otherwise because it describes the shape being written today.
70
+ */
71
+ interface StoredFlag<TValue> {
72
+ value: TValue;
73
+ setAt?: number;
74
+ setBy?: string;
75
+ expiresAt?: number;
76
+ reason?: string;
77
+ }
78
+
79
+ /** The same flag as the document spells it: `setAt` always present. */
80
+ interface CarriedFlag<TValue> {
81
+ value: TValue;
82
+ setAt: number;
83
+ setBy?: string;
84
+ expiresAt?: number;
85
+ reason?: string;
86
+ }
87
+
88
+ /** The format's sentinel for a flag set before the timestamp was recorded. */
89
+ const SET_AT_UNKNOWN = 0;
90
+
91
+ /**
92
+ * One flag, field by field rather than as a copy of the stored object, so no
93
+ * attribute a later build adds to the row travels without a line here.
94
+ *
95
+ * A row with no `setAt` exports at the sentinel. Dropping the flag would lose a
96
+ * decision its owner made, and stamping the export's own clock would claim it
97
+ * was made today.
98
+ */
99
+ const carriedFlag = <TValue>(
100
+ flag: StoredFlag<TValue>,
101
+ ): CarriedFlag<TValue> => ({
102
+ value: flag.value,
103
+ setAt: flag.setAt ?? SET_AT_UNKNOWN,
104
+ ...(flag.setBy === undefined ? {} : { setBy: flag.setBy }),
105
+ ...(flag.expiresAt === undefined ? {} : { expiresAt: flag.expiresAt }),
106
+ ...(flag.reason === undefined ? {} : { reason: flag.reason }),
107
+ });
108
+
48
109
  /**
49
110
  * Every mailbox in the configuration, by id, plus which account holds it.
50
111
  * Folder-referencing config is stored against mailbox ids, and every one of
@@ -111,7 +172,7 @@ const folderOverridesOf = (
111
172
  overrides.push({
112
173
  folderPath,
113
174
  displayName: override.displayName,
114
- muted: override.muted,
175
+ muted: override.muted === null ? null : carriedFlag(override.muted),
115
176
  });
116
177
  }
117
178
  return overrides;
@@ -163,7 +224,7 @@ const toConfigAccount = (
163
224
  username: account.smtpUsername,
164
225
  },
165
226
  displayName: settings.displayName,
166
- muted: settings.muted,
227
+ muted: settings.muted === null ? null : carriedFlag(settings.muted),
167
228
  composeLanguages: settings.composeLanguages,
168
229
  signature: {
169
230
  plainText: settings.signaturePlainText,
@@ -234,13 +295,13 @@ const toConfigFilter = (
234
295
  const userFlagsOf = (flags: AddressFlags | undefined): ConfigAddressFlags => {
235
296
  const stored = flags ?? {};
236
297
  const user: ConfigAddressFlags = {};
237
- if (stored.trusted) user.trusted = stored.trusted;
238
- if (stored.blocked) user.blocked = stored.blocked;
239
- if (stored.muted) user.muted = stored.muted;
240
- if (stored.vip) user.vip = stored.vip;
241
- if (stored.category) user.category = stored.category;
242
- if (stored.autoArchive) user.autoArchive = stored.autoArchive;
243
- if (stored.unsubscribed) user.unsubscribed = stored.unsubscribed;
298
+ if (stored.trusted) user.trusted = carriedFlag(stored.trusted);
299
+ if (stored.blocked) user.blocked = carriedFlag(stored.blocked);
300
+ if (stored.muted) user.muted = carriedFlag(stored.muted);
301
+ if (stored.vip) user.vip = carriedFlag(stored.vip);
302
+ if (stored.category) user.category = carriedFlag(stored.category);
303
+ if (stored.autoArchive) user.autoArchive = carriedFlag(stored.autoArchive);
304
+ if (stored.unsubscribed) user.unsubscribed = carriedFlag(stored.unsubscribed);
244
305
  return user;
245
306
  };
246
307
 
@@ -278,6 +339,25 @@ const readAddresses = async (
278
339
  return addresses;
279
340
  };
280
341
 
342
+ /**
343
+ * Where a refusal landed, said so an operator can act on it. A path into
344
+ * `addressFlags` names an array index of a list nobody can look up, so the
345
+ * index is replaced by the address itself; the flag is already the next segment.
346
+ */
347
+ const refusalLocation = (
348
+ path: readonly PropertyKey[],
349
+ document: ReaderConfigDocument,
350
+ ): string => {
351
+ const [head, index, ...rest] = path;
352
+ if (head !== "addressFlags" || typeof index !== "number")
353
+ return formatPath(path);
354
+ const email = document.addressFlags[index]?.normalizedEmail;
355
+ if (email === undefined) return formatPath(path);
356
+ return rest.length === 0
357
+ ? `addressFlags[${email}]`
358
+ : `addressFlags[${email}].${formatPath(rest)}`;
359
+ };
360
+
281
361
  const accountSettingsOf = (
282
362
  settings: SettingsView,
283
363
  accountId: string,
@@ -380,5 +460,11 @@ export const readConfigForExport = async (
380
460
  .map(toConfigAddressFlags),
381
461
  };
382
462
 
383
- return ReaderConfigDocumentSchema.parse(document);
463
+ const parsed = ReaderConfigDocumentSchema.safeParse(document);
464
+ if (parsed.success) return parsed.data;
465
+ throw new ConfigExportRefusedError(
466
+ parsed.error.issues.map(
467
+ (issue) => `${refusalLocation(issue.path, document)}: ${issue.message}`,
468
+ ),
469
+ );
384
470
  };
package/src/fixtures.ts CHANGED
@@ -168,6 +168,23 @@ export const makeAddress = (
168
168
  ...overrides,
169
169
  });
170
170
 
171
+ /**
172
+ * An address flagged before `setAt` was recorded, as v0.6 left it in the
173
+ * database. The stored type cannot spell the row — it describes what is written
174
+ * today — so the fixture asserts the shape rather than deriving it, which is
175
+ * the whole point: this is what the live instance actually holds.
176
+ */
177
+ export const makeLegacyFlaggedAddress = (): AddressItem =>
178
+ makeAddress({
179
+ addressId: "adr-legacy",
180
+ normalizedEmail: "old@legacy.example",
181
+ displayName: "Legacy",
182
+ flags: {
183
+ vip: { value: true, setBy: "web-client" },
184
+ category: { value: "newsletter" },
185
+ } as unknown as AddressItem["flags"],
186
+ });
187
+
171
188
  /** The fixture, behind the repository interfaces the export takes. */
172
189
  export const asRepositories = (
173
190
  fixture: ConfigFixture,
@@ -29,6 +29,7 @@ import {
29
29
  makeAnchor,
30
30
  makeFilter,
31
31
  makeLabel,
32
+ makeLegacyFlaggedAddress,
32
33
  makeMailbox,
33
34
  makeSetting,
34
35
  OAUTH_ACCOUNT_ID,
@@ -524,12 +525,20 @@ const sourceFixture = (): ConfigFixture => ({
524
525
  ],
525
526
  });
526
527
 
527
- const exportSource = () =>
528
- readConfigForExport(
529
- asRepositories(sourceFixture()),
530
- ACCOUNT_CONFIG_ID,
531
- IDENTITY,
532
- );
528
+ /**
529
+ * The same configuration with one sender flagged before `setAt` existed. Held
530
+ * apart from the source fixture rather than folded into it: half the tests here
531
+ * count what the file carries, and a legacy row is a fact about the database
532
+ * this instance happens to hold, not about every configuration.
533
+ */
534
+ const sourceWithLegacyFlagsFixture = (): ConfigFixture => {
535
+ const fixture = sourceFixture();
536
+ fixture.addresses = [...fixture.addresses, makeLegacyFlaggedAddress()];
537
+ return fixture;
538
+ };
539
+
540
+ const exportSource = (fixture: ConfigFixture = sourceFixture()) =>
541
+ readConfigForExport(asRepositories(fixture), ACCOUNT_CONFIG_ID, IDENTITY);
533
542
 
534
543
  const apply = (
535
544
  store: Store,
@@ -564,7 +573,7 @@ const discover = (
564
573
  };
565
574
 
566
575
  test("a configuration survives an export, an import and a discovery unchanged", async () => {
567
- const document = await exportSource();
576
+ const document = await exportSource(sourceWithLegacyFlagsFixture());
568
577
  const store = emptyStore();
569
578
 
570
579
  const report = reportOf(await apply(store, document));
@@ -770,6 +779,19 @@ test("a flagged sender arrives ahead of its mail, keyed on the email string", as
770
779
  assert.equal(news?.flags?.unsubscribed?.value, true);
771
780
  });
772
781
 
782
+ test("a flag that arrives at the sentinel is stored at the sentinel", async () => {
783
+ const document = await exportSource(sourceWithLegacyFlagsFixture());
784
+ const store = emptyStore();
785
+
786
+ await apply(store, document);
787
+
788
+ const legacy = store.addresses.find(
789
+ (address) => address.normalizedEmail === "old@legacy.example",
790
+ );
791
+ assert.equal(legacy?.flags?.vip?.setAt, 0);
792
+ assert.equal(legacy?.flags?.category?.setAt, 0);
793
+ });
794
+
773
795
  test("a folder no mailbox answers to yet is kept, then bound when discovery finds it", async () => {
774
796
  const document = await exportSource();
775
797
  const store = emptyStore();
package/src/index.ts CHANGED
@@ -5,7 +5,11 @@ export {
5
5
  type ConfigBinderRepositories,
6
6
  pendingImportOf,
7
7
  } from "./binder.js";
8
- export { carriesUserFlag, readConfigForExport } from "./export.js";
8
+ export {
9
+ ConfigExportRefusedError,
10
+ carriesUserFlag,
11
+ readConfigForExport,
12
+ } from "./export.js";
9
13
  export {
10
14
  ANCHOR_EMBEDDING_PENDING,
11
15
  type ImportConfigInput,