@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 +29 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +206 -0
- package/dist/schema.d.ts +1379 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +43 -0
- package/migrations/0000_initial.sql +42 -0
- package/package.json +38 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
};
|