@lythos/cold-pool 0.9.28 → 0.9.30
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 +1 -1
- package/src/cold-pool.test.ts +111 -1
- package/src/cold-pool.ts +97 -55
- package/src/db-helpers.ts +76 -0
- package/src/git-hash.test.ts +3 -0
- package/src/index.ts +3 -1
- package/src/metadata-db.ts +14 -48
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lythos/cold-pool",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.30",
|
|
4
4
|
"description": "Cold pool service layer — dedicated resource holder for skill repositories with intent/plan/execute primitives. Single owner of git side-effects; consumed by deck/curator/arena.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agent",
|
package/src/cold-pool.test.ts
CHANGED
|
@@ -2,9 +2,12 @@ import { describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, writeFileSync, existsSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
|
-
import { ColdPool, DEFAULT_COLD_POOL_PATH } from './cold-pool'
|
|
5
|
+
import { ColdPool, DEFAULT_COLD_POOL_PATH, buildListPlan, type DirEntry } from './cold-pool'
|
|
6
6
|
import { parseLocator } from './parse-locator'
|
|
7
7
|
|
|
8
|
+
function dir(relPath: string): DirEntry { return { relPath, isDirectory: true } }
|
|
9
|
+
function file(relPath: string): DirEntry { return { relPath, isDirectory: false } }
|
|
10
|
+
|
|
8
11
|
describe('ColdPool — constructor', () => {
|
|
9
12
|
test('default path when none given', () => {
|
|
10
13
|
const pool = new ColdPool()
|
|
@@ -100,6 +103,113 @@ describe('ColdPool — fs-backed read accessors', () => {
|
|
|
100
103
|
})
|
|
101
104
|
})
|
|
102
105
|
|
|
106
|
+
describe('buildListPlan — pure classification (no IO)', () => {
|
|
107
|
+
const root = '/pool'
|
|
108
|
+
|
|
109
|
+
test('canonical 3-segment: host/owner/repo', () => {
|
|
110
|
+
const entries: DirEntry[] = [
|
|
111
|
+
dir('github.com'),
|
|
112
|
+
dir('github.com/owner'),
|
|
113
|
+
dir('github.com/owner/repo-a'),
|
|
114
|
+
]
|
|
115
|
+
const plan = buildListPlan(root, entries)
|
|
116
|
+
expect(plan.entries).toEqual([
|
|
117
|
+
{ path: '/pool/github.com/owner/repo-a', kind: 'canonical' },
|
|
118
|
+
])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('skips hidden dirs', () => {
|
|
122
|
+
const entries: DirEntry[] = [
|
|
123
|
+
dir('.git'),
|
|
124
|
+
dir('github.com'),
|
|
125
|
+
dir('github.com/owner'),
|
|
126
|
+
dir('github.com/owner/repo-a'),
|
|
127
|
+
dir('github.com/owner/.DS_Store'),
|
|
128
|
+
]
|
|
129
|
+
const plan = buildListPlan(root, entries)
|
|
130
|
+
expect(plan.entries).toEqual([
|
|
131
|
+
{ path: '/pool/github.com/owner/repo-a', kind: 'canonical' },
|
|
132
|
+
])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('legacy depth-1: host/SKILL.md (missing owner+repo)', () => {
|
|
136
|
+
const entries: DirEntry[] = [
|
|
137
|
+
dir('legacy-skill'),
|
|
138
|
+
file('legacy-skill/SKILL.md'),
|
|
139
|
+
]
|
|
140
|
+
const plan = buildListPlan(root, entries)
|
|
141
|
+
expect(plan.entries).toEqual([
|
|
142
|
+
{ path: '/pool/legacy-skill', kind: 'legacy-depth1' },
|
|
143
|
+
])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('legacy depth-2: localhost/name/SKILL.md (missing repo)', () => {
|
|
147
|
+
const entries: DirEntry[] = [
|
|
148
|
+
dir('localhost'),
|
|
149
|
+
dir('localhost/legacy-name'),
|
|
150
|
+
file('localhost/legacy-name/SKILL.md'),
|
|
151
|
+
]
|
|
152
|
+
const plan = buildListPlan(root, entries)
|
|
153
|
+
expect(plan.entries).toEqual([
|
|
154
|
+
{ path: '/pool/localhost/legacy-name', kind: 'legacy-depth2' },
|
|
155
|
+
])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('mixed canonical + legacy in same pool', () => {
|
|
159
|
+
const entries: DirEntry[] = [
|
|
160
|
+
dir('github.com'),
|
|
161
|
+
dir('github.com/owner'),
|
|
162
|
+
dir('github.com/owner/repo-a'),
|
|
163
|
+
dir('localhost'),
|
|
164
|
+
dir('localhost/old-skill'),
|
|
165
|
+
file('localhost/old-skill/SKILL.md'),
|
|
166
|
+
]
|
|
167
|
+
const plan = buildListPlan(root, entries)
|
|
168
|
+
expect(plan.entries).toHaveLength(2)
|
|
169
|
+
expect(plan.entries).toContainEqual({ path: '/pool/github.com/owner/repo-a', kind: 'canonical' })
|
|
170
|
+
expect(plan.entries).toContainEqual({ path: '/pool/localhost/old-skill', kind: 'legacy-depth2' })
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('multiple repos under same owner', () => {
|
|
174
|
+
const entries: DirEntry[] = [
|
|
175
|
+
dir('github.com'),
|
|
176
|
+
dir('github.com/owner'),
|
|
177
|
+
dir('github.com/owner/repo-a'),
|
|
178
|
+
dir('github.com/owner/repo-b'),
|
|
179
|
+
dir('github.com/owner/repo-c'),
|
|
180
|
+
]
|
|
181
|
+
const plan = buildListPlan(root, entries)
|
|
182
|
+
expect(plan.entries).toHaveLength(3)
|
|
183
|
+
expect(plan.entries.every(e => e.kind === 'canonical')).toBe(true)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('multiple hosts', () => {
|
|
187
|
+
const entries: DirEntry[] = [
|
|
188
|
+
dir('github.com'),
|
|
189
|
+
dir('github.com/a'),
|
|
190
|
+
dir('github.com/a/r1'),
|
|
191
|
+
dir('gitlab.com'),
|
|
192
|
+
dir('gitlab.com/b'),
|
|
193
|
+
dir('gitlab.com/b/r2'),
|
|
194
|
+
]
|
|
195
|
+
const plan = buildListPlan(root, entries)
|
|
196
|
+
expect(plan.entries).toHaveLength(2)
|
|
197
|
+
expect(plan.entries.map(e => e.path).sort()).toEqual([
|
|
198
|
+
'/pool/github.com/a/r1',
|
|
199
|
+
'/pool/gitlab.com/b/r2',
|
|
200
|
+
])
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('returns empty when root has no entries', () => {
|
|
204
|
+
expect(buildListPlan(root, []).entries).toEqual([])
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('returns empty for hidden-only entries', () => {
|
|
208
|
+
const entries: DirEntry[] = [dir('.git'), dir('.hidden/.nested')]
|
|
209
|
+
expect(buildListPlan(root, entries).entries).toEqual([])
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
103
213
|
describe('ColdPool.metadata — MetadataDB integration', () => {
|
|
104
214
|
const root = mkdtempSync(join(tmpdir(), 'cold-pool-meta-test-'))
|
|
105
215
|
const pool = new ColdPool(root)
|
package/src/cold-pool.ts
CHANGED
|
@@ -1,20 +1,91 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ColdPool — dedicated resource holder.
|
|
3
|
-
*
|
|
4
|
-
* Owns the cold-pool path, exposes read-only accessors. Operations
|
|
5
|
-
* (fetch, validate, reconcile) are external functions that take a
|
|
6
|
-
* ColdPool and return Plan-shaped data, per the intent/plan/execute
|
|
7
|
-
* pattern.
|
|
8
|
-
*/
|
|
9
1
|
import { existsSync, readdirSync } from 'node:fs'
|
|
10
2
|
import { homedir } from 'node:os'
|
|
11
|
-
import { join } from 'node:path'
|
|
3
|
+
import { join, relative } from 'node:path'
|
|
12
4
|
import type { Locator } from './types.js'
|
|
13
5
|
import { MetadataDB } from './metadata-db.js'
|
|
14
6
|
|
|
15
7
|
export const DEFAULT_COLD_POOL_PATH = process.env.LYTHOS_COLD_POOL
|
|
16
8
|
?? join(homedir(), '.agents/skill-repos')
|
|
17
9
|
|
|
10
|
+
// ── DirEntry — pure, injectable fs entry ─────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface DirEntry {
|
|
13
|
+
/** Path relative to cold-pool root (e.g. "github.com/owner/repo") */
|
|
14
|
+
relPath: string
|
|
15
|
+
isDirectory: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── List plan: pure classification logic ────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface ListPlanEntry {
|
|
21
|
+
path: string
|
|
22
|
+
kind: 'canonical' | 'legacy-depth2' | 'legacy-depth1'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ListPlan {
|
|
26
|
+
entries: ListPlanEntry[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pure: given a cold-pool root path and a flat list of all fs entries
|
|
31
|
+
* (with relPath), classify every terminal repo directory.
|
|
32
|
+
*
|
|
33
|
+
* Canonical: <pool>/<host>/<owner>/<repo> (3 segments, no SKILL.md mid-tree)
|
|
34
|
+
* Legacy: <pool>/<host>/SKILL.md (depth 1 — no owner/repo)
|
|
35
|
+
* <pool>/<host>/<name>/SKILL.md (depth 2 — no repo segment)
|
|
36
|
+
*/
|
|
37
|
+
export function buildListPlan(rootPath: string, allEntries: DirEntry[]): ListPlan {
|
|
38
|
+
const plan: ListPlanEntry[] = []
|
|
39
|
+
const dirSet = new Set(allEntries.filter(e => e.isDirectory).map(e => e.relPath))
|
|
40
|
+
|
|
41
|
+
// Determine whether a dir is terminal (no child dirs)
|
|
42
|
+
function isTerminal(relPath: string): boolean {
|
|
43
|
+
const prefix = relPath + '/'
|
|
44
|
+
for (const d of dirSet) {
|
|
45
|
+
if (d.startsWith(prefix) && d !== relPath) return false
|
|
46
|
+
}
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check whether a dir directly contains SKILL.md
|
|
51
|
+
function hasSkillMd(dirRel: string): boolean {
|
|
52
|
+
return allEntries.some(e => e.relPath === `${dirRel}/SKILL.md` && !e.isDirectory)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Walk only terminal dirs — they are the leaves (repo-level entries)
|
|
56
|
+
for (const d of dirSet) {
|
|
57
|
+
if (d.startsWith('.') || d.split('/').some(s => s.startsWith('.'))) continue
|
|
58
|
+
if (!isTerminal(d)) continue
|
|
59
|
+
|
|
60
|
+
const segments = d.split('/')
|
|
61
|
+
|
|
62
|
+
// Legacy depth-1: <host>/SKILL.md — terminal dir with SKILL.md, depth=1
|
|
63
|
+
if (segments.length === 1 && hasSkillMd(d)) {
|
|
64
|
+
plan.push({ path: join(rootPath, d), kind: 'legacy-depth1' })
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Legacy depth-2: <host>/<name>/SKILL.md — terminal dir with SKILL.md, depth=2
|
|
69
|
+
if (segments.length === 2 && hasSkillMd(d)) {
|
|
70
|
+
plan.push({ path: join(rootPath, d), kind: 'legacy-depth2' })
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Canonical: <host>/<owner>/<repo> — terminal dir at depth 3, no SKILL.md mid-tree
|
|
75
|
+
if (segments.length === 3) {
|
|
76
|
+
// Verify no SKILL.md at intermediate levels (depth 1 or 2)
|
|
77
|
+
const parent = segments.slice(0, 2).join('/')
|
|
78
|
+
if (!hasSkillMd(segments[0]) && !hasSkillMd(parent)) {
|
|
79
|
+
plan.push({ path: join(rootPath, d), kind: 'canonical' })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { entries: plan }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── ColdPool ─────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
18
89
|
export class ColdPool {
|
|
19
90
|
readonly path: string
|
|
20
91
|
readonly metadata: MetadataDB
|
|
@@ -24,67 +95,38 @@ export class ColdPool {
|
|
|
24
95
|
this.metadata = new MetadataDB(join(this.path, '.cold-pool-meta.db'))
|
|
25
96
|
}
|
|
26
97
|
|
|
27
|
-
/**
|
|
28
|
-
* Compute the cold-pool directory for a locator. No fs check.
|
|
29
|
-
*
|
|
30
|
-
* Layout invariant: `<pool>/<host>/<owner>/<repo>` for ALL locators
|
|
31
|
-
* including localhost. No special-case branching — "directory layers
|
|
32
|
-
* = FQ locator segments" (per user 2026-05-07). Skill subpath
|
|
33
|
-
* extends within the repo dir (resolveDir returns the repo dir).
|
|
34
|
-
*/
|
|
35
98
|
resolveDir(locator: Locator): string {
|
|
36
99
|
return join(this.path, locator.host, locator.owner, locator.repo)
|
|
37
100
|
}
|
|
38
101
|
|
|
39
|
-
/** Whether a locator's repo directory exists in the pool. */
|
|
40
102
|
has(locator: Locator): boolean {
|
|
41
103
|
return existsSync(this.resolveDir(locator))
|
|
42
104
|
}
|
|
43
105
|
|
|
44
|
-
/**
|
|
45
|
-
* Enumerate cold-pool entries.
|
|
46
|
-
*
|
|
47
|
-
* Uniform layout: `<pool>/<host>/<owner>/<repo>`. localhost is just
|
|
48
|
-
* another host. No localhost special-case.
|
|
49
|
-
*
|
|
50
|
-
* Legacy drift: `<pool>/<x>/SKILL.md` (depth 2 with SKILL.md) or
|
|
51
|
-
* `<pool>/localhost/<name>/SKILL.md` (depth 3 with SKILL.md, missing
|
|
52
|
-
* owner/repo) are non-canonical state from older agents that bypassed
|
|
53
|
-
* FQ-only enforcement. Surface them so prune can offer cleanup.
|
|
54
|
-
*/
|
|
106
|
+
/** Enumerate cold-pool entries. Delegates classification to pure buildListPlan. */
|
|
55
107
|
list(): string[] {
|
|
56
108
|
if (!existsSync(this.path)) return []
|
|
57
|
-
const repos: string[] = []
|
|
58
|
-
|
|
59
|
-
for (const host of readdirSync(this.path, { withFileTypes: true })) {
|
|
60
|
-
if (!host.isDirectory() || host.name.startsWith('.')) continue
|
|
61
|
-
const hostPath = join(this.path, host.name)
|
|
62
109
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
110
|
+
const poolPath = this.path
|
|
111
|
+
const allEntries: DirEntry[] = []
|
|
112
|
+
function collectRecursive(dir: string): void {
|
|
113
|
+
let dirents: ReturnType<typeof readdirSync>
|
|
114
|
+
try {
|
|
115
|
+
dirents = readdirSync(dir, { withFileTypes: true })
|
|
116
|
+
} catch {
|
|
117
|
+
return
|
|
67
118
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// Legacy drift: <host>/<x>/SKILL.md (depth 2 missing repo level —
|
|
75
|
-
// typically `localhost/<name>/SKILL.md` from older agents)
|
|
76
|
-
if (existsSync(join(ownerPath, 'SKILL.md'))) {
|
|
77
|
-
repos.push(ownerPath)
|
|
78
|
-
continue
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
for (const repo of readdirSync(ownerPath, { withFileTypes: true })) {
|
|
82
|
-
if (!repo.isDirectory() || repo.name.startsWith('.')) continue
|
|
83
|
-
repos.push(join(ownerPath, repo.name))
|
|
119
|
+
for (const d of dirents) {
|
|
120
|
+
const rel = relative(poolPath, join(dir, d.name))
|
|
121
|
+
allEntries.push({ relPath: rel, isDirectory: d.isDirectory() })
|
|
122
|
+
if (d.isDirectory() && !d.name.startsWith('.')) {
|
|
123
|
+
collectRecursive(join(dir, d.name))
|
|
84
124
|
}
|
|
85
125
|
}
|
|
86
126
|
}
|
|
127
|
+
collectRecursive(poolPath)
|
|
87
128
|
|
|
88
|
-
|
|
129
|
+
const plan = buildListPlan(poolPath, allEntries)
|
|
130
|
+
return plan.entries.map(e => e.path)
|
|
89
131
|
}
|
|
90
132
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SQLite helpers — lazy-open, schema versioning, DRY wrappers.
|
|
3
|
+
*
|
|
4
|
+
* Used by MetadataDB (cold-pool) and curator's catalog DB.
|
|
5
|
+
* Consumers subclass and call super(dbPath) + define initSchema().
|
|
6
|
+
*/
|
|
7
|
+
import { Database } from 'bun:sqlite'
|
|
8
|
+
import { mkdirSync } from 'node:fs'
|
|
9
|
+
import { dirname } from 'node:path'
|
|
10
|
+
|
|
11
|
+
export abstract class SqliteDb {
|
|
12
|
+
protected abstract initSchema(): void
|
|
13
|
+
|
|
14
|
+
private dbPath: string
|
|
15
|
+
private _db: Database | null = null
|
|
16
|
+
|
|
17
|
+
constructor(dbPath: string) {
|
|
18
|
+
this.dbPath = dbPath
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Lazy-open: first DB access triggers creation. */
|
|
22
|
+
protected get db(): Database {
|
|
23
|
+
if (this._db == null) {
|
|
24
|
+
try {
|
|
25
|
+
mkdirSync(dirname(this.dbPath), { recursive: true })
|
|
26
|
+
} catch {
|
|
27
|
+
// Parent dir may be read-only (e.g. test paths).
|
|
28
|
+
}
|
|
29
|
+
this._db = new Database(this.dbPath, { create: true })
|
|
30
|
+
this.initSchema()
|
|
31
|
+
}
|
|
32
|
+
return this._db
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
protected exec(sql: string, params?: Record<string, unknown>): void {
|
|
36
|
+
const stmt = this.db.query(sql)
|
|
37
|
+
stmt.run(params ?? {})
|
|
38
|
+
stmt.finalize()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
protected queryOne<T>(sql: string, params?: Record<string, unknown>): T | null {
|
|
42
|
+
return this.db.query(sql).get(params ?? {}) as T | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
protected queryAll<T>(sql: string, params?: Record<string, unknown>): T[] {
|
|
46
|
+
return this.db.query(sql).all(params ?? {}) as T[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Schema version tracking: call from initSchema() to auto-migrate. */
|
|
50
|
+
protected migrateSchema(currentVersion: number, migrations: Array<{ version: number; sql: string }>): void {
|
|
51
|
+
this.exec(`CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER NOT NULL)`)
|
|
52
|
+
let row = this.queryOne<{ version: number }>(`SELECT version FROM _schema_version`)
|
|
53
|
+
let dbVersion = row?.version ?? 0
|
|
54
|
+
|
|
55
|
+
for (const m of migrations.sort((a, b) => a.version - b.version)) {
|
|
56
|
+
if (m.version > dbVersion) {
|
|
57
|
+
try {
|
|
58
|
+
this.db.query(m.sql).run()
|
|
59
|
+
} catch {
|
|
60
|
+
// Migration may fail if column already exists — fine for idempotent ADD COLUMN.
|
|
61
|
+
}
|
|
62
|
+
dbVersion = m.version
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (dbVersion !== (row?.version ?? 0)) {
|
|
67
|
+
this.exec(`DELETE FROM _schema_version`)
|
|
68
|
+
this.exec(`INSERT INTO _schema_version (version) VALUES ($v)`, { $v: dbVersion })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
close(): void {
|
|
73
|
+
this._db?.close()
|
|
74
|
+
this._db = null
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/git-hash.test.ts
CHANGED
|
@@ -11,6 +11,9 @@ beforeAll(async () => {
|
|
|
11
11
|
repoDir = mkdtempSync(join(tmpdir(), 'lythos-git-hash-test-'))
|
|
12
12
|
const git = simpleGit(repoDir)
|
|
13
13
|
await git.init(['--initial-branch=main'])
|
|
14
|
+
// CI runners often lack git identity; set local config so commit works.
|
|
15
|
+
await git.addConfig('user.name', 'test')
|
|
16
|
+
await git.addConfig('user.email', 'test@test.com')
|
|
14
17
|
writeFileSync(join(repoDir, 'SKILL.md'), '# Test Skill\n')
|
|
15
18
|
mkdirSync(join(repoDir, 'skills', 'pdf'), { recursive: true })
|
|
16
19
|
writeFileSync(join(repoDir, 'skills', 'pdf', 'SKILL.md'), '# PDF Skill\n> renders PDF\n')
|
package/src/index.ts
CHANGED
|
@@ -16,8 +16,10 @@ export type {
|
|
|
16
16
|
} from './types.js'
|
|
17
17
|
|
|
18
18
|
export { parseLocator, formatLocator } from './parse-locator.js'
|
|
19
|
-
export {
|
|
19
|
+
export type { DirEntry, ListPlan, ListPlanEntry } from './cold-pool.js'
|
|
20
|
+
export { ColdPool, DEFAULT_COLD_POOL_PATH, buildListPlan } from './cold-pool.js'
|
|
20
21
|
|
|
22
|
+
export { SqliteDb } from './db-helpers.js'
|
|
21
23
|
export type { RepoRef, SkillHash, DeckReference } from './metadata-db.js'
|
|
22
24
|
export { MetadataDB } from './metadata-db.js'
|
|
23
25
|
|
package/src/metadata-db.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Cold-pool metadata layer — SQLite-backed.
|
|
3
3
|
*
|
|
4
4
|
* Per ADR-20260507143241493: git-native hash, local-only trust, SQLite storage.
|
|
5
|
+
* Extends shared SqliteDb base for DRY SQLite wrappers + schema versioning.
|
|
5
6
|
*
|
|
6
7
|
* Three tables:
|
|
7
8
|
* repos — per-repo HEAD ref tracking
|
|
@@ -9,9 +10,7 @@
|
|
|
9
10
|
* deck_refs — cross-deck reference index
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
|
-
import {
|
|
13
|
-
import { mkdirSync } from 'node:fs'
|
|
14
|
-
import { dirname } from 'node:path'
|
|
13
|
+
import { SqliteDb } from './db-helpers.js'
|
|
15
14
|
|
|
16
15
|
export interface RepoRef {
|
|
17
16
|
host: string
|
|
@@ -37,32 +36,14 @@ export interface DeckReference {
|
|
|
37
36
|
declaredAlias: string | null
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
|
|
41
|
-
private dbPath: string
|
|
42
|
-
private _db: Database | null = null
|
|
39
|
+
const CURRENT_SCHEMA = 2
|
|
43
40
|
|
|
41
|
+
export class MetadataDB extends SqliteDb {
|
|
44
42
|
constructor(dbPath: string) {
|
|
45
|
-
|
|
43
|
+
super(dbPath)
|
|
46
44
|
}
|
|
47
45
|
|
|
48
|
-
|
|
49
|
-
* to be instantiated with read-only / non-existent paths (e.g. test
|
|
50
|
-
* fixtures) without failing on construction. */
|
|
51
|
-
private get db(): Database {
|
|
52
|
-
if (this._db == null) {
|
|
53
|
-
try {
|
|
54
|
-
mkdirSync(dirname(this.dbPath), { recursive: true })
|
|
55
|
-
} catch {
|
|
56
|
-
// Parent dir may be read-only (e.g. test paths like /cold).
|
|
57
|
-
// SQLite { create: true } will fail below with a clearer error.
|
|
58
|
-
}
|
|
59
|
-
this._db = new Database(this.dbPath, { create: true })
|
|
60
|
-
this.initSchema()
|
|
61
|
-
}
|
|
62
|
-
return this._db
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
private initSchema(): void {
|
|
46
|
+
protected initSchema(): void {
|
|
66
47
|
this.exec(`
|
|
67
48
|
CREATE TABLE IF NOT EXISTS repos (
|
|
68
49
|
host TEXT NOT NULL,
|
|
@@ -100,25 +81,15 @@ export class MetadataDB {
|
|
|
100
81
|
this.exec(`CREATE INDEX IF NOT EXISTS idx_deck_refs_deck ON deck_refs(deck_path)`)
|
|
101
82
|
this.exec(`CREATE INDEX IF NOT EXISTS idx_deck_refs_locator ON deck_refs(skill_locator)`)
|
|
102
83
|
this.exec(`CREATE INDEX IF NOT EXISTS idx_skills_repo ON skills(host, owner, repo)`)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// ── Small db util wrappers (no ORM, just DRY) ────────────────
|
|
106
|
-
|
|
107
|
-
/** Execute a statement that returns no rows. Auto-finalize. */
|
|
108
|
-
private exec(sql: string, params?: Record<string, unknown>): void {
|
|
109
|
-
const stmt = this.db.query(sql)
|
|
110
|
-
stmt.run(params ?? {})
|
|
111
|
-
stmt.finalize()
|
|
112
|
-
}
|
|
113
84
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
85
|
+
// Schema migrations
|
|
86
|
+
this.migrateSchema(CURRENT_SCHEMA, [
|
|
87
|
+
{ version: 1, sql: `CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER NOT NULL)` },
|
|
88
|
+
{
|
|
89
|
+
version: 2,
|
|
90
|
+
sql: `ALTER TABLE skills ADD COLUMN git_blob_hash TEXT`,
|
|
91
|
+
},
|
|
92
|
+
])
|
|
122
93
|
}
|
|
123
94
|
|
|
124
95
|
private now(): string {
|
|
@@ -228,9 +199,4 @@ export class MetadataDB {
|
|
|
228
199
|
insert.finalize()
|
|
229
200
|
})()
|
|
230
201
|
}
|
|
231
|
-
|
|
232
|
-
close(): void {
|
|
233
|
-
this._db?.close()
|
|
234
|
-
this._db = null
|
|
235
|
-
}
|
|
236
202
|
}
|