@mdsrs/store 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,18 @@
1
+ # @mdsrs/store
2
+
3
+ Persistence interface and in-memory implementation for `mdsrs`.
4
+
5
+ `@mdsrs/store` defines the contract that durable adapters, such as a future
6
+ Postgres adapter, should implement.
7
+
8
+ ## Example
9
+
10
+ ```ts
11
+ import { createMemoryStore } from '@mdsrs/store';
12
+
13
+ const store = createMemoryStore();
14
+
15
+ await store.syncCards(cards);
16
+ const queue = await store.getDueCards(cards);
17
+ await store.reviewCard(queue[0].card.hash, 'good');
18
+ ```
@@ -0,0 +1,66 @@
1
+ import { type BuildReviewQueueOptions, type BuildCollectionStatsOptions, type Card, type CardPerformance, type CollectionStats, type DeckTreeNode, type Grade, type ReviewQueueItem, type ReviewResult } from '@mdsrs/core';
2
+ export interface StoredCard {
3
+ cardHash: string;
4
+ deckName: string;
5
+ filePath: string;
6
+ familyHash: string | null;
7
+ frontMarkdown: string;
8
+ backMarkdown: string;
9
+ cardType: Card['content']['type'];
10
+ active: boolean;
11
+ addedAt: string;
12
+ lastSeenAt: string;
13
+ performance: CardPerformance;
14
+ }
15
+ export interface StoredReview {
16
+ reviewId: number;
17
+ cardHash: string;
18
+ reviewedAt: string;
19
+ grade: Grade;
20
+ stability: number;
21
+ difficulty: number;
22
+ intervalRaw: number;
23
+ intervalDays: number;
24
+ dueDate: string;
25
+ }
26
+ export interface CardStats {
27
+ cardHash: string;
28
+ reviewCount: number;
29
+ forgotCount: number;
30
+ hardCount: number;
31
+ goodCount: number;
32
+ easyCount: number;
33
+ hitRate: number | null;
34
+ missRate: number | null;
35
+ difficulty: number | null;
36
+ stability: number | null;
37
+ intervalDays: number | null;
38
+ dueDate: string | null;
39
+ lastReviewedAt: string | null;
40
+ active: boolean;
41
+ }
42
+ export interface SrsStore {
43
+ syncCards(cards: Card[], syncedAt?: Date): Promise<void>;
44
+ getCards(cardHashes?: string[]): Promise<Map<string, StoredCard>>;
45
+ getPerformances(cardHashes: string[]): Promise<Map<string, CardPerformance>>;
46
+ getDueCards(cards: Card[], options?: BuildReviewQueueOptions): Promise<ReviewQueueItem[]>;
47
+ reviewCard(cardHash: string, grade: Grade, reviewedAt?: Date): Promise<ReviewResult>;
48
+ /** Returns reviews oldest-to-newest by reviewedAt, then reviewId. */
49
+ getReviews(cardHashes?: string[]): Promise<StoredReview[]>;
50
+ getCardStats(cardHashes: string[]): Promise<Map<string, CardStats>>;
51
+ }
52
+ export declare const getCollectionStats: (store: SrsStore, cards: Card[], deckTree: DeckTreeNode[], options?: BuildCollectionStatsOptions) => Promise<CollectionStats>;
53
+ export interface MemoryStoreSnapshot {
54
+ cards: StoredCard[];
55
+ reviews: StoredReview[];
56
+ nextReviewId: number;
57
+ }
58
+ export declare class CardNotFoundError extends Error {
59
+ readonly cardHash: string;
60
+ constructor(cardHash: string);
61
+ }
62
+ export declare const createMemoryStore: (snapshot?: Partial<MemoryStoreSnapshot>) => SrsStore & {
63
+ snapshot(): MemoryStoreSnapshot;
64
+ };
65
+ export declare const isOverdue: (performance: Pick<CardPerformance, "dueDate">, now?: Date) => boolean;
66
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,188 @@
1
+ import { buildCollectionStats, buildReviewQueue, scheduleReview, toDateString, toTimestamp } from '@mdsrs/core';
2
+ export const getCollectionStats = async (store, cards, deckTree, options = {}) => {
3
+ const cardHashes = cards.map((card) => card.hash);
4
+ const [storedCards, reviews] = await Promise.all([
5
+ store.getCards(cardHashes),
6
+ store.getReviews(cardHashes)
7
+ ]);
8
+ return buildCollectionStats(cards, deckTree, [...storedCards.values()].map((card) => ({
9
+ cardHash: card.cardHash,
10
+ active: card.active,
11
+ addedAt: card.addedAt,
12
+ dueDate: card.performance.dueDate,
13
+ reviewCount: card.performance.reviewCount
14
+ })), reviews.map((review) => ({
15
+ cardHash: review.cardHash,
16
+ reviewedAt: review.reviewedAt,
17
+ grade: review.grade
18
+ })), options);
19
+ };
20
+ export class CardNotFoundError extends Error {
21
+ cardHash;
22
+ constructor(cardHash) {
23
+ super(`Card is not synced: ${cardHash}`);
24
+ this.cardHash = cardHash;
25
+ }
26
+ }
27
+ export const createMemoryStore = (snapshot) => {
28
+ const cards = new Map((snapshot?.cards ?? []).map((card) => [card.cardHash, cloneStoredCard(card)]));
29
+ const reviews = (snapshot?.reviews ?? []).map(cloneStoredReview);
30
+ let nextReviewId = snapshot?.nextReviewId ??
31
+ reviews.reduce((next, review) => Math.max(next, review.reviewId + 1), 1);
32
+ const store = {
33
+ async syncCards(sourceCards, syncedAt = new Date()) {
34
+ const lastSeenAt = toTimestamp(syncedAt);
35
+ const seenHashes = new Set();
36
+ for (const card of sourceCards) {
37
+ seenHashes.add(card.hash);
38
+ const existing = cards.get(card.hash);
39
+ const addedAt = existing?.addedAt ?? lastSeenAt;
40
+ cards.set(card.hash, {
41
+ cardHash: card.hash,
42
+ deckName: card.deckName,
43
+ filePath: card.filePath,
44
+ familyHash: card.familyHash,
45
+ frontMarkdown: card.frontMarkdown,
46
+ backMarkdown: card.backMarkdown,
47
+ cardType: card.content.type,
48
+ active: true,
49
+ addedAt,
50
+ lastSeenAt,
51
+ performance: existing?.performance
52
+ ? clonePerformance(existing.performance)
53
+ : emptyPerformance()
54
+ });
55
+ }
56
+ for (const [cardHash, card] of cards) {
57
+ if (!seenHashes.has(cardHash)) {
58
+ cards.set(cardHash, {
59
+ ...card,
60
+ active: false
61
+ });
62
+ }
63
+ }
64
+ },
65
+ async getCards(cardHashes) {
66
+ const wanted = cardHashes ? new Set(cardHashes) : null;
67
+ return new Map([...cards.entries()]
68
+ .filter(([cardHash]) => wanted == null || wanted.has(cardHash))
69
+ .map(([cardHash, card]) => [cardHash, cloneStoredCard(card)]));
70
+ },
71
+ async getPerformances(cardHashes) {
72
+ const performances = new Map();
73
+ for (const cardHash of cardHashes) {
74
+ const card = cards.get(cardHash);
75
+ if (card?.active)
76
+ performances.set(cardHash, clonePerformance(card.performance));
77
+ }
78
+ return performances;
79
+ },
80
+ async getDueCards(sourceCards, options = {}) {
81
+ const activeHashes = new Set([...cards.values()].filter((card) => card.active).map((card) => card.cardHash));
82
+ const syncedCards = sourceCards.filter((card) => activeHashes.has(card.hash));
83
+ const performances = await this.getPerformances(syncedCards.map((card) => card.hash));
84
+ return buildReviewQueue(syncedCards, performances, options);
85
+ },
86
+ async reviewCard(cardHash, grade, reviewedAt = new Date()) {
87
+ const card = cards.get(cardHash);
88
+ if (!card?.active)
89
+ throw new CardNotFoundError(cardHash);
90
+ const result = scheduleReview(card.performance, grade, reviewedAt);
91
+ const performance = reviewResultToPerformance(result);
92
+ cards.set(cardHash, {
93
+ ...card,
94
+ performance
95
+ });
96
+ reviews.push({
97
+ reviewId: nextReviewId++,
98
+ cardHash,
99
+ reviewedAt: result.lastReviewedAt,
100
+ grade,
101
+ stability: result.stability,
102
+ difficulty: result.difficulty,
103
+ intervalRaw: result.intervalRaw,
104
+ intervalDays: result.intervalDays,
105
+ dueDate: result.dueDate
106
+ });
107
+ return result;
108
+ },
109
+ async getReviews(cardHashes) {
110
+ const wanted = cardHashes ? new Set(cardHashes) : null;
111
+ return reviews
112
+ .filter((review) => wanted == null || wanted.has(review.cardHash))
113
+ .sort(compareReviews)
114
+ .map(cloneStoredReview);
115
+ },
116
+ async getCardStats(cardHashes) {
117
+ const stats = new Map();
118
+ for (const cardHash of cardHashes) {
119
+ const card = cards.get(cardHash);
120
+ if (!card)
121
+ continue;
122
+ const cardReviews = reviews.filter((review) => review.cardHash === cardHash);
123
+ const forgotCount = countGrade(cardReviews, 'forgot');
124
+ const hardCount = countGrade(cardReviews, 'hard');
125
+ const goodCount = countGrade(cardReviews, 'good');
126
+ const easyCount = countGrade(cardReviews, 'easy');
127
+ const hitCount = hardCount + goodCount + easyCount;
128
+ const reviewCount = card.performance.reviewCount;
129
+ stats.set(cardHash, {
130
+ cardHash,
131
+ reviewCount,
132
+ forgotCount,
133
+ hardCount,
134
+ goodCount,
135
+ easyCount,
136
+ hitRate: reviewCount === 0 ? null : hitCount / reviewCount,
137
+ missRate: reviewCount === 0 ? null : forgotCount / reviewCount,
138
+ difficulty: card.performance.difficulty,
139
+ stability: card.performance.stability,
140
+ intervalDays: card.performance.intervalDays,
141
+ dueDate: card.performance.dueDate,
142
+ lastReviewedAt: card.performance.lastReviewedAt,
143
+ active: card.active
144
+ });
145
+ }
146
+ return stats;
147
+ },
148
+ snapshot() {
149
+ return {
150
+ cards: [...cards.values()].map(cloneStoredCard),
151
+ reviews: reviews.map(cloneStoredReview),
152
+ nextReviewId
153
+ };
154
+ }
155
+ };
156
+ return store;
157
+ };
158
+ const emptyPerformance = () => ({
159
+ lastReviewedAt: null,
160
+ stability: null,
161
+ difficulty: null,
162
+ intervalRaw: null,
163
+ intervalDays: null,
164
+ dueDate: null,
165
+ reviewCount: 0
166
+ });
167
+ const reviewResultToPerformance = (result) => ({
168
+ lastReviewedAt: result.lastReviewedAt,
169
+ stability: result.stability,
170
+ difficulty: result.difficulty,
171
+ intervalRaw: result.intervalRaw,
172
+ intervalDays: result.intervalDays,
173
+ dueDate: result.dueDate,
174
+ reviewCount: result.reviewCount
175
+ });
176
+ const clonePerformance = (performance) => ({
177
+ ...performance
178
+ });
179
+ const cloneStoredCard = (card) => ({
180
+ ...card,
181
+ performance: clonePerformance(card.performance)
182
+ });
183
+ const cloneStoredReview = (review) => ({
184
+ ...review
185
+ });
186
+ const compareReviews = (left, right) => left.reviewedAt.localeCompare(right.reviewedAt) || left.reviewId - right.reviewId;
187
+ const countGrade = (reviews, grade) => reviews.filter((review) => review.grade === grade).length;
188
+ export const isOverdue = (performance, now = new Date()) => performance.dueDate != null && performance.dueDate < toDateString(now);
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@mdsrs/store",
3
+ "version": "0.0.0",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "dependencies": {
16
+ "@mdsrs/core": "0.0.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^24.0.0",
20
+ "typescript": "^6.0.3",
21
+ "vitest": "^4.0.0"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json",
25
+ "test": "vitest run",
26
+ "typecheck": "tsc -p tsconfig.json --noEmit"
27
+ }
28
+ }