@remit/drizzle-service 0.0.1
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/drizzle.config.ts +15 -0
- package/package.json +52 -0
- package/src/db.ts +13 -0
- package/src/dialect.ts +13 -0
- package/src/error.ts +37 -0
- package/src/id.ts +50 -0
- package/src/index.ts +62 -0
- package/src/pagination.ts +29 -0
- package/src/repos/cascade-delete.sqlite.test.ts +159 -0
- package/src/repos/cascade-delete.test.ts +423 -0
- package/src/repos/cascade-delete.ts +219 -0
- package/src/repos/envelope.sqlite.test.ts +94 -0
- package/src/repos/envelope.test.ts +225 -0
- package/src/repos/envelope.ts +342 -0
- package/src/repos/filter-anchor.test.ts +131 -0
- package/src/repos/filter-anchor.ts +105 -0
- package/src/repos/filter.test.ts +279 -0
- package/src/repos/filter.ts +253 -0
- package/src/repos/i4-account-config.test.ts +105 -0
- package/src/repos/i4-account-config.ts +257 -0
- package/src/repos/i4-account-export-request.ts +141 -0
- package/src/repos/i4-account-setting.test.ts +94 -0
- package/src/repos/i4-account-setting.ts +92 -0
- package/src/repos/i4-account.test.ts +223 -0
- package/src/repos/i4-account.ts +380 -0
- package/src/repos/i4-address-wellknown.ts +31 -0
- package/src/repos/i4-address.test.ts +358 -0
- package/src/repos/i4-address.ts +613 -0
- package/src/repos/i4-mailbox-lock.test.ts +368 -0
- package/src/repos/i4-mailbox-lock.ts +140 -0
- package/src/repos/i4-mailbox-special-use.ts +165 -0
- package/src/repos/i4-mailbox.test.ts +188 -0
- package/src/repos/i4-mailbox.ts +347 -0
- package/src/repos/i4-message-flag-push.test.ts +189 -0
- package/src/repos/i4-message-flag-push.ts +173 -0
- package/src/repos/i4-message-placement-move.test.ts +135 -0
- package/src/repos/i4-message-placement-move.ts +144 -0
- package/src/repos/i4-organize-job-request.ts +142 -0
- package/src/repos/i4-outbox-message.test.ts +298 -0
- package/src/repos/i4-outbox-message.ts +299 -0
- package/src/repos/label.conformance.sqlite.test.ts +23 -0
- package/src/repos/label.conformance.test.ts +19 -0
- package/src/repos/label.ts +125 -0
- package/src/repos/mappers.ts +171 -0
- package/src/repos/message-flag.ts +162 -0
- package/src/repos/message-label.test.ts +110 -0
- package/src/repos/message-label.ts +96 -0
- package/src/repos/message.sqlite.test.ts +118 -0
- package/src/repos/message.test.ts +488 -0
- package/src/repos/message.ts +558 -0
- package/src/repos/serialized-writes.sqlite.test.ts +199 -0
- package/src/repos/test-helpers.ts +222 -0
- package/src/repos/thread-message.sqlite.test.ts +198 -0
- package/src/repos/thread-message.test.ts +744 -0
- package/src/repos/thread-message.ts +832 -0
- package/src/repos/thread-search-predicates.ts +79 -0
- package/src/repos/unit-of-work.sqlite.test.ts +138 -0
- package/src/repos/unit-of-work.test.ts +105 -0
- package/src/repos/unit-of-work.ts +31 -0
- package/src/schema/active-entities.ts +19 -0
- package/src/schema/i4-account-config.ts +4 -0
- package/src/schema/i4-account-export-request.ts +3 -0
- package/src/schema/i4-account-setting.ts +3 -0
- package/src/schema/i4-address.ts +3 -0
- package/src/schema/i4-mailbox-lock.ts +3 -0
- package/src/schema/i4-mailbox.ts +4 -0
- package/src/schema/i4-message-flag-push.ts +3 -0
- package/src/schema/i4-message-placement-move.ts +3 -0
- package/src/schema/i4-organize-job-request.ts +1 -0
- package/src/schema/i4-outbox-message.ts +3 -0
- package/src/schema/message-data.ts +44 -0
- package/src/schema/outbox.ts +74 -0
- package/src/schema/thread-message.ts +3 -0
- package/src/schema-full-sqlite.ts +11 -0
- package/src/schema-full.ts +16 -0
- package/src/schema.ts +28 -0
- package/src/sqlite-client.ts +52 -0
- package/src/test-db-sqlite.ts +75 -0
- package/src/test-db.ts +76 -0
- package/src/tx.ts +208 -0
- package/src/vps-migrations-drift.test.ts +34 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const url =
|
|
2
|
+
process.env.DATABASE_URL ??
|
|
3
|
+
process.env.PG_CONNECTION_URL ??
|
|
4
|
+
"postgresql://remit:remit@localhost:5432/remit_dev";
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
dialect: "postgresql",
|
|
8
|
+
schema: "./src/schema.ts",
|
|
9
|
+
out: "./.drizzle",
|
|
10
|
+
dbCredentials: { url },
|
|
11
|
+
// The better-auth identity tables (auth_*) share this database but are owned
|
|
12
|
+
// by remit-auth-service's own drizzle config. Exclude them here so an entity
|
|
13
|
+
// push never proposes dropping tables it does not manage.
|
|
14
|
+
tablesFilter: ["!auth_*"],
|
|
15
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@remit/drizzle-service",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Drizzle ORM service parameterized by dialect (Postgres / SQLite)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"default": "./src/index.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test:typecheck": "tsgo --noEmit -p tsconfig.json",
|
|
16
|
+
"test:run:pg": "node --env-file=../../localhost-test-unit.env --import tsx --test 'src/**/!(*.sqlite).test.ts'",
|
|
17
|
+
"test:run:sqlite": "node --env-file=../../localhost-test-unit-sqlite.env --import tsx --test 'src/**/*.sqlite.test.ts'",
|
|
18
|
+
"test:run": "npm run test:run:pg && npm run test:run:sqlite",
|
|
19
|
+
"test": "npm run test:typecheck && npm run test:run",
|
|
20
|
+
"fix": "biome check --write src"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@remit/api-zod-schemas": "*",
|
|
24
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
25
|
+
"@types/pg": "^8.0.0",
|
|
26
|
+
"better-sqlite3": "^12.11.1",
|
|
27
|
+
"drizzle-kit": "^0.31.1",
|
|
28
|
+
"embedded-postgres": "^18.4.0-beta.17",
|
|
29
|
+
"tsx": "*",
|
|
30
|
+
"zod": "*"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@remit/data-ports": "*",
|
|
34
|
+
"@remit/domain-enums": "*",
|
|
35
|
+
"drizzle-orm": "^0.45.2",
|
|
36
|
+
"pg": "^8.0.0",
|
|
37
|
+
"postgres": "^3.4.5",
|
|
38
|
+
"short-uuid": "*",
|
|
39
|
+
"uuid": "*",
|
|
40
|
+
"@remit/drizzle-pg-schema": "*",
|
|
41
|
+
"@remit/drizzle-sqlite-schema": "*"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/remit-mail/remit.git",
|
|
50
|
+
"directory": "packages/drizzle-service"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { NodePgQueryResultHKT } from "drizzle-orm/node-postgres";
|
|
2
|
+
import type { PgDatabase } from "drizzle-orm/pg-core";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A drizzle handle a repository can run on: the top-level database or a
|
|
6
|
+
* transaction (or savepoint) bound to one. Both share the query-builder API, so
|
|
7
|
+
* a repo constructed with either behaves the same — standalone, or enlisted in a
|
|
8
|
+
* unit-of-work transaction.
|
|
9
|
+
*/
|
|
10
|
+
export type Db<TSchema extends Record<string, unknown>> = PgDatabase<
|
|
11
|
+
NodePgQueryResultHKT,
|
|
12
|
+
TSchema
|
|
13
|
+
>;
|
package/src/dialect.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The SQL dialect this process runs against (RFC 036 D1). One deployment is
|
|
2
|
+
// one backend, chosen once at startup by `DATA_BACKEND`: `sqlite` selects the
|
|
3
|
+
// SQLite entity tables, outbox table, transaction strategy, and search
|
|
4
|
+
// predicates; anything else keeps the Postgres behavior that predates this
|
|
5
|
+
// switch. Read once at module load — the repos and schema facade branch on it,
|
|
6
|
+
// and a single process never mixes dialects.
|
|
7
|
+
|
|
8
|
+
export type SqlDialect = "postgres" | "sqlite";
|
|
9
|
+
|
|
10
|
+
export const SQL_DIALECT: SqlDialect =
|
|
11
|
+
process.env.DATA_BACKEND === "sqlite" ? "sqlite" : "postgres";
|
|
12
|
+
|
|
13
|
+
export const isSqlite = (): boolean => SQL_DIALECT === "sqlite";
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Re-export the shared error contract so an error thrown by a pg repo is the
|
|
2
|
+
// same class the backend handlers and workers already catch (they import these
|
|
3
|
+
// from @remit/data-ports/errors). Defining parallel classes here made
|
|
4
|
+
// `instanceof NotFoundError` false across the adapter boundary — a fresh user's
|
|
5
|
+
// GET /config surfaced a 404 fatal overlay instead of the empty-config path.
|
|
6
|
+
export {
|
|
7
|
+
CreateFailedConflictError,
|
|
8
|
+
ForbiddenError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
} from "@remit/data-ports/errors";
|
|
11
|
+
|
|
12
|
+
export class NotImplementedError extends Error {
|
|
13
|
+
name = "NotImplementedError";
|
|
14
|
+
statusCode = 501;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PG_UNIQUE_VIOLATION = "23505";
|
|
18
|
+
// better-sqlite3 raises a `SqliteError` whose `.code` is one of these for a
|
|
19
|
+
// primary-key or unique-index collision (RFC 036 D1).
|
|
20
|
+
const SQLITE_UNIQUE_VIOLATIONS = new Set([
|
|
21
|
+
"SQLITE_CONSTRAINT_UNIQUE",
|
|
22
|
+
"SQLITE_CONSTRAINT_PRIMARYKEY",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
// Drizzle wraps the underlying driver error and carries the original (with its
|
|
26
|
+
// SQLSTATE / SQLite `code`) on `.cause`, so walk the cause chain — covers both
|
|
27
|
+
// the pg and better-sqlite3 drivers.
|
|
28
|
+
export const isUniqueViolation = (error: unknown): boolean => {
|
|
29
|
+
let current: unknown = error;
|
|
30
|
+
while (current) {
|
|
31
|
+
const code = (current as { code?: string }).code;
|
|
32
|
+
if (code === PG_UNIQUE_VIOLATION) return true;
|
|
33
|
+
if (code !== undefined && SQLITE_UNIQUE_VIOLATIONS.has(code)) return true;
|
|
34
|
+
current = (current as { cause?: unknown }).cause;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
};
|
package/src/id.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import shortUuid from "short-uuid";
|
|
2
|
+
import { v5 as uuidv5 } from "uuid";
|
|
3
|
+
|
|
4
|
+
const REMIT_NAMESPACE = "9e89694d-214b-4d9b-99f5-214b4d9b99f5";
|
|
5
|
+
|
|
6
|
+
const base36 = shortUuid.createTranslator(shortUuid.constants.uuid25Base36);
|
|
7
|
+
|
|
8
|
+
// A 25-char base36-encoded UUID, matching the id format the API contract
|
|
9
|
+
// enforces (path params carry minLength 25) and the electrodb adapter's
|
|
10
|
+
// base36uuid. The prior Math.random-based generator produced variable-length
|
|
11
|
+
// ids (~19 chars) that failed `{accountId}`/`{messageId}` path validation.
|
|
12
|
+
export const randomId = (): string => base36.generate();
|
|
13
|
+
|
|
14
|
+
export const generateDeterministicId = (name: string): string =>
|
|
15
|
+
uuidv5(name, REMIT_NAMESPACE);
|
|
16
|
+
|
|
17
|
+
// Deterministic base36-encoded UUIDv5, byte-identical to the electrodb
|
|
18
|
+
// adapter's `base36uuidv5` so a `MessageLabel` derived from the same
|
|
19
|
+
// (messageId, labelId) keys the same row on either backend.
|
|
20
|
+
export const deterministicBase36Id = (name: string): string =>
|
|
21
|
+
base36.fromUUID(uuidv5(name, REMIT_NAMESPACE));
|
|
22
|
+
|
|
23
|
+
export const bodyPartId = (messageId: string, partPath: string): string =>
|
|
24
|
+
generateDeterministicId(`${messageId}:${partPath}`);
|
|
25
|
+
|
|
26
|
+
export const rootBodyPartId = (messageId: string): string =>
|
|
27
|
+
bodyPartId(messageId, "0");
|
|
28
|
+
|
|
29
|
+
export const bodyPartParameterId = (
|
|
30
|
+
messageId: string,
|
|
31
|
+
partPath: string,
|
|
32
|
+
parameterName: string,
|
|
33
|
+
): string =>
|
|
34
|
+
generateDeterministicId(`${messageId}:${partPath}:${parameterName}`);
|
|
35
|
+
|
|
36
|
+
export const envelopeId = (messageId: string): string =>
|
|
37
|
+
generateDeterministicId(`envelope:${messageId}`);
|
|
38
|
+
|
|
39
|
+
export const envelopeAddressId = (
|
|
40
|
+
messageId: string,
|
|
41
|
+
role: string,
|
|
42
|
+
order: number,
|
|
43
|
+
): string =>
|
|
44
|
+
generateDeterministicId(`envelopeaddress:${messageId}:${role}:${order}`);
|
|
45
|
+
|
|
46
|
+
export const bodyPartContentId = (
|
|
47
|
+
messageId: string,
|
|
48
|
+
bodyPartId: string,
|
|
49
|
+
): string =>
|
|
50
|
+
generateDeterministicId(`bodypartcontent:${messageId}:${bodyPartId}`);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export { CreateFailedConflictError, NotFoundError } from "./error.js";
|
|
2
|
+
export {
|
|
3
|
+
type CascadeDeleteLogger,
|
|
4
|
+
type CascadeDeleter,
|
|
5
|
+
type CascadeEntity,
|
|
6
|
+
createCascadeDeleter,
|
|
7
|
+
createSqliteCascadeDeleter,
|
|
8
|
+
runDrizzleCascadeDelete,
|
|
9
|
+
} from "./repos/cascade-delete.js";
|
|
10
|
+
export { DrizzleEnvelopeRepository } from "./repos/envelope.js";
|
|
11
|
+
export { FilterRepo } from "./repos/filter.js";
|
|
12
|
+
export { FilterAnchorRepo } from "./repos/filter-anchor.js";
|
|
13
|
+
export * from "./repos/i4-account.js";
|
|
14
|
+
export * from "./repos/i4-account-config.js";
|
|
15
|
+
export * from "./repos/i4-account-export-request.js";
|
|
16
|
+
export * from "./repos/i4-account-setting.js";
|
|
17
|
+
export * from "./repos/i4-address.js";
|
|
18
|
+
export * from "./repos/i4-mailbox.js";
|
|
19
|
+
export * from "./repos/i4-mailbox-lock.js";
|
|
20
|
+
export * from "./repos/i4-mailbox-special-use.js";
|
|
21
|
+
export {
|
|
22
|
+
type FlagPushOperation,
|
|
23
|
+
type MessageFlagPushItem,
|
|
24
|
+
MessageFlagPushRepo,
|
|
25
|
+
type MessageFlagPushState,
|
|
26
|
+
type PutMessageFlagPushInput,
|
|
27
|
+
} from "./repos/i4-message-flag-push.js";
|
|
28
|
+
export {
|
|
29
|
+
type MessagePlacementMoveItem,
|
|
30
|
+
MessagePlacementMoveRepo,
|
|
31
|
+
type PutMessagePlacementMoveInput,
|
|
32
|
+
} from "./repos/i4-message-placement-move.js";
|
|
33
|
+
export * from "./repos/i4-organize-job-request.js";
|
|
34
|
+
export * from "./repos/i4-outbox-message.js";
|
|
35
|
+
export { LabelRepo } from "./repos/label.js";
|
|
36
|
+
export {
|
|
37
|
+
DrizzleMessageRepository,
|
|
38
|
+
deleteMessageSubtree,
|
|
39
|
+
MESSAGE_REMOVED_EVENT,
|
|
40
|
+
} from "./repos/message.js";
|
|
41
|
+
export { DrizzleMessageFlagRepository } from "./repos/message-flag.js";
|
|
42
|
+
export { MessageLabelRepo } from "./repos/message-label.js";
|
|
43
|
+
export { DrizzleThreadMessageRepository } from "./repos/thread-message.js";
|
|
44
|
+
export { DrizzleUnitOfWork } from "./repos/unit-of-work.js";
|
|
45
|
+
export * from "./schema/i4-account-config.js";
|
|
46
|
+
export * from "./schema/i4-account-export-request.js";
|
|
47
|
+
export * from "./schema/i4-account-setting.js";
|
|
48
|
+
export * from "./schema/i4-address.js";
|
|
49
|
+
export * from "./schema/i4-mailbox.js";
|
|
50
|
+
export * from "./schema/i4-mailbox-lock.js";
|
|
51
|
+
export * from "./schema/i4-message-flag-push.js";
|
|
52
|
+
export * from "./schema/i4-message-placement-move.js";
|
|
53
|
+
export * from "./schema/i4-organize-job-request.js";
|
|
54
|
+
export * from "./schema/i4-outbox-message.js";
|
|
55
|
+
export * from "./schema/message-data.js";
|
|
56
|
+
export { messageDataSchema } from "./schema/message-data.js";
|
|
57
|
+
export {
|
|
58
|
+
createSqliteDatabase,
|
|
59
|
+
type SqliteClient,
|
|
60
|
+
type SqliteClientOptions,
|
|
61
|
+
} from "./sqlite-client.js";
|
|
62
|
+
export { runInTransaction, serializeSqliteWrites } from "./tx.js";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ResultList } from "@remit/data-ports";
|
|
2
|
+
|
|
3
|
+
export function encodeToken(data: Record<string, unknown>): string {
|
|
4
|
+
return Buffer.from(JSON.stringify(data)).toString("base64url");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function decodeToken(
|
|
8
|
+
token: string,
|
|
9
|
+
): Record<string, unknown> | undefined {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(
|
|
12
|
+
Buffer.from(token, "base64url").toString("utf8"),
|
|
13
|
+
) as Record<string, unknown>;
|
|
14
|
+
} catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resultList<T>(
|
|
20
|
+
items: T[],
|
|
21
|
+
limit: number | undefined,
|
|
22
|
+
lastKey?: Record<string, unknown>,
|
|
23
|
+
): ResultList<T> {
|
|
24
|
+
const continuationToken =
|
|
25
|
+
lastKey && limit !== undefined && items.length === limit
|
|
26
|
+
? encodeToken(lastKey)
|
|
27
|
+
: undefined;
|
|
28
|
+
return { items, continuationToken };
|
|
29
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { after, before, describe, test } from "node:test";
|
|
6
|
+
import { MailboxCursorState } from "@remit/domain-enums";
|
|
7
|
+
import Database from "better-sqlite3";
|
|
8
|
+
import { messageDataSchema } from "../schema/message-data.js";
|
|
9
|
+
import {
|
|
10
|
+
accountTable,
|
|
11
|
+
envelopeTable,
|
|
12
|
+
mailboxTable,
|
|
13
|
+
messageTable,
|
|
14
|
+
} from "../schema.js";
|
|
15
|
+
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
16
|
+
import {
|
|
17
|
+
type CascadeEntity,
|
|
18
|
+
createSqliteCascadeDeleter,
|
|
19
|
+
} from "./cascade-delete.js";
|
|
20
|
+
|
|
21
|
+
// createSqliteCascadeDeleter opens the shared SQLite file itself, so the schema
|
|
22
|
+
// this harness pushes must live on a real file (not the in-memory default) for
|
|
23
|
+
// the deleter's own connection to see it. `deleteMessageSubtree` touches every
|
|
24
|
+
// message-data child table, so push the full message-data schema plus the
|
|
25
|
+
// account and mailbox containers.
|
|
26
|
+
const CASCADE_SCHEMA = {
|
|
27
|
+
...messageDataSchema,
|
|
28
|
+
account: accountTable,
|
|
29
|
+
mailbox: mailboxTable,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const log = { info: () => {} };
|
|
33
|
+
const NOW = 1700000000000;
|
|
34
|
+
const ACCOUNT_CONFIG_ID = "cfg-sqlite-1";
|
|
35
|
+
const ACCOUNT_ID = "acc-sqlite-1";
|
|
36
|
+
const MAILBOX_ID = "mbx-sqlite-1";
|
|
37
|
+
const MESSAGE_ID = "msg-sqlite-1";
|
|
38
|
+
const ENVELOPE_ID = "00000000-0000-4000-8000-0000000000a1";
|
|
39
|
+
const ROOT_BODY_PART_ID = "00000000-0000-4000-8000-0000000000a2";
|
|
40
|
+
|
|
41
|
+
describe("createSqliteCascadeDeleter", () => {
|
|
42
|
+
let filename: string;
|
|
43
|
+
let close: () => Promise<void>;
|
|
44
|
+
|
|
45
|
+
before(async () => {
|
|
46
|
+
const dir = await mkdtemp(join(tmpdir(), "remit-sqlite-cascade-"));
|
|
47
|
+
filename = join(dir, "cascade.db");
|
|
48
|
+
const created = await createSqliteTestDb(CASCADE_SCHEMA, { filename });
|
|
49
|
+
close = created.close;
|
|
50
|
+
const { db } = created;
|
|
51
|
+
|
|
52
|
+
await db.insert(accountTable).values({
|
|
53
|
+
accountId: ACCOUNT_ID,
|
|
54
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
55
|
+
username: "u",
|
|
56
|
+
email: "u@example.com",
|
|
57
|
+
authType: "password",
|
|
58
|
+
imapHost: "imap.example.com",
|
|
59
|
+
imapPort: 993,
|
|
60
|
+
imapTls: true,
|
|
61
|
+
imapStartTls: false,
|
|
62
|
+
smtpPort: 587,
|
|
63
|
+
isActive: true,
|
|
64
|
+
connectionState: "authenticated",
|
|
65
|
+
createdAt: NOW,
|
|
66
|
+
updatedAt: NOW,
|
|
67
|
+
});
|
|
68
|
+
await db.insert(mailboxTable).values({
|
|
69
|
+
mailboxId: MAILBOX_ID,
|
|
70
|
+
accountId: ACCOUNT_ID,
|
|
71
|
+
namespaceType: "personal",
|
|
72
|
+
namespacePrefix: "",
|
|
73
|
+
hierarchyDelimiter: "/",
|
|
74
|
+
fullPath: "INBOX",
|
|
75
|
+
uidValidity: 1,
|
|
76
|
+
uidNext: 1,
|
|
77
|
+
highestModseq: 0,
|
|
78
|
+
messageCount: 0,
|
|
79
|
+
unseenCount: 0,
|
|
80
|
+
deletedCount: 0,
|
|
81
|
+
totalSize: 0,
|
|
82
|
+
lastSyncUid: 0,
|
|
83
|
+
highWaterMarkUid: 0,
|
|
84
|
+
lastMessageSyncAt: NOW,
|
|
85
|
+
cursorState: MailboxCursorState.normal,
|
|
86
|
+
createdAt: NOW,
|
|
87
|
+
updatedAt: NOW,
|
|
88
|
+
});
|
|
89
|
+
await db.insert(messageTable).values({
|
|
90
|
+
messageId: MESSAGE_ID,
|
|
91
|
+
mailboxId: MAILBOX_ID,
|
|
92
|
+
uid: 1,
|
|
93
|
+
sequenceNumber: 1,
|
|
94
|
+
rfc822Size: 10,
|
|
95
|
+
internalDate: NOW,
|
|
96
|
+
envelopeId: ENVELOPE_ID,
|
|
97
|
+
rootBodyPartId: ROOT_BODY_PART_ID,
|
|
98
|
+
status: "active",
|
|
99
|
+
syncStatus: "synced",
|
|
100
|
+
category: "uncategorized",
|
|
101
|
+
createdAt: NOW,
|
|
102
|
+
updatedAt: NOW,
|
|
103
|
+
});
|
|
104
|
+
await db.insert(envelopeTable).values({
|
|
105
|
+
envelopeId: ENVELOPE_ID,
|
|
106
|
+
messageId: MESSAGE_ID,
|
|
107
|
+
dateValue: NOW,
|
|
108
|
+
dateRaw: "Mon, 14 Nov 2023 08:00:00 +0000",
|
|
109
|
+
createdAt: NOW,
|
|
110
|
+
updatedAt: NOW,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Close the seeding connection so the deleter's own WAL connection owns
|
|
114
|
+
// the file without a cross-connection lock.
|
|
115
|
+
await close();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
after(async () => {
|
|
119
|
+
// The seeding connection is already closed; nothing else to release.
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("deletes the message subtree, mailbox, and account over SQLite, emitting a removal event", async () => {
|
|
123
|
+
const entities: CascadeEntity[] = [
|
|
124
|
+
{ entityType: "Message", key: { messageId: MESSAGE_ID } },
|
|
125
|
+
{ entityType: "Envelope", key: { envelopeId: ENVELOPE_ID } },
|
|
126
|
+
{ entityType: "Mailbox", key: { mailboxId: MAILBOX_ID } },
|
|
127
|
+
{ entityType: "Account", key: { accountId: ACCOUNT_ID } },
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
const deleter = await createSqliteCascadeDeleter(filename);
|
|
131
|
+
await deleter(entities, log);
|
|
132
|
+
|
|
133
|
+
const sqlite = new Database(filename);
|
|
134
|
+
const count = (table: string, column: string, value: string): number =>
|
|
135
|
+
(
|
|
136
|
+
sqlite
|
|
137
|
+
.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${column} = ?`)
|
|
138
|
+
.get(value) as { n: number }
|
|
139
|
+
).n;
|
|
140
|
+
|
|
141
|
+
assert.equal(count("message", "message_id", MESSAGE_ID), 0, "message gone");
|
|
142
|
+
assert.equal(
|
|
143
|
+
count("envelope", "message_id", MESSAGE_ID),
|
|
144
|
+
0,
|
|
145
|
+
"envelope gone",
|
|
146
|
+
);
|
|
147
|
+
assert.equal(count("mailbox", "mailbox_id", MAILBOX_ID), 0, "mailbox gone");
|
|
148
|
+
assert.equal(count("account", "account_id", ACCOUNT_ID), 0, "account gone");
|
|
149
|
+
|
|
150
|
+
const outboxRows = (
|
|
151
|
+
sqlite.prepare("SELECT COUNT(*) AS n FROM outbox").get() as { n: number }
|
|
152
|
+
).n;
|
|
153
|
+
assert.ok(
|
|
154
|
+
outboxRows >= 1,
|
|
155
|
+
"a message-removed outbox row must be emitted for search-index cleanup",
|
|
156
|
+
);
|
|
157
|
+
sqlite.close();
|
|
158
|
+
});
|
|
159
|
+
});
|