@remit/config-transfer 0.0.1 → 0.0.2

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.1",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
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
+ };
@@ -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
+ }