@remit/drizzle-service 0.0.64 → 0.0.66

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.
@@ -1,8 +1,4 @@
1
- import {
2
- JUNK_FOLDER_NAMES,
3
- TRASH_FOLDER_NAMES,
4
- } from "@remit/data-ports/mailbox-role";
5
- import { MailboxSpecialUse } from "@remit/domain-enums";
1
+ import type { JunkRoleMailboxes } from "@remit/data-ports/folder-role";
6
2
 
7
3
  export interface JunkOnlyRepairClient {
8
4
  all(sql: string, params: readonly unknown[]): Promise<unknown[]>;
@@ -19,6 +15,12 @@ export interface JunkOnlyReport {
19
15
  readonly restored: number;
20
16
  }
21
17
 
18
+ /** A statement or fragment with the values its `?` placeholders take, in order. */
19
+ export interface BoundSql {
20
+ readonly sql: string;
21
+ readonly params: readonly unknown[];
22
+ }
23
+
22
24
  export const JUNK_ONLY_FLAG = "junkOnly";
23
25
 
24
26
  const STORED_FLAGS = "coalesce(nullif(address.flags, ''), '{}')";
@@ -26,41 +28,23 @@ const STORED_FLAGS = "coalesce(nullif(address.flags, ''), '{}')";
26
28
  const flagIsSet = (name: string): string =>
27
29
  `coalesce(json_extract(${STORED_FLAGS}, '$.${name}.value'), 0) = 1`;
28
30
 
29
- const quoted = (names: readonly string[]): string =>
30
- names.map((name) => `'${name}'`).join(", ");
31
-
32
- const MAILBOX_LEAF = `lower(substr(
33
- mailbox.full_path,
34
- length(rtrim(
35
- mailbox.full_path,
36
- replace(mailbox.full_path, mailbox.hierarchy_delimiter, '')
37
- )) + 1
38
- ))`;
39
-
40
- const mailboxCarriesRole = (
41
- specialUse: string,
42
- names: readonly string[],
43
- ): string => `(
44
- exists (
45
- SELECT 1 FROM mailbox_special_use_entry entry
46
- WHERE entry.mailbox_id = message.mailbox_id
47
- AND entry.special_use = '${specialUse}'
48
- )
49
- OR exists (
50
- SELECT 1 FROM mailbox
51
- WHERE mailbox.mailbox_id = message.mailbox_id
52
- AND (
53
- mailbox.special_use LIKE '%"${specialUse}"%'
54
- OR ${MAILBOX_LEAF} IN (${quoted(names)})
55
- )
56
- )
57
- )`;
31
+ const EMPTY: BoundSql = { sql: "", params: [] };
58
32
 
59
- const IN_JUNK = mailboxCarriesRole(MailboxSpecialUse.Junk, JUNK_FOLDER_NAMES);
60
- const IN_TRASH = mailboxCarriesRole(
61
- MailboxSpecialUse.Trash,
62
- TRASH_FOLDER_NAMES,
63
- );
33
+ const MATCHES_NOTHING: BoundSql = { sql: "0 = 1", params: [] };
34
+
35
+ /**
36
+ * Whether the message sits in one of these mailboxes. An empty list is
37
+ * false — no mailbox holds the role, so no message is in one. That reading is
38
+ * right for Trash and wrong for Junk, which is why an unresolvable Junk folder
39
+ * is handled before this is ever reached.
40
+ */
41
+ const inMailboxes = (mailboxIds: readonly string[]): BoundSql =>
42
+ mailboxIds.length === 0
43
+ ? { sql: "(0 = 1)", params: [] }
44
+ : {
45
+ sql: `(message.mailbox_id IN (${mailboxIds.map(() => "?").join(", ")}))`,
46
+ params: mailboxIds,
47
+ };
64
48
 
65
49
  const sightingWhere = (extra: string): string => `exists (
66
50
  SELECT 1 FROM envelope_address
@@ -68,47 +52,95 @@ const sightingWhere = (extra: string): string => `exists (
68
52
  WHERE envelope_address.address_id = address.address_id${extra}
69
53
  )`;
70
54
 
71
- const SIGHTING_IN_JUNK = sightingWhere(` AND ${IN_JUNK}`);
72
- const SIGHTING_IN_LIVE_MAIL = sightingWhere(
73
- ` AND NOT ${IN_JUNK} AND NOT ${IN_TRASH}`,
74
- );
75
-
76
- export const ACCOUNT_HAS_CORRESPONDED = `(
55
+ const ACCOUNT_HAS_CORRESPONDED = `(
77
56
  address.outbound_count > 0
78
57
  OR address.reply_count > 0
79
58
  OR ${flagIsSet("vip")}
80
59
  OR ${flagIsSet("trusted")}
81
60
  )`;
82
61
 
83
- export const WITHHOLDABLE = `NOT ${flagIsSet(JUNK_ONLY_FLAG)}
62
+ /**
63
+ * No account in scope has a Junk folder, so nothing is known about any
64
+ * sighting: the mark cannot be earned. Silence is not the same as "every
65
+ * message is live mail" — read that way, one move would restore every address
66
+ * the sweep had withheld.
67
+ */
68
+ const withholdable = (roles: JunkRoleMailboxes): BoundSql => {
69
+ if (roles.junkMailboxIds.length === 0) return MATCHES_NOTHING;
70
+ const inJunk = inMailboxes(roles.junkMailboxIds);
71
+ const inTrash = inMailboxes(roles.trashMailboxIds);
72
+ return {
73
+ sql: `NOT ${flagIsSet(JUNK_ONLY_FLAG)}
84
74
  AND NOT ${ACCOUNT_HAS_CORRESPONDED}
85
- AND ${SIGHTING_IN_JUNK}
86
- AND NOT ${SIGHTING_IN_LIVE_MAIL}`;
75
+ AND ${sightingWhere(` AND ${inJunk.sql}`)}
76
+ AND NOT ${sightingWhere(` AND NOT ${inJunk.sql} AND NOT ${inTrash.sql}`)}`,
77
+ params: [...inJunk.params, ...inJunk.params, ...inTrash.params],
78
+ };
79
+ };
87
80
 
88
- export const RESTORABLE = `${flagIsSet(JUNK_ONLY_FLAG)}
89
- AND (${ACCOUNT_HAS_CORRESPONDED} OR ${SIGHTING_IN_LIVE_MAIL})`;
81
+ /**
82
+ * The same silence lifts no mark either — "stands on live mail" is
83
+ * unanswerable without knowing which folder is Junk. Standing the account
84
+ * gave the address itself still lifts it: that evidence needs no folder.
85
+ */
86
+ const restorable = (roles: JunkRoleMailboxes): BoundSql => {
87
+ if (roles.junkMailboxIds.length === 0) {
88
+ return {
89
+ sql: `${flagIsSet(JUNK_ONLY_FLAG)}
90
+ AND ${ACCOUNT_HAS_CORRESPONDED}`,
91
+ params: [],
92
+ };
93
+ }
94
+ const inJunk = inMailboxes(roles.junkMailboxIds);
95
+ const inTrash = inMailboxes(roles.trashMailboxIds);
96
+ return {
97
+ sql: `${flagIsSet(JUNK_ONLY_FLAG)}
98
+ AND (${ACCOUNT_HAS_CORRESPONDED}
99
+ OR ${sightingWhere(` AND NOT ${inJunk.sql} AND NOT ${inTrash.sql}`)})`,
100
+ params: [...inJunk.params, ...inTrash.params],
101
+ };
102
+ };
90
103
 
91
- export const withholdSql = (scope = ""): string =>
92
- `UPDATE address
104
+ export const withholdSql = (
105
+ roles: JunkRoleMailboxes,
106
+ now: number,
107
+ setBy: string,
108
+ scope: BoundSql = EMPTY,
109
+ ): BoundSql => {
110
+ const predicate = withholdable(roles);
111
+ return {
112
+ sql: `UPDATE address
93
113
  SET flags = json_set(${STORED_FLAGS}, '$.${JUNK_ONLY_FLAG}',
94
114
  json_object('value', json('true'), 'setAt', CAST(? AS INTEGER), 'setBy', ?)),
95
115
  updated_at = ?
96
- WHERE ${WITHHOLDABLE}${scope}`;
116
+ WHERE ${predicate.sql}${scope.sql}`,
117
+ params: [now, setBy, now, ...predicate.params, ...scope.params],
118
+ };
119
+ };
97
120
 
98
- export const restoreSql = (scope = ""): string =>
99
- `UPDATE address
121
+ export const restoreSql = (
122
+ roles: JunkRoleMailboxes,
123
+ now: number,
124
+ scope: BoundSql = EMPTY,
125
+ ): BoundSql => {
126
+ const predicate = restorable(roles);
127
+ return {
128
+ sql: `UPDATE address
100
129
  SET flags = json_remove(${STORED_FLAGS}, '$.${JUNK_ONLY_FLAG}'), updated_at = ?
101
- WHERE ${RESTORABLE}${scope}`;
130
+ WHERE ${predicate.sql}${scope.sql}`,
131
+ params: [now, ...predicate.params, ...scope.params],
132
+ };
133
+ };
102
134
 
103
135
  const REPAIR_SET_BY = "junk-only-repair";
104
136
 
105
137
  const countWhere = async (
106
138
  client: JunkOnlyRepairClient,
107
- predicate: string,
139
+ predicate: BoundSql,
108
140
  ): Promise<number> => {
109
141
  const [row] = (await client.all(
110
- `SELECT count(*) AS row_count FROM address WHERE ${predicate}`,
111
- [],
142
+ `SELECT count(*) AS row_count FROM address WHERE ${predicate.sql}`,
143
+ predicate.params,
112
144
  )) as { row_count: number }[];
113
145
  return row?.row_count ?? 0;
114
146
  };
@@ -116,23 +148,39 @@ const countWhere = async (
116
148
  export const sweepJunkOnlyAddresses = async (
117
149
  client: JunkOnlyRepairClient,
118
150
  mode: JunkOnlyRepairMode,
151
+ roles: JunkRoleMailboxes,
119
152
  now: number = Date.now(),
120
153
  ): Promise<JunkOnlyReport> => {
121
- const withholdable = await countWhere(client, WITHHOLDABLE);
122
- const restorable = await countWhere(client, RESTORABLE);
154
+ const withholdableCount = await countWhere(client, withholdable(roles));
155
+ const restorableCount = await countWhere(client, restorable(roles));
123
156
 
124
157
  if (mode === "check") {
125
- return { mode, withholdable, withheld: 0, restorable, restored: 0 };
158
+ return {
159
+ mode,
160
+ withholdable: withholdableCount,
161
+ withheld: 0,
162
+ restorable: restorableCount,
163
+ restored: 0,
164
+ };
126
165
  }
127
166
 
167
+ const withhold = withholdSql(roles, now, REPAIR_SET_BY);
128
168
  const withheld =
129
- withholdable === 0
169
+ withholdableCount === 0
130
170
  ? 0
131
- : await client.run(withholdSql(), [now, REPAIR_SET_BY, now]);
132
-
133
- const restored = restorable === 0 ? 0 : await client.run(restoreSql(), [now]);
134
-
135
- return { mode, withholdable, withheld, restorable, restored };
171
+ : await client.run(withhold.sql, withhold.params);
172
+
173
+ const restore = restoreSql(roles, now);
174
+ const restored =
175
+ restorableCount === 0 ? 0 : await client.run(restore.sql, restore.params);
176
+
177
+ return {
178
+ mode,
179
+ withholdable: withholdableCount,
180
+ withheld,
181
+ restorable: restorableCount,
182
+ restored,
183
+ };
136
184
  };
137
185
 
138
186
  export const formatJunkOnlyReport = (report: JunkOnlyReport): string[] => {
@@ -0,0 +1,255 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import {
4
+ type ConfigImportRepositories,
5
+ importConfig,
6
+ } from "@remit/config-transfer";
7
+ import type { Db } from "../db.js";
8
+ import {
9
+ accountConfigTable,
10
+ accountSettingTable,
11
+ accountTable,
12
+ addressTable,
13
+ configImportTable,
14
+ filterAnchorTable,
15
+ filterTable,
16
+ labelTable,
17
+ mailboxTable,
18
+ } from "../schema.js";
19
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
20
+ import { runInTransaction, serializeSqliteWrites } from "../tx.js";
21
+ import { FilterRepo } from "./filter.js";
22
+ import { FilterAnchorRepo } from "./filter-anchor.js";
23
+ import { AccountRepo } from "./i4-account.js";
24
+ import { AccountConfigRepo } from "./i4-account-config.js";
25
+ import { AccountSettingRepo } from "./i4-account-setting.js";
26
+ import { AddressRepo } from "./i4-address.js";
27
+ import { ConfigImportRepo } from "./i4-config-import.js";
28
+ import { MailboxRepo } from "./i4-mailbox.js";
29
+ import { LabelRepo } from "./label.js";
30
+
31
+ // The import claims to be atomic, and the unit tests prove that claim against a
32
+ // fake that rolls back because it was written to. This one puts the claim on the
33
+ // real thing: the repos the backend composes, over better-sqlite3, inside the
34
+ // real `runInTransaction` savepoint. A filter write is made to fail after the
35
+ // account, the label and the configuration row have already been inserted — the
36
+ // exact shape of a half-applied import — and nothing may survive it.
37
+
38
+ // Every table the import touches, and only those: `pushSchema` creates exactly
39
+ // what this object names.
40
+ const IMPORT_SCHEMA = {
41
+ accountConfig: accountConfigTable,
42
+ account: accountTable,
43
+ accountSetting: accountSettingTable,
44
+ address: addressTable,
45
+ configImport: configImportTable,
46
+ filter: filterTable,
47
+ filterAnchor: filterAnchorTable,
48
+ label: labelTable,
49
+ mailbox: mailboxTable,
50
+ };
51
+
52
+ type ImportSchema = typeof IMPORT_SCHEMA;
53
+
54
+ const CONFIG_ID = "0d9c8b7a6e5f4d3cb2a10f9e8";
55
+ const ACCOUNT_ID = "6f4c2c309a2c4a7f9f9f1f2c3";
56
+ const USER_ID = "7c1f0a2e-3b4d-4c5e-8f90-a1b2c3d4e5f6";
57
+ const MESSAGE_ID = "c4b3a2918f7e6d5c4b3a29187";
58
+
59
+ const document = () => ({
60
+ kind: "reader.config",
61
+ schemaVersion: 1,
62
+ generator: {
63
+ app: "reader",
64
+ version: "v0.1.0",
65
+ exportedAt: "2026-08-27T09:15:00+02:00",
66
+ },
67
+ provenance: { accountConfigId: CONFIG_ID, instance: "reader.ischen.nl" },
68
+ accountConfig: { name: "Matthijs" },
69
+ accounts: [
70
+ {
71
+ accountId: ACCOUNT_ID,
72
+ email: "matthijs@ischen.nl",
73
+ username: "matthijs@ischen.nl",
74
+ authType: "password",
75
+ credentials: { required: "password" },
76
+ isActive: true,
77
+ imap: { host: "imap.ischen.nl", port: 993, tls: true, startTls: false },
78
+ smtp: {
79
+ enabled: true,
80
+ host: "smtp.ischen.nl",
81
+ port: 587,
82
+ tls: false,
83
+ startTls: true,
84
+ username: "",
85
+ },
86
+ displayName: "Matthijs",
87
+ muted: null,
88
+ composeLanguages: ["nl"],
89
+ signature: { plainText: "Matthijs", html: "<p>Matthijs</p>" },
90
+ folderRoles: [],
91
+ folderOverrides: [],
92
+ pinnedFolders: ["INBOX"],
93
+ },
94
+ ],
95
+ labels: [{ name: "Facturen", color: "Default" }],
96
+ filters: [
97
+ {
98
+ name: "Invoices",
99
+ scope: "Standing",
100
+ expiresAt: null,
101
+ matchOperator: "And",
102
+ literalClauses: [{ field: "From", value: "billing@example.com" }],
103
+ actionLabelName: "Facturen",
104
+ actionFolder: null,
105
+ anchor: {
106
+ sourceText: "the release note this filter was drawn from",
107
+ embeddingId: "amazon.titan-embed-text-v2:0@1024",
108
+ sourceMessageId: MESSAGE_ID,
109
+ },
110
+ },
111
+ ],
112
+ addressFlags: [],
113
+ });
114
+
115
+ describe("a config import is atomic over the real sqlite savepoint", () => {
116
+ let db: Db<ImportSchema>;
117
+ let close: () => Promise<void>;
118
+ let repositories: ConfigImportRepositories;
119
+
120
+ before(async () => {
121
+ const created = await createSqliteTestDb<ImportSchema>(IMPORT_SCHEMA);
122
+ close = created.close;
123
+ db = serializeSqliteWrites(created.db);
124
+
125
+ // `as never` is how every repo test hands over a pushed test schema: the
126
+ // repos declare the widened `Db<Record<string, unknown>>`.
127
+ repositories = {
128
+ accountConfig: new AccountConfigRepo(db as never),
129
+ account: new AccountRepo(db as never),
130
+ accountSetting: new AccountSettingRepo(db as never),
131
+ mailbox: new MailboxRepo(db as never),
132
+ label: new LabelRepo(db as never),
133
+ filter: new FilterRepo(db as never),
134
+ filterAnchor: new FilterAnchorRepo(db as never),
135
+ address: new AddressRepo(db as never),
136
+ configImport: new ConfigImportRepo(db as never),
137
+ };
138
+ });
139
+
140
+ after(async () => {
141
+ await close();
142
+ });
143
+
144
+ test("a filter write that fails rolls back the account, the label and the configuration row", async () => {
145
+ // Delegating rather than spreading: the repos are class instances, and a
146
+ // spread would copy no prototype method at all.
147
+ const refusingFilters: ConfigImportRepositories["filter"] = {
148
+ listByAccountConfig: (...args) =>
149
+ repositories.filter.listByAccountConfig(...args),
150
+ update: (...args) => repositories.filter.update(...args),
151
+ create: () => Promise.reject(new Error("filter write refused")),
152
+ };
153
+
154
+ const outcome = await importConfig(
155
+ {
156
+ repositories: { ...repositories, filter: refusingFilters },
157
+ appointFolderRole: () => {
158
+ throw new Error("this document names no folder roles");
159
+ },
160
+ transaction: (run) => runInTransaction(db, () => run()),
161
+ embedAnchor: async () => ({
162
+ embedding: [0.11, 0.22, 0.33],
163
+ embeddingId: "amazon.titan-embed-text-v2:0@1024",
164
+ }),
165
+ },
166
+ {
167
+ accountConfigId: CONFIG_ID,
168
+ userId: USER_ID,
169
+ document: document(),
170
+ mode: "apply",
171
+ onExisting: "abort",
172
+ },
173
+ );
174
+
175
+ assert.equal(outcome.outcome, "report");
176
+ if (outcome.outcome !== "report") throw new Error("unreachable");
177
+ assert.equal(outcome.report.applied, false);
178
+ assert.equal(outcome.report.errors[0]?.code, "import_write_failed");
179
+ assert.match(
180
+ outcome.report.errors[0]?.message ?? "",
181
+ /Nothing was written/,
182
+ );
183
+
184
+ // The store itself, read back through the same repos. Accounts and labels
185
+ // are written before filters, so a savepoint that did not roll back would
186
+ // leave both here.
187
+ assert.deepEqual(
188
+ await repositories.account.listAllByAccountConfig(CONFIG_ID),
189
+ [],
190
+ );
191
+ assert.deepEqual(
192
+ await repositories.label.listByAccountConfig(CONFIG_ID),
193
+ [],
194
+ );
195
+ assert.deepEqual(
196
+ await repositories.filter.listByAccountConfig(CONFIG_ID),
197
+ [],
198
+ );
199
+ assert.deepEqual(
200
+ await repositories.configImport.listByAccountConfig(CONFIG_ID),
201
+ [],
202
+ );
203
+ await assert.rejects(() => repositories.accountConfig.get(CONFIG_ID));
204
+ });
205
+
206
+ test("the same document applies whole once the write succeeds", async () => {
207
+ const outcome = await importConfig(
208
+ {
209
+ repositories,
210
+ appointFolderRole: () => {
211
+ throw new Error("this document names no folder roles");
212
+ },
213
+ transaction: (run) => runInTransaction(db, () => run()),
214
+ embedAnchor: async () => ({
215
+ embedding: [0.11, 0.22, 0.33],
216
+ embeddingId: "amazon.titan-embed-text-v2:0@1024",
217
+ }),
218
+ },
219
+ {
220
+ accountConfigId: CONFIG_ID,
221
+ userId: USER_ID,
222
+ document: document(),
223
+ mode: "apply",
224
+ onExisting: "abort",
225
+ },
226
+ );
227
+
228
+ assert.equal(outcome.outcome, "report");
229
+ if (outcome.outcome !== "report") throw new Error("unreachable");
230
+ assert.equal(outcome.report.applied, true);
231
+ assert.equal(outcome.report.errors.length, 0);
232
+
233
+ const accounts =
234
+ await repositories.account.listAllByAccountConfig(CONFIG_ID);
235
+ assert.equal(accounts.length, 1);
236
+ assert.equal(accounts[0]?.accountId, ACCOUNT_ID);
237
+ assert.equal(accounts[0]?.isActive, false);
238
+ assert.equal(
239
+ (await repositories.label.listByAccountConfig(CONFIG_ID)).length,
240
+ 1,
241
+ );
242
+ assert.equal(
243
+ (await repositories.filter.listByAccountConfig(CONFIG_ID)).length,
244
+ 1,
245
+ );
246
+ assert.equal(
247
+ (await repositories.configImport.listByAccountConfig(CONFIG_ID)).length,
248
+ 1,
249
+ );
250
+ assert.equal(
251
+ (await repositories.accountConfig.get(CONFIG_ID)).name,
252
+ "Matthijs",
253
+ );
254
+ });
255
+ });
@@ -5,7 +5,7 @@ import type {
5
5
  IAccountSettingRepository,
6
6
  UpsertAccountSettingInput,
7
7
  } from "@remit/data-ports";
8
- import { eq } from "drizzle-orm";
8
+ import { and, eq, inArray } from "drizzle-orm";
9
9
  import type { Db } from "../db.js";
10
10
  import { accountSettingTable } from "../schema/i4-account-setting.js";
11
11
 
@@ -48,6 +48,28 @@ export class AccountSettingRepo implements IAccountSettingRepository {
48
48
  return row ? rowToAccountSetting(row) : null;
49
49
  }
50
50
 
51
+ /**
52
+ * The named settings of several configs in one read, for callers that would
53
+ * otherwise `get` once per account. Missing rows are simply absent from the
54
+ * result rather than an error.
55
+ */
56
+ async getMany(
57
+ accountConfigIds: readonly string[],
58
+ names: readonly string[],
59
+ ): Promise<AccountSettingItem[]> {
60
+ if (accountConfigIds.length === 0 || names.length === 0) return [];
61
+ const rows = await this.db
62
+ .select()
63
+ .from(accountSettingTable)
64
+ .where(
65
+ and(
66
+ inArray(accountSettingTable.accountConfigId, [...accountConfigIds]),
67
+ inArray(accountSettingTable.name, [...names]),
68
+ ),
69
+ );
70
+ return rows.map(rowToAccountSetting);
71
+ }
72
+
51
73
  async listByAccountConfig(
52
74
  accountConfigId: string,
53
75
  ): Promise<AccountSettingItem[]> {