@remit/drizzle-service 0.0.105 → 0.0.107
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 +4 -2
- package/src/index.ts +10 -0
- package/src/repair/junk-only-address.sqlite.test.ts +2 -0
- package/src/repos/account-config-erase.test.ts +127 -0
- package/src/repos/account-config-erase.ts +170 -0
- package/src/repos/account.conformance.d1.test.ts +3 -1
- package/src/repos/account.conformance.sqlite.test.ts +2 -0
- package/src/repos/calendar-default.suite.ts +5 -1
- package/src/repos/calendar-put.suite.ts +5 -1
- package/src/repos/cascade-delete.ts +1 -1
- package/src/repos/envelope.sqlite.test.ts +2 -0
- package/src/repos/envelope.test.ts +2 -0
- package/src/repos/i4-account-config.suite.ts +5 -1
- package/src/repos/i4-account.test.ts +3 -0
- package/src/repos/i4-address-junk-move.sqlite.test.ts +2 -0
- package/src/repos/i4-mailbox-special-use.sqlite.test.ts +2 -0
- package/src/repos/i4-mailbox.sqlite.test.ts +3 -0
- package/src/repos/i4-mailbox.test.ts +4 -1
- package/src/repos/i4-mailbox.ts +7 -2
- package/src/repos/i4-outbox-attachment.test.ts +2 -0
- package/src/repos/message-objects.sqlite.test.ts +217 -0
- package/src/repos/message-objects.ts +76 -0
- package/src/repos/message.sqlite.test.ts +2 -0
- package/src/repos/message.test.ts +2 -0
- package/src/repos/message.ts +34 -14
- package/src/repos/thread-message-body-muted.sqlite.test.ts +2 -0
- package/src/repos/thread-message-field-terms.sqlite.test.ts +2 -0
- package/src/repos/thread-message.sqlite.test.ts +2 -0
- package/src/repos/thread-message.test.ts +5 -0
- package/src/search/thread-message-search.sqlite.test.ts +2 -0
- package/src/sqlite-repositories.ts +13 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/drizzle-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.107",
|
|
4
4
|
"description": "Drizzle ORM service over SQLite",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
"uuid": "^13.0.2",
|
|
37
37
|
"@remit/drizzle-sqlite-schema": "*",
|
|
38
38
|
"better-sqlite3": "^12.11.1",
|
|
39
|
-
"@types/better-sqlite3": "^7.6.13"
|
|
39
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
40
|
+
"@remit/storage-service": "*",
|
|
41
|
+
"p-map": "^2.1.0"
|
|
40
42
|
},
|
|
41
43
|
"license": "MIT",
|
|
42
44
|
"publishConfig": {
|
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";
|
|
@@ -69,6 +74,11 @@ export {
|
|
|
69
74
|
} from "./repos/message.js";
|
|
70
75
|
export { DrizzleMessageFlagRepository } from "./repos/message-flag.js";
|
|
71
76
|
export { MessageLabelRepo } from "./repos/message-label.js";
|
|
77
|
+
export {
|
|
78
|
+
DEFAULT_MESSAGE_OBJECT_DELETE_CONCURRENCY,
|
|
79
|
+
type MessageObjectStore,
|
|
80
|
+
messageObjectsInMemory,
|
|
81
|
+
} from "./repos/message-objects.js";
|
|
72
82
|
export { QuarantineRepo } from "./repos/quarantine.js";
|
|
73
83
|
export { SyncWarningRepo } from "./repos/sync-warning.js";
|
|
74
84
|
export { DrizzleThreadMessageRepository } from "./repos/thread-message.js";
|
|
@@ -7,6 +7,7 @@ import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
|
7
7
|
import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
|
|
8
8
|
import { AccountSettingRepo } from "../repos/i4-account-setting.js";
|
|
9
9
|
import type { MailboxSpecialUseRepo } from "../repos/i4-mailbox-special-use.js";
|
|
10
|
+
import { messageObjectsInMemory } from "../repos/message-objects.js";
|
|
10
11
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
11
12
|
import { applyMigrationSet } from "../test-shipped-sqlite-schema.js";
|
|
12
13
|
import {
|
|
@@ -190,6 +191,7 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
190
191
|
search: db,
|
|
191
192
|
},
|
|
192
193
|
BETTER_SQLITE3_LIMITS,
|
|
194
|
+
messageObjectsInMemory(),
|
|
193
195
|
).mailboxSpecialUse;
|
|
194
196
|
accountSetting = new AccountSettingRepo(db as never);
|
|
195
197
|
applyMigrationSet(sqlite, "app");
|
|
@@ -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
|
+
};
|
|
@@ -3,6 +3,7 @@ import { NotFoundError } from "../error.js";
|
|
|
3
3
|
import { randomId } from "../id.js";
|
|
4
4
|
import { composeSqliteRepositories } from "../sqlite-repositories.js";
|
|
5
5
|
import { openD1TestStores } from "../test-d1.js";
|
|
6
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
6
7
|
|
|
7
8
|
let close: (() => Promise<void>) | undefined;
|
|
8
9
|
|
|
@@ -10,7 +11,8 @@ accountRepositoryConformance({
|
|
|
10
11
|
async createRepository() {
|
|
11
12
|
const opened = await openD1TestStores();
|
|
12
13
|
close = opened.close;
|
|
13
|
-
return composeSqliteRepositories(opened.stores)
|
|
14
|
+
return composeSqliteRepositories(opened.stores, messageObjectsInMemory())
|
|
15
|
+
.account;
|
|
14
16
|
},
|
|
15
17
|
teardown: async () => {
|
|
16
18
|
await close?.();
|
|
@@ -5,6 +5,7 @@ import { randomId } from "../id.js";
|
|
|
5
5
|
import { accountTable } from "../schema.js";
|
|
6
6
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
7
7
|
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
8
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
8
9
|
|
|
9
10
|
let close: (() => Promise<void>) | undefined;
|
|
10
11
|
|
|
@@ -21,6 +22,7 @@ accountRepositoryConformance({
|
|
|
21
22
|
search: db,
|
|
22
23
|
},
|
|
23
24
|
BETTER_SQLITE3_LIMITS,
|
|
25
|
+
messageObjectsInMemory(),
|
|
24
26
|
).account;
|
|
25
27
|
},
|
|
26
28
|
teardown: async () => {
|
|
@@ -6,6 +6,7 @@ import type { RepositoryDb } from "../db.js";
|
|
|
6
6
|
import { composeSqliteRepositories } from "../sqlite-repositories.js";
|
|
7
7
|
import type { OpenTestStores } from "../test-stores.js";
|
|
8
8
|
import { CalendarCollectionRepo } from "./calendar-collection.js";
|
|
9
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
9
10
|
|
|
10
11
|
const ACCOUNT_CONFIG_ID = "account-config-default";
|
|
11
12
|
|
|
@@ -19,7 +20,10 @@ export const calendarDefaultSuite = (open: OpenTestStores): void => {
|
|
|
19
20
|
const opened = await open();
|
|
20
21
|
db = opened.stores.app.db;
|
|
21
22
|
close = opened.close;
|
|
22
|
-
store = composeSqliteRepositories(
|
|
23
|
+
store = composeSqliteRepositories(
|
|
24
|
+
opened.stores,
|
|
25
|
+
messageObjectsInMemory(),
|
|
26
|
+
).calendarStore;
|
|
23
27
|
});
|
|
24
28
|
|
|
25
29
|
after(() => close());
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from "../schema.js";
|
|
19
19
|
import { composeSqliteRepositories } from "../sqlite-repositories.js";
|
|
20
20
|
import type { OpenTestStores } from "../test-stores.js";
|
|
21
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
21
22
|
|
|
22
23
|
const ACCOUNT_CONFIG_ID = "account-config-1";
|
|
23
24
|
|
|
@@ -74,7 +75,10 @@ export const calendarPutSuite = (open: OpenTestStores): void => {
|
|
|
74
75
|
const opened = await open();
|
|
75
76
|
db = opened.stores.app.db;
|
|
76
77
|
close = opened.close;
|
|
77
|
-
store = composeSqliteRepositories(
|
|
78
|
+
store = composeSqliteRepositories(
|
|
79
|
+
opened.stores,
|
|
80
|
+
messageObjectsInMemory(),
|
|
81
|
+
).calendarStore;
|
|
78
82
|
const collection = await provisionDefaultCalendar(
|
|
79
83
|
store,
|
|
80
84
|
ACCOUNT_CONFIG_ID,
|
|
@@ -102,7 +102,7 @@ export const runDrizzleCascadeDelete = async (
|
|
|
102
102
|
const items: SqliteBatchItem[] = [];
|
|
103
103
|
const appItems: SqliteBatchItem[] = [];
|
|
104
104
|
const messageIds = (grouped.get("Message") ?? []).map((k) => k.messageId);
|
|
105
|
-
items.push(...deleteMessageSubtree(mail, messageIds));
|
|
105
|
+
items.push(...deleteMessageSubtree(mail, messageIds).statements);
|
|
106
106
|
|
|
107
107
|
const threadMessageIds = (grouped.get("ThreadMessage") ?? []).map(
|
|
108
108
|
(k) => k.threadMessageId,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
9
9
|
import { createSqliteTestDb, type SqliteTestDb } from "../test-db-sqlite.js";
|
|
10
10
|
import type { DrizzleEnvelopeRepository } from "./envelope.js";
|
|
11
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
11
12
|
|
|
12
13
|
// Envelope repo on sqlite (RFC 036 D1): the onConflictDoUpdate upsert path and
|
|
13
14
|
// the transactional body-part upsert both run on better-sqlite3 unchanged.
|
|
@@ -24,6 +25,7 @@ describe("DrizzleEnvelopeRepository (sqlite)", () => {
|
|
|
24
25
|
repo = createSqliteRepositories(
|
|
25
26
|
{ app: db, mail: db, search: db },
|
|
26
27
|
BETTER_SQLITE3_LIMITS,
|
|
28
|
+
messageObjectsInMemory(),
|
|
27
29
|
).envelope;
|
|
28
30
|
});
|
|
29
31
|
|
|
@@ -5,6 +5,7 @@ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
|
|
|
5
5
|
import { envelopeId as deriveEnvelopeId } from "../id.js";
|
|
6
6
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
7
7
|
import type { DrizzleEnvelopeRepository } from "./envelope.js";
|
|
8
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
8
9
|
import { createTestDb, type TestDb } from "./test-helpers.js";
|
|
9
10
|
|
|
10
11
|
describe("DrizzleEnvelopeRepository", () => {
|
|
@@ -23,6 +24,7 @@ describe("DrizzleEnvelopeRepository", () => {
|
|
|
23
24
|
repo = createSqliteRepositories(
|
|
24
25
|
{ app: db, mail: db, search: db },
|
|
25
26
|
BETTER_SQLITE3_LIMITS,
|
|
27
|
+
messageObjectsInMemory(),
|
|
26
28
|
).envelope;
|
|
27
29
|
|
|
28
30
|
await repo.upsertEnvelope({
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from "../sqlite-repositories.js";
|
|
8
8
|
import type { OpenTestStores } from "../test-stores.js";
|
|
9
9
|
import type { AccountConfigRepo } from "./i4-account-config.js";
|
|
10
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
10
11
|
|
|
11
12
|
export const accountConfigRepoSuite = (open: OpenTestStores): void => {
|
|
12
13
|
describe("AccountConfigRepo", () => {
|
|
@@ -17,7 +18,10 @@ export const accountConfigRepoSuite = (open: OpenTestStores): void => {
|
|
|
17
18
|
before(async () => {
|
|
18
19
|
const opened = await open();
|
|
19
20
|
close = opened.close;
|
|
20
|
-
repos = composeSqliteRepositories(
|
|
21
|
+
repos = composeSqliteRepositories(
|
|
22
|
+
opened.stores,
|
|
23
|
+
messageObjectsInMemory(),
|
|
24
|
+
);
|
|
21
25
|
repo = repos.accountConfig;
|
|
22
26
|
});
|
|
23
27
|
|
|
@@ -4,6 +4,7 @@ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
|
|
|
4
4
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
5
5
|
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
6
6
|
import type { AccountRepo } from "./i4-account.js";
|
|
7
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
7
8
|
import { mailboxCreated } from "./test-helpers.js";
|
|
8
9
|
|
|
9
10
|
function makeAccountInput(accountConfigId: string) {
|
|
@@ -34,6 +35,7 @@ describe("AccountRepo", () => {
|
|
|
34
35
|
search: db,
|
|
35
36
|
},
|
|
36
37
|
BETTER_SQLITE3_LIMITS,
|
|
38
|
+
messageObjectsInMemory(),
|
|
37
39
|
).account;
|
|
38
40
|
});
|
|
39
41
|
|
|
@@ -175,6 +177,7 @@ describe("AccountRepo", () => {
|
|
|
175
177
|
search: db,
|
|
176
178
|
},
|
|
177
179
|
BETTER_SQLITE3_LIMITS,
|
|
180
|
+
messageObjectsInMemory(),
|
|
178
181
|
).mailbox;
|
|
179
182
|
mailboxCreated(
|
|
180
183
|
await mailboxRepo.create({
|
|
@@ -6,6 +6,7 @@ import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
|
6
6
|
import { createTestDb, type TestDb } from "../test-db.js";
|
|
7
7
|
import { AddressRepo } from "./i4-address.js";
|
|
8
8
|
import type { MailboxSpecialUseRepo } from "./i4-mailbox-special-use.js";
|
|
9
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
9
10
|
|
|
10
11
|
const CONFIG = "cfg-1";
|
|
11
12
|
|
|
@@ -133,6 +134,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
133
134
|
search: db,
|
|
134
135
|
},
|
|
135
136
|
BETTER_SQLITE3_LIMITS,
|
|
137
|
+
messageObjectsInMemory(),
|
|
136
138
|
).mailboxSpecialUse;
|
|
137
139
|
});
|
|
138
140
|
|
|
@@ -18,6 +18,7 @@ import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
|
18
18
|
import type { AccountSettingRepo } from "./i4-account-setting.js";
|
|
19
19
|
import type { MailboxRepo } from "./i4-mailbox.js";
|
|
20
20
|
import type { MailboxSpecialUseRepo } from "./i4-mailbox-special-use.js";
|
|
21
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
21
22
|
import { mailboxCreated } from "./test-helpers.js";
|
|
22
23
|
|
|
23
24
|
const makeMailboxInput = (
|
|
@@ -65,6 +66,7 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
65
66
|
search: testDb.db,
|
|
66
67
|
},
|
|
67
68
|
BETTER_SQLITE3_LIMITS,
|
|
69
|
+
messageObjectsInMemory(),
|
|
68
70
|
);
|
|
69
71
|
repo = repos.mailboxSpecialUse;
|
|
70
72
|
mailboxes = repos.mailbox;
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
shippedTableDdl,
|
|
14
14
|
} from "../test-shipped-sqlite-schema.js";
|
|
15
15
|
import type { MailboxRepo } from "./i4-mailbox.js";
|
|
16
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
16
17
|
import { mailboxCreated } from "./test-helpers.js";
|
|
17
18
|
|
|
18
19
|
function makeMailboxInput(accountId: string, fullPath = "INBOX") {
|
|
@@ -49,6 +50,7 @@ describe("MailboxRepo (sqlite)", () => {
|
|
|
49
50
|
search: testDb.db,
|
|
50
51
|
},
|
|
51
52
|
BETTER_SQLITE3_LIMITS,
|
|
53
|
+
messageObjectsInMemory(),
|
|
52
54
|
).mailbox;
|
|
53
55
|
});
|
|
54
56
|
|
|
@@ -222,6 +224,7 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
|
222
224
|
search: db,
|
|
223
225
|
},
|
|
224
226
|
BETTER_SQLITE3_LIMITS,
|
|
227
|
+
messageObjectsInMemory(),
|
|
225
228
|
).mailbox;
|
|
226
229
|
close = async () => {
|
|
227
230
|
sqlite.close();
|
|
@@ -18,6 +18,7 @@ import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
|
18
18
|
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
19
19
|
import type { MailboxRepo } from "./i4-mailbox.js";
|
|
20
20
|
import { deleteMessageSubtree } from "./message.js";
|
|
21
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
21
22
|
import { mailboxCreated } from "./test-helpers.js";
|
|
22
23
|
|
|
23
24
|
/** Every state a row can carry, for a transition that decides against none. */
|
|
@@ -62,6 +63,7 @@ describe("MailboxRepo", () => {
|
|
|
62
63
|
search: db,
|
|
63
64
|
},
|
|
64
65
|
BETTER_SQLITE3_LIMITS,
|
|
66
|
+
messageObjectsInMemory(),
|
|
65
67
|
).mailbox;
|
|
66
68
|
});
|
|
67
69
|
|
|
@@ -554,6 +556,7 @@ describe("MailboxRepo", () => {
|
|
|
554
556
|
search: db,
|
|
555
557
|
},
|
|
556
558
|
BETTER_SQLITE3_LIMITS,
|
|
559
|
+
messageObjectsInMemory(),
|
|
557
560
|
).message.create({
|
|
558
561
|
messageId,
|
|
559
562
|
mailboxId,
|
|
@@ -682,7 +685,7 @@ describe("MailboxRepo", () => {
|
|
|
682
685
|
|
|
683
686
|
// The shape an interruption leaves: one message's rows already gone,
|
|
684
687
|
// the mailbox row still there and still `deleting`.
|
|
685
|
-
await syncBatch(db)(deleteMessageSubtree(db, [first]));
|
|
688
|
+
await syncBatch(db)(deleteMessageSubtree(db, [first]).statements);
|
|
686
689
|
await repo.transition(accountId, mailbox.mailboxId, {
|
|
687
690
|
from: EVERY_STATE,
|
|
688
691
|
to: MailboxSyncStatus.deleting,
|
package/src/repos/i4-mailbox.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { threadMessageTable } from "../schema/thread-message.js";
|
|
|
46
46
|
import type { SearchIndex } from "../search/thread-message-search.js";
|
|
47
47
|
import { inList } from "./in-list.js";
|
|
48
48
|
import { deleteMessageSubtree } from "./message.js";
|
|
49
|
+
import type { MessageObjects } from "./message-objects.js";
|
|
49
50
|
|
|
50
51
|
const base36Translator = shortUuid.createTranslator(
|
|
51
52
|
shortUuid.constants.uuid25Base36,
|
|
@@ -155,6 +156,7 @@ export class MailboxRepo implements IMailboxRepository {
|
|
|
155
156
|
constructor(
|
|
156
157
|
store: SqliteStore<Record<string, unknown>>,
|
|
157
158
|
private search: SearchIndex,
|
|
159
|
+
private messageObjects: MessageObjects,
|
|
158
160
|
) {
|
|
159
161
|
this.db = store.db;
|
|
160
162
|
this.batch = store.batch;
|
|
@@ -562,13 +564,16 @@ export class MailboxRepo implements IMailboxRepository {
|
|
|
562
564
|
|
|
563
565
|
// The primitive the rest of the codebase deletes mail with: nine
|
|
564
566
|
// per-message child tables. A bespoke table list would orphan them.
|
|
565
|
-
|
|
566
|
-
|
|
567
|
+
const subtree = deleteMessageSubtree(this.db, messageIds);
|
|
568
|
+
const [removed] = await this.batch([
|
|
569
|
+
subtree.removed,
|
|
570
|
+
...subtree.statements,
|
|
567
571
|
this.db
|
|
568
572
|
.delete(threadMessageTable)
|
|
569
573
|
.where(inList(threadMessageTable.messageId, messageIds)),
|
|
570
574
|
]);
|
|
571
575
|
await this.search.sync(listings.map((row) => row.threadMessageId));
|
|
576
|
+
await this.messageObjects.reclaim(removed);
|
|
572
577
|
}
|
|
573
578
|
|
|
574
579
|
await this.db
|
|
@@ -4,6 +4,7 @@ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
|
|
|
4
4
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
5
5
|
import { createTestDb, randomId } from "../test-db.js";
|
|
6
6
|
import type { OutboxAttachmentRepo } from "./i4-outbox-attachment.js";
|
|
7
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
7
8
|
|
|
8
9
|
const CAP = { maxTotalBytes: 1000, maxCount: 3, nowSeconds: 1_000_000 };
|
|
9
10
|
|
|
@@ -36,6 +37,7 @@ before(async () => {
|
|
|
36
37
|
search: harness.db,
|
|
37
38
|
},
|
|
38
39
|
BETTER_SQLITE3_LIMITS,
|
|
40
|
+
messageObjectsInMemory(),
|
|
39
41
|
).outboxAttachment;
|
|
40
42
|
});
|
|
41
43
|
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { after, before, describe, test } from "node:test";
|
|
12
|
+
import type { StorageService } from "@remit/storage-service";
|
|
13
|
+
import { createFilesystemStorageService } from "@remit/storage-service/filesystem";
|
|
14
|
+
import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
|
|
15
|
+
import {
|
|
16
|
+
envelopeId as deriveEnvelopeId,
|
|
17
|
+
rootBodyPartId as deriveRootBodyPartId,
|
|
18
|
+
} from "../id.js";
|
|
19
|
+
import {
|
|
20
|
+
createSqliteRepositories,
|
|
21
|
+
type SqliteRepositories,
|
|
22
|
+
} from "../sqlite-repositories.js";
|
|
23
|
+
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
24
|
+
import { mailboxCreated } from "./test-helpers.js";
|
|
25
|
+
|
|
26
|
+
const ACCOUNT_CONFIG_ID = "cfg-objects";
|
|
27
|
+
|
|
28
|
+
describe("message objects follow the last row that references them", () => {
|
|
29
|
+
let db: TestDb;
|
|
30
|
+
let close: () => Promise<void>;
|
|
31
|
+
let directory: string;
|
|
32
|
+
let storage: StorageService;
|
|
33
|
+
let failDeletesAfter = Number.POSITIVE_INFINITY;
|
|
34
|
+
let repos: SqliteRepositories;
|
|
35
|
+
|
|
36
|
+
before(async () => {
|
|
37
|
+
({ db, close } = await createTestDb());
|
|
38
|
+
directory = mkdtempSync(join(tmpdir(), "message-objects-"));
|
|
39
|
+
storage = createFilesystemStorageService(directory);
|
|
40
|
+
repos = createSqliteRepositories(
|
|
41
|
+
{ app: db, mail: db, search: db },
|
|
42
|
+
BETTER_SQLITE3_LIMITS,
|
|
43
|
+
{
|
|
44
|
+
storage: {
|
|
45
|
+
deletePrefix: (prefix) => {
|
|
46
|
+
if (failDeletesAfter <= 0) {
|
|
47
|
+
return Promise.reject(new Error("storage went away"));
|
|
48
|
+
}
|
|
49
|
+
failDeletesAfter -= 1;
|
|
50
|
+
return storage.deletePrefix(prefix);
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
messageObjectDeleteConcurrency: 1,
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
after(async () => {
|
|
59
|
+
await close();
|
|
60
|
+
rmSync(directory, { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const folder = async (accountId: string, fullPath: string) =>
|
|
64
|
+
mailboxCreated(
|
|
65
|
+
await repos.mailbox.create({
|
|
66
|
+
accountId,
|
|
67
|
+
namespaceType: "personal",
|
|
68
|
+
namespacePrefix: "",
|
|
69
|
+
hierarchyDelimiter: "/",
|
|
70
|
+
fullPath,
|
|
71
|
+
uidValidity: 1,
|
|
72
|
+
uidNext: 1,
|
|
73
|
+
highestModseq: "0",
|
|
74
|
+
messageCount: 0,
|
|
75
|
+
unseenCount: 0,
|
|
76
|
+
deletedCount: 0,
|
|
77
|
+
totalSize: 0,
|
|
78
|
+
lastSyncUid: 0,
|
|
79
|
+
highWaterMarkUid: 0,
|
|
80
|
+
lastMessageSyncAt: 0,
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const message = async (
|
|
85
|
+
accountId: string,
|
|
86
|
+
mailboxId: string,
|
|
87
|
+
uid: number,
|
|
88
|
+
body: { from?: string } = {},
|
|
89
|
+
) => {
|
|
90
|
+
const messageId = randomId();
|
|
91
|
+
const owner = body.from ?? messageId;
|
|
92
|
+
const stored = await storage.storeMessageBody({
|
|
93
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
94
|
+
accountId,
|
|
95
|
+
messageId: owner,
|
|
96
|
+
content: Buffer.from(`body of ${owner}`),
|
|
97
|
+
});
|
|
98
|
+
await storage.storeBodyPart({
|
|
99
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
100
|
+
accountId,
|
|
101
|
+
messageId,
|
|
102
|
+
partPath: "1",
|
|
103
|
+
content: Buffer.from("part"),
|
|
104
|
+
});
|
|
105
|
+
await repos.message.create({
|
|
106
|
+
messageId,
|
|
107
|
+
mailboxId,
|
|
108
|
+
uid,
|
|
109
|
+
sequenceNumber: uid,
|
|
110
|
+
rfc822Size: 10,
|
|
111
|
+
internalDate: 1700000000000,
|
|
112
|
+
envelopeId: deriveEnvelopeId(messageId),
|
|
113
|
+
rootBodyPartId: deriveRootBodyPartId(messageId),
|
|
114
|
+
bodyStorageKey: stored.uri,
|
|
115
|
+
});
|
|
116
|
+
return { messageId, accountId, bodyStorageKey: stored.uri };
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const part = (row: { accountId: string; messageId: string }) =>
|
|
120
|
+
storage.retrieveBodyPart(
|
|
121
|
+
ACCOUNT_CONFIG_ID,
|
|
122
|
+
row.accountId,
|
|
123
|
+
row.messageId,
|
|
124
|
+
"1",
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
test("a copy sharing the source's body keeps it until the copy goes too", async () => {
|
|
128
|
+
const accountId = randomId();
|
|
129
|
+
const inbox = await folder(accountId, "INBOX");
|
|
130
|
+
const source = await message(accountId, inbox.mailboxId, 1);
|
|
131
|
+
const copy = await message(accountId, inbox.mailboxId, 2, {
|
|
132
|
+
from: source.messageId,
|
|
133
|
+
});
|
|
134
|
+
assert.equal(copy.bodyStorageKey, source.bodyStorageKey);
|
|
135
|
+
|
|
136
|
+
await repos.message.delete(source.messageId);
|
|
137
|
+
|
|
138
|
+
assert.ok(await storage.retrieve(copy.bodyStorageKey));
|
|
139
|
+
assert.ok(await part(copy));
|
|
140
|
+
|
|
141
|
+
await repos.message.delete(copy.messageId);
|
|
142
|
+
|
|
143
|
+
assert.equal(await storage.exists(copy.bodyStorageKey), false);
|
|
144
|
+
assert.equal(await part(copy), null);
|
|
145
|
+
assert.equal(await part(source), null);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a placement that no longer matches keeps the row and its objects", async () => {
|
|
149
|
+
const accountId = randomId();
|
|
150
|
+
const inbox = await folder(accountId, "INBOX");
|
|
151
|
+
const kept = await message(accountId, inbox.mailboxId, 1);
|
|
152
|
+
|
|
153
|
+
assert.equal(
|
|
154
|
+
await repos.message.deleteIfPlacement(kept.messageId, { uid: 2 }),
|
|
155
|
+
false,
|
|
156
|
+
);
|
|
157
|
+
assert.ok(await storage.retrieve(kept.bodyStorageKey));
|
|
158
|
+
|
|
159
|
+
assert.equal(
|
|
160
|
+
await repos.message.deleteIfPlacement(kept.messageId, { uid: 1 }),
|
|
161
|
+
true,
|
|
162
|
+
);
|
|
163
|
+
assert.equal(await storage.exists(kept.bodyStorageKey), false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("a body stored under the key shape without an account config removes its message prefix", async () => {
|
|
167
|
+
const accountId = randomId();
|
|
168
|
+
const inbox = await folder(accountId, "INBOX");
|
|
169
|
+
const messageId = randomId();
|
|
170
|
+
const legacy = join(
|
|
171
|
+
directory,
|
|
172
|
+
"accounts",
|
|
173
|
+
accountId,
|
|
174
|
+
"messages",
|
|
175
|
+
messageId,
|
|
176
|
+
);
|
|
177
|
+
mkdirSync(join(legacy, "parts"), { recursive: true });
|
|
178
|
+
writeFileSync(join(legacy, "body.eml"), "legacy");
|
|
179
|
+
writeFileSync(join(legacy, "parts", "1"), "part");
|
|
180
|
+
await repos.message.create({
|
|
181
|
+
messageId,
|
|
182
|
+
mailboxId: inbox.mailboxId,
|
|
183
|
+
uid: 1,
|
|
184
|
+
sequenceNumber: 1,
|
|
185
|
+
rfc822Size: 10,
|
|
186
|
+
internalDate: 1700000000000,
|
|
187
|
+
envelopeId: deriveEnvelopeId(messageId),
|
|
188
|
+
rootBodyPartId: deriveRootBodyPartId(messageId),
|
|
189
|
+
bodyStorageKey: `file://${join(legacy, "body.eml")}`,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await repos.message.delete(messageId);
|
|
193
|
+
|
|
194
|
+
assert.equal(existsSync(legacy), false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("a folder delete that fails part way has already removed the batches it finished", async () => {
|
|
198
|
+
const accountId = randomId();
|
|
199
|
+
const doomed = await folder(accountId, "Doomed");
|
|
200
|
+
const rows = [];
|
|
201
|
+
for (let uid = 1; uid <= 101; uid += 1) {
|
|
202
|
+
rows.push(await message(accountId, doomed.mailboxId, uid));
|
|
203
|
+
}
|
|
204
|
+
failDeletesAfter = 100;
|
|
205
|
+
|
|
206
|
+
await assert.rejects(
|
|
207
|
+
repos.mailbox.deleteMailboxWithMail(accountId, doomed.mailboxId),
|
|
208
|
+
/storage went away/,
|
|
209
|
+
);
|
|
210
|
+
failDeletesAfter = Number.POSITIVE_INFINITY;
|
|
211
|
+
|
|
212
|
+
const gone = await Promise.all(
|
|
213
|
+
rows.map((row) => storage.exists(row.bodyStorageKey)),
|
|
214
|
+
);
|
|
215
|
+
assert.equal(gone.filter((exists) => !exists).length, 100);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMockStorageService,
|
|
3
|
+
messageObjectPrefixOf,
|
|
4
|
+
type StorageService,
|
|
5
|
+
} from "@remit/storage-service";
|
|
6
|
+
import pMap from "p-map";
|
|
7
|
+
import type { RepositoryDb } from "../db.js";
|
|
8
|
+
import { messageTable } from "../schema/message-data.js";
|
|
9
|
+
import { inList } from "./in-list.js";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_MESSAGE_OBJECT_DELETE_CONCURRENCY = 8;
|
|
12
|
+
|
|
13
|
+
export interface MessageObjectStore {
|
|
14
|
+
storage: Pick<StorageService, "deletePrefix">;
|
|
15
|
+
messageObjectDeleteConcurrency: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const messageObjectsInMemory = (): MessageObjectStore => ({
|
|
19
|
+
storage: createMockStorageService(),
|
|
20
|
+
messageObjectDeleteConcurrency: 1,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export interface RemovedMessageRow {
|
|
24
|
+
messageId: string;
|
|
25
|
+
bodyStorageKey: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Candidate {
|
|
29
|
+
prefix: string;
|
|
30
|
+
bodyStorageKey: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const candidatesOf = (row: RemovedMessageRow): Candidate[] => {
|
|
34
|
+
const { messageId, bodyStorageKey } = row;
|
|
35
|
+
if (bodyStorageKey === null) return [];
|
|
36
|
+
const ownerPrefix = messageObjectPrefixOf(bodyStorageKey);
|
|
37
|
+
const ownPrefix = ownerPrefix.replace(/[^/]+\/$/, `${messageId}/`);
|
|
38
|
+
const owner = { prefix: ownerPrefix, bodyStorageKey };
|
|
39
|
+
if (ownPrefix === ownerPrefix) return [owner];
|
|
40
|
+
const at = bodyStorageKey.lastIndexOf(ownerPrefix);
|
|
41
|
+
const ownBodyStorageKey = `${bodyStorageKey.slice(0, at)}${ownPrefix}${bodyStorageKey.slice(at + ownerPrefix.length)}`;
|
|
42
|
+
return [owner, { prefix: ownPrefix, bodyStorageKey: ownBodyStorageKey }];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export class MessageObjects {
|
|
46
|
+
constructor(
|
|
47
|
+
private readonly db: Pick<RepositoryDb<Record<string, unknown>>, "select">,
|
|
48
|
+
private readonly store: MessageObjectStore,
|
|
49
|
+
) {}
|
|
50
|
+
|
|
51
|
+
async reclaim(removed: readonly RemovedMessageRow[]): Promise<void> {
|
|
52
|
+
const candidates = new Map(
|
|
53
|
+
removed.flatMap(candidatesOf).map((c) => [c.prefix, c]),
|
|
54
|
+
);
|
|
55
|
+
if (candidates.size === 0) return;
|
|
56
|
+
const keys = [
|
|
57
|
+
...new Set([...candidates.values()].map((c) => c.bodyStorageKey)),
|
|
58
|
+
];
|
|
59
|
+
const referenced = await this.db
|
|
60
|
+
.select({ bodyStorageKey: messageTable.bodyStorageKey })
|
|
61
|
+
.from(messageTable)
|
|
62
|
+
.where(inList(messageTable.bodyStorageKey, keys));
|
|
63
|
+
const stillReferenced = new Set(
|
|
64
|
+
referenced.flatMap((row) =>
|
|
65
|
+
row.bodyStorageKey === null ? [] : [row.bodyStorageKey],
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
await pMap(
|
|
69
|
+
[...candidates.values()].filter(
|
|
70
|
+
(c) => !stillReferenced.has(c.bodyStorageKey),
|
|
71
|
+
),
|
|
72
|
+
(c) => this.store.storage.deletePrefix(c.prefix),
|
|
73
|
+
{ concurrency: this.store.messageObjectDeleteConcurrency },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -10,6 +10,7 @@ import { threadMessageTable } from "../schema/thread-message.js";
|
|
|
10
10
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
11
11
|
import { createSqliteTestDb, type SqliteTestDb } from "../test-db-sqlite.js";
|
|
12
12
|
import type { DrizzleMessageRepository } from "./message.js";
|
|
13
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
13
14
|
|
|
14
15
|
const SCHEMA = { ...messageDataSchema, threadMessages: threadMessageTable };
|
|
15
16
|
|
|
@@ -47,6 +48,7 @@ describe("DrizzleMessageRepository (sqlite)", () => {
|
|
|
47
48
|
repo = createSqliteRepositories(
|
|
48
49
|
{ app: db, mail: db, search: db },
|
|
49
50
|
BETTER_SQLITE3_LIMITS,
|
|
51
|
+
messageObjectsInMemory(),
|
|
50
52
|
).message;
|
|
51
53
|
});
|
|
52
54
|
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
9
9
|
import type { DrizzleEnvelopeRepository } from "./envelope.js";
|
|
10
10
|
import type { DrizzleMessageRepository } from "./message.js";
|
|
11
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
11
12
|
import { createTestDb, type TestDb } from "./test-helpers.js";
|
|
12
13
|
|
|
13
14
|
describe("DrizzleMessageRepository", () => {
|
|
@@ -41,6 +42,7 @@ describe("DrizzleMessageRepository", () => {
|
|
|
41
42
|
const repos = createSqliteRepositories(
|
|
42
43
|
{ app: db, mail: db, search: db },
|
|
43
44
|
BETTER_SQLITE3_LIMITS,
|
|
45
|
+
messageObjectsInMemory(),
|
|
44
46
|
);
|
|
45
47
|
messageRepo = repos.message;
|
|
46
48
|
envelopeRepo = repos.envelope;
|
package/src/repos/message.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
toMessageReferenceItem,
|
|
56
56
|
toRawMessageStorageItem,
|
|
57
57
|
} from "./mappers.js";
|
|
58
|
+
import type { MessageObjects } from "./message-objects.js";
|
|
58
59
|
|
|
59
60
|
type DB = RepositoryDb<Record<string, unknown>>;
|
|
60
61
|
|
|
@@ -112,22 +113,33 @@ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
|
|
|
112
113
|
};
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
export type SubtreeDb = Pick<
|
|
116
|
+
export type SubtreeDb = Pick<
|
|
117
|
+
RepositoryDb<Record<string, unknown>>,
|
|
118
|
+
"delete" | "select"
|
|
119
|
+
>;
|
|
116
120
|
|
|
117
121
|
/**
|
|
118
122
|
* The statements that delete a message and its whole per-message subtree by
|
|
119
123
|
* message id: the child tables, then the message row. The caller runs them as
|
|
120
|
-
* one batch so the subtree goes atomically
|
|
121
|
-
*
|
|
122
|
-
* a guard that reads the message row holds until the last
|
|
124
|
+
* one batch so the subtree goes atomically. `removed` reads the rows the batch
|
|
125
|
+
* deletes; it goes first, so it sees them under the same guard. `guard` rides
|
|
126
|
+
* every statement, so a guard that reads the message row holds until the last
|
|
127
|
+
* statement removes it.
|
|
123
128
|
*/
|
|
124
129
|
export function deleteMessageSubtree(
|
|
125
130
|
db: SubtreeDb,
|
|
126
131
|
messageIds: string[],
|
|
127
132
|
guard?: SQL,
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
) {
|
|
134
|
+
const removed = db
|
|
135
|
+
.select({
|
|
136
|
+
messageId: messageTable.messageId,
|
|
137
|
+
bodyStorageKey: messageTable.bodyStorageKey,
|
|
138
|
+
})
|
|
139
|
+
.from(messageTable)
|
|
140
|
+
.where(and(inList(messageTable.messageId, messageIds), guard));
|
|
141
|
+
if (messageIds.length === 0) return { removed, statements: [] };
|
|
142
|
+
const statements: SqliteBatchItem[] = [
|
|
131
143
|
db
|
|
132
144
|
.delete(messageFlagTable)
|
|
133
145
|
.where(and(inList(messageFlagTable.messageId, messageIds), guard)),
|
|
@@ -162,9 +174,9 @@ export function deleteMessageSubtree(
|
|
|
162
174
|
.where(and(inList(envelopeTable.messageId, messageIds), guard)),
|
|
163
175
|
db
|
|
164
176
|
.delete(messageTable)
|
|
165
|
-
.where(and(inList(messageTable.messageId, messageIds), guard))
|
|
166
|
-
.returning({ messageId: messageTable.messageId }),
|
|
177
|
+
.where(and(inList(messageTable.messageId, messageIds), guard)),
|
|
167
178
|
];
|
|
179
|
+
return { removed, statements };
|
|
168
180
|
}
|
|
169
181
|
|
|
170
182
|
/**
|
|
@@ -202,6 +214,7 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
202
214
|
constructor(
|
|
203
215
|
store: SqliteStore<Record<string, unknown>>,
|
|
204
216
|
private search: SearchIndex,
|
|
217
|
+
private messageObjects: MessageObjects,
|
|
205
218
|
) {
|
|
206
219
|
this.db = store.db;
|
|
207
220
|
this.batch = store.batch;
|
|
@@ -533,20 +546,27 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
533
546
|
.select({ threadMessageId: threadMessageTable.threadMessageId })
|
|
534
547
|
.from(threadMessageTable)
|
|
535
548
|
.where(eq(threadMessageTable.messageId, messageId));
|
|
536
|
-
const
|
|
549
|
+
const subtree = deleteMessageSubtree(this.db, [messageId], placed);
|
|
550
|
+
const [removed] = await this.batch([
|
|
551
|
+
subtree.removed,
|
|
537
552
|
this.db
|
|
538
553
|
.delete(threadMessageTable)
|
|
539
554
|
.where(and(eq(threadMessageTable.messageId, messageId), placed)),
|
|
540
|
-
...
|
|
555
|
+
...subtree.statements,
|
|
541
556
|
]);
|
|
542
557
|
await this.search.sync(listings.map((row) => row.threadMessageId));
|
|
543
|
-
|
|
544
|
-
return
|
|
558
|
+
await this.messageObjects.reclaim(removed);
|
|
559
|
+
return removed.length > 0;
|
|
545
560
|
}
|
|
546
561
|
|
|
547
562
|
async deleteMany(messageIds: string[]): Promise<void> {
|
|
548
563
|
if (messageIds.length === 0) return;
|
|
549
|
-
|
|
564
|
+
const subtree = deleteMessageSubtree(this.db, messageIds);
|
|
565
|
+
const [removed] = await this.batch([
|
|
566
|
+
subtree.removed,
|
|
567
|
+
...subtree.statements,
|
|
568
|
+
]);
|
|
569
|
+
await this.messageObjects.reclaim(removed);
|
|
550
570
|
}
|
|
551
571
|
|
|
552
572
|
async listByMailbox(
|
|
@@ -16,6 +16,7 @@ import { messageSearchTextTable } from "../schema/message-data.js";
|
|
|
16
16
|
import { threadMessageTable } from "../schema/thread-message.js";
|
|
17
17
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
18
18
|
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
19
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
19
20
|
import type { DrizzleThreadMessageRepository } from "./thread-message.js";
|
|
20
21
|
|
|
21
22
|
const ACCOUNT = "acct-seam";
|
|
@@ -68,6 +69,7 @@ describe("thread-message body and muted-sender predicates (sqlite)", () => {
|
|
|
68
69
|
search: db,
|
|
69
70
|
},
|
|
70
71
|
BETTER_SQLITE3_LIMITS,
|
|
72
|
+
messageObjectsInMemory(),
|
|
71
73
|
).threadMessage;
|
|
72
74
|
});
|
|
73
75
|
|
|
@@ -6,6 +6,7 @@ import { messageSearchTextTable } from "../schema/message-data.js";
|
|
|
6
6
|
import { threadMessageTable } from "../schema/thread-message.js";
|
|
7
7
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
8
8
|
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
9
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
9
10
|
import {
|
|
10
11
|
type DrizzleThreadMessageRepository,
|
|
11
12
|
THREAD_SEARCH_MAX_LIMIT,
|
|
@@ -67,6 +68,7 @@ describe("DrizzleThreadMessageRepository.listByFieldTerms (sqlite, #459)", () =>
|
|
|
67
68
|
search: created.db,
|
|
68
69
|
},
|
|
69
70
|
BETTER_SQLITE3_LIMITS,
|
|
71
|
+
messageObjectsInMemory(),
|
|
70
72
|
).threadMessage;
|
|
71
73
|
|
|
72
74
|
await repo.create(
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
LIVE_TOTALS,
|
|
14
14
|
totalRows,
|
|
15
15
|
} from "./category-fixture.js";
|
|
16
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
16
17
|
import {
|
|
17
18
|
type DrizzleThreadMessageRepository,
|
|
18
19
|
THREAD_SEARCH_MAX_LIMIT,
|
|
@@ -68,6 +69,7 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
|
|
|
68
69
|
search: db,
|
|
69
70
|
},
|
|
70
71
|
BETTER_SQLITE3_LIMITS,
|
|
72
|
+
messageObjectsInMemory(),
|
|
71
73
|
).threadMessage;
|
|
72
74
|
});
|
|
73
75
|
|
|
@@ -10,6 +10,7 @@ import { messageSearchTextTable } from "../schema/message-data.js";
|
|
|
10
10
|
import { threadMessageTable } from "../schema/thread-message.js";
|
|
11
11
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
12
12
|
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
13
|
+
import { messageObjectsInMemory } from "./message-objects.js";
|
|
13
14
|
import {
|
|
14
15
|
clampThreadSearchLimit,
|
|
15
16
|
type DrizzleThreadMessageRepository,
|
|
@@ -102,6 +103,7 @@ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox"
|
|
|
102
103
|
search: harness.db,
|
|
103
104
|
},
|
|
104
105
|
BETTER_SQLITE3_LIMITS,
|
|
106
|
+
messageObjectsInMemory(),
|
|
105
107
|
).threadMessage;
|
|
106
108
|
});
|
|
107
109
|
|
|
@@ -411,6 +413,7 @@ describe("DrizzleThreadMessageRepository.searchByDate", () => {
|
|
|
411
413
|
search: harness.db,
|
|
412
414
|
},
|
|
413
415
|
BETTER_SQLITE3_LIMITS,
|
|
416
|
+
messageObjectsInMemory(),
|
|
414
417
|
);
|
|
415
418
|
repo = repositories.threadMessage;
|
|
416
419
|
messages = repositories.message;
|
|
@@ -949,6 +952,7 @@ describe("DrizzleThreadMessageRepository — native text search", () => {
|
|
|
949
952
|
search: harness.db,
|
|
950
953
|
},
|
|
951
954
|
BETTER_SQLITE3_LIMITS,
|
|
955
|
+
messageObjectsInMemory(),
|
|
952
956
|
).threadMessage;
|
|
953
957
|
});
|
|
954
958
|
|
|
@@ -1128,6 +1132,7 @@ describe("DrizzleThreadMessageRepository — core CRUD", () => {
|
|
|
1128
1132
|
search: harness.db,
|
|
1129
1133
|
},
|
|
1130
1134
|
BETTER_SQLITE3_LIMITS,
|
|
1135
|
+
messageObjectsInMemory(),
|
|
1131
1136
|
).threadMessage;
|
|
1132
1137
|
});
|
|
1133
1138
|
|
|
@@ -6,6 +6,7 @@ import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
|
6
6
|
import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
|
|
7
7
|
import { syncStore } from "../databases.js";
|
|
8
8
|
import type { Db } from "../db.js";
|
|
9
|
+
import { messageObjectsInMemory } from "../repos/message-objects.js";
|
|
9
10
|
import { messageSearchTextTable } from "../schema/message-data.js";
|
|
10
11
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
11
12
|
import { applyMigrationSet } from "../test-shipped-sqlite-schema.js";
|
|
@@ -62,6 +63,7 @@ describe("the thread-message search index in its own database", () => {
|
|
|
62
63
|
search: search.db,
|
|
63
64
|
},
|
|
64
65
|
BETTER_SQLITE3_LIMITS,
|
|
66
|
+
messageObjectsInMemory(),
|
|
65
67
|
).threadMessage;
|
|
66
68
|
|
|
67
69
|
const found = async (query: string): Promise<string[]> =>
|
|
@@ -31,20 +31,24 @@ import { LabelRepo } from "./repos/label.js";
|
|
|
31
31
|
import { DrizzleMessageRepository } from "./repos/message.js";
|
|
32
32
|
import { DrizzleMessageFlagRepository } from "./repos/message-flag.js";
|
|
33
33
|
import { MessageLabelRepo } from "./repos/message-label.js";
|
|
34
|
+
import {
|
|
35
|
+
type MessageObjectStore,
|
|
36
|
+
MessageObjects,
|
|
37
|
+
} from "./repos/message-objects.js";
|
|
34
38
|
import { QuarantineRepo } from "./repos/quarantine.js";
|
|
35
39
|
import { SyncWarningRepo } from "./repos/sync-warning.js";
|
|
36
40
|
import { DrizzleThreadMessageRepository } from "./repos/thread-message.js";
|
|
37
41
|
import { SearchIndex } from "./search/thread-message-search.js";
|
|
38
42
|
|
|
39
|
-
export const composeSqliteRepositories = (
|
|
40
|
-
app,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}: SqliteStores) => {
|
|
43
|
+
export const composeSqliteRepositories = (
|
|
44
|
+
{ app, mail, search }: SqliteStores,
|
|
45
|
+
objects: MessageObjectStore,
|
|
46
|
+
) => {
|
|
44
47
|
const searchIndex = new SearchIndex(mail.db, search);
|
|
48
|
+
const messageObjects = new MessageObjects(mail.db, objects);
|
|
45
49
|
const accountSetting = new AccountSettingRepo(app.db);
|
|
46
50
|
const address = new AddressRepo(mail.db);
|
|
47
|
-
const mailbox = new MailboxRepo(mail, searchIndex);
|
|
51
|
+
const mailbox = new MailboxRepo(mail, searchIndex, messageObjects);
|
|
48
52
|
const account = new AccountRepo(app.db, mailbox);
|
|
49
53
|
const calendarSuggestion = new CalendarSuggestionRepo(mail.db);
|
|
50
54
|
return {
|
|
@@ -59,7 +63,7 @@ export const composeSqliteRepositories = ({
|
|
|
59
63
|
accountSetting,
|
|
60
64
|
),
|
|
61
65
|
mailboxLock: new MailboxLockRepo(mail.db),
|
|
62
|
-
message: new DrizzleMessageRepository(mail, searchIndex),
|
|
66
|
+
message: new DrizzleMessageRepository(mail, searchIndex, messageObjects),
|
|
63
67
|
messageFlag: new DrizzleMessageFlagRepository(mail.db),
|
|
64
68
|
outboxMessage: new OutboxMessageRepo(mail.db),
|
|
65
69
|
outboxAttachment: new OutboxAttachmentRepo(mail),
|
|
@@ -90,5 +94,6 @@ export type SqliteRepositories = ReturnType<typeof composeSqliteRepositories>;
|
|
|
90
94
|
export const createSqliteRepositories = (
|
|
91
95
|
databases: SqliteDatabases,
|
|
92
96
|
limits: SqliteLimits,
|
|
97
|
+
objects: MessageObjectStore,
|
|
93
98
|
): SqliteRepositories =>
|
|
94
|
-
composeSqliteRepositories(syncStores(databases, limits));
|
|
99
|
+
composeSqliteRepositories(syncStores(databases, limits), objects);
|