@remit/drizzle-service 0.0.105 → 0.0.106
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
package/src/index.ts
CHANGED
|
@@ -22,6 +22,11 @@ export {
|
|
|
22
22
|
} from "./databases.js";
|
|
23
23
|
export type { RepositoryDb, SqliteStore } from "./db.js";
|
|
24
24
|
export { CreateFailedConflictError, NotFoundError } from "./error.js";
|
|
25
|
+
export {
|
|
26
|
+
accountIdsOf,
|
|
27
|
+
eraseAccountConfigAppRows,
|
|
28
|
+
runDrizzleAccountConfigErase,
|
|
29
|
+
} from "./repos/account-config-erase.js";
|
|
25
30
|
export { CalendarCollectionRepo } from "./repos/calendar-collection.js";
|
|
26
31
|
export { CalendarEventIndexRepo } from "./repos/calendar-event-index.js";
|
|
27
32
|
export { CalendarFeedTokenRepo } from "./repos/calendar-feed-token.js";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { eq, getTableColumns } from "drizzle-orm";
|
|
4
|
+
import {
|
|
5
|
+
getTableConfig,
|
|
6
|
+
type SQLiteColumn,
|
|
7
|
+
SQLiteTable,
|
|
8
|
+
} from "drizzle-orm/sqlite-core";
|
|
9
|
+
import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
|
|
10
|
+
import { syncStores } from "../databases.js";
|
|
11
|
+
import * as appTables from "../schema-sets/app.js";
|
|
12
|
+
import * as mailTables from "../schema-sets/mail.js";
|
|
13
|
+
import { createTestDb, type TestDb } from "../test-db.js";
|
|
14
|
+
import { runDrizzleAccountConfigErase } from "./account-config-erase.js";
|
|
15
|
+
|
|
16
|
+
const log = { info: () => {} };
|
|
17
|
+
|
|
18
|
+
const ERASED = "cfg-erased";
|
|
19
|
+
const KEPT = "cfg-kept";
|
|
20
|
+
|
|
21
|
+
const ownerIdsOf = (tenant: string): Record<string, string> => ({
|
|
22
|
+
account_config_id: tenant,
|
|
23
|
+
account_id: `${tenant}-account`,
|
|
24
|
+
mailbox_id: `${tenant}-mailbox`,
|
|
25
|
+
message_id: `${tenant}-message`,
|
|
26
|
+
calendar_id: `${tenant}-calendar`,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
interface Owned {
|
|
30
|
+
readonly name: string;
|
|
31
|
+
readonly table: SQLiteTable;
|
|
32
|
+
readonly ownerColumn: SQLiteColumn;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const owned: Owned[] = Object.entries({ ...appTables, ...mailTables }).flatMap(
|
|
36
|
+
([name, table]) => {
|
|
37
|
+
if (!(table instanceof SQLiteTable)) return [];
|
|
38
|
+
const ownerKeys = Object.keys(ownerIdsOf(ERASED));
|
|
39
|
+
const ownerColumn = ownerKeys
|
|
40
|
+
.map((key) =>
|
|
41
|
+
getTableConfig(table).columns.find((column) => column.name === key),
|
|
42
|
+
)
|
|
43
|
+
.find((column) => column !== undefined);
|
|
44
|
+
return ownerColumn === undefined ? [] : [{ name, table, ownerColumn }];
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const fillerOf = (column: SQLiteColumn, tenant: string): unknown => {
|
|
49
|
+
switch (column.dataType) {
|
|
50
|
+
case "number":
|
|
51
|
+
return 1;
|
|
52
|
+
case "boolean":
|
|
53
|
+
return false;
|
|
54
|
+
case "json":
|
|
55
|
+
return {};
|
|
56
|
+
case "buffer":
|
|
57
|
+
return Buffer.from(tenant);
|
|
58
|
+
default:
|
|
59
|
+
return `${tenant}-${column.name}`;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const rowOf = (table: SQLiteTable, tenant: string): Record<string, unknown> => {
|
|
64
|
+
const ownerIds = ownerIdsOf(tenant);
|
|
65
|
+
return Object.fromEntries(
|
|
66
|
+
Object.entries(getTableColumns(table)).flatMap(([key, column]) => {
|
|
67
|
+
const ownerId = ownerIds[column.name];
|
|
68
|
+
if (ownerId !== undefined) return [[key, ownerId]];
|
|
69
|
+
if (column.notNull && !column.hasDefault)
|
|
70
|
+
return [[key, fillerOf(column, tenant)]];
|
|
71
|
+
return [];
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const countOf = async (
|
|
77
|
+
db: TestDb,
|
|
78
|
+
{ table, ownerColumn }: Owned,
|
|
79
|
+
tenant: string,
|
|
80
|
+
): Promise<number> => {
|
|
81
|
+
const ownerId = ownerIdsOf(tenant)[ownerColumn.name] ?? "";
|
|
82
|
+
return (await db.select().from(table).where(eq(ownerColumn, ownerId))).length;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
describe("runDrizzleAccountConfigErase", () => {
|
|
86
|
+
let db: TestDb;
|
|
87
|
+
let close: () => Promise<void>;
|
|
88
|
+
|
|
89
|
+
before(async () => {
|
|
90
|
+
({ db, close } = await createTestDb());
|
|
91
|
+
for (const tenant of [ERASED, KEPT]) {
|
|
92
|
+
for (const { table } of owned) {
|
|
93
|
+
await db.insert(table).values(rowOf(table, tenant) as never);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const stores = syncStores(
|
|
97
|
+
{ app: db, mail: db, search: db } as never,
|
|
98
|
+
BETTER_SQLITE3_LIMITS,
|
|
99
|
+
);
|
|
100
|
+
await runDrizzleAccountConfigErase(stores, ERASED, log);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
after(async () => {
|
|
104
|
+
await close();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("leaves no row of the erased account config in any table it owns", async () => {
|
|
108
|
+
for (const entry of owned) {
|
|
109
|
+
const expected = entry.table === appTables.accountConfigs ? 1 : 0;
|
|
110
|
+
assert.equal(
|
|
111
|
+
await countOf(db, entry, ERASED),
|
|
112
|
+
expected,
|
|
113
|
+
`${entry.name} keeps rows of the erased account config`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("keeps every row of another account config", async () => {
|
|
119
|
+
for (const entry of owned) {
|
|
120
|
+
assert.equal(
|
|
121
|
+
await countOf(db, entry, KEPT),
|
|
122
|
+
1,
|
|
123
|
+
`${entry.name} lost rows of another account config`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import {
|
|
3
|
+
getTableConfig,
|
|
4
|
+
type SQLiteColumn,
|
|
5
|
+
SQLiteTable,
|
|
6
|
+
} from "drizzle-orm/sqlite-core";
|
|
7
|
+
import type { SqliteBatchItem } from "../batch.js";
|
|
8
|
+
import { chunk } from "../bound-parameters.js";
|
|
9
|
+
import type { SqliteStores } from "../databases.js";
|
|
10
|
+
import type { SqliteStore } from "../db.js";
|
|
11
|
+
import {
|
|
12
|
+
accountConfigTable,
|
|
13
|
+
accountTable,
|
|
14
|
+
} from "../schema/i4-account-config.js";
|
|
15
|
+
import { mailboxTable } from "../schema/i4-mailbox.js";
|
|
16
|
+
import { calendarTable, messageTable } from "../schema.js";
|
|
17
|
+
import * as appTables from "../schema-sets/app.js";
|
|
18
|
+
import * as mailTables from "../schema-sets/mail.js";
|
|
19
|
+
import { SearchIndex } from "../search/thread-message-search.js";
|
|
20
|
+
import type { CascadeDeleteLogger } from "./cascade-delete.js";
|
|
21
|
+
import { inList } from "./in-list.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The column an account config owns a table's rows by: its own id where the
|
|
25
|
+
* table carries it, else the first id of the parent it hangs off.
|
|
26
|
+
*/
|
|
27
|
+
export const OWNER_KEYS = [
|
|
28
|
+
"account_config_id",
|
|
29
|
+
"account_id",
|
|
30
|
+
"mailbox_id",
|
|
31
|
+
"message_id",
|
|
32
|
+
"calendar_id",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
export type OwnerKey = (typeof OWNER_KEYS)[number];
|
|
36
|
+
|
|
37
|
+
export interface OwnedTable {
|
|
38
|
+
readonly table: SQLiteTable;
|
|
39
|
+
readonly ownerKey: OwnerKey;
|
|
40
|
+
readonly ownerColumn: SQLiteColumn;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const ownedTablesOf = (tables: Record<string, unknown>): OwnedTable[] =>
|
|
44
|
+
Object.values(tables).flatMap((table) => {
|
|
45
|
+
if (!(table instanceof SQLiteTable)) return [];
|
|
46
|
+
const { columns } = getTableConfig(table);
|
|
47
|
+
for (const ownerKey of OWNER_KEYS) {
|
|
48
|
+
const ownerColumn = columns.find((column) => column.name === ownerKey);
|
|
49
|
+
if (ownerColumn !== undefined) return [{ table, ownerKey, ownerColumn }];
|
|
50
|
+
}
|
|
51
|
+
return [];
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Durable Object SQLite caps a bound value at 2 MB; a chunk of ids stays far
|
|
55
|
+
// below it.
|
|
56
|
+
const IDS_PER_STATEMENT = 5_000;
|
|
57
|
+
|
|
58
|
+
type OwnerIds = Readonly<Record<OwnerKey, readonly string[]>>;
|
|
59
|
+
|
|
60
|
+
const keyedDeletes = (
|
|
61
|
+
store: SqliteStore<Record<string, unknown>>,
|
|
62
|
+
tables: readonly OwnedTable[],
|
|
63
|
+
ids: OwnerIds,
|
|
64
|
+
): SqliteBatchItem[] =>
|
|
65
|
+
tables
|
|
66
|
+
.filter(({ table }) => table !== accountConfigTable)
|
|
67
|
+
.flatMap(({ table, ownerKey, ownerColumn }) =>
|
|
68
|
+
ownerKey === "account_config_id"
|
|
69
|
+
? ids.account_config_id.map((id) =>
|
|
70
|
+
store.db.delete(table).where(eq(ownerColumn, id)),
|
|
71
|
+
)
|
|
72
|
+
: chunk(ids[ownerKey], IDS_PER_STATEMENT).map((slice) =>
|
|
73
|
+
store.db.delete(table).where(inList(ownerColumn, slice)),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const APP_TABLES = ownedTablesOf(appTables);
|
|
78
|
+
const MAIL_TABLES = ownedTablesOf(mailTables);
|
|
79
|
+
|
|
80
|
+
const idsOf = async <Row>(
|
|
81
|
+
rows: PromiseLike<Row[]>,
|
|
82
|
+
pick: (row: Row) => string,
|
|
83
|
+
): Promise<string[]> => (await rows).map(pick);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Delete the app database rows an account config owns, except the
|
|
87
|
+
* AccountConfig row itself, which the caller deletes last.
|
|
88
|
+
*/
|
|
89
|
+
export const eraseAccountConfigAppRows = async (
|
|
90
|
+
app: SqliteStore<Record<string, unknown>>,
|
|
91
|
+
accountConfigId: string,
|
|
92
|
+
accountIds: readonly string[],
|
|
93
|
+
): Promise<void> => {
|
|
94
|
+
const calendarIds = await idsOf(
|
|
95
|
+
app.db
|
|
96
|
+
.select({ calendarId: calendarTable.calendarId })
|
|
97
|
+
.from(calendarTable)
|
|
98
|
+
.where(eq(calendarTable.accountConfigId, accountConfigId)),
|
|
99
|
+
(row) => row.calendarId,
|
|
100
|
+
);
|
|
101
|
+
await app.batch(
|
|
102
|
+
keyedDeletes(app, APP_TABLES, {
|
|
103
|
+
account_config_id: [accountConfigId],
|
|
104
|
+
account_id: accountIds,
|
|
105
|
+
mailbox_id: [],
|
|
106
|
+
message_id: [],
|
|
107
|
+
calendar_id: calendarIds,
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const accountIdsOf = (
|
|
113
|
+
app: SqliteStore<Record<string, unknown>>,
|
|
114
|
+
accountConfigId: string,
|
|
115
|
+
): Promise<string[]> =>
|
|
116
|
+
idsOf(
|
|
117
|
+
app.db
|
|
118
|
+
.select({ accountId: accountTable.accountId })
|
|
119
|
+
.from(accountTable)
|
|
120
|
+
.where(eq(accountTable.accountConfigId, accountConfigId)),
|
|
121
|
+
(row) => row.accountId,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Delete every row an account config owns across the app, mail and search
|
|
126
|
+
* databases, one keyed statement per table. A rerun deletes what is left.
|
|
127
|
+
*/
|
|
128
|
+
export const runDrizzleAccountConfigErase = async (
|
|
129
|
+
stores: SqliteStores,
|
|
130
|
+
accountConfigId: string,
|
|
131
|
+
log: CascadeDeleteLogger,
|
|
132
|
+
): Promise<void> => {
|
|
133
|
+
const { app, mail, search } = stores;
|
|
134
|
+
const accountIds = await accountIdsOf(app, accountConfigId);
|
|
135
|
+
const mailboxIds = await idsOf(
|
|
136
|
+
mail.db
|
|
137
|
+
.select({ mailboxId: mailboxTable.mailboxId })
|
|
138
|
+
.from(mailboxTable)
|
|
139
|
+
.where(inList(mailboxTable.accountId, accountIds)),
|
|
140
|
+
(row) => row.mailboxId,
|
|
141
|
+
);
|
|
142
|
+
const messageIds = await idsOf(
|
|
143
|
+
mail.db
|
|
144
|
+
.select({ messageId: messageTable.messageId })
|
|
145
|
+
.from(messageTable)
|
|
146
|
+
.where(inList(messageTable.mailboxId, mailboxIds)),
|
|
147
|
+
(row) => row.messageId,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await new SearchIndex(mail.db, search).removeAccount(accountConfigId);
|
|
151
|
+
await mail.batch(
|
|
152
|
+
keyedDeletes(mail, MAIL_TABLES, {
|
|
153
|
+
account_config_id: [accountConfigId],
|
|
154
|
+
account_id: accountIds,
|
|
155
|
+
mailbox_id: mailboxIds,
|
|
156
|
+
message_id: messageIds,
|
|
157
|
+
calendar_id: [],
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
await eraseAccountConfigAppRows(app, accountConfigId, accountIds);
|
|
161
|
+
|
|
162
|
+
log.info(
|
|
163
|
+
{
|
|
164
|
+
accountConfigId,
|
|
165
|
+
accountCount: accountIds.length,
|
|
166
|
+
messageCount: messageIds.length,
|
|
167
|
+
},
|
|
168
|
+
"Drizzle account config erase complete",
|
|
169
|
+
);
|
|
170
|
+
};
|