@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.
- package/package.json +2 -1
- package/src/index.ts +2 -0
- package/src/repair/junk-only-address.sqlite.test.ts +221 -72
- package/src/repair/junk-only-address.ts +116 -68
- package/src/repos/config-import-atomicity.sqlite.test.ts +255 -0
- package/src/repos/i4-account-setting.ts +23 -1
- package/src/repos/i4-address-junk-move.sqlite.test.ts +119 -15
- package/src/repos/i4-address.ts +15 -7
- package/src/repos/i4-config-import.ts +94 -0
- package/src/repos/i4-mailbox-special-use.ts +161 -1
- package/src/schema/i4-config-import.ts +3 -0
- package/src/schema.ts +1 -0
|
@@ -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
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
86
|
-
AND NOT ${
|
|
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
|
-
|
|
89
|
-
|
|
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 = (
|
|
92
|
-
|
|
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 ${
|
|
116
|
+
WHERE ${predicate.sql}${scope.sql}`,
|
|
117
|
+
params: [now, setBy, now, ...predicate.params, ...scope.params],
|
|
118
|
+
};
|
|
119
|
+
};
|
|
97
120
|
|
|
98
|
-
export const restoreSql = (
|
|
99
|
-
|
|
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 ${
|
|
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:
|
|
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
|
|
122
|
-
const
|
|
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 {
|
|
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
|
-
|
|
169
|
+
withholdableCount === 0
|
|
130
170
|
? 0
|
|
131
|
-
: await client.run(
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
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[]> {
|