@remit/drizzle-service 0.0.104 → 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 +1 -1
- package/src/bound-parameters.test.ts +57 -0
- package/src/bound-parameters.ts +49 -0
- package/src/databases.ts +10 -9
- package/src/db.ts +1 -0
- package/src/index.ts +9 -0
- package/src/repair/junk-only-address.sqlite.test.ts +9 -5
- package/src/repos/account-config-erase.test.ts +127 -0
- package/src/repos/account-config-erase.ts +170 -0
- package/src/repos/account.conformance.sqlite.test.ts +9 -5
- package/src/repos/calendar-event-index.conformance.d1.test.ts +1 -1
- package/src/repos/calendar-event-index.conformance.sqlite.test.ts +3 -1
- package/src/repos/calendar-event-index.ts +36 -21
- package/src/repos/calendar-store.ts +17 -10
- package/src/repos/cascade-delete.test.ts +9 -2
- package/src/repos/cascade-delete.ts +2 -1
- package/src/repos/envelope.d1.test.ts +66 -0
- package/src/repos/envelope.sqlite.test.ts +5 -1
- package/src/repos/envelope.test.ts +5 -1
- package/src/repos/envelope.ts +40 -31
- package/src/repos/i4-account.test.ts +17 -10
- package/src/repos/i4-address-junk-move.sqlite.test.ts +9 -5
- package/src/repos/i4-mailbox-special-use.sqlite.test.ts +9 -5
- package/src/repos/i4-mailbox.sqlite.test.ts +17 -10
- package/src/repos/i4-mailbox.test.ts +17 -10
- package/src/repos/i4-outbox-attachment.test.ts +9 -5
- package/src/repos/message.sqlite.test.ts +5 -1
- package/src/repos/message.test.ts +5 -1
- package/src/repos/sync-warning.ts +35 -22
- package/src/repos/thread-message-body-muted.sqlite.test.ts +9 -5
- package/src/repos/thread-message-field-terms.sqlite.test.ts +9 -5
- package/src/repos/thread-message.sqlite.test.ts +9 -5
- package/src/repos/thread-message.test.ts +33 -20
- package/src/search/thread-message-search.sqlite.test.ts +13 -6
- package/src/search/thread-message-search.ts +14 -17
- package/src/sqlite-repositories.ts +6 -3
- package/src/test-d1.ts +36 -19
- package/src/test-stores.ts +15 -2
package/package.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
4
|
+
import { chunkRows, rowsPerStatement } from "./bound-parameters.js";
|
|
5
|
+
|
|
6
|
+
const tenColumns = sqliteTable("ten_columns", {
|
|
7
|
+
c0: text("c0"),
|
|
8
|
+
c1: text("c1"),
|
|
9
|
+
c2: text("c2"),
|
|
10
|
+
c3: text("c3"),
|
|
11
|
+
c4: text("c4"),
|
|
12
|
+
c5: text("c5"),
|
|
13
|
+
c6: text("c6"),
|
|
14
|
+
c7: text("c7"),
|
|
15
|
+
c8: text("c8"),
|
|
16
|
+
c9: integer("c9"),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const rows = (count: number): number[] =>
|
|
20
|
+
Array.from({ length: count }, (_, index) => index);
|
|
21
|
+
|
|
22
|
+
describe("chunkRows", () => {
|
|
23
|
+
test("no rows make no statements", () => {
|
|
24
|
+
assert.deepEqual(chunkRows(tenColumns, [], 100), []);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("rows that fill the limit exactly make one statement", () => {
|
|
28
|
+
const chunks = chunkRows(tenColumns, rows(10), 100);
|
|
29
|
+
assert.deepEqual(
|
|
30
|
+
chunks.map((slice) => slice.length),
|
|
31
|
+
[10],
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("one row over the limit makes a second statement", () => {
|
|
36
|
+
const chunks = chunkRows(tenColumns, rows(11), 100);
|
|
37
|
+
assert.deepEqual(
|
|
38
|
+
chunks.map((slice) => slice.length),
|
|
39
|
+
[10, 1],
|
|
40
|
+
);
|
|
41
|
+
assert.deepEqual(chunks.flat(), rows(11));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("reserved parameters shrink every statement", () => {
|
|
45
|
+
const chunks = chunkRows(tenColumns, rows(10), 100, 1);
|
|
46
|
+
assert.deepEqual(
|
|
47
|
+
chunks.map((slice) => slice.length),
|
|
48
|
+
[9, 1],
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("rowsPerStatement", () => {
|
|
54
|
+
test("a row wider than the limit is refused", () => {
|
|
55
|
+
assert.throws(() => rowsPerStatement(100, 101), RangeError);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { getTableColumns, type Table } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
export interface SqliteLimits {
|
|
4
|
+
/** The most parameters the host's SQLite binds in one statement: 32766 on better-sqlite3, 100 on D1 and Durable Object storage. */
|
|
5
|
+
maxBoundParameters: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const BETTER_SQLITE3_LIMITS: SqliteLimits = {
|
|
9
|
+
maxBoundParameters: 32766,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const chunk = <T>(items: readonly T[], size: number): T[][] => {
|
|
13
|
+
const chunks: T[][] = [];
|
|
14
|
+
for (let start = 0; start < items.length; start += size) {
|
|
15
|
+
chunks.push(items.slice(start, start + size));
|
|
16
|
+
}
|
|
17
|
+
return chunks;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const rowsPerStatement = (
|
|
21
|
+
maxBoundParameters: number,
|
|
22
|
+
parametersPerRow: number,
|
|
23
|
+
reservedParameters = 0,
|
|
24
|
+
): number => {
|
|
25
|
+
const rows = Math.floor(
|
|
26
|
+
(maxBoundParameters - reservedParameters) / parametersPerRow,
|
|
27
|
+
);
|
|
28
|
+
if (rows < 1) {
|
|
29
|
+
throw new RangeError(
|
|
30
|
+
`a row of ${parametersPerRow} bound parameters does not fit a statement limit of ${maxBoundParameters}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return rows;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const chunkRows = <T>(
|
|
37
|
+
table: Table,
|
|
38
|
+
rows: readonly T[],
|
|
39
|
+
maxBoundParameters: number,
|
|
40
|
+
reservedParameters = 0,
|
|
41
|
+
): T[][] =>
|
|
42
|
+
chunk(
|
|
43
|
+
rows,
|
|
44
|
+
rowsPerStatement(
|
|
45
|
+
maxBoundParameters,
|
|
46
|
+
Object.keys(getTableColumns(table)).length,
|
|
47
|
+
reservedParameters,
|
|
48
|
+
),
|
|
49
|
+
);
|
package/src/databases.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { syncBatch } from "./batch.js";
|
|
2
|
+
import type { SqliteLimits } from "./bound-parameters.js";
|
|
2
3
|
import type { Db, SqliteStore } from "./db.js";
|
|
3
4
|
import { messageDataSchema } from "./schema/message-data.js";
|
|
4
5
|
import { createSqliteDatabase } from "./sqlite-client.js";
|
|
@@ -22,16 +23,16 @@ export interface SqliteStores {
|
|
|
22
23
|
|
|
23
24
|
export const syncStore = <TSchema extends Record<string, unknown>>(
|
|
24
25
|
db: Db<TSchema>,
|
|
25
|
-
|
|
26
|
+
{ maxBoundParameters }: SqliteLimits,
|
|
27
|
+
): SqliteStore<TSchema> => ({ db, batch: syncBatch(db), maxBoundParameters });
|
|
26
28
|
|
|
27
|
-
export const syncStores = (
|
|
28
|
-
app,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
search: syncStore(search),
|
|
29
|
+
export const syncStores = (
|
|
30
|
+
{ app, mail, search }: SqliteDatabases,
|
|
31
|
+
limits: SqliteLimits,
|
|
32
|
+
): SqliteStores => ({
|
|
33
|
+
app: syncStore(app, limits),
|
|
34
|
+
mail: syncStore(mail, limits),
|
|
35
|
+
search: syncStore(search, limits),
|
|
35
36
|
});
|
|
36
37
|
|
|
37
38
|
export interface SqliteDatabasePaths {
|
package/src/db.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -5,6 +5,10 @@ export {
|
|
|
5
5
|
syncBatch,
|
|
6
6
|
type WriteBatch,
|
|
7
7
|
} from "./batch.js";
|
|
8
|
+
export {
|
|
9
|
+
BETTER_SQLITE3_LIMITS,
|
|
10
|
+
type SqliteLimits,
|
|
11
|
+
} from "./bound-parameters.js";
|
|
8
12
|
export {
|
|
9
13
|
hasSqliteDatabaseEnv,
|
|
10
14
|
openSqliteDatabases,
|
|
@@ -18,6 +22,11 @@ export {
|
|
|
18
22
|
} from "./databases.js";
|
|
19
23
|
export type { RepositoryDb, SqliteStore } from "./db.js";
|
|
20
24
|
export { CreateFailedConflictError, NotFoundError } from "./error.js";
|
|
25
|
+
export {
|
|
26
|
+
accountIdsOf,
|
|
27
|
+
eraseAccountConfigAppRows,
|
|
28
|
+
runDrizzleAccountConfigErase,
|
|
29
|
+
} from "./repos/account-config-erase.js";
|
|
21
30
|
export { CalendarCollectionRepo } from "./repos/calendar-collection.js";
|
|
22
31
|
export { CalendarEventIndexRepo } from "./repos/calendar-event-index.js";
|
|
23
32
|
export { CalendarFeedTokenRepo } from "./repos/calendar-feed-token.js";
|
|
@@ -4,6 +4,7 @@ import { composeFolderRoleAppointmentName } from "@remit/data-ports/folder-role"
|
|
|
4
4
|
import { CanonicalMailboxRole } from "@remit/domain-enums";
|
|
5
5
|
import Database from "better-sqlite3";
|
|
6
6
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
7
|
+
import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
|
|
7
8
|
import { AccountSettingRepo } from "../repos/i4-account-setting.js";
|
|
8
9
|
import type { MailboxSpecialUseRepo } from "../repos/i4-mailbox-special-use.js";
|
|
9
10
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
@@ -182,11 +183,14 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
182
183
|
before(() => {
|
|
183
184
|
sqlite = new Database(":memory:");
|
|
184
185
|
const db = drizzle(sqlite);
|
|
185
|
-
specialUse = createSqliteRepositories(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
186
|
+
specialUse = createSqliteRepositories(
|
|
187
|
+
{
|
|
188
|
+
app: db,
|
|
189
|
+
mail: db,
|
|
190
|
+
search: db,
|
|
191
|
+
},
|
|
192
|
+
BETTER_SQLITE3_LIMITS,
|
|
193
|
+
).mailboxSpecialUse;
|
|
190
194
|
accountSetting = new AccountSettingRepo(db as never);
|
|
191
195
|
applyMigrationSet(sqlite, "app");
|
|
192
196
|
applyMigrationSet(sqlite, "mail");
|
|
@@ -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
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { accountRepositoryConformance } from "@remit/data-ports/conformance";
|
|
2
|
+
import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
|
|
2
3
|
import { NotFoundError } from "../error.js";
|
|
3
4
|
import { randomId } from "../id.js";
|
|
4
5
|
import { accountTable } from "../schema.js";
|
|
@@ -13,11 +14,14 @@ accountRepositoryConformance({
|
|
|
13
14
|
accounts: accountTable,
|
|
14
15
|
});
|
|
15
16
|
close = closeDb;
|
|
16
|
-
return createSqliteRepositories(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
return createSqliteRepositories(
|
|
18
|
+
{
|
|
19
|
+
app: db,
|
|
20
|
+
mail: db,
|
|
21
|
+
search: db,
|
|
22
|
+
},
|
|
23
|
+
BETTER_SQLITE3_LIMITS,
|
|
24
|
+
).account;
|
|
21
25
|
},
|
|
22
26
|
teardown: async () => {
|
|
23
27
|
await close?.();
|
|
@@ -10,7 +10,7 @@ calendarEventIndexRepositoryConformance({
|
|
|
10
10
|
async createRepository() {
|
|
11
11
|
const opened = await openD1TestStores();
|
|
12
12
|
close = opened.close;
|
|
13
|
-
return new CalendarEventIndexRepo(opened.stores.app
|
|
13
|
+
return new CalendarEventIndexRepo(opened.stores.app);
|
|
14
14
|
},
|
|
15
15
|
teardown: async () => {
|
|
16
16
|
await close?.();
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { calendarEventIndexRepositoryConformance } from "@remit/data-ports/conformance";
|
|
2
|
+
import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
|
|
3
|
+
import { syncStore } from "../databases.js";
|
|
2
4
|
import { NotFoundError } from "../error.js";
|
|
3
5
|
import { randomId } from "../id.js";
|
|
4
6
|
import { calendarEventIndexTable } from "../schema.js";
|
|
@@ -13,7 +15,7 @@ calendarEventIndexRepositoryConformance({
|
|
|
13
15
|
calendarEventIndexes: calendarEventIndexTable,
|
|
14
16
|
});
|
|
15
17
|
close = closeDb;
|
|
16
|
-
return new CalendarEventIndexRepo(db
|
|
18
|
+
return new CalendarEventIndexRepo(syncStore(db, BETTER_SQLITE3_LIMITS));
|
|
17
19
|
},
|
|
18
20
|
teardown: async () => {
|
|
19
21
|
await close?.();
|
|
@@ -4,7 +4,9 @@ import type {
|
|
|
4
4
|
ICalendarEventIndexRepository,
|
|
5
5
|
} from "@remit/data-ports";
|
|
6
6
|
import { and, asc, eq, gte, lt } from "drizzle-orm";
|
|
7
|
-
import type {
|
|
7
|
+
import type { SqliteBatchItem, WriteBatch } from "../batch.js";
|
|
8
|
+
import { chunkRows } from "../bound-parameters.js";
|
|
9
|
+
import type { RepositoryDb, SqliteStore } from "../db.js";
|
|
8
10
|
import { calendarEventIndexTable } from "../schema.js";
|
|
9
11
|
|
|
10
12
|
type DB = RepositoryDb<Record<string, unknown>>;
|
|
@@ -28,39 +30,52 @@ function rowToOccurrence(
|
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export class CalendarEventIndexRepo implements ICalendarEventIndexRepository {
|
|
31
|
-
|
|
33
|
+
private readonly db: DB;
|
|
34
|
+
private readonly batch: WriteBatch;
|
|
35
|
+
private readonly maxBoundParameters: number;
|
|
36
|
+
|
|
37
|
+
constructor(store: SqliteStore<Record<string, unknown>>) {
|
|
38
|
+
this.db = store.db;
|
|
39
|
+
this.batch = store.batch;
|
|
40
|
+
this.maxBoundParameters = store.maxBoundParameters;
|
|
41
|
+
}
|
|
32
42
|
|
|
33
43
|
async replaceForObject(
|
|
34
44
|
calendarId: string,
|
|
35
45
|
calendarObjectId: string,
|
|
36
46
|
occurrences: CalendarOccurrenceInput[],
|
|
37
47
|
): Promise<void> {
|
|
38
|
-
await this.deleteForObject(calendarId, calendarObjectId);
|
|
39
|
-
if (occurrences.length === 0) return;
|
|
40
|
-
|
|
41
48
|
const now = Date.now();
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
const rows = occurrences.map((occurrence) => ({
|
|
50
|
+
calendarId,
|
|
51
|
+
calendarObjectId,
|
|
52
|
+
recurrenceId: occurrence.recurrenceId,
|
|
53
|
+
startAt: occurrence.startAt,
|
|
54
|
+
endAt: occurrence.endAt,
|
|
55
|
+
allDay: occurrence.allDay,
|
|
56
|
+
summary: occurrence.summary,
|
|
57
|
+
status: occurrence.status,
|
|
58
|
+
transparency: occurrence.transparency,
|
|
59
|
+
createdAt: now,
|
|
60
|
+
updatedAt: now,
|
|
61
|
+
}));
|
|
62
|
+
const inserts: SqliteBatchItem[] = chunkRows(
|
|
63
|
+
calendarEventIndexTable,
|
|
64
|
+
rows,
|
|
65
|
+
this.maxBoundParameters,
|
|
66
|
+
).map((slice) => this.db.insert(calendarEventIndexTable).values(slice));
|
|
67
|
+
await this.batch([this.clear(calendarId, calendarObjectId), ...inserts]);
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
async deleteForObject(
|
|
60
71
|
calendarId: string,
|
|
61
72
|
calendarObjectId: string,
|
|
62
73
|
): Promise<void> {
|
|
63
|
-
await this.
|
|
74
|
+
await this.clear(calendarId, calendarObjectId);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private clear(calendarId: string, calendarObjectId: string) {
|
|
78
|
+
return this.db
|
|
64
79
|
.delete(calendarEventIndexTable)
|
|
65
80
|
.where(
|
|
66
81
|
and(
|