@mandujs/core 0.11.0 → 0.12.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/package.json +2 -1
- package/src/constants.ts +15 -0
- package/src/content/content-layer.ts +314 -0
- package/src/content/content.test.ts +433 -0
- package/src/content/data-store.ts +245 -0
- package/src/content/digest.ts +133 -0
- package/src/content/index.ts +164 -0
- package/src/content/loader-context.ts +172 -0
- package/src/content/loaders/api.ts +216 -0
- package/src/content/loaders/file.ts +169 -0
- package/src/content/loaders/glob.ts +252 -0
- package/src/content/loaders/index.ts +34 -0
- package/src/content/loaders/types.ts +137 -0
- package/src/content/meta-store.ts +209 -0
- package/src/content/types.ts +282 -0
- package/src/content/watcher.ts +135 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Layer Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
6
|
+
import { generateDigest, generateFileDigest, combineDigests, digestsMatch } from "./digest";
|
|
7
|
+
import { createDataStore, type ContentDataStore } from "./data-store";
|
|
8
|
+
import { createMetaStore, type ContentMetaStore } from "./meta-store";
|
|
9
|
+
import { createLoaderContext, createSimpleMarkdownRenderer } from "./loader-context";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import fs from "fs";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import os from "os";
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Digest Tests
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
describe("digest", () => {
|
|
20
|
+
test("generateDigest creates consistent hash for same data", () => {
|
|
21
|
+
const data = { title: "Hello", count: 42 };
|
|
22
|
+
const hash1 = generateDigest(data);
|
|
23
|
+
const hash2 = generateDigest(data);
|
|
24
|
+
|
|
25
|
+
expect(hash1).toBe(hash2);
|
|
26
|
+
expect(hash1.length).toBe(16);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("generateDigest creates different hash for different data", () => {
|
|
30
|
+
const hash1 = generateDigest({ a: 1 });
|
|
31
|
+
const hash2 = generateDigest({ a: 2 });
|
|
32
|
+
|
|
33
|
+
expect(hash1).not.toBe(hash2);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("generateDigest handles nested objects", () => {
|
|
37
|
+
const data = {
|
|
38
|
+
user: { name: "John", age: 30 },
|
|
39
|
+
tags: ["a", "b"],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const hash = generateDigest(data);
|
|
43
|
+
expect(hash.length).toBe(16);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("generateDigest is order-independent for object keys", () => {
|
|
47
|
+
const hash1 = generateDigest({ a: 1, b: 2 });
|
|
48
|
+
const hash2 = generateDigest({ b: 2, a: 1 });
|
|
49
|
+
|
|
50
|
+
expect(hash1).toBe(hash2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("generateFileDigest works with string content", () => {
|
|
54
|
+
const content = "# Hello World\n\nThis is content.";
|
|
55
|
+
const hash = generateFileDigest(content);
|
|
56
|
+
|
|
57
|
+
expect(hash.length).toBe(16);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("combineDigests combines multiple digests", () => {
|
|
61
|
+
const d1 = generateDigest({ a: 1 });
|
|
62
|
+
const d2 = generateDigest({ b: 2 });
|
|
63
|
+
const combined = combineDigests([d1, d2]);
|
|
64
|
+
|
|
65
|
+
expect(combined.length).toBe(16);
|
|
66
|
+
expect(combined).not.toBe(d1);
|
|
67
|
+
expect(combined).not.toBe(d2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("digestsMatch compares digests correctly", () => {
|
|
71
|
+
const d1 = generateDigest({ x: 1 });
|
|
72
|
+
const d2 = generateDigest({ x: 1 });
|
|
73
|
+
const d3 = generateDigest({ x: 2 });
|
|
74
|
+
|
|
75
|
+
expect(digestsMatch(d1, d2)).toBe(true);
|
|
76
|
+
expect(digestsMatch(d1, d3)).toBe(false);
|
|
77
|
+
expect(digestsMatch(undefined, d1)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ============================================================================
|
|
82
|
+
// DataStore Tests
|
|
83
|
+
// ============================================================================
|
|
84
|
+
|
|
85
|
+
describe("DataStore", () => {
|
|
86
|
+
let store: ContentDataStore;
|
|
87
|
+
let tempDir: string;
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mandu-test-"));
|
|
91
|
+
store = createDataStore({
|
|
92
|
+
filePath: path.join(tempDir, "store.json"),
|
|
93
|
+
autoSave: false,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
store.dispose();
|
|
99
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("getStore returns a DataStore interface", () => {
|
|
103
|
+
const posts = store.getStore("posts");
|
|
104
|
+
|
|
105
|
+
expect(posts.get).toBeDefined();
|
|
106
|
+
expect(posts.set).toBeDefined();
|
|
107
|
+
expect(posts.delete).toBeDefined();
|
|
108
|
+
expect(posts.entries).toBeDefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("set and get work correctly", () => {
|
|
112
|
+
const posts = store.getStore("posts");
|
|
113
|
+
|
|
114
|
+
posts.set({
|
|
115
|
+
id: "hello-world",
|
|
116
|
+
data: { title: "Hello World" },
|
|
117
|
+
digest: "abc123",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const entry = posts.get("hello-world");
|
|
121
|
+
expect(entry).toBeDefined();
|
|
122
|
+
expect(entry?.data.title).toBe("Hello World");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("set returns true for new/changed entries", () => {
|
|
126
|
+
const posts = store.getStore("posts");
|
|
127
|
+
|
|
128
|
+
const result1 = posts.set({
|
|
129
|
+
id: "post1",
|
|
130
|
+
data: { title: "Post 1" },
|
|
131
|
+
digest: "digest1",
|
|
132
|
+
});
|
|
133
|
+
expect(result1).toBe(true);
|
|
134
|
+
|
|
135
|
+
// Same digest, no change
|
|
136
|
+
const result2 = posts.set({
|
|
137
|
+
id: "post1",
|
|
138
|
+
data: { title: "Post 1" },
|
|
139
|
+
digest: "digest1",
|
|
140
|
+
});
|
|
141
|
+
expect(result2).toBe(false);
|
|
142
|
+
|
|
143
|
+
// Different digest, changed
|
|
144
|
+
const result3 = posts.set({
|
|
145
|
+
id: "post1",
|
|
146
|
+
data: { title: "Post 1 Updated" },
|
|
147
|
+
digest: "digest2",
|
|
148
|
+
});
|
|
149
|
+
expect(result3).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("delete removes entry", () => {
|
|
153
|
+
const posts = store.getStore("posts");
|
|
154
|
+
|
|
155
|
+
posts.set({ id: "to-delete", data: {}, digest: "x" });
|
|
156
|
+
expect(posts.has("to-delete")).toBe(true);
|
|
157
|
+
|
|
158
|
+
posts.delete("to-delete");
|
|
159
|
+
expect(posts.has("to-delete")).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("entries returns all entries", () => {
|
|
163
|
+
const posts = store.getStore("posts");
|
|
164
|
+
|
|
165
|
+
posts.set({ id: "p1", data: { n: 1 }, digest: "a" });
|
|
166
|
+
posts.set({ id: "p2", data: { n: 2 }, digest: "b" });
|
|
167
|
+
|
|
168
|
+
const entries = posts.entries();
|
|
169
|
+
expect(entries.length).toBe(2);
|
|
170
|
+
expect(entries.map(([id]) => id).sort()).toEqual(["p1", "p2"]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("size returns entry count", () => {
|
|
174
|
+
const posts = store.getStore("posts");
|
|
175
|
+
|
|
176
|
+
expect(posts.size()).toBe(0);
|
|
177
|
+
|
|
178
|
+
posts.set({ id: "p1", data: {}, digest: "a" });
|
|
179
|
+
posts.set({ id: "p2", data: {}, digest: "b" });
|
|
180
|
+
|
|
181
|
+
expect(posts.size()).toBe(2);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("clear removes all entries", () => {
|
|
185
|
+
const posts = store.getStore("posts");
|
|
186
|
+
|
|
187
|
+
posts.set({ id: "p1", data: {}, digest: "a" });
|
|
188
|
+
posts.set({ id: "p2", data: {}, digest: "b" });
|
|
189
|
+
|
|
190
|
+
posts.clear();
|
|
191
|
+
expect(posts.size()).toBe(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("save and load persist data", async () => {
|
|
195
|
+
const posts = store.getStore("posts");
|
|
196
|
+
|
|
197
|
+
posts.set({
|
|
198
|
+
id: "persistent",
|
|
199
|
+
data: { title: "Persistent Post" },
|
|
200
|
+
digest: "persistent-digest",
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await store.save();
|
|
204
|
+
|
|
205
|
+
// Create new store and load
|
|
206
|
+
const store2 = createDataStore({
|
|
207
|
+
filePath: path.join(tempDir, "store.json"),
|
|
208
|
+
autoSave: false,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await store2.load();
|
|
212
|
+
|
|
213
|
+
const posts2 = store2.getStore("posts");
|
|
214
|
+
const entry = posts2.get("persistent");
|
|
215
|
+
|
|
216
|
+
expect(entry).toBeDefined();
|
|
217
|
+
expect(entry?.data.title).toBe("Persistent Post");
|
|
218
|
+
|
|
219
|
+
store2.dispose();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ============================================================================
|
|
224
|
+
// MetaStore Tests
|
|
225
|
+
// ============================================================================
|
|
226
|
+
|
|
227
|
+
describe("MetaStore", () => {
|
|
228
|
+
let metaStore: ContentMetaStore;
|
|
229
|
+
let tempDir: string;
|
|
230
|
+
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mandu-meta-test-"));
|
|
233
|
+
metaStore = createMetaStore({
|
|
234
|
+
filePath: path.join(tempDir, "meta.json"),
|
|
235
|
+
autoSave: false,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
afterEach(() => {
|
|
240
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("getStore returns MetaStore interface", () => {
|
|
244
|
+
const meta = metaStore.getStore("posts");
|
|
245
|
+
|
|
246
|
+
expect(meta.get).toBeDefined();
|
|
247
|
+
expect(meta.set).toBeDefined();
|
|
248
|
+
expect(meta.has).toBeDefined();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("set and get work correctly", () => {
|
|
252
|
+
const meta = metaStore.getStore("api-data");
|
|
253
|
+
|
|
254
|
+
meta.set("lastSync", "2024-01-15T10:00:00Z");
|
|
255
|
+
meta.set("cursor", "abc123");
|
|
256
|
+
|
|
257
|
+
expect(meta.get("lastSync")).toBe("2024-01-15T10:00:00Z");
|
|
258
|
+
expect(meta.get("cursor")).toBe("abc123");
|
|
259
|
+
expect(meta.get("nonexistent")).toBeUndefined();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("has checks existence", () => {
|
|
263
|
+
const meta = metaStore.getStore("collection");
|
|
264
|
+
|
|
265
|
+
expect(meta.has("key")).toBe(false);
|
|
266
|
+
|
|
267
|
+
meta.set("key", "value");
|
|
268
|
+
expect(meta.has("key")).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("delete removes key", () => {
|
|
272
|
+
const meta = metaStore.getStore("collection");
|
|
273
|
+
|
|
274
|
+
meta.set("toDelete", "value");
|
|
275
|
+
expect(meta.has("toDelete")).toBe(true);
|
|
276
|
+
|
|
277
|
+
meta.delete("toDelete");
|
|
278
|
+
expect(meta.has("toDelete")).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("entries returns all key-value pairs", () => {
|
|
282
|
+
const meta = metaStore.getStore("collection");
|
|
283
|
+
|
|
284
|
+
meta.set("k1", "v1");
|
|
285
|
+
meta.set("k2", "v2");
|
|
286
|
+
|
|
287
|
+
const entries = meta.entries();
|
|
288
|
+
expect(entries.length).toBe(2);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ============================================================================
|
|
293
|
+
// LoaderContext Tests
|
|
294
|
+
// ============================================================================
|
|
295
|
+
|
|
296
|
+
describe("LoaderContext", () => {
|
|
297
|
+
let dataStore: ContentDataStore;
|
|
298
|
+
let metaStore: ContentMetaStore;
|
|
299
|
+
let tempDir: string;
|
|
300
|
+
|
|
301
|
+
beforeEach(() => {
|
|
302
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mandu-ctx-test-"));
|
|
303
|
+
dataStore = createDataStore({ autoSave: false });
|
|
304
|
+
metaStore = createMetaStore({ autoSave: false });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
afterEach(() => {
|
|
308
|
+
dataStore.dispose();
|
|
309
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("creates context with all properties", () => {
|
|
313
|
+
const context = createLoaderContext({
|
|
314
|
+
collection: "posts",
|
|
315
|
+
store: dataStore.getStore("posts"),
|
|
316
|
+
meta: metaStore.getStore("posts"),
|
|
317
|
+
config: { root: tempDir },
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(context.collection).toBe("posts");
|
|
321
|
+
expect(context.store).toBeDefined();
|
|
322
|
+
expect(context.meta).toBeDefined();
|
|
323
|
+
expect(context.logger).toBeDefined();
|
|
324
|
+
expect(context.parseData).toBeDefined();
|
|
325
|
+
expect(context.generateDigest).toBeDefined();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("parseData validates with schema", async () => {
|
|
329
|
+
const schema = z.object({
|
|
330
|
+
title: z.string(),
|
|
331
|
+
count: z.number(),
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const context = createLoaderContext({
|
|
335
|
+
collection: "posts",
|
|
336
|
+
store: dataStore.getStore("posts"),
|
|
337
|
+
meta: metaStore.getStore("posts"),
|
|
338
|
+
config: { root: tempDir },
|
|
339
|
+
schema,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Valid data
|
|
343
|
+
const valid = await context.parseData({
|
|
344
|
+
id: "test",
|
|
345
|
+
data: { title: "Hello", count: 5 },
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
expect(valid.title).toBe("Hello");
|
|
349
|
+
expect(valid.count).toBe(5);
|
|
350
|
+
|
|
351
|
+
// Invalid data should throw
|
|
352
|
+
await expect(
|
|
353
|
+
context.parseData({
|
|
354
|
+
id: "invalid",
|
|
355
|
+
data: { title: 123, count: "not a number" },
|
|
356
|
+
})
|
|
357
|
+
).rejects.toThrow();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("parseData passes through without schema", async () => {
|
|
361
|
+
const context = createLoaderContext({
|
|
362
|
+
collection: "posts",
|
|
363
|
+
store: dataStore.getStore("posts"),
|
|
364
|
+
meta: metaStore.getStore("posts"),
|
|
365
|
+
config: { root: tempDir },
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const data = await context.parseData({
|
|
369
|
+
id: "test",
|
|
370
|
+
data: { anything: "goes" },
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect(data.anything).toBe("goes");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("generateDigest creates hash", () => {
|
|
377
|
+
const context = createLoaderContext({
|
|
378
|
+
collection: "posts",
|
|
379
|
+
store: dataStore.getStore("posts"),
|
|
380
|
+
meta: metaStore.getStore("posts"),
|
|
381
|
+
config: { root: tempDir },
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const hash = context.generateDigest({ test: "data" });
|
|
385
|
+
expect(hash.length).toBe(16);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// ============================================================================
|
|
390
|
+
// Markdown Renderer Tests
|
|
391
|
+
// ============================================================================
|
|
392
|
+
|
|
393
|
+
describe("SimpleMarkdownRenderer", () => {
|
|
394
|
+
const render = createSimpleMarkdownRenderer();
|
|
395
|
+
|
|
396
|
+
test("renders headings", async () => {
|
|
397
|
+
const result = await render("# Heading 1\n## Heading 2");
|
|
398
|
+
|
|
399
|
+
expect(result.html).toContain("<h1>Heading 1</h1>");
|
|
400
|
+
expect(result.html).toContain("<h2>Heading 2</h2>");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("extracts headings", async () => {
|
|
404
|
+
const result = await render("# Title\n## Section 1\n### Subsection");
|
|
405
|
+
|
|
406
|
+
expect(result.headings).toBeDefined();
|
|
407
|
+
expect(result.headings?.length).toBe(3);
|
|
408
|
+
expect(result.headings?.[0]).toEqual({
|
|
409
|
+
depth: 1,
|
|
410
|
+
text: "Title",
|
|
411
|
+
slug: "title",
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("renders bold and italic", async () => {
|
|
416
|
+
const result = await render("**bold** and *italic*");
|
|
417
|
+
|
|
418
|
+
expect(result.html).toContain("<strong>bold</strong>");
|
|
419
|
+
expect(result.html).toContain("<em>italic</em>");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("renders links", async () => {
|
|
423
|
+
const result = await render("[Link Text](https://example.com)");
|
|
424
|
+
|
|
425
|
+
expect(result.html).toContain('<a href="https://example.com">Link Text</a>');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("renders code", async () => {
|
|
429
|
+
const result = await render("`inline code`");
|
|
430
|
+
|
|
431
|
+
expect(result.html).toContain("<code>inline code</code>");
|
|
432
|
+
});
|
|
433
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataStore - 콘텐츠 데이터 저장소
|
|
3
|
+
*
|
|
4
|
+
* 컬렉션별 데이터 엔트리를 메모리에 저장하고
|
|
5
|
+
* 영속화를 위해 JSON 파일로 직렬화
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DataEntry, DataStore } from "./types";
|
|
9
|
+
import { digestsMatch } from "./digest";
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 스토어 옵션
|
|
15
|
+
*/
|
|
16
|
+
export interface DataStoreOptions {
|
|
17
|
+
/** 영속화 파일 경로 */
|
|
18
|
+
filePath?: string;
|
|
19
|
+
/** 자동 저장 활성화 */
|
|
20
|
+
autoSave?: boolean;
|
|
21
|
+
/** 저장 디바운스 (ms) */
|
|
22
|
+
saveDebounce?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 직렬화된 스토어 형식
|
|
27
|
+
*/
|
|
28
|
+
interface SerializedStore {
|
|
29
|
+
version: number;
|
|
30
|
+
collections: Record<string, Record<string, DataEntry>>;
|
|
31
|
+
timestamp: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const STORE_VERSION = 1;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 컬렉션별 DataStore 관리자
|
|
38
|
+
*/
|
|
39
|
+
export class ContentDataStore {
|
|
40
|
+
private collections: Map<string, Map<string, DataEntry>> = new Map();
|
|
41
|
+
private options: DataStoreOptions;
|
|
42
|
+
private saveTimer: NodeJS.Timeout | null = null;
|
|
43
|
+
private dirty: boolean = false;
|
|
44
|
+
|
|
45
|
+
constructor(options: DataStoreOptions = {}) {
|
|
46
|
+
this.options = {
|
|
47
|
+
autoSave: true,
|
|
48
|
+
saveDebounce: 500,
|
|
49
|
+
...options,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 컬렉션용 DataStore 인터페이스 생성
|
|
55
|
+
*/
|
|
56
|
+
getStore(collection: string): DataStore {
|
|
57
|
+
if (!this.collections.has(collection)) {
|
|
58
|
+
this.collections.set(collection, new Map());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const store = this.collections.get(collection)!;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
get: <T>(id: string) => store.get(id) as DataEntry<T> | undefined,
|
|
65
|
+
|
|
66
|
+
set: <T>(entry: DataEntry<T>) => {
|
|
67
|
+
const existing = store.get(entry.id);
|
|
68
|
+
const changed = !existing || !digestsMatch(existing.digest, entry.digest);
|
|
69
|
+
|
|
70
|
+
if (changed) {
|
|
71
|
+
store.set(entry.id, entry as DataEntry);
|
|
72
|
+
this.markDirty();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return changed;
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
delete: (id: string) => {
|
|
79
|
+
if (store.has(id)) {
|
|
80
|
+
store.delete(id);
|
|
81
|
+
this.markDirty();
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
clear: () => {
|
|
86
|
+
if (store.size > 0) {
|
|
87
|
+
store.clear();
|
|
88
|
+
this.markDirty();
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
entries: <T>() =>
|
|
93
|
+
Array.from(store.entries()) as Array<[string, DataEntry<T>]>,
|
|
94
|
+
|
|
95
|
+
has: (id: string) => store.has(id),
|
|
96
|
+
|
|
97
|
+
size: () => store.size,
|
|
98
|
+
|
|
99
|
+
keys: () => Array.from(store.keys()),
|
|
100
|
+
|
|
101
|
+
values: <T>() => Array.from(store.values()) as Array<DataEntry<T>>,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 컬렉션 삭제
|
|
107
|
+
*/
|
|
108
|
+
deleteCollection(collection: string): void {
|
|
109
|
+
if (this.collections.has(collection)) {
|
|
110
|
+
this.collections.delete(collection);
|
|
111
|
+
this.markDirty();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 모든 컬렉션 이름 조회
|
|
117
|
+
*/
|
|
118
|
+
getCollectionNames(): string[] {
|
|
119
|
+
return Array.from(this.collections.keys());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 변경 표시 및 자동 저장 스케줄링
|
|
124
|
+
*/
|
|
125
|
+
private markDirty(): void {
|
|
126
|
+
this.dirty = true;
|
|
127
|
+
|
|
128
|
+
if (this.options.autoSave && this.options.filePath) {
|
|
129
|
+
if (this.saveTimer) {
|
|
130
|
+
clearTimeout(this.saveTimer);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.saveTimer = setTimeout(() => {
|
|
134
|
+
this.save();
|
|
135
|
+
}, this.options.saveDebounce);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 파일에서 로드
|
|
141
|
+
*/
|
|
142
|
+
async load(): Promise<void> {
|
|
143
|
+
if (!this.options.filePath) return;
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
if (!fs.existsSync(this.options.filePath)) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const content = fs.readFileSync(this.options.filePath, "utf-8");
|
|
151
|
+
const data: SerializedStore = JSON.parse(content);
|
|
152
|
+
|
|
153
|
+
if (data.version !== STORE_VERSION) {
|
|
154
|
+
console.warn(
|
|
155
|
+
`[ContentStore] Store version mismatch (${data.version} vs ${STORE_VERSION}), starting fresh`
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const [collection, entries] of Object.entries(data.collections)) {
|
|
161
|
+
const store = new Map<string, DataEntry>();
|
|
162
|
+
for (const [id, entry] of Object.entries(entries)) {
|
|
163
|
+
store.set(id, entry);
|
|
164
|
+
}
|
|
165
|
+
this.collections.set(collection, store);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.dirty = false;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.warn(
|
|
171
|
+
`[ContentStore] Failed to load store:`,
|
|
172
|
+
error instanceof Error ? error.message : error
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 파일에 저장
|
|
179
|
+
*/
|
|
180
|
+
async save(): Promise<void> {
|
|
181
|
+
if (!this.options.filePath || !this.dirty) return;
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const dir = path.dirname(this.options.filePath);
|
|
185
|
+
if (!fs.existsSync(dir)) {
|
|
186
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const data: SerializedStore = {
|
|
190
|
+
version: STORE_VERSION,
|
|
191
|
+
collections: {},
|
|
192
|
+
timestamp: new Date().toISOString(),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
for (const [collection, store] of this.collections) {
|
|
196
|
+
data.collections[collection] = Object.fromEntries(store);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
fs.writeFileSync(this.options.filePath, JSON.stringify(data, null, 2));
|
|
200
|
+
this.dirty = false;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
console.error(
|
|
203
|
+
`[ContentStore] Failed to save store:`,
|
|
204
|
+
error instanceof Error ? error.message : error
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 저장 타이머 정리
|
|
211
|
+
*/
|
|
212
|
+
dispose(): void {
|
|
213
|
+
if (this.saveTimer) {
|
|
214
|
+
clearTimeout(this.saveTimer);
|
|
215
|
+
this.saveTimer = null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 남은 변경사항 저장
|
|
219
|
+
if (this.dirty && this.options.filePath) {
|
|
220
|
+
this.save();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 전체 스토어 통계
|
|
226
|
+
*/
|
|
227
|
+
getStats(): { collections: number; totalEntries: number } {
|
|
228
|
+
let totalEntries = 0;
|
|
229
|
+
for (const store of this.collections.values()) {
|
|
230
|
+
totalEntries += store.size;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
collections: this.collections.size,
|
|
235
|
+
totalEntries,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* DataStore 팩토리
|
|
242
|
+
*/
|
|
243
|
+
export function createDataStore(options?: DataStoreOptions): ContentDataStore {
|
|
244
|
+
return new ContentDataStore(options);
|
|
245
|
+
}
|