@remit/config-transfer 0.0.1 → 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 +1 -1
- package/src/binder.ts +184 -0
- package/src/export.test.ts +51 -1
- package/src/export.ts +96 -10
- package/src/fixtures.ts +17 -0
- package/src/import-repositories.ts +97 -0
- package/src/import.test.ts +1200 -0
- package/src/import.ts +1165 -0
- package/src/index.ts +34 -1
- package/src/report.ts +69 -0
package/package.json
CHANGED
package/src/binder.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { readConfigDocument } from "@remit/config-format";
|
|
2
|
+
import type {
|
|
3
|
+
ConfigImportItem,
|
|
4
|
+
ConfigImportUnresolvedRefItem,
|
|
5
|
+
IAccountSettingRepository,
|
|
6
|
+
IConfigImportRepository,
|
|
7
|
+
IFilterRepository,
|
|
8
|
+
IMailboxRepository,
|
|
9
|
+
} from "@remit/data-ports";
|
|
10
|
+
import { composeSettingName } from "@remit/data-ports/account-settings";
|
|
11
|
+
import {
|
|
12
|
+
AccountSettingName,
|
|
13
|
+
ConfigImportRefKind,
|
|
14
|
+
ConfigImportState,
|
|
15
|
+
} from "@remit/domain-enums";
|
|
16
|
+
import type { AppointFolderRole } from "./import-repositories.js";
|
|
17
|
+
|
|
18
|
+
export interface ConfigBinderRepositories {
|
|
19
|
+
configImport: Pick<IConfigImportRepository, "listByAccountConfig" | "update">;
|
|
20
|
+
accountSetting: Pick<IAccountSettingRepository, "upsert">;
|
|
21
|
+
filter: Pick<IFilterRepository, "update">;
|
|
22
|
+
mailbox: Pick<IMailboxRepository, "listAllByAccount">;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ConfigBinderDeps {
|
|
26
|
+
repositories: ConfigBinderRepositories;
|
|
27
|
+
appointFolderRole: AppointFolderRole;
|
|
28
|
+
now?: () => number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What one folder path an import was waiting for is still waiting for, told as
|
|
33
|
+
* a count so a caller can log it without walking the refs again.
|
|
34
|
+
*/
|
|
35
|
+
export interface BindResult {
|
|
36
|
+
bound: number;
|
|
37
|
+
stillPending: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Bind the folder references an import could not resolve, now that discovery
|
|
42
|
+
* has produced this account's mailboxes.
|
|
43
|
+
*
|
|
44
|
+
* Every reference names a folder by IMAP path, because the ids are new on every
|
|
45
|
+
* discovery. This runs where discovery ends, resolves what it can, writes the
|
|
46
|
+
* row each reference belongs to, and drops it. An import whose last reference
|
|
47
|
+
* is gone is Complete; the rest stay, and surface on `GET /config` until the
|
|
48
|
+
* folder they name shows up or the person removes the expectation.
|
|
49
|
+
*
|
|
50
|
+
* Idempotent and replayable: every write is an upsert or an update onto a row
|
|
51
|
+
* the import already created, so a second discovery finds nothing to do rather
|
|
52
|
+
* than doing it again differently.
|
|
53
|
+
*/
|
|
54
|
+
export const bindImportedFolders = async (
|
|
55
|
+
deps: ConfigBinderDeps,
|
|
56
|
+
accountConfigId: string,
|
|
57
|
+
accountId: string,
|
|
58
|
+
): Promise<BindResult> => {
|
|
59
|
+
const { repositories } = deps;
|
|
60
|
+
const now = deps.now ?? Date.now;
|
|
61
|
+
|
|
62
|
+
const imports = (
|
|
63
|
+
await repositories.configImport.listByAccountConfig(accountConfigId)
|
|
64
|
+
).filter((row) => row.state === ConfigImportState.Pending);
|
|
65
|
+
if (imports.length === 0) return { bound: 0, stillPending: 0 };
|
|
66
|
+
|
|
67
|
+
const byPath = new Map(
|
|
68
|
+
(await repositories.mailbox.listAllByAccount(accountId)).map(
|
|
69
|
+
(mailbox) => [mailbox.fullPath, mailbox.mailboxId] as const,
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
let bound = 0;
|
|
74
|
+
let stillPending = 0;
|
|
75
|
+
|
|
76
|
+
for (const row of imports) {
|
|
77
|
+
const document = readConfigDocument(row.document);
|
|
78
|
+
const remaining: ConfigImportUnresolvedRefItem[] = [];
|
|
79
|
+
|
|
80
|
+
for (const ref of row.unresolvedRefs) {
|
|
81
|
+
const mailboxId =
|
|
82
|
+
ref.accountId === accountId ? byPath.get(ref.folderPath) : undefined;
|
|
83
|
+
if (mailboxId === undefined) {
|
|
84
|
+
remaining.push(ref);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
await bindRef(deps, accountConfigId, ref, mailboxId, document);
|
|
88
|
+
bound++;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
stillPending += remaining.length;
|
|
92
|
+
if (remaining.length === row.unresolvedRefs.length) continue;
|
|
93
|
+
|
|
94
|
+
await repositories.configImport.update(row.importId, {
|
|
95
|
+
unresolvedRefs: remaining,
|
|
96
|
+
...(remaining.length === 0
|
|
97
|
+
? { state: ConfigImportState.Complete, completedAt: now() }
|
|
98
|
+
: {}),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { bound, stillPending };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
type BoundDocument = ReturnType<typeof readConfigDocument>;
|
|
106
|
+
|
|
107
|
+
const bindRef = async (
|
|
108
|
+
deps: ConfigBinderDeps,
|
|
109
|
+
accountConfigId: string,
|
|
110
|
+
ref: ConfigImportUnresolvedRefItem,
|
|
111
|
+
mailboxId: string,
|
|
112
|
+
document: BoundDocument,
|
|
113
|
+
): Promise<void> => {
|
|
114
|
+
const { repositories } = deps;
|
|
115
|
+
if (ref.kind === ConfigImportRefKind.FilterAction) {
|
|
116
|
+
await repositories.filter.update(accountConfigId, ref.target, {
|
|
117
|
+
actionMailboxId: mailboxId,
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (ref.kind === ConfigImportRefKind.FolderRole) {
|
|
123
|
+
await deps.appointFolderRole(
|
|
124
|
+
accountConfigId,
|
|
125
|
+
ref.accountId,
|
|
126
|
+
ref.target,
|
|
127
|
+
mailboxId,
|
|
128
|
+
ref.folderPath,
|
|
129
|
+
);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The account the file named, when this instance still holds it under that
|
|
134
|
+
// id — it does for every account the import created, because the id crosses
|
|
135
|
+
// verbatim. A merge onto an account already here can have renumbered it, and
|
|
136
|
+
// then the path is the only thing left to match on.
|
|
137
|
+
const named = document.accounts.find(
|
|
138
|
+
(account) => account.accountId === ref.accountId,
|
|
139
|
+
);
|
|
140
|
+
const override = (
|
|
141
|
+
named
|
|
142
|
+
? named.folderOverrides
|
|
143
|
+
: document.accounts.flatMap((a) => a.folderOverrides)
|
|
144
|
+
).find((candidate) => candidate.folderPath === ref.folderPath);
|
|
145
|
+
if (!override) return;
|
|
146
|
+
|
|
147
|
+
if (override.displayName !== "") {
|
|
148
|
+
await repositories.accountSetting.upsert({
|
|
149
|
+
accountConfigId,
|
|
150
|
+
name: composeSettingName(
|
|
151
|
+
AccountSettingName.MailboxDisplayName,
|
|
152
|
+
mailboxId,
|
|
153
|
+
),
|
|
154
|
+
value: { kind: "String", value: override.displayName },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (override.muted !== null) {
|
|
158
|
+
await repositories.accountSetting.upsert({
|
|
159
|
+
accountConfigId,
|
|
160
|
+
name: composeSettingName(AccountSettingName.MailboxMuted, mailboxId),
|
|
161
|
+
value: { kind: "MutedFlag", value: override.muted },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The folder paths an import is still waiting on, for `GET /config`. The newest
|
|
168
|
+
* pending import only: an older one waiting for the same folder is waiting for
|
|
169
|
+
* the same thing, and naming it twice tells nobody anything.
|
|
170
|
+
*/
|
|
171
|
+
export const pendingImportOf = (
|
|
172
|
+
imports: readonly ConfigImportItem[],
|
|
173
|
+
): { importId: string; folderPaths: string[] } | undefined => {
|
|
174
|
+
const pending = imports
|
|
175
|
+
.filter((row) => row.state === ConfigImportState.Pending)
|
|
176
|
+
.sort((a, b) => b.createdAt - a.createdAt)[0];
|
|
177
|
+
if (!pending || pending.unresolvedRefs.length === 0) return undefined;
|
|
178
|
+
return {
|
|
179
|
+
importId: pending.importId,
|
|
180
|
+
folderPaths: [
|
|
181
|
+
...new Set(pending.unresolvedRefs.map((ref) => ref.folderPath)),
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
};
|
package/src/export.test.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccountConfigItem,
|
|
3
|
+
CreateAccountConfigInput,
|
|
4
|
+
IAccountRepository,
|
|
5
|
+
IAccountSettingRepository,
|
|
6
|
+
IAddressRepository,
|
|
7
|
+
IConfigImportRepository,
|
|
8
|
+
IFilterAnchorRepository,
|
|
9
|
+
IFilterRepository,
|
|
10
|
+
ILabelRepository,
|
|
11
|
+
IMailboxRepository,
|
|
12
|
+
UpdateAccountConfigInput,
|
|
13
|
+
} from "@remit/data-ports";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Everything the import reads and writes, and nothing else. Repositories rather
|
|
17
|
+
* than a client or an HTTP surface, for the same reason the export takes them:
|
|
18
|
+
* what a configuration contains is decided here, not by whichever process
|
|
19
|
+
* happens to be holding the file.
|
|
20
|
+
*/
|
|
21
|
+
export interface ConfigImportRepositories {
|
|
22
|
+
accountConfig: {
|
|
23
|
+
get: (accountConfigId: string) => Promise<AccountConfigItem>;
|
|
24
|
+
create: (input: CreateAccountConfigInput) => Promise<AccountConfigItem>;
|
|
25
|
+
update: (
|
|
26
|
+
accountConfigId: string,
|
|
27
|
+
input: UpdateAccountConfigInput,
|
|
28
|
+
) => Promise<AccountConfigItem>;
|
|
29
|
+
};
|
|
30
|
+
account: Pick<
|
|
31
|
+
IAccountRepository,
|
|
32
|
+
"listAllByAccountConfig" | "create" | "update"
|
|
33
|
+
>;
|
|
34
|
+
accountSetting: Pick<
|
|
35
|
+
IAccountSettingRepository,
|
|
36
|
+
"listByAccountConfig" | "upsert"
|
|
37
|
+
>;
|
|
38
|
+
mailbox: Pick<IMailboxRepository, "listAllByAccount">;
|
|
39
|
+
label: Pick<ILabelRepository, "listByAccountConfig" | "create" | "update">;
|
|
40
|
+
filter: Pick<IFilterRepository, "listByAccountConfig" | "create" | "update">;
|
|
41
|
+
filterAnchor: Pick<IFilterAnchorRepository, "put">;
|
|
42
|
+
address: Pick<
|
|
43
|
+
IAddressRepository,
|
|
44
|
+
"listByAccountConfig" | "upsertAddress" | "mergeFlags"
|
|
45
|
+
>;
|
|
46
|
+
configImport: Pick<
|
|
47
|
+
IConfigImportRepository,
|
|
48
|
+
"create" | "update" | "listByAccountConfig"
|
|
49
|
+
>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A filter anchor, re-embedded from the source text the file carries. The
|
|
54
|
+
* stored vector is a function of the model that produced it, so it never
|
|
55
|
+
* travels; this is the one thing an import has to compute rather than copy.
|
|
56
|
+
*
|
|
57
|
+
* Optional, and free to answer `undefined`, because a configuration file has to
|
|
58
|
+
* import on a deployment that ships no embedding model at all. Either way the
|
|
59
|
+
* anchor lands stamped `ANCHOR_EMBEDDING_PENDING`, which the existing lazy
|
|
60
|
+
* repair (`refreshAnchorForEmbedder`) treats as needing a fresh vector the
|
|
61
|
+
* first time the filter is matched.
|
|
62
|
+
*/
|
|
63
|
+
export type EmbedAnchor = (
|
|
64
|
+
sourceText: string,
|
|
65
|
+
) => Promise<{ embedding: number[]; embeddingId: string } | undefined>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Persist one role's folder appointment. Injected rather than composed here:
|
|
69
|
+
* an appointment row means a person decided, and it keeps that meaning only
|
|
70
|
+
* while one writer produces it — the same one every other appointment in the
|
|
71
|
+
* repo goes through.
|
|
72
|
+
*/
|
|
73
|
+
export type AppointFolderRole = (
|
|
74
|
+
accountConfigId: string,
|
|
75
|
+
accountId: string,
|
|
76
|
+
role: string,
|
|
77
|
+
mailboxId: string,
|
|
78
|
+
lastKnownPath: string,
|
|
79
|
+
) => Promise<void>;
|
|
80
|
+
|
|
81
|
+
export interface ConfigImportDeps {
|
|
82
|
+
repositories: ConfigImportRepositories;
|
|
83
|
+
appointFolderRole: AppointFolderRole;
|
|
84
|
+
/**
|
|
85
|
+
* Runs the whole apply as one write set, rolling back on any throw.
|
|
86
|
+
*
|
|
87
|
+
* Optional, and its presence is the fact the report words itself from: a
|
|
88
|
+
* backend that supplies this rolls a failed apply back to nothing, and one
|
|
89
|
+
* that does not leaves behind whatever landed before the failure. Only the
|
|
90
|
+
* caller knows which, so it says so by passing this or omitting it rather
|
|
91
|
+
* than by handing over a pass-through that would claim atomicity it has not
|
|
92
|
+
* got.
|
|
93
|
+
*/
|
|
94
|
+
transaction?: <T>(run: () => Promise<T>) => Promise<T>;
|
|
95
|
+
embedAnchor?: EmbedAnchor;
|
|
96
|
+
now?: () => number;
|
|
97
|
+
}
|