@remit/drizzle-service 0.0.35 → 0.0.37
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 +3 -10
- package/src/db.ts +6 -6
- package/src/error.ts +2 -5
- package/src/index.ts +0 -1
- package/src/repair/thread-message-category-contract.ts +2 -4
- package/src/repair/thread-message-category.sqlite.test.ts +1 -2
- package/src/repair/thread-message-category.ts +15 -25
- package/src/repos/cascade-delete.ts +8 -22
- package/src/repos/category-fixture.ts +2 -19
- package/src/repos/filter-anchor.ts +4 -4
- package/src/repos/filter.ts +3 -3
- package/src/repos/i4-account-config.ts +2 -2
- package/src/repos/i4-account-export-request.ts +2 -2
- package/src/repos/i4-account-setting.ts +2 -2
- package/src/repos/i4-account.ts +2 -2
- package/src/repos/i4-mailbox-lock.ts +2 -2
- package/src/repos/i4-mailbox-special-use.ts +2 -2
- package/src/repos/i4-mailbox.ts +2 -2
- package/src/repos/i4-message-flag-push.test.ts +1 -1
- package/src/repos/i4-message-flag-push.ts +3 -3
- package/src/repos/i4-message-placement-move.test.ts +1 -1
- package/src/repos/i4-message-placement-move.ts +4 -3
- package/src/repos/i4-organize-job-request.ts +2 -2
- package/src/repos/i4-outbox-message.ts +2 -2
- package/src/repos/label.ts +2 -2
- package/src/repos/message-flag.ts +2 -2
- package/src/repos/message-label.ts +2 -2
- package/src/repos/message.ts +8 -10
- package/src/repos/quarantine.ts +2 -2
- package/src/repos/test-helpers.ts +11 -213
- package/src/repos/thread-message.sqlite.test.ts +35 -10
- package/src/repos/thread-message.test.ts +45 -105
- package/src/repos/thread-message.ts +11 -25
- package/src/repos/thread-search-predicates.ts +13 -41
- package/src/repos/unit-of-work.ts +1 -1
- package/src/schema/i4-account-config.ts +1 -1
- package/src/schema/i4-account-export-request.ts +1 -1
- package/src/schema/i4-account-setting.ts +1 -1
- package/src/schema/i4-address.ts +1 -1
- package/src/schema/i4-mailbox-lock.ts +1 -1
- package/src/schema/i4-mailbox.ts +1 -1
- package/src/schema/i4-message-flag-push.ts +1 -1
- package/src/schema/i4-message-placement-move.ts +1 -1
- package/src/schema/i4-organize-job-request.ts +1 -1
- package/src/schema/i4-outbox-message.ts +1 -1
- package/src/schema/message-data.ts +1 -1
- package/src/schema/outbox.ts +13 -56
- package/src/schema/quarantine.ts +1 -1
- package/src/schema/thread-message.ts +1 -1
- package/src/schema-full-sqlite.ts +8 -6
- package/src/schema.ts +4 -4
- package/src/sqlite-client.ts +4 -5
- package/src/test-db-sqlite.ts +4 -9
- package/src/test-db.ts +11 -66
- package/src/tx.ts +6 -12
- package/drizzle.config.ts +0 -15
- package/src/dialect.ts +0 -13
- package/src/repair/thread-message-category.test.ts +0 -229
- package/src/repos/thread-message-category.test.ts +0 -156
- package/src/schema/active-entities.ts +0 -19
- package/src/schema-full.ts +0 -16
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
type SQL,
|
|
20
20
|
sql,
|
|
21
21
|
} from "drizzle-orm";
|
|
22
|
-
import { drizzle } from "drizzle-orm/node-postgres";
|
|
23
22
|
import shortUuid from "short-uuid";
|
|
24
23
|
import { v5 as uuidv5 } from "uuid";
|
|
25
24
|
import type { Db } from "../db.js";
|
|
@@ -90,7 +89,6 @@ function decodeAccountCursor(token: string): AccountCursor {
|
|
|
90
89
|
|
|
91
90
|
// ─── Schema ──────────────────────────────────────────────────────────────────
|
|
92
91
|
|
|
93
|
-
const SCHEMA = { threadMessage: threadMessageTable };
|
|
94
92
|
type Row = typeof threadMessageTable.$inferSelect;
|
|
95
93
|
|
|
96
94
|
// ─── Row mapping ─────────────────────────────────────────────────────────────
|
|
@@ -128,9 +126,9 @@ function toItem(row: Row): ThreadMessageItem {
|
|
|
128
126
|
|
|
129
127
|
// ─── Search predicates ────────────────────────────────────────────────────────
|
|
130
128
|
|
|
131
|
-
// The accent-/case-insensitive substring predicates are the one
|
|
132
|
-
// seam
|
|
133
|
-
//
|
|
129
|
+
// The accent-/case-insensitive substring predicates are the one engine-specific
|
|
130
|
+
// text-search seam; they live in ./thread-search-predicates.ts (the FTS5 trigram
|
|
131
|
+
// index, with a folded LIKE fallback below three characters, RFC 036 D4).
|
|
134
132
|
|
|
135
133
|
// Translate SearchOptions into SQL conditions: subject/from/query as indexed
|
|
136
134
|
// text predicates, the rest as plain column equalities. A multi-word `query`
|
|
@@ -196,22 +194,12 @@ export class DrizzleThreadMessageRepository
|
|
|
196
194
|
{
|
|
197
195
|
private db: Db<Record<string, unknown>>;
|
|
198
196
|
|
|
199
|
-
constructor(
|
|
200
|
-
this.db =
|
|
201
|
-
typeof connectionOrDb === "string"
|
|
202
|
-
? drizzle(connectionOrDb, { schema: SCHEMA })
|
|
203
|
-
: connectionOrDb;
|
|
197
|
+
constructor(db: Db<Record<string, unknown>>) {
|
|
198
|
+
this.db = db;
|
|
204
199
|
}
|
|
205
200
|
|
|
206
201
|
async close(): Promise<void> {
|
|
207
|
-
// The underlying driver differs by dialect: a pg Pool closes with `end()`,
|
|
208
|
-
// a better-sqlite3 Database with `close()`. Feature-detect so a sqlite
|
|
209
|
-
// handle never hits a missing `end()`.
|
|
210
202
|
const client = (this.db as unknown as { $client?: unknown }).$client;
|
|
211
|
-
if (client && typeof (client as { end?: unknown }).end === "function") {
|
|
212
|
-
await (client as { end: () => Promise<void> }).end();
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
203
|
if (client && typeof (client as { close?: unknown }).close === "function") {
|
|
216
204
|
(client as { close: () => void }).close();
|
|
217
205
|
}
|
|
@@ -831,8 +819,8 @@ export class DrizzleThreadMessageRepository
|
|
|
831
819
|
* the boolean filters) runs in SQL over the whole mailbox via the trigram
|
|
832
820
|
* indexes, ordered by sent_date with an id tiebreak. `limit` is a page size
|
|
833
821
|
* over MATCHES (clamped to THREAD_SEARCH_MAX_LIMIT); the DynamoDB name is
|
|
834
|
-
* kept for interface parity, but there is no recent-window read bound —
|
|
835
|
-
*
|
|
822
|
+
* kept for interface parity, but there is no recent-window read bound — the
|
|
823
|
+
* text is indexed, so a match anywhere in the mailbox is reachable.
|
|
836
824
|
*
|
|
837
825
|
* Cursor: the last returned row `(sentDate, threadMessageId)`. A full page
|
|
838
826
|
* yields a cursor so callers can resume.
|
|
@@ -888,21 +876,19 @@ export class DrizzleThreadMessageRepository
|
|
|
888
876
|
}
|
|
889
877
|
|
|
890
878
|
/**
|
|
891
|
-
* COUNT of matches over the SAME predicate as `searchByMailboxWindow`,
|
|
892
|
-
*
|
|
893
|
-
*
|
|
879
|
+
* COUNT of matches over the SAME predicate as `searchByMailboxWindow`, over
|
|
880
|
+
* the whole mailbox. `THREAD_SEARCH_MAX_LIMIT` bounds a page of rows; a count
|
|
881
|
+
* returns no rows, so it is answered in full however many messages match.
|
|
894
882
|
*/
|
|
895
883
|
async countByMailbox(
|
|
896
884
|
accountConfigId: string,
|
|
897
885
|
mailboxId: string,
|
|
898
886
|
search: SearchOptions,
|
|
899
887
|
options?: {
|
|
900
|
-
limit?: number;
|
|
901
888
|
excludeDeleted?: boolean;
|
|
902
889
|
order?: "asc" | "desc";
|
|
903
890
|
},
|
|
904
891
|
): Promise<number> {
|
|
905
|
-
const cap = clampThreadSearchLimit(options?.limit);
|
|
906
892
|
const [{ count }] = await this.db
|
|
907
893
|
.select({ count: sql<number>`cast(count(*) as int)` })
|
|
908
894
|
.from(threadMessageTable)
|
|
@@ -916,7 +902,7 @@ export class DrizzleThreadMessageRepository
|
|
|
916
902
|
...buildSearchConditions(search),
|
|
917
903
|
),
|
|
918
904
|
);
|
|
919
|
-
return
|
|
905
|
+
return count;
|
|
920
906
|
}
|
|
921
907
|
|
|
922
908
|
async listAllByAccount(
|
|
@@ -1,34 +1,14 @@
|
|
|
1
1
|
import { type SQL, sql } from "drizzle-orm";
|
|
2
|
-
import { isSqlite } from "../dialect.js";
|
|
3
2
|
|
|
4
3
|
// Accent- and case-insensitive substring match over the whole mailbox, isolated
|
|
5
|
-
// here as the one text-search seam
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// literally.
|
|
4
|
+
// here as the one text-search seam whose behaviour is engine-specific. The
|
|
5
|
+
// subject and sender predicates match the `contains()` substring contract:
|
|
6
|
+
// LIKE metacharacters (`\`, `%`, `_`) are escaped in JS so the needle arrives as
|
|
7
|
+
// bind-parameter text, and the escaped form is treated literally.
|
|
10
8
|
|
|
11
9
|
const escapeLike = (term: string): string => term.replace(/[\\%_]/g, "\\$&");
|
|
12
10
|
|
|
13
|
-
//
|
|
14
|
-
// The folded expressions must reproduce the indexed expressions in
|
|
15
|
-
// npm-scripts/pg-search-index.sql exactly (immutable unaccent + lower over the
|
|
16
|
-
// coalesced text) so the planner uses the trigram GIN indexes. Matching runs
|
|
17
|
-
// over the whole mailbox — Postgres indexes the text, so there is no
|
|
18
|
-
// recent-window read bound.
|
|
19
|
-
const PG_SUBJECT_FOLDED = sql`remit_immutable_unaccent(lower(coalesce(subject, '')))`;
|
|
20
|
-
const PG_FROM_FOLDED = sql`remit_immutable_unaccent(lower(coalesce(from_name, '') || ' ' || coalesce(from_email, '')))`;
|
|
21
|
-
|
|
22
|
-
const pgLikePattern = (term: string): SQL =>
|
|
23
|
-
sql`'%' || remit_immutable_unaccent(lower(${escapeLike(term)})) || '%'`;
|
|
24
|
-
|
|
25
|
-
const pgSubjectMatch = (term: string): SQL =>
|
|
26
|
-
sql`${PG_SUBJECT_FOLDED} like ${pgLikePattern(term)}`;
|
|
27
|
-
const pgFromMatch = (term: string): SQL =>
|
|
28
|
-
sql`${PG_FROM_FOLDED} like ${pgLikePattern(term)}`;
|
|
29
|
-
|
|
30
|
-
// ─── SQLite ──────────────────────────────────────────────────────────────────
|
|
31
|
-
// Text search on SQLite is the external-content FTS5 trigram index that
|
|
11
|
+
// Text search is the external-content FTS5 trigram index that
|
|
32
12
|
// npm-scripts/sqlite-search-index.sql installs (RFC 036 D4): `thread_message_fts`
|
|
33
13
|
// indexes the folded subject and sender, and MATCH is an accent- and
|
|
34
14
|
// case-insensitive substring search (the tokenizer folds both sides, so the
|
|
@@ -39,7 +19,7 @@ const pgFromMatch = (term: string): SQL =>
|
|
|
39
19
|
// falls back to the unindexed folded LIKE scan D4 names — lower() both sides,
|
|
40
20
|
// substring-match, `escape '\'` making the JS-escaped metacharacters literal.
|
|
41
21
|
// It is case-insensitive for ASCII and does not fold diacritics; the accepted
|
|
42
|
-
//
|
|
22
|
+
// difference between a short term and an indexed one (contract C10).
|
|
43
23
|
|
|
44
24
|
// FTS5 treats bare query text as its match grammar (AND/OR/NEAR/`*`/`-`/`:`), so
|
|
45
25
|
// wrap the term as a double-quoted string literal — doubling embedded quotes —
|
|
@@ -54,26 +34,18 @@ const isTrigramIndexable = (term: string): boolean => [...term].length >= 3;
|
|
|
54
34
|
const ftsRowidMatch = (matchExpr: string): SQL =>
|
|
55
35
|
sql`"thread_message"."rowid" in (select "rowid" from "thread_message_fts" where "thread_message_fts" match ${matchExpr})`;
|
|
56
36
|
|
|
57
|
-
const
|
|
58
|
-
const
|
|
37
|
+
const SUBJECT_FOLDED = sql`lower(coalesce(subject, ''))`;
|
|
38
|
+
const FROM_FOLDED = sql`lower(coalesce(from_name, '') || ' ' || coalesce(from_email, ''))`;
|
|
59
39
|
|
|
60
|
-
const
|
|
40
|
+
const likePattern = (term: string): SQL =>
|
|
61
41
|
sql`'%' || lower(${escapeLike(term)}) || '%'`;
|
|
62
42
|
|
|
63
|
-
const
|
|
43
|
+
export const subjectMatch = (term: string): SQL =>
|
|
64
44
|
isTrigramIndexable(term)
|
|
65
45
|
? ftsRowidMatch(`subject : ${ftsPhrase(term)}`)
|
|
66
|
-
: sql`${
|
|
46
|
+
: sql`${SUBJECT_FOLDED} like ${likePattern(term)} escape '\\'`;
|
|
67
47
|
|
|
68
|
-
const
|
|
48
|
+
export const fromMatch = (term: string): SQL =>
|
|
69
49
|
isTrigramIndexable(term)
|
|
70
50
|
? ftsRowidMatch(`sender : ${ftsPhrase(term)}`)
|
|
71
|
-
: sql`${
|
|
72
|
-
|
|
73
|
-
// ─── Dialect selection ───────────────────────────────────────────────────────
|
|
74
|
-
|
|
75
|
-
export const subjectMatch = (term: string): SQL =>
|
|
76
|
-
isSqlite() ? sqliteSubjectMatch(term) : pgSubjectMatch(term);
|
|
77
|
-
|
|
78
|
-
export const fromMatch = (term: string): SQL =>
|
|
79
|
-
isSqlite() ? sqliteFromMatch(term) : pgFromMatch(term);
|
|
51
|
+
: sql`${FROM_FOLDED} like ${likePattern(term)} escape '\\'`;
|
|
@@ -8,7 +8,7 @@ import { DrizzleMessageRepository } from "./message.js";
|
|
|
8
8
|
import { DrizzleThreadMessageRepository } from "./thread-message.js";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Runs a write set inside a single
|
|
11
|
+
* Runs a write set inside a single transaction. The repositories handed
|
|
12
12
|
* to the callback are bound to that transaction, so the data rows and the
|
|
13
13
|
* transactional-outbox rows the message write appends commit atomically — a
|
|
14
14
|
* throw anywhere rolls the whole set back, outbox included.
|
package/src/schema/i4-address.ts
CHANGED
package/src/schema/i4-mailbox.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { organizeJobRequests as organizeJobRequestTable } from "@remit/drizzle-
|
|
1
|
+
export { organizeJobRequests as organizeJobRequestTable } from "@remit/drizzle-sqlite-schema";
|
package/src/schema/outbox.ts
CHANGED
|
@@ -1,74 +1,31 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
|
-
import {
|
|
3
|
-
bigint,
|
|
4
|
-
jsonb,
|
|
5
|
-
index as pgIndex,
|
|
6
|
-
pgTable,
|
|
7
|
-
text as pgText,
|
|
8
|
-
timestamp,
|
|
9
|
-
uuid,
|
|
10
|
-
} from "drizzle-orm/pg-core";
|
|
11
|
-
import {
|
|
12
|
-
index as sqliteIndex,
|
|
13
|
-
integer as sqliteInteger,
|
|
14
|
-
sqliteTable,
|
|
15
|
-
text as sqliteText,
|
|
16
|
-
} from "drizzle-orm/sqlite-core";
|
|
17
|
-
import { isSqlite } from "../dialect.js";
|
|
2
|
+
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
18
3
|
|
|
19
4
|
/**
|
|
20
5
|
* Transactional outbox. It has no TypeSpec entity — it is infrastructure for
|
|
21
6
|
* the search-index worker (append a row per body change / move, drain by id,
|
|
22
7
|
* mark `processed_at`). The partial index selects unprocessed rows for the
|
|
23
|
-
*
|
|
24
|
-
* RFC 036 D2). It is hand-written per dialect because the two column-builder
|
|
25
|
-
* sets share no surface; both keep identical column names so the repos and the
|
|
26
|
-
* drain logic read the same rows on either backend.
|
|
8
|
+
* short-cadence poll (RFC 036 D2).
|
|
27
9
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* the Postgres type so the repos keep one static shape (RFC 036 D1).
|
|
10
|
+
* Exported raw for committed-migration generation (schema-full-sqlite) as well
|
|
11
|
+
* as for the repos and the drain logic.
|
|
31
12
|
*/
|
|
32
|
-
export const
|
|
13
|
+
export const outboxTable = sqliteTable(
|
|
33
14
|
"outbox",
|
|
34
15
|
{
|
|
35
|
-
id:
|
|
36
|
-
messageId:
|
|
37
|
-
event:
|
|
38
|
-
payload:
|
|
39
|
-
createdAt:
|
|
40
|
-
.defaultNow()
|
|
41
|
-
.notNull(),
|
|
42
|
-
processedAt: bigint("processed_at", { mode: "number" }),
|
|
43
|
-
},
|
|
44
|
-
(t) => [
|
|
45
|
-
pgIndex("outbox_message_id_idx").on(t.messageId),
|
|
46
|
-
pgIndex("outbox_unprocessed_idx")
|
|
47
|
-
.on(t.createdAt)
|
|
48
|
-
.where(sql`${t.processedAt} IS NULL`),
|
|
49
|
-
],
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
export const sqliteOutboxTable = sqliteTable(
|
|
53
|
-
"outbox",
|
|
54
|
-
{
|
|
55
|
-
id: sqliteText("id").primaryKey(),
|
|
56
|
-
messageId: sqliteText("message_id").notNull(),
|
|
57
|
-
event: sqliteText("event").notNull(),
|
|
58
|
-
payload: sqliteText("payload", { mode: "json" }).notNull(),
|
|
59
|
-
createdAt: sqliteInteger("created_at", { mode: "timestamp_ms" })
|
|
16
|
+
id: text("id").primaryKey(),
|
|
17
|
+
messageId: text("message_id").notNull(),
|
|
18
|
+
event: text("event").notNull(),
|
|
19
|
+
payload: text("payload", { mode: "json" }).notNull(),
|
|
20
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
60
21
|
.$defaultFn(() => new Date())
|
|
61
22
|
.notNull(),
|
|
62
|
-
processedAt:
|
|
23
|
+
processedAt: integer("processed_at", { mode: "number" }),
|
|
63
24
|
},
|
|
64
25
|
(t) => [
|
|
65
|
-
|
|
66
|
-
|
|
26
|
+
index("outbox_message_id_idx").on(t.messageId),
|
|
27
|
+
index("outbox_unprocessed_idx")
|
|
67
28
|
.on(t.createdAt)
|
|
68
29
|
.where(sql`${t.processedAt} IS NULL`),
|
|
69
30
|
],
|
|
70
31
|
);
|
|
71
|
-
|
|
72
|
-
export const outboxTable: typeof pgOutboxTable = isSqlite()
|
|
73
|
-
? (sqliteOutboxTable as unknown as typeof pgOutboxTable)
|
|
74
|
-
: pgOutboxTable;
|
package/src/schema/quarantine.ts
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// (RFC 036 D5) — consumed by packages/migrate/drizzle.entities.sqlite.config.ts
|
|
3
3
|
// and the drift guard (npm-scripts/check-vps-migrations.mjs), never at runtime.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// It pulls the generated entity package in wholesale, so a new TypeSpec entity
|
|
6
|
+
// flows into the committed migration with nothing to hand-maintain. The only
|
|
7
|
+
// addition is the `outbox` infra table, which has no entity.
|
|
8
|
+
//
|
|
9
|
+
// schema.ts stays the app/dev surface (single `*Table` alias per table, what
|
|
10
|
+
// the repos and `pushSchema` need); this file exposes canonical names, so it
|
|
11
|
+
// must not be fed to `pushSchema` alongside schema.ts (duplicate index names).
|
|
10
12
|
export * from "@remit/drizzle-sqlite-schema";
|
|
11
|
-
export {
|
|
13
|
+
export { outboxTable } from "./schema/outbox.js";
|
package/src/schema.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// the names the repos and `pushSchema` (test-db.ts) consume. Each table appears
|
|
3
3
|
// exactly once here — `pushSchema` registers a table's indexes per exported
|
|
4
4
|
// binding, so exposing one table under two names creates duplicate index names
|
|
5
|
-
// and breaks `apply()`. The committed-migration `generate` reads
|
|
6
|
-
// instead (the entity package wholesale), so the
|
|
7
|
-
// entities, not by this hand-maintained alias list.
|
|
5
|
+
// and breaks `apply()`. The committed-migration `generate` reads
|
|
6
|
+
// schema-full-sqlite.ts instead (the entity package wholesale), so the
|
|
7
|
+
// migration is driven by the entities, not by this hand-maintained alias list.
|
|
8
8
|
|
|
9
|
-
import
|
|
9
|
+
import * as entities from "@remit/drizzle-sqlite-schema";
|
|
10
10
|
|
|
11
11
|
export const filterAnchorTable = entities.filterAnchors;
|
|
12
12
|
export const filterTable = entities.filters;
|
package/src/sqlite-client.ts
CHANGED
|
@@ -12,10 +12,9 @@ import { serializeSqliteWrites } from "./tx.js";
|
|
|
12
12
|
// serialization (RFC 036 D3). `run`/`transaction`/reads pass through by design —
|
|
13
13
|
// see the wrapper's comment.
|
|
14
14
|
//
|
|
15
|
-
// better-sqlite3 and its drizzle driver are imported dynamically so the
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// remit-backend/src/service/dynamodb.ts).
|
|
15
|
+
// better-sqlite3 and its drizzle driver are imported dynamically so the whole
|
|
16
|
+
// module stays out of the DynamoDB Lambda bundle (this package is `external`
|
|
17
|
+
// there — see remit-backend/src/service/data-client.ts).
|
|
19
18
|
|
|
20
19
|
export interface SqliteClientOptions {
|
|
21
20
|
filename: string;
|
|
@@ -41,7 +40,7 @@ export async function createSqliteDatabase<
|
|
|
41
40
|
sqlite.pragma("synchronous = NORMAL");
|
|
42
41
|
sqlite.pragma("foreign_keys = ON");
|
|
43
42
|
|
|
44
|
-
const base = drizzle(sqlite, { schema })
|
|
43
|
+
const base: Db<TSchema> = drizzle(sqlite, { schema });
|
|
45
44
|
|
|
46
45
|
return {
|
|
47
46
|
db: serializeSqliteWrites(base),
|
package/src/test-db-sqlite.ts
CHANGED
|
@@ -12,14 +12,9 @@ const searchIndexDdl = (): string =>
|
|
|
12
12
|
"utf8",
|
|
13
13
|
);
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// repo runs against the exact dialect it ships on, no hand-maintained DDL.
|
|
19
|
-
//
|
|
20
|
-
// The tests that use it run in a `DATA_BACKEND=sqlite` process (see
|
|
21
|
-
// test:run:sqlite), so the schema facades resolve to the sqlite tables and the
|
|
22
|
-
// repos take the sqlite transaction / predicate paths.
|
|
15
|
+
// A real better-sqlite3 database (in-memory by default) with the schema pushed
|
|
16
|
+
// from the drizzle table objects — the sqlite `pushSchema` — so a repo runs
|
|
17
|
+
// against the exact engine it ships on, no hand-maintained DDL.
|
|
23
18
|
|
|
24
19
|
export type SqliteTestDb<TSchema extends Record<string, unknown>> = Db<TSchema>;
|
|
25
20
|
|
|
@@ -36,7 +31,7 @@ export async function createSqliteTestDb<
|
|
|
36
31
|
const sqlite = new Database(options?.filename ?? ":memory:");
|
|
37
32
|
sqlite.pragma("foreign_keys = ON");
|
|
38
33
|
|
|
39
|
-
const db = drizzle(sqlite, { schema })
|
|
34
|
+
const db: SqliteTestDb<TSchema> = drizzle(sqlite, { schema });
|
|
40
35
|
|
|
41
36
|
// pushSQLiteSchema derives the CREATE statements from the table objects;
|
|
42
37
|
// better-sqlite3 rejects its own `apply()` (it issues the DDL through a
|
package/src/test-db.ts
CHANGED
|
@@ -1,76 +1,21 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import EmbeddedPostgres from "embedded-postgres";
|
|
4
|
-
import pg from "pg";
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import type { Db } from "./db.js";
|
|
5
3
|
import * as schema from "./schema.js";
|
|
4
|
+
import { createSqliteTestDb } from "./test-db-sqlite.js";
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export type TestDb = ReturnType<typeof drizzle<typeof schema>>;
|
|
10
|
-
|
|
11
|
-
let _instance: EmbeddedPostgres | null = null;
|
|
12
|
-
let _port = 0;
|
|
13
|
-
|
|
14
|
-
async function ensureStarted(): Promise<{ port: number }> {
|
|
15
|
-
if (_instance) return { port: _port };
|
|
16
|
-
|
|
17
|
-
for (let attempt = 0; attempt < 10; attempt++) {
|
|
18
|
-
const port = 15000 + Math.floor(Math.random() * 5000);
|
|
19
|
-
const instance = new EmbeddedPostgres({
|
|
20
|
-
databaseDir: `/tmp/remit-test-pg-${process.pid}-${port}-${attempt}`,
|
|
21
|
-
port,
|
|
22
|
-
persistent: false,
|
|
23
|
-
});
|
|
24
|
-
try {
|
|
25
|
-
await instance.initialise();
|
|
26
|
-
await instance.start();
|
|
27
|
-
} catch (err) {
|
|
28
|
-
await instance.stop().catch(() => undefined);
|
|
29
|
-
if (attempt === 9) throw err;
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
_instance = instance;
|
|
33
|
-
_port = port;
|
|
34
|
-
process.on("exit", () => {
|
|
35
|
-
instance.stop().catch(() => undefined);
|
|
36
|
-
});
|
|
37
|
-
return { port };
|
|
38
|
-
}
|
|
39
|
-
throw new Error("Failed to start embedded postgres");
|
|
40
|
-
}
|
|
6
|
+
export type TestDb = Db<typeof schema>;
|
|
41
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The whole app schema on a throwaway in-memory SQLite database, with the FTS5
|
|
10
|
+
* search objects installed on top — the harness a repo test takes when it needs
|
|
11
|
+
* more tables than its own.
|
|
12
|
+
*/
|
|
42
13
|
export async function createTestDb(): Promise<{
|
|
43
14
|
db: TestDb;
|
|
44
|
-
|
|
15
|
+
sqlite: Database.Database;
|
|
45
16
|
close: () => Promise<void>;
|
|
46
17
|
}> {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const pool = new Pool({
|
|
50
|
-
host: "localhost",
|
|
51
|
-
port,
|
|
52
|
-
user: "postgres",
|
|
53
|
-
password: "password",
|
|
54
|
-
database: "postgres",
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
const db = drizzle(pool, { schema }) as TestDb;
|
|
58
|
-
|
|
59
|
-
const { apply } = await pushSchema(schema, drizzle(pool));
|
|
60
|
-
await apply();
|
|
61
|
-
|
|
62
|
-
return {
|
|
63
|
-
db,
|
|
64
|
-
pool,
|
|
65
|
-
close: async () => {
|
|
66
|
-
await pool.end();
|
|
67
|
-
if (_instance) {
|
|
68
|
-
const inst = _instance;
|
|
69
|
-
_instance = null;
|
|
70
|
-
await inst.stop();
|
|
71
|
-
}
|
|
72
|
-
},
|
|
73
|
-
};
|
|
18
|
+
return createSqliteTestDb(schema, { searchIndex: true });
|
|
74
19
|
}
|
|
75
20
|
|
|
76
21
|
export { randomId } from "./id.js";
|
package/src/tx.ts
CHANGED
|
@@ -2,14 +2,13 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { type SQL, sql } from "drizzle-orm";
|
|
4
4
|
import type { Db } from "./db.js";
|
|
5
|
-
import { isSqlite } from "./dialect.js";
|
|
6
5
|
|
|
7
|
-
// Runs a write set in one transaction
|
|
6
|
+
// Runs a write set in one transaction.
|
|
8
7
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
8
|
+
// better-sqlite3's native transaction runner rejects a callback that returns a
|
|
9
|
+
// promise, and the repos' write sets are async, so drizzle's own
|
|
10
|
+
// `db.transaction()` cannot be used. The callback is bracketed with a SAVEPOINT
|
|
11
|
+
// instead — a savepoint opens a transaction when none is active and commits
|
|
13
12
|
// when the outermost one is released.
|
|
14
13
|
//
|
|
15
14
|
// All writers on this backend share one better-sqlite3 connection (RFC 036 D3),
|
|
@@ -66,10 +65,6 @@ export async function runInTransaction<
|
|
|
66
65
|
TSchema extends Record<string, unknown>,
|
|
67
66
|
T,
|
|
68
67
|
>(db: Db<TSchema>, fn: (tx: Db<TSchema>) => Promise<T>): Promise<T> {
|
|
69
|
-
if (!isSqlite()) {
|
|
70
|
-
return db.transaction(fn);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
68
|
if (inSqliteTx.getStore()) {
|
|
74
69
|
// Already inside a top-level transaction on this connection — nest with a
|
|
75
70
|
// savepoint, do not re-queue.
|
|
@@ -184,8 +179,7 @@ function wrapWriteBuilder<B extends object>(builder: B): B {
|
|
|
184
179
|
// savepoint DDL, already inside a serialized unit), `transaction`, and reads —
|
|
185
180
|
// reads are not serialized, so a read issued during another unit's open
|
|
186
181
|
// transaction can still observe uncommitted rows; the wrapper closes the
|
|
187
|
-
// write-side rollback hazard, not read isolation.
|
|
188
|
-
// applied.
|
|
182
|
+
// write-side rollback hazard, not read isolation.
|
|
189
183
|
export function serializeSqliteWrites<TDb extends Db<Record<string, unknown>>>(
|
|
190
184
|
db: TDb,
|
|
191
185
|
): TDb {
|
package/drizzle.config.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
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/src/dialect.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
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";
|