@remit/config-transfer 0.0.10 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/binder.ts +219 -40
- package/src/export.test.ts +8 -38
- package/src/export.ts +2 -25
- package/src/fixtures.ts +2 -20
- package/src/import-repositories.ts +0 -29
- package/src/import.test.ts +587 -160
- package/src/import.ts +71 -139
- package/src/index.ts +2 -2
- package/src/repositories.ts +0 -2
package/package.json
CHANGED
package/src/binder.ts
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { readConfigDocument } from "@remit/config-format";
|
|
2
|
-
import
|
|
3
|
-
ConfigImportItem,
|
|
4
|
-
ConfigImportUnresolvedRefItem,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
import {
|
|
3
|
+
type ConfigImportItem,
|
|
4
|
+
type ConfigImportUnresolvedRefItem,
|
|
5
|
+
type CreateMailboxResult,
|
|
6
|
+
FILTER_NO_ACTION,
|
|
7
|
+
type FilterItem,
|
|
8
|
+
type IAccountSettingRepository,
|
|
9
|
+
type IConfigImportRepository,
|
|
10
|
+
type IFilterRepository,
|
|
11
|
+
type IMailboxRepository,
|
|
12
|
+
type MailboxItem,
|
|
9
13
|
} from "@remit/data-ports";
|
|
10
14
|
import { composeSettingName } from "@remit/data-ports/account-settings";
|
|
11
15
|
import {
|
|
12
16
|
AccountSettingName,
|
|
13
17
|
ConfigImportRefKind,
|
|
14
18
|
ConfigImportState,
|
|
19
|
+
FilterDisabledReason,
|
|
20
|
+
FilterState,
|
|
21
|
+
MailboxSyncStatus,
|
|
15
22
|
} from "@remit/domain-enums";
|
|
16
23
|
import type { AppointFolderRole } from "./import-repositories.js";
|
|
17
24
|
|
|
@@ -19,12 +26,18 @@ export interface ConfigBinderRepositories {
|
|
|
19
26
|
configImport: Pick<IConfigImportRepository, "listByAccountConfig" | "update">;
|
|
20
27
|
accountSetting: Pick<IAccountSettingRepository, "upsert">;
|
|
21
28
|
filter: Pick<IFilterRepository, "listByAccountConfig" | "update">;
|
|
22
|
-
mailbox: Pick<IMailboxRepository, "listAllByAccount">;
|
|
29
|
+
mailbox: Pick<IMailboxRepository, "listAllByAccount" | "resolveAccountId">;
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
export type CreateImportedFolder = (
|
|
33
|
+
accountId: string,
|
|
34
|
+
folderPath: string,
|
|
35
|
+
) => Promise<CreateMailboxResult>;
|
|
36
|
+
|
|
25
37
|
export interface ConfigBinderDeps {
|
|
26
38
|
repositories: ConfigBinderRepositories;
|
|
27
39
|
appointFolderRole: AppointFolderRole;
|
|
40
|
+
createFolder: CreateImportedFolder;
|
|
28
41
|
now?: () => number;
|
|
29
42
|
}
|
|
30
43
|
|
|
@@ -35,27 +48,34 @@ export interface ConfigBinderDeps {
|
|
|
35
48
|
export interface BindResult {
|
|
36
49
|
bound: number;
|
|
37
50
|
dropped: number;
|
|
51
|
+
created: number;
|
|
52
|
+
disabled: number;
|
|
38
53
|
stillPending: number;
|
|
39
54
|
}
|
|
40
55
|
|
|
41
56
|
type RefState =
|
|
42
57
|
| { kind: "TargetGone" }
|
|
43
|
-
| { kind: "Waiting" }
|
|
44
|
-
| { kind: "Ready"; mailboxId: string }
|
|
58
|
+
| { kind: "Waiting"; mailboxId: string }
|
|
59
|
+
| { kind: "Ready"; mailboxId: string }
|
|
60
|
+
| { kind: "Absent" }
|
|
61
|
+
| { kind: "Refused"; reason: FilterItem["disabledReason"] };
|
|
45
62
|
|
|
46
63
|
/**
|
|
47
64
|
* Bind the folder references an import could not resolve, now that discovery
|
|
48
65
|
* has produced this account's mailboxes.
|
|
49
66
|
*
|
|
50
67
|
* Every reference names a folder by IMAP path, because the ids are new on every
|
|
51
|
-
* discovery.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
68
|
+
* discovery. A path discovery did not produce is a folder the account lacks,
|
|
69
|
+
* so the binder creates it through the folder-create mutation and waits for
|
|
70
|
+
* the server to confirm it (imap-mutations R1, R2: wait), binding to that row
|
|
71
|
+
* by id because the server may have normalized its path. A create the server
|
|
72
|
+
* refused, or a created folder that vanished, drops the reference and turns
|
|
73
|
+
* its filter off with the reason.
|
|
55
74
|
*
|
|
56
75
|
* Idempotent and replayable: every write is an upsert or an update onto a row
|
|
57
|
-
* the import already created,
|
|
58
|
-
* than doing it
|
|
76
|
+
* the import already created, and a create is recorded on the reference before
|
|
77
|
+
* the next run, so a second discovery finds nothing to do rather than doing it
|
|
78
|
+
* again differently.
|
|
59
79
|
*/
|
|
60
80
|
export const bindImportedFolders = async (
|
|
61
81
|
deps: ConfigBinderDeps,
|
|
@@ -64,48 +84,108 @@ export const bindImportedFolders = async (
|
|
|
64
84
|
): Promise<BindResult> => {
|
|
65
85
|
const { repositories } = deps;
|
|
66
86
|
const now = deps.now ?? Date.now;
|
|
87
|
+
const result: BindResult = {
|
|
88
|
+
bound: 0,
|
|
89
|
+
dropped: 0,
|
|
90
|
+
created: 0,
|
|
91
|
+
disabled: 0,
|
|
92
|
+
stillPending: 0,
|
|
93
|
+
};
|
|
67
94
|
|
|
68
95
|
const imports = (
|
|
69
96
|
await repositories.configImport.listByAccountConfig(accountConfigId)
|
|
70
97
|
).filter((row) => row.state === ConfigImportState.Pending);
|
|
71
|
-
if (imports.length === 0) return
|
|
98
|
+
if (imports.length === 0) return result;
|
|
72
99
|
|
|
100
|
+
const mailboxes = await repositories.mailbox.listAllByAccount(accountId);
|
|
73
101
|
const byPath = new Map(
|
|
74
|
-
(
|
|
75
|
-
|
|
76
|
-
|
|
102
|
+
mailboxes.map((mailbox) => [mailbox.fullPath, mailbox] as const),
|
|
103
|
+
);
|
|
104
|
+
const byId = new Map(
|
|
105
|
+
mailboxes.map((mailbox) => [mailbox.mailboxId, mailbox] as const),
|
|
77
106
|
);
|
|
78
107
|
|
|
79
|
-
const
|
|
108
|
+
const filtersById = new Map(
|
|
80
109
|
(await repositories.filter.listByAccountConfig(accountConfigId)).map(
|
|
81
|
-
(filter) => filter.filterId,
|
|
110
|
+
(filter) => [filter.filterId, filter] as const,
|
|
82
111
|
),
|
|
83
112
|
);
|
|
84
113
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
114
|
+
const createdByPath = new Map<string, string>();
|
|
115
|
+
const createFolder = async (folderPath: string): Promise<string> => {
|
|
116
|
+
const held = createdByPath.get(folderPath);
|
|
117
|
+
if (held !== undefined) return held;
|
|
118
|
+
const created = await deps.createFolder(accountId, folderPath);
|
|
119
|
+
if (created.outcome === "PathTaken") return FILTER_NO_ACTION;
|
|
120
|
+
createdByPath.set(folderPath, created.mailbox.mailboxId);
|
|
121
|
+
result.created++;
|
|
122
|
+
return created.mailbox.mailboxId;
|
|
123
|
+
};
|
|
88
124
|
|
|
89
125
|
for (const row of imports) {
|
|
90
126
|
const document = readConfigDocument(row.document);
|
|
91
127
|
const remaining: ConfigImportUnresolvedRefItem[] = [];
|
|
128
|
+
let changed = false;
|
|
92
129
|
|
|
93
130
|
for (const ref of row.unresolvedRefs) {
|
|
94
|
-
const state = refStateOf(ref, accountId, byPath,
|
|
131
|
+
const state = refStateOf(ref, accountId, byPath, byId, filtersById);
|
|
132
|
+
if (
|
|
133
|
+
(state.kind === "Ready" || state.kind === "Refused") &&
|
|
134
|
+
!movesIntoNothing(ref, filtersById)
|
|
135
|
+
) {
|
|
136
|
+
await settlePickedFolder(
|
|
137
|
+
repositories,
|
|
138
|
+
accountConfigId,
|
|
139
|
+
filtersById.get(ref.target),
|
|
140
|
+
);
|
|
141
|
+
result.dropped++;
|
|
142
|
+
changed = true;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
95
145
|
if (state.kind === "TargetGone") {
|
|
96
|
-
dropped++;
|
|
146
|
+
result.dropped++;
|
|
147
|
+
changed = true;
|
|
97
148
|
continue;
|
|
98
149
|
}
|
|
99
150
|
if (state.kind === "Waiting") {
|
|
100
|
-
|
|
151
|
+
if (state.mailboxId !== ref.mailboxId) changed = true;
|
|
152
|
+
remaining.push({ ...ref, mailboxId: state.mailboxId });
|
|
101
153
|
continue;
|
|
102
154
|
}
|
|
103
|
-
|
|
104
|
-
|
|
155
|
+
if (state.kind === "Absent") {
|
|
156
|
+
remaining.push({
|
|
157
|
+
...ref,
|
|
158
|
+
mailboxId: await createFolder(ref.folderPath),
|
|
159
|
+
});
|
|
160
|
+
changed = true;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (state.kind === "Refused") {
|
|
164
|
+
if (ref.kind === ConfigImportRefKind.FilterAction) {
|
|
165
|
+
await repositories.filter.update(accountConfigId, ref.target, {
|
|
166
|
+
state: FilterState.Disabled,
|
|
167
|
+
disabledReason: state.reason,
|
|
168
|
+
});
|
|
169
|
+
result.disabled++;
|
|
170
|
+
}
|
|
171
|
+
result.dropped++;
|
|
172
|
+
changed = true;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
await bindRef(
|
|
176
|
+
deps,
|
|
177
|
+
accountConfigId,
|
|
178
|
+
ref,
|
|
179
|
+
state.mailboxId,
|
|
180
|
+
document,
|
|
181
|
+
filtersById,
|
|
182
|
+
);
|
|
183
|
+
result.bound++;
|
|
184
|
+
changed = true;
|
|
105
185
|
}
|
|
106
186
|
|
|
107
|
-
stillPending += remaining.length;
|
|
108
|
-
if (
|
|
187
|
+
result.stillPending += remaining.length;
|
|
188
|
+
if (!changed) continue;
|
|
109
189
|
|
|
110
190
|
await repositories.configImport.update(row.importId, {
|
|
111
191
|
unresolvedRefs: remaining,
|
|
@@ -115,27 +195,116 @@ export const bindImportedFolders = async (
|
|
|
115
195
|
});
|
|
116
196
|
}
|
|
117
197
|
|
|
118
|
-
return
|
|
198
|
+
return result;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export const disableFiltersMissingFolders = async (
|
|
202
|
+
repositories: {
|
|
203
|
+
filter: Pick<IFilterRepository, "listByAccountConfig" | "update">;
|
|
204
|
+
mailbox: Pick<IMailboxRepository, "resolveAccountId">;
|
|
205
|
+
},
|
|
206
|
+
accountConfigId: string,
|
|
207
|
+
): Promise<number> => {
|
|
208
|
+
const filters =
|
|
209
|
+
await repositories.filter.listByAccountConfig(accountConfigId);
|
|
210
|
+
let disabled = 0;
|
|
211
|
+
for (const filter of filters) {
|
|
212
|
+
if (filter.state !== FilterState.Active) continue;
|
|
213
|
+
if (filter.actionMailboxId === FILTER_NO_ACTION) continue;
|
|
214
|
+
const owner = await repositories.mailbox.resolveAccountId(
|
|
215
|
+
filter.actionMailboxId,
|
|
216
|
+
);
|
|
217
|
+
if (owner !== null) continue;
|
|
218
|
+
await repositories.filter.update(accountConfigId, filter.filterId, {
|
|
219
|
+
state: FilterState.Disabled,
|
|
220
|
+
disabledReason: FilterDisabledReason.FolderMissing,
|
|
221
|
+
});
|
|
222
|
+
disabled++;
|
|
223
|
+
}
|
|
224
|
+
return disabled;
|
|
119
225
|
};
|
|
120
226
|
|
|
121
227
|
type BoundDocument = ReturnType<typeof readConfigDocument>;
|
|
122
228
|
|
|
229
|
+
const FOLDERLESS_REASONS: ReadonlySet<FilterItem["disabledReason"]> = new Set([
|
|
230
|
+
FilterDisabledReason.AwaitingFolder,
|
|
231
|
+
FilterDisabledReason.FolderCreateFailed,
|
|
232
|
+
]);
|
|
233
|
+
|
|
234
|
+
const settlePickedFolder = async (
|
|
235
|
+
repositories: ConfigBinderRepositories,
|
|
236
|
+
accountConfigId: string,
|
|
237
|
+
filter: FilterItem | undefined,
|
|
238
|
+
): Promise<void> => {
|
|
239
|
+
if (!filter || filter.state !== FilterState.Disabled) return;
|
|
240
|
+
if (!FOLDERLESS_REASONS.has(filter.disabledReason)) return;
|
|
241
|
+
await repositories.filter.update(accountConfigId, filter.filterId, {
|
|
242
|
+
disabledReason: FilterDisabledReason.UserDisabled,
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const movesIntoNothing = (
|
|
247
|
+
ref: ConfigImportUnresolvedRefItem,
|
|
248
|
+
filtersById: ReadonlyMap<string, FilterItem>,
|
|
249
|
+
): boolean =>
|
|
250
|
+
ref.kind !== ConfigImportRefKind.FilterAction ||
|
|
251
|
+
filtersById.get(ref.target)?.actionMailboxId === FILTER_NO_ACTION;
|
|
252
|
+
|
|
253
|
+
const findByPath = (
|
|
254
|
+
folderPath: string,
|
|
255
|
+
byPath: ReadonlyMap<string, MailboxItem>,
|
|
256
|
+
): MailboxItem | undefined => {
|
|
257
|
+
const exact = byPath.get(folderPath);
|
|
258
|
+
if (exact) return exact;
|
|
259
|
+
const prefix = byPath.get("INBOX")?.namespacePrefix ?? "";
|
|
260
|
+
if (prefix === "" || folderPath.startsWith(prefix)) return undefined;
|
|
261
|
+
return byPath.get(`${prefix}${folderPath}`);
|
|
262
|
+
};
|
|
263
|
+
|
|
123
264
|
const refStateOf = (
|
|
124
265
|
ref: ConfigImportUnresolvedRefItem,
|
|
125
266
|
accountId: string,
|
|
126
|
-
byPath: ReadonlyMap<string,
|
|
127
|
-
|
|
267
|
+
byPath: ReadonlyMap<string, MailboxItem>,
|
|
268
|
+
byId: ReadonlyMap<string, MailboxItem>,
|
|
269
|
+
filtersById: ReadonlyMap<string, FilterItem>,
|
|
128
270
|
): RefState => {
|
|
129
271
|
if (
|
|
130
272
|
ref.kind === ConfigImportRefKind.FilterAction &&
|
|
131
|
-
!
|
|
273
|
+
!filtersById.has(ref.target)
|
|
132
274
|
) {
|
|
133
275
|
return { kind: "TargetGone" };
|
|
134
276
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
277
|
+
if (ref.accountId !== accountId) {
|
|
278
|
+
return { kind: "Waiting", mailboxId: ref.mailboxId };
|
|
279
|
+
}
|
|
280
|
+
const created = byId.get(ref.mailboxId);
|
|
281
|
+
if (created) {
|
|
282
|
+
if (created.syncStatus === MailboxSyncStatus.synced) {
|
|
283
|
+
return { kind: "Ready", mailboxId: created.mailboxId };
|
|
284
|
+
}
|
|
285
|
+
if (
|
|
286
|
+
created.syncStatus === MailboxSyncStatus.failed &&
|
|
287
|
+
created.pendingPath === undefined
|
|
288
|
+
) {
|
|
289
|
+
return {
|
|
290
|
+
kind: "Refused",
|
|
291
|
+
reason: FilterDisabledReason.FolderCreateFailed,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return { kind: "Waiting", mailboxId: created.mailboxId };
|
|
295
|
+
}
|
|
296
|
+
const found = findByPath(ref.folderPath, byPath);
|
|
297
|
+
if (found?.syncStatus === MailboxSyncStatus.synced) {
|
|
298
|
+
return { kind: "Ready", mailboxId: found.mailboxId };
|
|
299
|
+
}
|
|
300
|
+
if (found) return { kind: "Waiting", mailboxId: FILTER_NO_ACTION };
|
|
301
|
+
if (ref.mailboxId !== FILTER_NO_ACTION) {
|
|
302
|
+
return {
|
|
303
|
+
kind: "Refused",
|
|
304
|
+
reason: FilterDisabledReason.FolderCreateFailed,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return { kind: "Absent" };
|
|
139
308
|
};
|
|
140
309
|
|
|
141
310
|
const bindRef = async (
|
|
@@ -144,11 +313,21 @@ const bindRef = async (
|
|
|
144
313
|
ref: ConfigImportUnresolvedRefItem,
|
|
145
314
|
mailboxId: string,
|
|
146
315
|
document: BoundDocument,
|
|
316
|
+
filtersById: ReadonlyMap<string, FilterItem>,
|
|
147
317
|
): Promise<void> => {
|
|
148
318
|
const { repositories } = deps;
|
|
149
319
|
if (ref.kind === ConfigImportRefKind.FilterAction) {
|
|
320
|
+
const awaiting =
|
|
321
|
+
filtersById.get(ref.target)?.disabledReason ===
|
|
322
|
+
FilterDisabledReason.AwaitingFolder;
|
|
150
323
|
await repositories.filter.update(accountConfigId, ref.target, {
|
|
151
324
|
actionMailboxId: mailboxId,
|
|
325
|
+
...(awaiting
|
|
326
|
+
? {
|
|
327
|
+
state: FilterState.Active,
|
|
328
|
+
disabledReason: FilterDisabledReason.None,
|
|
329
|
+
}
|
|
330
|
+
: {}),
|
|
152
331
|
});
|
|
153
332
|
return;
|
|
154
333
|
}
|
package/src/export.test.ts
CHANGED
|
@@ -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-
|
|
162
|
-
name: "
|
|
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
|
|
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,
|
|
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,
|
|
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(
|
|
394
|
-
assert.equal(
|
|
395
|
-
assert.equal(
|
|
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
|
|
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;
|
|
@@ -129,11 +127,11 @@ export const makeFilter = (
|
|
|
129
127
|
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
130
128
|
scope: "Standing",
|
|
131
129
|
state: "Active",
|
|
132
|
-
|
|
130
|
+
disabledReason: "None",
|
|
133
131
|
ruleChangedAt: NOW,
|
|
134
132
|
actionChangedAt: NOW,
|
|
135
133
|
matchOperator: "And",
|
|
136
|
-
literalClauses: [],
|
|
134
|
+
literalClauses: [{ field: "Subject", value: "release notes" }],
|
|
137
135
|
actionLabelId: "None",
|
|
138
136
|
actionMailboxId: "None",
|
|
139
137
|
createdAt: NOW,
|
|
@@ -141,19 +139,6 @@ export const makeFilter = (
|
|
|
141
139
|
...overrides,
|
|
142
140
|
});
|
|
143
141
|
|
|
144
|
-
export const makeAnchor = (
|
|
145
|
-
overrides: Partial<FilterAnchorItem> & Pick<FilterAnchorItem, "filterId">,
|
|
146
|
-
): FilterAnchorItem => ({
|
|
147
|
-
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
148
|
-
anchorEmbedding: [0.11, 0.22, 0.33],
|
|
149
|
-
anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
|
|
150
|
-
anchorSourceText: "the release note this filter was drawn from",
|
|
151
|
-
anchorMessageId: "2c9e4b117d5a4f609a315b8c0",
|
|
152
|
-
createdAt: NOW,
|
|
153
|
-
updatedAt: NOW,
|
|
154
|
-
...overrides,
|
|
155
|
-
});
|
|
156
|
-
|
|
157
142
|
export const makeAddress = (
|
|
158
143
|
overrides: Partial<AddressItem> &
|
|
159
144
|
Pick<AddressItem, "addressId" | "normalizedEmail">,
|
|
@@ -286,8 +271,5 @@ export const asRepositories = (
|
|
|
286
271
|
filter: {
|
|
287
272
|
listByAccountConfig: async () => fixture.filters,
|
|
288
273
|
},
|
|
289
|
-
filterAnchor: {
|
|
290
|
-
listByAccountConfig: async () => fixture.anchors,
|
|
291
|
-
},
|
|
292
274
|
address: asAddressRepository(fixture),
|
|
293
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
|
}
|