@mdsrs/postgres-drizzle 0.0.0

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/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # @mdsrs/postgres-drizzle
2
+
3
+ Postgres persistence adapter for the `@mdsrs/store` interface.
4
+
5
+ This package is driver-agnostic: pass a Drizzle Postgres database instance that
6
+ uses the exported `schema`.
7
+
8
+ ```ts
9
+ import { createPostgresDrizzleStore, schema } from '@mdsrs/postgres-drizzle';
10
+
11
+ const store = createPostgresDrizzleStore(db);
12
+
13
+ await store.syncCards(cards);
14
+ const queue = await store.getDueCards(cards);
15
+ ```
16
+
17
+ Run the SQL in `migrations/0000_initial.sql` before using the adapter.
18
+
19
+ ## Integration Tests
20
+
21
+ The default test suite does not require a running database. To exercise this
22
+ adapter against real Postgres, set `MDSRS_POSTGRES_URL` and run:
23
+
24
+ ```sh
25
+ pnpm --filter @mdsrs/postgres-drizzle test:integration
26
+ ```
27
+
28
+ The integration test creates a temporary schema, runs the migration, and drops
29
+ the schema when it finishes.
@@ -0,0 +1,33 @@
1
+ import { type CardPerformance } from '@mdsrs/core';
2
+ import { type CardStats, type SrsStore, type StoredCard, type StoredReview } from '@mdsrs/store';
3
+ import { type SrsCardRow, type SrsReviewRow } from './schema.js';
4
+ export { mdsrsCards, mdsrsReviews, schema, srsCards, srsReviews } from './schema.js';
5
+ export type { MdsrsCardRow, MdsrsReviewRow, NewMdsrsCardRow, NewMdsrsReviewRow, NewSrsCardRow, NewSrsReviewRow, SrsCardRow, SrsReviewRow } from './schema.js';
6
+ export interface DrizzlePostgresDatabase {
7
+ select: (fields?: unknown) => unknown;
8
+ insert: (table: unknown) => unknown;
9
+ update: (table: unknown) => unknown;
10
+ }
11
+ export declare const createPostgresDrizzleStore: (db: DrizzlePostgresDatabase) => SrsStore;
12
+ export declare const rowToPerformance: (row: Pick<SrsCardRow, "lastReviewedAt" | "stability" | "difficulty" | "intervalRaw" | "intervalDays" | "dueDate" | "reviewCount">) => CardPerformance;
13
+ export declare const rowToStoredCard: (row: SrsCardRow) => StoredCard;
14
+ export declare const rowToStoredReview: (row: SrsReviewRow) => StoredReview;
15
+ interface StatsRow {
16
+ cardHash: string;
17
+ reviewCount: number;
18
+ difficulty: number | null;
19
+ stability: number | null;
20
+ intervalDays: number | null;
21
+ dueDate: string | null;
22
+ lastReviewedAt: Date | string | null;
23
+ active: boolean;
24
+ forgotCount: number | string | bigint;
25
+ hardCount: number | string | bigint;
26
+ goodCount: number | string | bigint;
27
+ easyCount: number | string | bigint;
28
+ }
29
+ export declare const statsRowToCardStats: (row: StatsRow) => CardStats;
30
+ export declare const migrations: {
31
+ initial: import("url").URL;
32
+ };
33
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAA+C,KAAK,eAAe,EAAc,MAAM,aAAa,CAAC;AAC5G,OAAO,EAEN,KAAK,SAAS,EACd,KAAK,QAAQ,EACb,KAAK,UAAU,EACf,KAAK,YAAY,EACjB,MAAM,cAAc,CAAC;AACtB,OAAO,EAIN,KAAK,UAAU,EACf,KAAK,YAAY,EAEjB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACrF,YAAY,EACX,YAAY,EACZ,cAAc,EACd,eAAe,EACf,iBAAiB,EACjB,aAAa,EACb,eAAe,EACf,UAAU,EACV,YAAY,EACZ,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,uBAAuB;IACvC,MAAM,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC;IACtC,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC;IACpC,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC;CACpC;AAED,eAAO,MAAM,0BAA0B,GAAI,IAAI,uBAAuB,KAAG,QA2KvE,CAAC;AA4BH,eAAO,MAAM,gBAAgB,GAAI,KAAK,IAAI,CAAC,UAAU,EAAE,gBAAgB,GAAG,WAAW,GAAG,YAAY,GAAG,aAAa,GAAG,cAAc,GAAG,SAAS,GAAG,aAAa,CAAC,KAAG,eAQnK,CAAC;AAEH,eAAO,MAAM,eAAe,GAAI,KAAK,UAAU,KAAG,UAYhD,CAAC;AAEH,eAAO,MAAM,iBAAiB,GAAI,KAAK,YAAY,KAAG,YAUpD,CAAC;AAEH,UAAU,QAAQ;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,cAAc,EAAE,IAAI,GAAG,MAAM,GAAG,IAAI,CAAC;IACrC,MAAM,EAAE,OAAO,CAAC;IAChB,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;IACtC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;IACpC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;IACpC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;CACpC;AAED,eAAO,MAAM,mBAAmB,GAAI,KAAK,QAAQ,KAAG,SAuBnD,CAAC;AAUF,eAAO,MAAM,UAAU;;CAEtB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,206 @@
1
+ import { and, asc, eq, inArray, not, sql } from 'drizzle-orm';
2
+ import { buildReviewQueue, scheduleReview } from '@mdsrs/core';
3
+ import { CardNotFoundError } from '@mdsrs/store';
4
+ import { srsCards, srsReviews } from './schema.js';
5
+ export { mdsrsCards, mdsrsReviews, schema, srsCards, srsReviews } from './schema.js';
6
+ export const createPostgresDrizzleStore = (db) => ({
7
+ async syncCards(cards, syncedAt = new Date()) {
8
+ const database = db;
9
+ if (cards.length === 0) {
10
+ await database.update(srsCards).set({ active: false });
11
+ return;
12
+ }
13
+ const cardHashes = cards.map((card) => card.hash);
14
+ await database
15
+ .insert(srsCards)
16
+ .values(cards.map((card) => ({
17
+ cardHash: card.hash,
18
+ deckName: card.deckName,
19
+ filePath: card.filePath,
20
+ familyHash: card.familyHash,
21
+ frontMarkdown: card.frontMarkdown,
22
+ backMarkdown: card.backMarkdown,
23
+ cardType: card.content.type,
24
+ active: true,
25
+ addedAt: syncedAt,
26
+ lastSeenAt: syncedAt
27
+ })))
28
+ .onConflictDoUpdate({
29
+ target: srsCards.cardHash,
30
+ set: {
31
+ deckName: sql `excluded.deck_name`,
32
+ filePath: sql `excluded.file_path`,
33
+ familyHash: sql `excluded.family_hash`,
34
+ frontMarkdown: sql `excluded.front_markdown`,
35
+ backMarkdown: sql `excluded.back_markdown`,
36
+ cardType: sql `excluded.card_type`,
37
+ active: true,
38
+ lastSeenAt: syncedAt
39
+ }
40
+ });
41
+ await database
42
+ .update(srsCards)
43
+ .set({ active: false })
44
+ .where(not(inArray(srsCards.cardHash, cardHashes)));
45
+ },
46
+ async getCards(cardHashes) {
47
+ const database = db;
48
+ const query = database.select().from(srsCards);
49
+ const rows = (cardHashes && cardHashes.length > 0
50
+ ? await query.where(inArray(srsCards.cardHash, cardHashes))
51
+ : await query);
52
+ return new Map(rows.map((row) => [row.cardHash, rowToStoredCard(row)]));
53
+ },
54
+ async getPerformances(cardHashes) {
55
+ if (cardHashes.length === 0)
56
+ return new Map();
57
+ const database = db;
58
+ const rows = (await database
59
+ .select()
60
+ .from(srsCards)
61
+ .where(and(inArray(srsCards.cardHash, cardHashes), eq(srsCards.active, true))));
62
+ return new Map(rows.map((row) => [row.cardHash, rowToPerformance(row)]));
63
+ },
64
+ async getDueCards(cards, options = {}) {
65
+ const activeCards = await this.getCards(cards.map((card) => card.hash));
66
+ const syncedCards = cards.filter((card) => activeCards.get(card.hash)?.active);
67
+ const performances = await this.getPerformances(syncedCards.map((card) => card.hash));
68
+ return buildReviewQueue(syncedCards, performances, options);
69
+ },
70
+ async reviewCard(cardHash, grade, reviewedAt = new Date()) {
71
+ const database = db;
72
+ const rows = (await database
73
+ .select()
74
+ .from(srsCards)
75
+ .where(and(eq(srsCards.cardHash, cardHash), eq(srsCards.active, true)))
76
+ .limit(1));
77
+ const row = rows[0];
78
+ if (!row)
79
+ throw new CardNotFoundError(cardHash);
80
+ const result = scheduleReview(rowToPerformance(row), grade, reviewedAt);
81
+ const reviewedAtDate = new Date(result.lastReviewedAt);
82
+ await database
83
+ .update(srsCards)
84
+ .set({
85
+ lastReviewedAt: reviewedAtDate,
86
+ stability: result.stability,
87
+ difficulty: result.difficulty,
88
+ intervalRaw: result.intervalRaw,
89
+ intervalDays: result.intervalDays,
90
+ dueDate: result.dueDate,
91
+ reviewCount: result.reviewCount
92
+ })
93
+ .where(eq(srsCards.cardHash, cardHash));
94
+ const review = {
95
+ reviewCardHash: cardHash,
96
+ reviewedAt: reviewedAtDate,
97
+ grade,
98
+ stability: result.stability,
99
+ difficulty: result.difficulty,
100
+ intervalRaw: result.intervalRaw,
101
+ intervalDays: result.intervalDays,
102
+ dueDate: result.dueDate
103
+ };
104
+ await database.insert(srsReviews).values(review);
105
+ return result;
106
+ },
107
+ async getReviews(cardHashes) {
108
+ const database = db;
109
+ const query = database.select().from(srsReviews);
110
+ const filtered = cardHashes && cardHashes.length > 0
111
+ ? query.where(inArray(srsReviews.reviewCardHash, cardHashes))
112
+ : query;
113
+ const rows = (await filtered.orderBy(asc(srsReviews.reviewedAt), asc(srsReviews.reviewId)));
114
+ return rows.map(rowToStoredReview);
115
+ },
116
+ async getCardStats(cardHashes) {
117
+ if (cardHashes.length === 0)
118
+ return new Map();
119
+ const database = db;
120
+ const rows = await database
121
+ .select({
122
+ cardHash: srsCards.cardHash,
123
+ reviewCount: srsCards.reviewCount,
124
+ difficulty: srsCards.difficulty,
125
+ stability: srsCards.stability,
126
+ intervalDays: srsCards.intervalDays,
127
+ dueDate: srsCards.dueDate,
128
+ lastReviewedAt: srsCards.lastReviewedAt,
129
+ active: srsCards.active,
130
+ forgotCount: sql `count(*) filter (where ${srsReviews.grade} = 'forgot')`,
131
+ hardCount: sql `count(*) filter (where ${srsReviews.grade} = 'hard')`,
132
+ goodCount: sql `count(*) filter (where ${srsReviews.grade} = 'good')`,
133
+ easyCount: sql `count(*) filter (where ${srsReviews.grade} = 'easy')`
134
+ })
135
+ .from(srsCards)
136
+ .leftJoin(srsReviews, eq(srsReviews.reviewCardHash, srsCards.cardHash))
137
+ .where(inArray(srsCards.cardHash, cardHashes))
138
+ .groupBy(srsCards.cardHash, srsCards.reviewCount, srsCards.difficulty, srsCards.stability, srsCards.intervalDays, srsCards.dueDate, srsCards.lastReviewedAt, srsCards.active);
139
+ return new Map(rows.map((row) => [row.cardHash, statsRowToCardStats(row)]));
140
+ }
141
+ });
142
+ export const rowToPerformance = (row) => ({
143
+ lastReviewedAt: toIsoString(row.lastReviewedAt),
144
+ stability: row.stability,
145
+ difficulty: row.difficulty,
146
+ intervalRaw: row.intervalRaw,
147
+ intervalDays: row.intervalDays,
148
+ dueDate: row.dueDate,
149
+ reviewCount: row.reviewCount
150
+ });
151
+ export const rowToStoredCard = (row) => ({
152
+ cardHash: row.cardHash,
153
+ deckName: row.deckName,
154
+ filePath: row.filePath,
155
+ familyHash: row.familyHash,
156
+ frontMarkdown: row.frontMarkdown,
157
+ backMarkdown: row.backMarkdown,
158
+ cardType: row.cardType === 'cloze' ? 'cloze' : 'basic',
159
+ active: row.active,
160
+ addedAt: toIsoString(row.addedAt) ?? new Date(0).toISOString(),
161
+ lastSeenAt: toIsoString(row.lastSeenAt) ?? new Date(0).toISOString(),
162
+ performance: rowToPerformance(row)
163
+ });
164
+ export const rowToStoredReview = (row) => ({
165
+ reviewId: row.reviewId,
166
+ cardHash: row.reviewCardHash,
167
+ reviewedAt: toIsoString(row.reviewedAt) ?? new Date(0).toISOString(),
168
+ grade: toGrade(row.grade),
169
+ stability: row.stability,
170
+ difficulty: row.difficulty,
171
+ intervalRaw: row.intervalRaw,
172
+ intervalDays: row.intervalDays,
173
+ dueDate: row.dueDate
174
+ });
175
+ export const statsRowToCardStats = (row) => {
176
+ const forgotCount = Number(row.forgotCount);
177
+ const hardCount = Number(row.hardCount);
178
+ const goodCount = Number(row.goodCount);
179
+ const easyCount = Number(row.easyCount);
180
+ const hitCount = hardCount + goodCount + easyCount;
181
+ return {
182
+ cardHash: row.cardHash,
183
+ reviewCount: row.reviewCount,
184
+ forgotCount,
185
+ hardCount,
186
+ goodCount,
187
+ easyCount,
188
+ hitRate: row.reviewCount === 0 ? null : hitCount / row.reviewCount,
189
+ missRate: row.reviewCount === 0 ? null : forgotCount / row.reviewCount,
190
+ difficulty: row.difficulty,
191
+ stability: row.stability,
192
+ intervalDays: row.intervalDays,
193
+ dueDate: row.dueDate,
194
+ lastReviewedAt: toIsoString(row.lastReviewedAt),
195
+ active: row.active
196
+ };
197
+ };
198
+ const toIsoString = (value) => value instanceof Date ? value.toISOString() : value;
199
+ const toGrade = (value) => {
200
+ if (value === 'forgot' || value === 'hard' || value === 'good' || value === 'easy')
201
+ return value;
202
+ throw new Error(`Invalid review grade in database: ${value}`);
203
+ };
204
+ export const migrations = {
205
+ initial: new URL('../migrations/0000_initial.sql', import.meta.url)
206
+ };