@remit/config-format 0.0.6 → 0.0.8

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-format",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "schemaVersion": {
10
10
  "type": "number",
11
- "const": 1
11
+ "const": 2
12
12
  },
13
13
  "generator": {
14
14
  "description": "Which reader wrote this file, and when.",
@@ -429,6 +429,7 @@
429
429
  ]
430
430
  },
431
431
  "literalClauses": {
432
+ "minItems": 1,
432
433
  "type": "array",
433
434
  "items": {
434
435
  "type": "object",
@@ -494,38 +495,6 @@
494
495
  "type": "null"
495
496
  }
496
497
  ]
497
- },
498
- "anchor": {
499
- "anyOf": [
500
- {
501
- "type": "object",
502
- "properties": {
503
- "sourceText": {
504
- "type": "string",
505
- "minLength": 1,
506
- "maxLength": 512
507
- },
508
- "embeddingId": {
509
- "type": "string",
510
- "minLength": 1
511
- },
512
- "sourceMessageId": {
513
- "type": "string",
514
- "minLength": 25,
515
- "maxLength": 25
516
- }
517
- },
518
- "required": [
519
- "sourceText",
520
- "embeddingId",
521
- "sourceMessageId"
522
- ],
523
- "additionalProperties": false
524
- },
525
- {
526
- "type": "null"
527
- }
528
- ]
529
498
  }
530
499
  },
531
500
  "required": [
@@ -535,8 +504,7 @@
535
504
  "matchOperator",
536
505
  "literalClauses",
537
506
  "actionLabelName",
538
- "actionFolder",
539
- "anchor"
507
+ "actionFolder"
540
508
  ],
541
509
  "additionalProperties": false
542
510
  }
@@ -616,6 +584,36 @@
616
584
  ],
617
585
  "additionalProperties": false
618
586
  },
587
+ "neverSpam": {
588
+ "type": "object",
589
+ "properties": {
590
+ "value": {
591
+ "type": "boolean"
592
+ },
593
+ "setAt": {
594
+ "description": "Epoch milliseconds the flag was set. 0 means it was set at an unknown time, on a row written before the field existed.",
595
+ "type": "integer",
596
+ "minimum": -9007199254740991,
597
+ "maximum": 9007199254740991
598
+ },
599
+ "setBy": {
600
+ "type": "string"
601
+ },
602
+ "expiresAt": {
603
+ "type": "integer",
604
+ "minimum": -9007199254740991,
605
+ "maximum": 9007199254740991
606
+ },
607
+ "reason": {
608
+ "type": "string"
609
+ }
610
+ },
611
+ "required": [
612
+ "value",
613
+ "setAt"
614
+ ],
615
+ "additionalProperties": false
616
+ },
619
617
  "muted": {
620
618
  "type": "object",
621
619
  "properties": {
@@ -822,6 +820,6 @@
822
820
  "addressFlags"
823
821
  ],
824
822
  "additionalProperties": false,
825
- "$id": "https://schemas.remit.email/reader-config.v1.schema.json",
826
- "title": "reader configuration document, v1"
823
+ "$id": "https://schemas.remit.email/reader-config.v2.schema.json",
824
+ "title": "reader configuration document, v2"
827
825
  }
@@ -97,7 +97,6 @@ test("a filter names its label by name and its folder by account and path", () =
97
97
  accountId: "6f4c2c309a2c4a7f9f9f1f2c3",
98
98
  folderPath: "INBOX.Facturen",
99
99
  },
100
- anchor: null,
101
100
  };
102
101
 
103
102
  assert.equal(FilterSchema.safeParse(filter).success, true);
@@ -114,37 +113,6 @@ test("a filter names its label by name and its folder by account and path", () =
114
113
  );
115
114
  });
116
115
 
117
- test("a filter anchor carries its source text and no vector", () => {
118
- const anchored = {
119
- name: "like this",
120
- scope: "Standing",
121
- expiresAt: null,
122
- matchOperator: "And",
123
- literalClauses: [],
124
- actionLabelName: null,
125
- actionFolder: null,
126
- anchor: {
127
- sourceText: "the release note we file every month",
128
- embeddingId: "amazon.titan-embed-text-v2:0@1024",
129
- sourceMessageId: "2c9e4b117d5a4f609a315b8c0",
130
- },
131
- };
132
-
133
- assert.equal(FilterSchema.safeParse(anchored).success, true);
134
- assert.equal(
135
- FilterSchema.safeParse({
136
- ...anchored,
137
- anchor: {
138
- sourceText: "x",
139
- embeddingId: "m@1",
140
- sourceMessageId: "2c9e4b117d5a4f609a315b8c0",
141
- anchorEmbedding: [0.1, 0.2],
142
- },
143
- }).success,
144
- false,
145
- );
146
- });
147
-
148
116
  test("an address flag keeps its whole payload", () => {
149
117
  const entry = {
150
118
  normalizedEmail: "post@bank.example",
package/src/document.ts CHANGED
@@ -98,6 +98,7 @@ const CategoryFlagSchema = z.strictObject({
98
98
  export const AddressFlagsSchema = z.strictObject({
99
99
  trusted: FlagMetadataSchema.optional(),
100
100
  blocked: FlagMetadataSchema.optional(),
101
+ neverSpam: FlagMetadataSchema.optional(),
101
102
  muted: FlagMetadataSchema.optional(),
102
103
  vip: FlagMetadataSchema.optional(),
103
104
  category: CategoryFlagSchema.optional(),
@@ -202,19 +203,6 @@ export const FilterClauseSchema = z.strictObject({
202
203
  value: z.string().min(1).max(256),
203
204
  });
204
205
 
205
- /**
206
- * A semantic anchor as text, never as numbers. The vector is a function of the
207
- * embedding model that produced it, so it means nothing on an instance running
208
- * a different one; the source text re-embeds against whatever model the
209
- * importing instance actually has.
210
- */
211
- export const FilterAnchorSchema = z.strictObject({
212
- sourceText: z.string().min(1).max(512),
213
- /** `<modelId>@<dimensions>` the vector was built with, for provenance. */
214
- embeddingId: NonEmpty,
215
- sourceMessageId: StoredId,
216
- });
217
-
218
206
  /** A folder named the only way it can be named across instances. */
219
207
  export const FolderRefSchema = z.strictObject({
220
208
  accountId: StoredId,
@@ -227,11 +215,10 @@ export const FilterSchema = z.strictObject({
227
215
  /** ISO 8601 with offset when the scope is Temporary, otherwise null. */
228
216
  expiresAt: IsoDateTime.nullable(),
229
217
  matchOperator: z.enum(FilterMatchOperator),
230
- literalClauses: z.array(FilterClauseSchema),
218
+ literalClauses: z.array(FilterClauseSchema).min(1),
231
219
  /** The label this filter applies, by name — ids are local to an instance. */
232
220
  actionLabelName: z.string().min(1).max(140).nullable(),
233
221
  actionFolder: FolderRefSchema.nullable(),
234
- anchor: FilterAnchorSchema.nullable(),
235
222
  });
236
223
 
237
224
  /**
@@ -1,10 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
  import { readGoldenConfigDocument } from "./fixtures.js";
4
+ import { liftToCurrentVersion } from "./migrations.js";
4
5
  import { readConfigDocument } from "./read.js";
5
6
  import { CURRENT_SCHEMA_VERSION } from "./version.js";
6
7
 
7
- test("the golden v1 document parses", () => {
8
+ test("the golden v1 document reads at the current version", () => {
8
9
  const document = readConfigDocument(readGoldenConfigDocument());
9
10
 
10
11
  assert.equal(document.schemaVersion, CURRENT_SCHEMA_VERSION);
@@ -15,8 +16,11 @@ test("the golden v1 document parses", () => {
15
16
  );
16
17
  });
17
18
 
18
- test("the golden v1 document round-trips through JSON unchanged", () => {
19
- const source = readGoldenConfigDocument();
19
+ test("the lifted golden document round-trips through JSON unchanged", () => {
20
+ const source = liftToCurrentVersion(
21
+ readGoldenConfigDocument() as Record<string, unknown>,
22
+ 1,
23
+ );
20
24
 
21
25
  const parsed = readConfigDocument(source);
22
26
 
@@ -39,7 +43,7 @@ test("the golden v1 document carries the parts a move has to preserve", () => {
39
43
  );
40
44
  assert.equal(document.filters[0]?.actionFolder?.folderPath, "INBOX.Facturen");
41
45
  assert.equal(document.filters[0]?.actionLabelName, "Facturen");
42
- assert.ok(document.filters[1]?.anchor?.sourceText);
46
+ assert.equal(document.filters.length, 1, "the anchor-only filter is dropped");
43
47
  assert.equal(document.addressFlags[0]?.flags.vip?.reason, "payment alerts");
44
48
  assert.equal(
45
49
  document.addressFlags[1]?.flags.unsubscribed?.expiresAt,
@@ -6,7 +6,11 @@ import {
6
6
  serializeConfigJsonSchema,
7
7
  } from "./json-schema.js";
8
8
  import { artifactSchemaPath, committedSchemaPath } from "./schema-artifact.js";
9
- import { CONFIG_SCHEMA_ARTIFACT_PATH, CONFIG_SCHEMA_ID } from "./version.js";
9
+ import {
10
+ CONFIG_SCHEMA_ARTIFACT_PATH,
11
+ CONFIG_SCHEMA_ID,
12
+ CURRENT_SCHEMA_VERSION,
13
+ } from "./version.js";
10
14
 
11
15
  type JsonObject = Record<string, unknown>;
12
16
 
@@ -24,7 +28,10 @@ test("the emitted schema pins the envelope and closes the document", () => {
24
28
  const properties = emitted.properties as JsonObject;
25
29
 
26
30
  assert.deepEqual((properties.kind as JsonObject).const, "reader.config");
27
- assert.deepEqual((properties.schemaVersion as JsonObject).const, 1);
31
+ assert.deepEqual(
32
+ (properties.schemaVersion as JsonObject).const,
33
+ CURRENT_SCHEMA_VERSION,
34
+ );
28
35
  assert.equal(emitted.additionalProperties, false);
29
36
  assert.deepEqual(emitted.required, [
30
37
  "kind",
@@ -10,8 +10,20 @@ import {
10
10
  import { readConfigDocument } from "./read.js";
11
11
  import { CURRENT_SCHEMA_VERSION } from "./version.js";
12
12
 
13
- test("v1 needs no migrations, and the chain is empty rather than absent", () => {
14
- assert.deepEqual(configMigrations, []);
13
+ test("v1 lifts to v2 by dropping each anchor and every anchor-only filter", () => {
14
+ const source = readGoldenConfigDocument() as Record<string, unknown>;
15
+
16
+ const lifted = liftToCurrentVersion(source, 1, configMigrations, 2);
17
+
18
+ assert.equal(lifted.schemaVersion, 2);
19
+ const filters = lifted.filters as Record<string, unknown>[];
20
+ assert.deepEqual(
21
+ filters.map((filter) => filter.name),
22
+ ["Invoices to Facturen"],
23
+ );
24
+ for (const filter of filters) {
25
+ assert.equal("anchor" in filter, false);
26
+ }
15
27
  });
16
28
 
17
29
  test("a document already at the current version passes through untouched", () => {
@@ -72,7 +84,11 @@ test("a document already at the current version never enters the chain", () => {
72
84
  },
73
85
  ];
74
86
 
75
- const parsed = readConfigDocument(readGoldenConfigDocument(), migrations);
87
+ const current = liftToCurrentVersion(
88
+ readGoldenConfigDocument() as Record<string, unknown>,
89
+ 1,
90
+ );
91
+ const parsed = readConfigDocument(current, migrations);
76
92
 
77
93
  assert.equal(ran, 0);
78
94
  assert.equal(parsed.schemaVersion, CURRENT_SCHEMA_VERSION);
package/src/migrations.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { z } from "zod/v4";
1
2
  import { ConfigVersionError } from "./errors.js";
2
3
  import { CURRENT_SCHEMA_VERSION } from "./version.js";
3
4
 
@@ -15,12 +16,29 @@ export type ConfigMigration = {
15
16
  migrate(document: ConfigDocumentShape): ConfigDocumentShape;
16
17
  };
17
18
 
19
+ const V1FiltersSchema = z.array(
20
+ z.looseObject({ literalClauses: z.array(z.unknown()), anchor: z.unknown() }),
21
+ );
22
+
23
+ /** An anchor-only filter has no clauses left to match on, so it goes with its anchor. */
24
+ const dropFilterAnchors: ConfigMigration = {
25
+ from: 1,
26
+ to: 2,
27
+ migrate: (document) => ({
28
+ ...document,
29
+ schemaVersion: 2,
30
+ filters: V1FiltersSchema.parse(document.filters)
31
+ .filter((filter) => filter.literalClauses.length > 0)
32
+ .map(({ anchor: _anchor, ...filter }) => filter),
33
+ }),
34
+ };
35
+
18
36
  /**
19
- * Empty at v1, and the golden fixture is the seed that keeps it honest: the
20
- * first migration added here has a committed v1 document to lift, so the chain
21
- * is exercised by the same test that proves v1 still parses.
37
+ * The golden v1 fixture is the seed that keeps the chain honest: every step
38
+ * has a committed v1 document to lift, so the chain is exercised by the same
39
+ * test that proves v1 still reads.
22
40
  */
23
- export const configMigrations: readonly ConfigMigration[] = [];
41
+ export const configMigrations: readonly ConfigMigration[] = [dropFilterAnchors];
24
42
 
25
43
  /**
26
44
  * Apply the chain from `fromVersion` up to the current version. A gap in the
package/src/read.test.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from "./errors.js";
12
12
  import { readGoldenConfigDocument } from "./fixtures.js";
13
13
  import { readConfigDocument } from "./read.js";
14
+ import { CURRENT_SCHEMA_VERSION } from "./version.js";
14
15
 
15
16
  type Mutable = Record<string, unknown>;
16
17
  type MutableAccount = Record<string, unknown>;
@@ -50,14 +51,15 @@ test("a document with no kind at all is refused as the wrong kind", () => {
50
51
  });
51
52
 
52
53
  test("a newer document is refused by version, before its shape is judged", () => {
53
- const source = { ...document(), schemaVersion: 2, somethingV2Added: true };
54
+ const newer = CURRENT_SCHEMA_VERSION + 1;
55
+ const source = { ...document(), schemaVersion: newer, somethingAdded: true };
54
56
 
55
57
  assert.throws(
56
58
  () => readConfigDocument(source),
57
59
  (error: unknown) => {
58
60
  assert.ok(error instanceof ConfigVersionError);
59
- assert.equal(error.documentVersion, 2);
60
- assert.equal(error.supportedVersion, 1);
61
+ assert.equal(error.documentVersion, newer);
62
+ assert.equal(error.supportedVersion, CURRENT_SCHEMA_VERSION);
61
63
  return true;
62
64
  },
63
65
  );
@@ -9,7 +9,7 @@ import { CONFIG_SCHEMA_ARTIFACT_PATH } from "./version.js";
9
9
  */
10
10
  export const committedSchemaPath = (): string =>
11
11
  fileURLToPath(
12
- new URL("../schema/reader-config.v1.schema.json", import.meta.url),
12
+ new URL("../schema/reader-config.v2.schema.json", import.meta.url),
13
13
  );
14
14
 
15
15
  /** The generated artifact, at its path under the repository root. */
package/src/version.ts CHANGED
@@ -7,12 +7,12 @@
7
7
  export const CONFIG_KIND = "reader.config";
8
8
 
9
9
  /** The document version this package writes and can read up to. */
10
- export const CURRENT_SCHEMA_VERSION = 1;
10
+ export const CURRENT_SCHEMA_VERSION = 2;
11
11
 
12
12
  /** `$id` of the emitted JSON Schema artifact. */
13
13
  export const CONFIG_SCHEMA_ID =
14
- "https://schemas.remit.email/reader-config.v1.schema.json";
14
+ "https://schemas.remit.email/reader-config.v2.schema.json";
15
15
 
16
16
  /** Path of the emitted JSON Schema artifact, relative to the repository root. */
17
17
  export const CONFIG_SCHEMA_ARTIFACT_PATH =
18
- "build/config-schema/reader-config.v1.schema.json";
18
+ "build/config-schema/reader-config.v2.schema.json";