@remit/drizzle-service 0.0.65 → 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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/drizzle-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.66",
|
|
4
4
|
"description": "Drizzle ORM service over SQLite",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"fix": "biome check --write src"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
+
"@remit/config-transfer": "*",
|
|
21
22
|
"drizzle-kit": "^0.31.1",
|
|
22
23
|
"tsx": "*"
|
|
23
24
|
},
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export * from "./repos/i4-account-config.js";
|
|
|
15
15
|
export * from "./repos/i4-account-export-request.js";
|
|
16
16
|
export * from "./repos/i4-account-setting.js";
|
|
17
17
|
export * from "./repos/i4-address.js";
|
|
18
|
+
export * from "./repos/i4-config-import.js";
|
|
18
19
|
export * from "./repos/i4-mailbox.js";
|
|
19
20
|
export * from "./repos/i4-mailbox-lock.js";
|
|
20
21
|
export * from "./repos/i4-mailbox-special-use.js";
|
|
@@ -49,6 +50,7 @@ export * from "./schema/i4-account-config.js";
|
|
|
49
50
|
export * from "./schema/i4-account-export-request.js";
|
|
50
51
|
export * from "./schema/i4-account-setting.js";
|
|
51
52
|
export * from "./schema/i4-address.js";
|
|
53
|
+
export * from "./schema/i4-config-import.js";
|
|
52
54
|
export * from "./schema/i4-mailbox.js";
|
|
53
55
|
export * from "./schema/i4-mailbox-lock.js";
|
|
54
56
|
export * from "./schema/i4-message-flag-push.js";
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConfigImportItem,
|
|
3
|
+
CreateConfigImportInput,
|
|
4
|
+
IConfigImportRepository,
|
|
5
|
+
UpdateConfigImportInput,
|
|
6
|
+
} from "@remit/data-ports";
|
|
7
|
+
import { desc, eq } from "drizzle-orm";
|
|
8
|
+
import type { Db } from "../db.js";
|
|
9
|
+
import { NotFoundError } from "../error.js";
|
|
10
|
+
import { randomId } from "../id.js";
|
|
11
|
+
import { configImportTable } from "../schema/i4-config-import.js";
|
|
12
|
+
|
|
13
|
+
type DB = Db<Record<string, unknown>>;
|
|
14
|
+
|
|
15
|
+
function rowToConfigImport(
|
|
16
|
+
row: typeof configImportTable.$inferSelect,
|
|
17
|
+
): ConfigImportItem {
|
|
18
|
+
return {
|
|
19
|
+
importId: row.importId,
|
|
20
|
+
accountConfigId: row.accountConfigId,
|
|
21
|
+
schemaVersion: row.schemaVersion,
|
|
22
|
+
state: row.state as ConfigImportItem["state"],
|
|
23
|
+
document: row.document as ConfigImportItem["document"],
|
|
24
|
+
unresolvedRefs: row.unresolvedRefs as ConfigImportItem["unresolvedRefs"],
|
|
25
|
+
createdAt: row.createdAt,
|
|
26
|
+
completedAt: row.completedAt,
|
|
27
|
+
updatedAt: row.updatedAt,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class ConfigImportRepo implements IConfigImportRepository {
|
|
32
|
+
constructor(private db: DB) {}
|
|
33
|
+
|
|
34
|
+
async create(input: CreateConfigImportInput): Promise<ConfigImportItem> {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
const [row] = await this.db
|
|
37
|
+
.insert(configImportTable)
|
|
38
|
+
.values({
|
|
39
|
+
importId: randomId(),
|
|
40
|
+
accountConfigId: input.accountConfigId,
|
|
41
|
+
schemaVersion: input.schemaVersion,
|
|
42
|
+
state: input.state ?? "Pending",
|
|
43
|
+
document: input.document as never,
|
|
44
|
+
unresolvedRefs: input.unresolvedRefs as never,
|
|
45
|
+
createdAt: now,
|
|
46
|
+
completedAt: input.completedAt ?? 0,
|
|
47
|
+
updatedAt: now,
|
|
48
|
+
})
|
|
49
|
+
.returning();
|
|
50
|
+
return rowToConfigImport(row);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async get(importId: string): Promise<ConfigImportItem> {
|
|
54
|
+
const [row] = await this.db
|
|
55
|
+
.select()
|
|
56
|
+
.from(configImportTable)
|
|
57
|
+
.where(eq(configImportTable.importId, importId));
|
|
58
|
+
if (!row) throw new NotFoundError(`ConfigImport not found: ${importId}`);
|
|
59
|
+
return rowToConfigImport(row);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async update(
|
|
63
|
+
importId: string,
|
|
64
|
+
input: UpdateConfigImportInput,
|
|
65
|
+
): Promise<ConfigImportItem> {
|
|
66
|
+
const updates: Partial<typeof configImportTable.$inferInsert> = {
|
|
67
|
+
updatedAt: Date.now(),
|
|
68
|
+
};
|
|
69
|
+
if (input.state !== undefined) updates.state = input.state;
|
|
70
|
+
if (input.unresolvedRefs !== undefined)
|
|
71
|
+
updates.unresolvedRefs = input.unresolvedRefs as never;
|
|
72
|
+
if (input.completedAt !== undefined)
|
|
73
|
+
updates.completedAt = input.completedAt;
|
|
74
|
+
|
|
75
|
+
const [row] = await this.db
|
|
76
|
+
.update(configImportTable)
|
|
77
|
+
.set(updates)
|
|
78
|
+
.where(eq(configImportTable.importId, importId))
|
|
79
|
+
.returning();
|
|
80
|
+
if (!row) throw new NotFoundError(`ConfigImport not found: ${importId}`);
|
|
81
|
+
return rowToConfigImport(row);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async listByAccountConfig(
|
|
85
|
+
accountConfigId: string,
|
|
86
|
+
): Promise<ConfigImportItem[]> {
|
|
87
|
+
const rows = await this.db
|
|
88
|
+
.select()
|
|
89
|
+
.from(configImportTable)
|
|
90
|
+
.where(eq(configImportTable.accountConfigId, accountConfigId))
|
|
91
|
+
.orderBy(desc(configImportTable.createdAt));
|
|
92
|
+
return rows.map(rowToConfigImport);
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -17,6 +17,7 @@ export * from "./schema/i4-account-config.js";
|
|
|
17
17
|
export * from "./schema/i4-account-export-request.js";
|
|
18
18
|
export * from "./schema/i4-account-setting.js";
|
|
19
19
|
export * from "./schema/i4-address.js";
|
|
20
|
+
export * from "./schema/i4-config-import.js";
|
|
20
21
|
export * from "./schema/i4-mailbox.js";
|
|
21
22
|
export * from "./schema/i4-mailbox-lock.js";
|
|
22
23
|
export * from "./schema/i4-message-flag-push.js";
|