dexin-content 0.1.2 → 0.2.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/LICENSE +202 -21
- package/bin/dexin-content.mjs +6 -0
- package/cli/build.ts +188 -0
- package/cli/check.ts +124 -0
- package/cli/main.ts +207 -0
- package/cli/package.ts +137 -0
- package/cli/serve.ts +135 -0
- package/cli/validate.ts +136 -0
- package/{collection.ts → core/artifact/collection.ts} +297 -289
- package/{diff.ts → core/artifact/diff.ts} +125 -124
- package/{query.ts → core/artifact/query.ts} +56 -56
- package/{store.ts → core/artifact/store.ts} +128 -128
- package/core/{compiler.ts → compiler/compiler.ts} +24 -21
- package/core/compiler/discovery.ts +116 -0
- package/core/{frontmatter.ts → parser/frontmatter.ts} +145 -144
- package/core/{markdown.ts → parser/markdown.ts} +78 -62
- package/core/types/index.ts +131 -0
- package/core/types/lessonAST.ts +264 -0
- package/core/validator/integrity.ts +199 -0
- package/domains/document/identity.ts +27 -0
- package/domains/document/index.ts +5 -0
- package/domains/document/parser.ts +105 -0
- package/domains/lesson/blocks.ts +205 -0
- package/domains/lesson/identity.ts +40 -0
- package/domains/lesson/index.ts +7 -0
- package/domains/lesson/parser.ts +200 -0
- package/index.ts +57 -8
- package/package.json +82 -69
- package/core/discovery.ts +0 -110
- package/core/types.ts +0 -162
|
@@ -1,124 +1,125 @@
|
|
|
1
|
-
// ─────────────────────────────────────────────────────────────
|
|
2
|
-
// dexin-content/diff.ts — 共享 Canonical/Diff 原语
|
|
3
|
-
//
|
|
4
|
-
// 统一 CANONICALIZE
|
|
5
|
-
//
|
|
6
|
-
// *
|
|
7
|
-
// *
|
|
8
|
-
// *
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
.split('\
|
|
33
|
-
.split('\
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// dexin-content/diff.ts — 共享 Canonical/Diff 原语
|
|
3
|
+
//
|
|
4
|
+
// 统一 run-p0.ts 与 regression.ts 的 CANONICALIZE 语义。这是 P0
|
|
5
|
+
// 阶段唯一可信的:
|
|
6
|
+
// * Golden 侧递归剥离 `_` 前缀键(CANONICALIZE §3 Rule 1 v1.1)
|
|
7
|
+
// * Candidate 侧 `_` 前缀键路径扫描(CANONICALIZE §3 Rule 2 v1.1)
|
|
8
|
+
// * 对象级 firstDiff(键序不敏感,首差异 JSON-path)
|
|
9
|
+
// * Canonical JSON 序列化(2 空格、键字典序、LF、末换行、
|
|
10
|
+
// Unicode 不转义、U+2028/U+2029 转义)
|
|
11
|
+
// ─────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface DiffResult {
|
|
14
|
+
path: string
|
|
15
|
+
expected: unknown
|
|
16
|
+
actual: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── 对象与键工具 ──────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export function isPlainObject (v: unknown): v is Record<string, unknown> {
|
|
22
|
+
return !!v && typeof v === 'object' && !Array.isArray(v)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── 1) Canonical JSON 序列化 ─────────────────────────────
|
|
26
|
+
// 规则:2 空格缩进 + 对象每层级键字典序 + LF 换行 + 尾部换行 +
|
|
27
|
+
// Unicode 原文保留(不 \u 转义,除 JSON 强制字符)+
|
|
28
|
+
// 行分隔符 U+2028 / 段分隔符 U+2029 转义(老 JS 引擎兼容)。
|
|
29
|
+
|
|
30
|
+
export function toCanonicalJSON (value: unknown): string {
|
|
31
|
+
return JSON.stringify(sortKeysDeep(value), null, 2)
|
|
32
|
+
.split('\r\n').join('\n')
|
|
33
|
+
.split('\u2028').join('\\u2028')
|
|
34
|
+
.split('\u2029').join('\\u2029') + '\n'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function sortKeysDeep (v: unknown): unknown {
|
|
38
|
+
if (Array.isArray(v)) return v.map(sortKeysDeep)
|
|
39
|
+
if (isPlainObject(v)) {
|
|
40
|
+
const keys = Object.keys(v).sort()
|
|
41
|
+
const out: Record<string, unknown> = {}
|
|
42
|
+
for (const k of keys) out[k] = sortKeysDeep((v as Record<string, unknown>)[k])
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
return v
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── 2) Golden 侧 _ 键递归剥离(CANONICALIZE §3 Rule 1 v1.1) ─
|
|
49
|
+
// 只剥离 Golden。Candidate 若含 _ 前缀键,属于 Rule 2 违规,
|
|
50
|
+
// 应走 underscorePrefixedPaths 判 FAIL,绝不在 diff 中掩盖。
|
|
51
|
+
|
|
52
|
+
export function stripUnderscoreKeysGolden (obj: unknown): unknown {
|
|
53
|
+
if (Array.isArray(obj)) return obj.map(stripUnderscoreKeysGolden)
|
|
54
|
+
if (isPlainObject(obj)) {
|
|
55
|
+
const out: Record<string, unknown> = {}
|
|
56
|
+
for (const k of Object.keys(obj)) {
|
|
57
|
+
if (k.startsWith('_')) continue
|
|
58
|
+
out[k] = stripUnderscoreKeysGolden((obj as Record<string, unknown>)[k])
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
return obj
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── 3) Candidate/B3 侧全层级 _ 前缀键扫描 ─────────────────
|
|
66
|
+
// 返回 JSON-path 字符串数组(空 = 干净)。
|
|
67
|
+
|
|
68
|
+
export function underscorePrefixedPaths (obj: unknown): string[] {
|
|
69
|
+
const out: string[] = []
|
|
70
|
+
walk(obj, '$')
|
|
71
|
+
return out
|
|
72
|
+
function walk (v: unknown, at: string): void {
|
|
73
|
+
if (Array.isArray(v)) {
|
|
74
|
+
for (let i = 0; i < v.length; i++) walk(v[i], `${at}[${i}]`)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (isPlainObject(v)) {
|
|
78
|
+
for (const k of Object.keys(v)) {
|
|
79
|
+
if (k.startsWith('_')) out.push(`${at}.${k}`)
|
|
80
|
+
walk((v as Record<string, unknown>)[k], `${at}.${k}`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── 4) 对象级 firstDiff(键序不敏感) ─────────────────────
|
|
87
|
+
// 返回 null(相等)或首个差异(JSON-path + 两边值)。
|
|
88
|
+
// 相等判定使用 Object.is —— NaN 对 NaN 视为相等。
|
|
89
|
+
|
|
90
|
+
export function firstDiff (actual: unknown, expected: unknown, root: string): DiffResult | null {
|
|
91
|
+
if (isPlainObject(expected) && isPlainObject(actual)) {
|
|
92
|
+
const keys = Array.from(
|
|
93
|
+
new Set([...Object.keys(expected), ...Object.keys(actual)])
|
|
94
|
+
).sort()
|
|
95
|
+
for (const k of keys) {
|
|
96
|
+
const e = (expected as Record<string, unknown>)[k]
|
|
97
|
+
const a = (actual as Record<string, unknown>)[k]
|
|
98
|
+
if (!(k in (expected as object))) {
|
|
99
|
+
return { path: `${root}.${k}`, expected: '<absent>', actual: shortStr(a) }
|
|
100
|
+
}
|
|
101
|
+
if (!(k in (actual as object))) {
|
|
102
|
+
return { path: `${root}.${k}`, expected: shortStr(e), actual: '<absent>' }
|
|
103
|
+
}
|
|
104
|
+
const sub = firstDiff(a, e, `${root}.${k}`)
|
|
105
|
+
if (sub) return sub
|
|
106
|
+
}
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(expected) && Array.isArray(actual)) {
|
|
110
|
+
if (expected.length !== actual.length) {
|
|
111
|
+
return { path: root + '.length', expected: expected.length, actual: actual.length }
|
|
112
|
+
}
|
|
113
|
+
for (let i = 0; i < expected.length; i++) {
|
|
114
|
+
const sub = firstDiff(actual[i], expected[i], `${root}[${i}]`)
|
|
115
|
+
if (sub) return sub
|
|
116
|
+
}
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
if (Object.is(actual, expected)) return null
|
|
120
|
+
return { path: root, expected, actual }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function shortStr (v: unknown): string {
|
|
124
|
+
try { return JSON.stringify(v) } catch { return String(v) }
|
|
125
|
+
}
|
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
// ─────────────────────────────────────────────────────────────
|
|
2
|
-
// dexin-content/query.ts
|
|
3
|
-
//
|
|
4
|
-
// Runtime query — read-only over Artifact store.
|
|
5
|
-
//
|
|
6
|
-
// Query NEVER parses Markdown. No index → throw. byPath/byId
|
|
7
|
-
// return PositiveArtifact (with content.blocks); collection/list
|
|
8
|
-
// return IndexEntry[] (metadata + routing only).
|
|
9
|
-
//
|
|
10
|
-
// grep-zero: no business vocabulary appears anywhere in this
|
|
11
|
-
// module — including error messages.
|
|
12
|
-
// ─────────────────────────────────────────────────────────────
|
|
13
|
-
|
|
14
|
-
import type { PositiveArtifact } from '
|
|
15
|
-
import type { ArtifactStore, IndexEntry } from './store'
|
|
16
|
-
|
|
17
|
-
export interface QueryOptions {
|
|
18
|
-
where?: (entry: IndexEntry) => boolean
|
|
19
|
-
sort?: (a: IndexEntry, b: IndexEntry) => number
|
|
20
|
-
limit?: number
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export class ContentQuery {
|
|
24
|
-
constructor (private store: ArtifactStore) {}
|
|
25
|
-
|
|
26
|
-
/** List all entries (optionally filtered to one collection). */
|
|
27
|
-
async list (collection?: string, opts: QueryOptions = {}): Promise<IndexEntry[]> {
|
|
28
|
-
const index = await this.store.readIndex()
|
|
29
|
-
if (!index) throw new Error('[Query] No index — run compile first (runtime never reparses source).')
|
|
30
|
-
let rows = collection ? index.entries.filter(e => e.collection === collection) : [...index.entries]
|
|
31
|
-
if (opts.where) rows = rows.filter(opts.where)
|
|
32
|
-
if (opts.sort) rows.sort(opts.sort)
|
|
33
|
-
if (opts.limit != null) rows = rows.slice(0, opts.limit)
|
|
34
|
-
return rows
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Fetch single doc by URL path. Returns null if not found. */
|
|
38
|
-
async byPath (path: string): Promise<PositiveArtifact | null> {
|
|
39
|
-
const norm = path.replace(/\/+$/, '') || '/'
|
|
40
|
-
const index = await this.store.readIndex()
|
|
41
|
-
if (!index) throw new Error('[Query] No index — run compile first.')
|
|
42
|
-
const entry = index.entries.find(e => e.path === norm)
|
|
43
|
-
if (!entry) return null
|
|
44
|
-
return this.store.readDoc(entry.id)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Fetch single doc by stable id (= PositiveArtifact.
|
|
48
|
-
async byId (id: string): Promise<PositiveArtifact | null> {
|
|
49
|
-
return this.store.readDoc(id)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** List metadata entries for a collection (host runtime usage). */
|
|
53
|
-
async collection (name: string, opts: QueryOptions = {}): Promise<IndexEntry[]> {
|
|
54
|
-
return this.list(name, opts)
|
|
55
|
-
}
|
|
56
|
-
}
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// dexin-content/query.ts
|
|
3
|
+
//
|
|
4
|
+
// Runtime query — read-only over Artifact store (API-FREEZE §3.6).
|
|
5
|
+
//
|
|
6
|
+
// Query NEVER parses Markdown. No index → throw. byPath/byId
|
|
7
|
+
// return PositiveArtifact (with content.blocks); collection/list
|
|
8
|
+
// return IndexEntry[] (metadata + routing only).
|
|
9
|
+
//
|
|
10
|
+
// grep-zero: no business vocabulary appears anywhere in this
|
|
11
|
+
// module — including error messages.
|
|
12
|
+
// ─────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
import type { PositiveArtifact } from '../types'
|
|
15
|
+
import type { ArtifactStore, IndexEntry } from './store'
|
|
16
|
+
|
|
17
|
+
export interface QueryOptions {
|
|
18
|
+
where?: (entry: IndexEntry) => boolean
|
|
19
|
+
sort?: (a: IndexEntry, b: IndexEntry) => number
|
|
20
|
+
limit?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ContentQuery {
|
|
24
|
+
constructor (private store: ArtifactStore) {}
|
|
25
|
+
|
|
26
|
+
/** List all entries (optionally filtered to one collection). */
|
|
27
|
+
async list (collection?: string, opts: QueryOptions = {}): Promise<IndexEntry[]> {
|
|
28
|
+
const index = await this.store.readIndex()
|
|
29
|
+
if (!index) throw new Error('[Query] No index — run compile first (runtime never reparses source).')
|
|
30
|
+
let rows = collection ? index.entries.filter(e => e.collection === collection) : [...index.entries]
|
|
31
|
+
if (opts.where) rows = rows.filter(opts.where)
|
|
32
|
+
if (opts.sort) rows.sort(opts.sort)
|
|
33
|
+
if (opts.limit != null) rows = rows.slice(0, opts.limit)
|
|
34
|
+
return rows
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Fetch single doc by URL path. Returns null if not found. */
|
|
38
|
+
async byPath (path: string): Promise<PositiveArtifact | null> {
|
|
39
|
+
const norm = path.replace(/\/+$/, '') || '/'
|
|
40
|
+
const index = await this.store.readIndex()
|
|
41
|
+
if (!index) throw new Error('[Query] No index — run compile first.')
|
|
42
|
+
const entry = index.entries.find(e => e.path === norm)
|
|
43
|
+
if (!entry) return null
|
|
44
|
+
return this.store.readDoc(entry.id)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Fetch single doc by stable id (= PositiveArtifact.fixture). */
|
|
48
|
+
async byId (id: string): Promise<PositiveArtifact | null> {
|
|
49
|
+
return this.store.readDoc(id)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** List metadata entries for a collection (host runtime usage). */
|
|
53
|
+
async collection (name: string, opts: QueryOptions = {}): Promise<IndexEntry[]> {
|
|
54
|
+
return this.list(name, opts)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -1,128 +1,128 @@
|
|
|
1
|
-
// ─────────────────────────────────────────────────────────────
|
|
2
|
-
// dexin-content/store.ts
|
|
3
|
-
//
|
|
4
|
-
// Artifact storage abstraction.
|
|
5
|
-
//
|
|
6
|
-
// The stored Doc shape IS the formal-layer PositiveArtifact
|
|
7
|
-
// (core/types.ts
|
|
8
|
-
// the Store reads/writes the exact shape
|
|
9
|
-
//
|
|
10
|
-
// The Index (ContentIndex / IndexEntry) is the routing overlay layer:
|
|
11
|
-
// IndexEntry.id maps to PositiveArtifact.
|
|
12
|
-
// IndexEntry.path/file/collection are path-derived routing info
|
|
13
|
-
// independent of the domain-specific identity inside the Artifact.
|
|
14
|
-
//
|
|
15
|
-
// grep-zero: no business vocabulary appears anywhere in this
|
|
16
|
-
// module — including error messages.
|
|
17
|
-
// ─────────────────────────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
import { mkdir, writeFile, readFile, access } from 'node:fs/promises'
|
|
20
|
-
import { dirname } from 'node:path'
|
|
21
|
-
import type { PositiveArtifact, Meta } from '
|
|
22
|
-
import { toCanonicalJSON } from './diff'
|
|
23
|
-
|
|
24
|
-
// ── Index types ──
|
|
25
|
-
|
|
26
|
-
export interface IndexEntry {
|
|
27
|
-
/** Stable doc id; equals PositiveArtifact.
|
|
28
|
-
id: string
|
|
29
|
-
/** URL semantic path: '/' + id. */
|
|
30
|
-
path: string
|
|
31
|
-
/** Owning collection name. */
|
|
32
|
-
collection: string
|
|
33
|
-
/** Domain tag (consistent with Artifact.domain). */
|
|
34
|
-
domain: string
|
|
35
|
-
/** Physical path relative to content root, INCLUDING .md. */
|
|
36
|
-
file: string
|
|
37
|
-
/** Scalar-only frontmatter projection. */
|
|
38
|
-
meta: Meta
|
|
39
|
-
/** First 12 hex chars of body md5 (change detection). */
|
|
40
|
-
checksum: string
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface ContentIndex {
|
|
44
|
-
generator: 'dexin-content'
|
|
45
|
-
builtAt: string
|
|
46
|
-
entries: IndexEntry[]
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ── Store interface ──
|
|
50
|
-
|
|
51
|
-
export interface ArtifactStore {
|
|
52
|
-
writeIndex(index: ContentIndex): Promise<void>
|
|
53
|
-
writeDoc(doc: PositiveArtifact): Promise<void>
|
|
54
|
-
readIndex(): Promise<ContentIndex | null>
|
|
55
|
-
readDoc(id: string): Promise<PositiveArtifact | null>
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ── File-system store ──
|
|
59
|
-
|
|
60
|
-
export interface FsOps {
|
|
61
|
-
mkdir(p: string): Promise<void>
|
|
62
|
-
writeFile(p: string, data: string): Promise<void>
|
|
63
|
-
readFile(p: string): Promise<string>
|
|
64
|
-
exists(p: string): Promise<boolean>
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const defaultFsOps: FsOps = {
|
|
68
|
-
async mkdir (p) { await mkdir(p, { recursive: true }) },
|
|
69
|
-
async writeFile (p, data) { await writeFile(p, data, 'utf8') },
|
|
70
|
-
async readFile (p) { return readFile(p, 'utf8') },
|
|
71
|
-
async exists (p) { try { await access(p); return true } catch { return false } }
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* File-system Artifact Store.
|
|
76
|
-
* Layout: <baseDir>/index.json + <baseDir>/
|
|
77
|
-
* Docs are serialised via toCanonicalJSON (2-space, key-lexicographic,
|
|
78
|
-
* LF, trailing newline) to match
|
|
79
|
-
*/
|
|
80
|
-
export function createFsArtifactStore (
|
|
81
|
-
baseDir: string,
|
|
82
|
-
fsOps: FsOps = defaultFsOps
|
|
83
|
-
): ArtifactStore {
|
|
84
|
-
const idxPath = () => `${baseDir}/index.json`
|
|
85
|
-
const docPath = (id: string) => `${baseDir}/
|
|
86
|
-
|
|
87
|
-
return {
|
|
88
|
-
async writeIndex (index) {
|
|
89
|
-
await fsOps.mkdir(baseDir)
|
|
90
|
-
await fsOps.writeFile(idxPath(), toCanonicalJSON(index))
|
|
91
|
-
},
|
|
92
|
-
|
|
93
|
-
async writeDoc (doc) {
|
|
94
|
-
const p = docPath(doc.
|
|
95
|
-
await fsOps.mkdir(dirname(p))
|
|
96
|
-
await fsOps.writeFile(p, toCanonicalJSON(doc))
|
|
97
|
-
},
|
|
98
|
-
|
|
99
|
-
async readIndex () {
|
|
100
|
-
if (!(await fsOps.exists(idxPath()))) return null
|
|
101
|
-
return JSON.parse(await fsOps.readFile(idxPath())) as ContentIndex
|
|
102
|
-
},
|
|
103
|
-
|
|
104
|
-
async readDoc (id) {
|
|
105
|
-
if (!(await fsOps.exists(docPath(id)))) return null
|
|
106
|
-
return JSON.parse(await fsOps.readFile(docPath(id))) as PositiveArtifact
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// ── Memory store (Dev / test) ──
|
|
112
|
-
|
|
113
|
-
export interface MemoryStore extends ArtifactStore {
|
|
114
|
-
snapshot(): { index: ContentIndex | null; docs: Map<string, PositiveArtifact> }
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function createMemoryArtifactStore (): MemoryStore {
|
|
118
|
-
let index: ContentIndex | null = null
|
|
119
|
-
const docs = new Map<string, PositiveArtifact>()
|
|
120
|
-
|
|
121
|
-
return {
|
|
122
|
-
async writeIndex (i) { index = i },
|
|
123
|
-
async writeDoc (d) { docs.set(d.
|
|
124
|
-
async readIndex () { return index },
|
|
125
|
-
async readDoc (id) { return docs.get(id) ?? null },
|
|
126
|
-
snapshot () { return { index, docs } }
|
|
127
|
-
}
|
|
128
|
-
}
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// dexin-content/store.ts
|
|
3
|
+
//
|
|
4
|
+
// Artifact storage abstraction (API-FREEZE §3.5).
|
|
5
|
+
//
|
|
6
|
+
// The stored Doc shape IS the formal-layer PositiveArtifact
|
|
7
|
+
// (core/types.ts frozen form). No second Doc alias is introduced;
|
|
8
|
+
// the Store reads/writes the exact shape golden expected freezes.
|
|
9
|
+
//
|
|
10
|
+
// The Index (ContentIndex / IndexEntry) is the routing overlay layer:
|
|
11
|
+
// IndexEntry.id maps to PositiveArtifact.fixture.
|
|
12
|
+
// IndexEntry.path/file/collection are path-derived routing info
|
|
13
|
+
// independent of the domain-specific identity inside the Artifact.
|
|
14
|
+
//
|
|
15
|
+
// grep-zero: no business vocabulary appears anywhere in this
|
|
16
|
+
// module — including error messages.
|
|
17
|
+
// ─────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
import { mkdir, writeFile, readFile, access } from 'node:fs/promises'
|
|
20
|
+
import { dirname } from 'node:path'
|
|
21
|
+
import type { PositiveArtifact, Meta } from '../types'
|
|
22
|
+
import { toCanonicalJSON } from './diff'
|
|
23
|
+
|
|
24
|
+
// ── Index types (prototype shape, API-FREEZE §2.2) ──
|
|
25
|
+
|
|
26
|
+
export interface IndexEntry {
|
|
27
|
+
/** Stable doc id; equals PositiveArtifact.fixture. */
|
|
28
|
+
id: string
|
|
29
|
+
/** URL semantic path: '/' + id. */
|
|
30
|
+
path: string
|
|
31
|
+
/** Owning collection name. */
|
|
32
|
+
collection: string
|
|
33
|
+
/** Domain tag (consistent with Artifact.domain). */
|
|
34
|
+
domain: string
|
|
35
|
+
/** Physical path relative to content root, INCLUDING .md. */
|
|
36
|
+
file: string
|
|
37
|
+
/** Scalar-only frontmatter projection. */
|
|
38
|
+
meta: Meta
|
|
39
|
+
/** First 12 hex chars of body md5 (change detection). */
|
|
40
|
+
checksum: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ContentIndex {
|
|
44
|
+
generator: 'dexin-content'
|
|
45
|
+
builtAt: string
|
|
46
|
+
entries: IndexEntry[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Store interface ──
|
|
50
|
+
|
|
51
|
+
export interface ArtifactStore {
|
|
52
|
+
writeIndex(index: ContentIndex): Promise<void>
|
|
53
|
+
writeDoc(doc: PositiveArtifact): Promise<void>
|
|
54
|
+
readIndex(): Promise<ContentIndex | null>
|
|
55
|
+
readDoc(id: string): Promise<PositiveArtifact | null>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── File-system store ──
|
|
59
|
+
|
|
60
|
+
export interface FsOps {
|
|
61
|
+
mkdir(p: string): Promise<void>
|
|
62
|
+
writeFile(p: string, data: string): Promise<void>
|
|
63
|
+
readFile(p: string): Promise<string>
|
|
64
|
+
exists(p: string): Promise<boolean>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const defaultFsOps: FsOps = {
|
|
68
|
+
async mkdir (p) { await mkdir(p, { recursive: true }) },
|
|
69
|
+
async writeFile (p, data) { await writeFile(p, data, 'utf8') },
|
|
70
|
+
async readFile (p) { return readFile(p, 'utf8') },
|
|
71
|
+
async exists (p) { try { await access(p); return true } catch { return false } }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* File-system Artifact Store.
|
|
76
|
+
* Layout: <baseDir>/index.json + <baseDir>/lessons/<id>.json
|
|
77
|
+
* Docs are serialised via toCanonicalJSON (2-space, key-lexicographic,
|
|
78
|
+
* LF, trailing newline) to match golden candidate form.
|
|
79
|
+
*/
|
|
80
|
+
export function createFsArtifactStore (
|
|
81
|
+
baseDir: string,
|
|
82
|
+
fsOps: FsOps = defaultFsOps
|
|
83
|
+
): ArtifactStore {
|
|
84
|
+
const idxPath = () => `${baseDir}/index.json`
|
|
85
|
+
const docPath = (id: string) => `${baseDir}/lessons/${id}.json`
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
async writeIndex (index) {
|
|
89
|
+
await fsOps.mkdir(baseDir)
|
|
90
|
+
await fsOps.writeFile(idxPath(), toCanonicalJSON(index))
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async writeDoc (doc) {
|
|
94
|
+
const p = docPath(doc.fixture)
|
|
95
|
+
await fsOps.mkdir(dirname(p))
|
|
96
|
+
await fsOps.writeFile(p, toCanonicalJSON(doc))
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async readIndex () {
|
|
100
|
+
if (!(await fsOps.exists(idxPath()))) return null
|
|
101
|
+
return JSON.parse(await fsOps.readFile(idxPath())) as ContentIndex
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async readDoc (id) {
|
|
105
|
+
if (!(await fsOps.exists(docPath(id)))) return null
|
|
106
|
+
return JSON.parse(await fsOps.readFile(docPath(id))) as PositiveArtifact
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Memory store (Dev / test) ──
|
|
112
|
+
|
|
113
|
+
export interface MemoryStore extends ArtifactStore {
|
|
114
|
+
snapshot(): { index: ContentIndex | null; docs: Map<string, PositiveArtifact> }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createMemoryArtifactStore (): MemoryStore {
|
|
118
|
+
let index: ContentIndex | null = null
|
|
119
|
+
const docs = new Map<string, PositiveArtifact>()
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
async writeIndex (i) { index = i },
|
|
123
|
+
async writeDoc (d) { docs.set(d.fixture, d) },
|
|
124
|
+
async readIndex () { return index },
|
|
125
|
+
async readDoc (id) { return docs.get(id) ?? null },
|
|
126
|
+
snapshot () { return { index, docs } }
|
|
127
|
+
}
|
|
128
|
+
}
|