@principal-ai/principal-view-core 0.28.5 → 0.28.7
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/dist/auxiliary/AuxiliaryManifestValidator.d.ts +4 -1
- package/dist/auxiliary/AuxiliaryManifestValidator.d.ts.map +1 -1
- package/dist/auxiliary/AuxiliaryManifestValidator.js +12 -9
- package/dist/auxiliary/AuxiliaryManifestValidator.js.map +1 -1
- package/dist/events/EventsCanvasValidator.d.ts +3 -0
- package/dist/events/EventsCanvasValidator.d.ts.map +1 -1
- package/dist/events/EventsCanvasValidator.js +5 -4
- package/dist/events/EventsCanvasValidator.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +4 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +11 -2
- package/dist/node.js.map +1 -1
- package/dist/scopes/ScopesCanvasValidator.d.ts +3 -0
- package/dist/scopes/ScopesCanvasValidator.d.ts.map +1 -1
- package/dist/scopes/ScopesCanvasValidator.js +5 -4
- package/dist/scopes/ScopesCanvasValidator.js.map +1 -1
- package/dist/storage/topic-types.d.ts +189 -0
- package/dist/storage/topic-types.d.ts.map +1 -0
- package/dist/storage/topic-types.js +59 -0
- package/dist/storage/topic-types.js.map +1 -0
- package/dist/storage/topicStore.d.ts +130 -0
- package/dist/storage/topicStore.d.ts.map +1 -0
- package/dist/storage/topicStore.js +389 -0
- package/dist/storage/topicStore.js.map +1 -0
- package/package.json +1 -1
- package/src/auxiliary/AuxiliaryManifestValidator.test.ts +14 -13
- package/src/auxiliary/AuxiliaryManifestValidator.ts +12 -9
- package/src/browser-safety.test.ts +170 -0
- package/src/events/EventsCanvasValidator.test.ts +2 -1
- package/src/events/EventsCanvasValidator.ts +5 -4
- package/src/index.ts +13 -0
- package/src/node.ts +24 -0
- package/src/scopes/ScopesCanvasValidator.test.ts +6 -5
- package/src/scopes/ScopesCanvasValidator.ts +5 -4
- package/src/storage/topic-types.ts +220 -0
- package/src/storage/topicStore.test.ts +156 -0
- package/src/storage/topicStore.ts +481 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TopicStore — file-per-topic CRUD, index rebuild, and legacy-blob migration.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from 'fs';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { TopicStore } from './topicStore';
|
|
10
|
+
import type { DraftTopic } from './topic-types';
|
|
11
|
+
|
|
12
|
+
let dir: string;
|
|
13
|
+
let topicsDir: string;
|
|
14
|
+
let legacyBlob: string;
|
|
15
|
+
|
|
16
|
+
const makeStore = () => new TopicStore(topicsDir, legacyBlob);
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
dir = mkdtempSync(join(tmpdir(), 'topicstore-'));
|
|
20
|
+
topicsDir = join(dir, 'topics');
|
|
21
|
+
legacyBlob = join(dir, 'topics.json');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('CRUD', () => {
|
|
29
|
+
test('create generates an id, persists a file, and reads back', async () => {
|
|
30
|
+
const store = makeStore();
|
|
31
|
+
const created = await store.createTopic({ title: 'Auth flow', trailIds: [] });
|
|
32
|
+
expect(created.id).toMatch(/^topic-/);
|
|
33
|
+
expect(existsSync(join(topicsDir, `${created.id}.json`))).toBe(true);
|
|
34
|
+
|
|
35
|
+
const got = await store.getTopic(created.id);
|
|
36
|
+
expect(got?.title).toBe('Auth flow');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('create honors an explicit id and rejects duplicates', async () => {
|
|
40
|
+
const store = makeStore();
|
|
41
|
+
await store.createTopic({ id: 'topic-x', title: 'A', trailIds: [] });
|
|
42
|
+
await expect(
|
|
43
|
+
store.createTopic({ id: 'topic-x', title: 'B', trailIds: [] }),
|
|
44
|
+
).rejects.toThrow(/already exists/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('update changes fields but never id/createdAt/trailIds', async () => {
|
|
48
|
+
const store = makeStore();
|
|
49
|
+
const t = await store.createTopic({ id: 'topic-u', title: 'A', trailIds: ['t1'] });
|
|
50
|
+
const updated = await store.updateTopic('topic-u', { title: 'B', description: 'hi' });
|
|
51
|
+
expect(updated.title).toBe('B');
|
|
52
|
+
expect(updated.description).toBe('hi');
|
|
53
|
+
expect(updated.createdAt).toBe(t.createdAt);
|
|
54
|
+
expect(updated.trailIds).toEqual(['t1']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('trail membership add/remove/reorder', async () => {
|
|
58
|
+
const store = makeStore();
|
|
59
|
+
await store.createTopic({ id: 'topic-m', title: 'M', trailIds: [] });
|
|
60
|
+
await store.addTrailToTopic('topic-m', 'a');
|
|
61
|
+
await store.addTrailToTopic('topic-m', 'b');
|
|
62
|
+
await store.addTrailToTopic('topic-m', 'a'); // dup no-op
|
|
63
|
+
expect((await store.getTopic('topic-m'))?.trailIds).toEqual(['a', 'b']);
|
|
64
|
+
|
|
65
|
+
await store.reorderTopicTrails('topic-m', ['b', 'a']);
|
|
66
|
+
expect((await store.getTopic('topic-m'))?.trailIds).toEqual(['b', 'a']);
|
|
67
|
+
|
|
68
|
+
await expect(
|
|
69
|
+
store.reorderTopicTrails('topic-m', ['b', 'c']),
|
|
70
|
+
).rejects.toThrow(/permutation/);
|
|
71
|
+
|
|
72
|
+
await store.removeTrailFromTopic('topic-m', 'b');
|
|
73
|
+
expect((await store.getTopic('topic-m'))?.trailIds).toEqual(['a']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('delete removes the file and the index entry', async () => {
|
|
77
|
+
const store = makeStore();
|
|
78
|
+
await store.createTopic({ id: 'topic-d', title: 'D', trailIds: [] });
|
|
79
|
+
expect(await store.deleteTopic('topic-d')).toBe(true);
|
|
80
|
+
expect(existsSync(join(topicsDir, 'topic-d.json'))).toBe(false);
|
|
81
|
+
expect(await store.getTopic('topic-d')).toBeNull();
|
|
82
|
+
expect(await store.deleteTopic('topic-d')).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('no eviction cap — many topics all survive', async () => {
|
|
86
|
+
const store = makeStore();
|
|
87
|
+
for (let i = 0; i < 120; i++) {
|
|
88
|
+
await store.createTopic({ id: `topic-${i}`, title: `T${i}`, trailIds: [] });
|
|
89
|
+
}
|
|
90
|
+
expect((await store.getTopics()).length).toBe(120);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('index rebuild', () => {
|
|
95
|
+
test('a corrupt _index.json is rebuilt by scanning topic files', async () => {
|
|
96
|
+
const store = makeStore();
|
|
97
|
+
await store.createTopic({ id: 'topic-r', title: 'R', trailIds: ['z'] });
|
|
98
|
+
|
|
99
|
+
// Corrupt the manifest and force a fresh store to rebuild from disk.
|
|
100
|
+
writeFileSync(join(topicsDir, '_index.json'), 'not json', 'utf8');
|
|
101
|
+
const fresh = makeStore();
|
|
102
|
+
const list = await fresh.list();
|
|
103
|
+
expect(list.map((e) => e.id)).toContain('topic-r');
|
|
104
|
+
expect(list.find((e) => e.id === 'topic-r')?.trailCount).toBe(1);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('migration', () => {
|
|
109
|
+
const seedLegacy = (topics: Partial<DraftTopic>[]) => {
|
|
110
|
+
writeFileSync(
|
|
111
|
+
legacyBlob,
|
|
112
|
+
JSON.stringify({ version: '1.0.0', topics }, null, 2),
|
|
113
|
+
'utf8',
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
test('fans the blob out to file-per-topic and backs up the blob', async () => {
|
|
118
|
+
seedLegacy([
|
|
119
|
+
{ id: 'topic-1', title: 'One', trailIds: ['a'], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z' },
|
|
120
|
+
{ id: 'topic-2', title: 'Two', trailIds: [] },
|
|
121
|
+
]);
|
|
122
|
+
const store = makeStore();
|
|
123
|
+
const result = await store.migrateFromLegacyBlob();
|
|
124
|
+
|
|
125
|
+
expect(result.migrated).toBe(2);
|
|
126
|
+
expect(result.noLegacyBlob).toBe(false);
|
|
127
|
+
expect(result.backupPath).toBe(`${legacyBlob}.bak`);
|
|
128
|
+
expect(existsSync(legacyBlob)).toBe(false);
|
|
129
|
+
expect(existsSync(`${legacyBlob}.bak`)).toBe(true);
|
|
130
|
+
|
|
131
|
+
const one = await store.getTopic('topic-1');
|
|
132
|
+
expect(one?.title).toBe('One');
|
|
133
|
+
expect(one?.createdAt).toBe('2026-01-01T00:00:00.000Z');
|
|
134
|
+
expect(existsSync(join(topicsDir, 'topic-1.json'))).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('skips legacy entries with no id', async () => {
|
|
138
|
+
seedLegacy([{ title: 'no id' }, { id: 'topic-ok', title: 'ok', trailIds: [] }]);
|
|
139
|
+
const result = await makeStore().migrateFromLegacyBlob();
|
|
140
|
+
expect(result.migrated).toBe(1);
|
|
141
|
+
expect(result.skipped).toBe(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('reports noLegacyBlob when there is nothing to migrate', async () => {
|
|
145
|
+
const result = await makeStore().migrateFromLegacyBlob();
|
|
146
|
+
expect(result.noLegacyBlob).toBe(true);
|
|
147
|
+
expect(result.migrated).toBe(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('migrated files are greppable pretty-printed JSON', async () => {
|
|
151
|
+
seedLegacy([{ id: 'topic-g', title: 'Grep me', trailIds: [] }]);
|
|
152
|
+
await makeStore().migrateFromLegacyBlob();
|
|
153
|
+
const raw = readFileSync(join(topicsDir, 'topic-g.json'), 'utf8');
|
|
154
|
+
expect(raw).toContain('\n "title": "Grep me"');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-per-topic store under `~/.principal/topics/`.
|
|
3
|
+
*
|
|
4
|
+
* Layout (mirrors the trail store at `~/.principal/trails/`, so topics become
|
|
5
|
+
* locally greppable and an agent can read one directly):
|
|
6
|
+
*
|
|
7
|
+
* ~/.principal/topics/
|
|
8
|
+
* _index.json private, rebuildable manifest (entries[])
|
|
9
|
+
* <id>.json one pretty-printed DraftTopic per file
|
|
10
|
+
*
|
|
11
|
+
* Topics are the *least* repo-bound artifact (a bundle of trails spanning
|
|
12
|
+
* repos), so — unlike trails, which bucket by repo Purl — they are stored flat
|
|
13
|
+
* by id. No purl, no buckets, no `node:os`/Purl dependency beyond `homedir()`.
|
|
14
|
+
*
|
|
15
|
+
* The `_index.json` manifest is store-private and rebuildable: it is rebuilt by
|
|
16
|
+
* scanning the directory whenever it is missing or unparseable. It exists only
|
|
17
|
+
* to make `getTopics()` / `list()` cheap (no per-file read for listing).
|
|
18
|
+
*
|
|
19
|
+
* There is intentionally NO eviction cap. The trail store caps trails per repo
|
|
20
|
+
* (`PER_REPO_CAP`); topics are few, user-curated, and must never be silently
|
|
21
|
+
* dropped, so the cap is deliberately not carried over.
|
|
22
|
+
*
|
|
23
|
+
* Migration from the legacy single-blob `~/.alexandria/topics.json` is explicit
|
|
24
|
+
* (`migrateFromLegacyBlob`) — it is never run automatically on load; the desktop
|
|
25
|
+
* triggers it from a Settings action.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { dirname, join } from 'node:path';
|
|
30
|
+
import {
|
|
31
|
+
mkdir,
|
|
32
|
+
readFile,
|
|
33
|
+
writeFile,
|
|
34
|
+
unlink,
|
|
35
|
+
readdir,
|
|
36
|
+
rename,
|
|
37
|
+
access,
|
|
38
|
+
} from 'node:fs/promises';
|
|
39
|
+
import type { DraftTopic, TopicStatus } from './topic-types';
|
|
40
|
+
|
|
41
|
+
/** Root of the global, cross-repo principal narration store. */
|
|
42
|
+
export const PRINCIPAL_DIR = join(homedir(), '.principal');
|
|
43
|
+
/** File-per-topic directory. */
|
|
44
|
+
export const TOPICS_DIR = join(PRINCIPAL_DIR, 'topics');
|
|
45
|
+
/** Legacy single-blob path the migration reads from. */
|
|
46
|
+
export const LEGACY_TOPICS_BLOB = join(homedir(), '.alexandria', 'topics.json');
|
|
47
|
+
|
|
48
|
+
const INDEX_FILENAME = '_index.json';
|
|
49
|
+
const DESCRIPTION_PREVIEW_MAX = 200;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Cheap-to-list projection of a topic, held in the private manifest so listing
|
|
53
|
+
* never reads every file. Everything here is derivable from the topic file, so
|
|
54
|
+
* the manifest can always be rebuilt by scanning.
|
|
55
|
+
*/
|
|
56
|
+
export interface TopicIndexEntry {
|
|
57
|
+
id: string;
|
|
58
|
+
title: string;
|
|
59
|
+
descriptionPreview: string;
|
|
60
|
+
trailCount: number;
|
|
61
|
+
/** `status.state` lifted out for filtering/sorting without a file read. */
|
|
62
|
+
state?: TopicStatus['state'];
|
|
63
|
+
createdAt: string;
|
|
64
|
+
updatedAt: string;
|
|
65
|
+
createdBy?: { githubId: number; githubLogin: string };
|
|
66
|
+
hasAssets: boolean;
|
|
67
|
+
sizeBytes: number;
|
|
68
|
+
/** File name relative to `TOPICS_DIR` (e.g. `topic-123.json`). */
|
|
69
|
+
fileName: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface IndexFileV1 {
|
|
73
|
+
version: 1;
|
|
74
|
+
entries: TopicIndexEntry[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const emptyIndex = (): IndexFileV1 => ({ version: 1, entries: [] });
|
|
78
|
+
|
|
79
|
+
const sanitizeSegment = (value: string): string =>
|
|
80
|
+
value.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
81
|
+
|
|
82
|
+
const nowIso = (): string => new Date().toISOString();
|
|
83
|
+
|
|
84
|
+
/** Locally-unique topic id, matching the legacy `topic-<ts>-<rand>` shape. */
|
|
85
|
+
const generateTopicId = (): string =>
|
|
86
|
+
`topic-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
87
|
+
|
|
88
|
+
const descriptionPreview = (description?: string): string => {
|
|
89
|
+
if (!description) return '';
|
|
90
|
+
const trimmed = description.trim();
|
|
91
|
+
if (trimmed.length <= DESCRIPTION_PREVIEW_MAX) return trimmed;
|
|
92
|
+
return `${trimmed.slice(0, DESCRIPTION_PREVIEW_MAX - 1)}…`;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const buildEntry = (topic: DraftTopic, sizeBytes: number): TopicIndexEntry => ({
|
|
96
|
+
id: topic.id,
|
|
97
|
+
title: topic.title || 'Untitled topic',
|
|
98
|
+
descriptionPreview: descriptionPreview(topic.description),
|
|
99
|
+
trailCount: topic.trailIds.length,
|
|
100
|
+
state: topic.status?.state,
|
|
101
|
+
createdAt: topic.createdAt,
|
|
102
|
+
updatedAt: topic.updatedAt,
|
|
103
|
+
createdBy: topic.createdBy,
|
|
104
|
+
hasAssets: (topic.assets?.length ?? 0) > 0,
|
|
105
|
+
sizeBytes,
|
|
106
|
+
fileName: `${sanitizeSegment(topic.id)}.json`,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** Shape the legacy `~/.alexandria/topics.json` blob is parsed as. */
|
|
110
|
+
interface LegacyTopicsBlob {
|
|
111
|
+
version?: string;
|
|
112
|
+
topics?: DraftTopic[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface MigrationResult {
|
|
116
|
+
/** Number of topics written to the file-per-topic store. */
|
|
117
|
+
migrated: number;
|
|
118
|
+
/** Number of legacy topics skipped (missing id, write failure). */
|
|
119
|
+
skipped: number;
|
|
120
|
+
/** Absolute path of the `.bak` the legacy blob was renamed to, if any. */
|
|
121
|
+
backupPath?: string;
|
|
122
|
+
/** True when there was no legacy blob to migrate. */
|
|
123
|
+
noLegacyBlob: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Fields a caller may set when updating a topic. `id`, `createdAt`, and
|
|
128
|
+
* `trailIds` are not updatable here — use the trail-membership methods for
|
|
129
|
+
* `trailIds`, and `id`/`createdAt` are immutable.
|
|
130
|
+
*/
|
|
131
|
+
export type TopicUpdate = Partial<
|
|
132
|
+
Pick<DraftTopic, 'title' | 'description' | 'status' | 'createdBy' | 'assets'>
|
|
133
|
+
>;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Fields a caller may set when creating a topic. `id`, `createdAt`,
|
|
137
|
+
* `updatedAt` are filled in when omitted; passing `id` lets a caller control
|
|
138
|
+
* it (e.g. round-tripping a server id).
|
|
139
|
+
*/
|
|
140
|
+
export type TopicCreate = Omit<DraftTopic, 'createdAt' | 'updatedAt'> & {
|
|
141
|
+
id?: string;
|
|
142
|
+
createdAt?: string;
|
|
143
|
+
updatedAt?: string;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export class TopicStore {
|
|
147
|
+
private readonly baseDir: string;
|
|
148
|
+
private readonly indexPath: string;
|
|
149
|
+
private readonly legacyBlobPath: string;
|
|
150
|
+
private index: IndexFileV1 | null = null;
|
|
151
|
+
private writeQueue: Promise<void> = Promise.resolve();
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @param baseDir file-per-topic directory (defaults to `TOPICS_DIR`)
|
|
155
|
+
* @param legacyBlobPath legacy blob the migration reads (defaults to
|
|
156
|
+
* `LEGACY_TOPICS_BLOB`); injectable for tests.
|
|
157
|
+
*/
|
|
158
|
+
constructor(baseDir: string = TOPICS_DIR, legacyBlobPath: string = LEGACY_TOPICS_BLOB) {
|
|
159
|
+
this.baseDir = baseDir;
|
|
160
|
+
this.indexPath = join(baseDir, INDEX_FILENAME);
|
|
161
|
+
this.legacyBlobPath = legacyBlobPath;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ===== Topic CRUD =====
|
|
165
|
+
|
|
166
|
+
async getTopics(): Promise<DraftTopic[]> {
|
|
167
|
+
const idx = await this.getIndex();
|
|
168
|
+
const topics: DraftTopic[] = [];
|
|
169
|
+
for (const entry of idx.entries) {
|
|
170
|
+
const topic = await this.readTopic(entry.fileName);
|
|
171
|
+
if (topic) topics.push(topic);
|
|
172
|
+
}
|
|
173
|
+
return topics;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async getTopic(id: string): Promise<DraftTopic | null> {
|
|
177
|
+
const idx = await this.getIndex();
|
|
178
|
+
const entry = idx.entries.find((e) => e.id === id);
|
|
179
|
+
if (!entry) return null;
|
|
180
|
+
return this.readTopic(entry.fileName);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Cheap listing straight off the manifest — no per-topic file read. */
|
|
184
|
+
async list(): Promise<TopicIndexEntry[]> {
|
|
185
|
+
const idx = await this.getIndex();
|
|
186
|
+
return idx.entries
|
|
187
|
+
.slice()
|
|
188
|
+
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async createTopic(input: TopicCreate): Promise<DraftTopic> {
|
|
192
|
+
const idx = await this.getIndex();
|
|
193
|
+
const now = nowIso();
|
|
194
|
+
const id = input.id?.trim() || generateTopicId();
|
|
195
|
+
if (idx.entries.some((e) => e.id === id)) {
|
|
196
|
+
throw new Error(`Topic with id '${id}' already exists`);
|
|
197
|
+
}
|
|
198
|
+
const topic: DraftTopic = {
|
|
199
|
+
...input,
|
|
200
|
+
id,
|
|
201
|
+
title: input.title || 'Untitled topic',
|
|
202
|
+
trailIds: input.trailIds ?? [],
|
|
203
|
+
createdAt: input.createdAt ?? now,
|
|
204
|
+
updatedAt: input.updatedAt ?? now,
|
|
205
|
+
};
|
|
206
|
+
await this.writeTopic(topic, idx);
|
|
207
|
+
return topic;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async updateTopic(id: string, updates: TopicUpdate): Promise<DraftTopic> {
|
|
211
|
+
const idx = await this.getIndex();
|
|
212
|
+
const existing = await this.requireTopic(idx, id);
|
|
213
|
+
const next: DraftTopic = {
|
|
214
|
+
...existing,
|
|
215
|
+
...updates,
|
|
216
|
+
id: existing.id,
|
|
217
|
+
trailIds: existing.trailIds,
|
|
218
|
+
createdAt: existing.createdAt,
|
|
219
|
+
updatedAt: nowIso(),
|
|
220
|
+
};
|
|
221
|
+
await this.writeTopic(next, idx);
|
|
222
|
+
return next;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async deleteTopic(id: string): Promise<boolean> {
|
|
226
|
+
const idx = await this.getIndex();
|
|
227
|
+
const entryIdx = idx.entries.findIndex((e) => e.id === id);
|
|
228
|
+
if (entryIdx < 0) return false;
|
|
229
|
+
const { fileName } = idx.entries[entryIdx];
|
|
230
|
+
idx.entries.splice(entryIdx, 1);
|
|
231
|
+
await this.unlinkRelative(fileName);
|
|
232
|
+
await this.persistIndex();
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ===== Trail membership =====
|
|
237
|
+
|
|
238
|
+
async addTrailToTopic(topicId: string, trailId: string): Promise<DraftTopic> {
|
|
239
|
+
const idx = await this.getIndex();
|
|
240
|
+
const topic = await this.requireTopic(idx, topicId);
|
|
241
|
+
if (topic.trailIds.includes(trailId)) return topic;
|
|
242
|
+
const next: DraftTopic = {
|
|
243
|
+
...topic,
|
|
244
|
+
trailIds: [...topic.trailIds, trailId],
|
|
245
|
+
updatedAt: nowIso(),
|
|
246
|
+
};
|
|
247
|
+
await this.writeTopic(next, idx);
|
|
248
|
+
return next;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async removeTrailFromTopic(topicId: string, trailId: string): Promise<DraftTopic> {
|
|
252
|
+
const idx = await this.getIndex();
|
|
253
|
+
const topic = await this.requireTopic(idx, topicId);
|
|
254
|
+
if (!topic.trailIds.includes(trailId)) return topic;
|
|
255
|
+
const next: DraftTopic = {
|
|
256
|
+
...topic,
|
|
257
|
+
trailIds: topic.trailIds.filter((t) => t !== trailId),
|
|
258
|
+
updatedAt: nowIso(),
|
|
259
|
+
};
|
|
260
|
+
await this.writeTopic(next, idx);
|
|
261
|
+
return next;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async reorderTopicTrails(topicId: string, trailIds: string[]): Promise<DraftTopic> {
|
|
265
|
+
const idx = await this.getIndex();
|
|
266
|
+
const topic = await this.requireTopic(idx, topicId);
|
|
267
|
+
const current = new Set(topic.trailIds);
|
|
268
|
+
const next = new Set(trailIds);
|
|
269
|
+
if (current.size !== next.size || [...current].some((t) => !next.has(t))) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
'reorderTopicTrails expects a permutation of the existing trail list',
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const updated: DraftTopic = {
|
|
275
|
+
...topic,
|
|
276
|
+
trailIds: [...trailIds],
|
|
277
|
+
updatedAt: nowIso(),
|
|
278
|
+
};
|
|
279
|
+
await this.writeTopic(updated, idx);
|
|
280
|
+
return updated;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async getTopicsForTrail(trailId: string): Promise<DraftTopic[]> {
|
|
284
|
+
const topics = await this.getTopics();
|
|
285
|
+
return topics.filter((t) => t.trailIds.includes(trailId));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ===== Migration =====
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* One-shot migration from the legacy single-blob `~/.alexandria/topics.json`
|
|
292
|
+
* to file-per-topic. Reads the blob's `topics[]`, writes each as
|
|
293
|
+
* `<id>.json`, rebuilds the index, then renames the blob to `<blob>.bak` so
|
|
294
|
+
* a re-run is a no-op (mirrors the trail store's `migrateLegacyIfPresent`).
|
|
295
|
+
*
|
|
296
|
+
* Explicit by design — the desktop calls this from a Settings action, never
|
|
297
|
+
* on load. Idempotent: once the blob is `.bak`'d, subsequent calls report
|
|
298
|
+
* `noLegacyBlob`. An existing topic id is overwritten (the blob wins on a
|
|
299
|
+
* first migration), so re-importing after edits is the caller's decision.
|
|
300
|
+
*/
|
|
301
|
+
async migrateFromLegacyBlob(): Promise<MigrationResult> {
|
|
302
|
+
let raw: string;
|
|
303
|
+
try {
|
|
304
|
+
raw = await readFile(this.legacyBlobPath, 'utf8');
|
|
305
|
+
} catch {
|
|
306
|
+
return { migrated: 0, skipped: 0, noLegacyBlob: true };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
let blob: LegacyTopicsBlob;
|
|
310
|
+
try {
|
|
311
|
+
blob = JSON.parse(raw) as LegacyTopicsBlob;
|
|
312
|
+
} catch {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`Legacy topics blob at ${this.legacyBlobPath} is not valid JSON; not migrating.`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const idx = await this.getIndex();
|
|
319
|
+
let migrated = 0;
|
|
320
|
+
let skipped = 0;
|
|
321
|
+
for (const legacy of blob.topics ?? []) {
|
|
322
|
+
if (!legacy?.id) {
|
|
323
|
+
skipped++;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const now = nowIso();
|
|
327
|
+
const topic: DraftTopic = {
|
|
328
|
+
...legacy,
|
|
329
|
+
title: legacy.title || 'Untitled topic',
|
|
330
|
+
trailIds: legacy.trailIds ?? [],
|
|
331
|
+
createdAt: legacy.createdAt ?? now,
|
|
332
|
+
updatedAt: legacy.updatedAt ?? now,
|
|
333
|
+
};
|
|
334
|
+
try {
|
|
335
|
+
await this.writeTopic(topic, idx);
|
|
336
|
+
migrated++;
|
|
337
|
+
} catch {
|
|
338
|
+
skipped++;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const backupPath = `${this.legacyBlobPath}.bak`;
|
|
343
|
+
let backedUp: string | undefined;
|
|
344
|
+
if (!(await this.pathExists(backupPath))) {
|
|
345
|
+
try {
|
|
346
|
+
await rename(this.legacyBlobPath, backupPath);
|
|
347
|
+
backedUp = backupPath;
|
|
348
|
+
} catch {
|
|
349
|
+
// Non-fatal: topics are migrated; the blob just wasn't renamed.
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
migrated,
|
|
355
|
+
skipped,
|
|
356
|
+
noLegacyBlob: false,
|
|
357
|
+
...(backedUp ? { backupPath: backedUp } : {}),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ===== Internals =====
|
|
362
|
+
|
|
363
|
+
private async getIndex(): Promise<IndexFileV1> {
|
|
364
|
+
if (!this.index) {
|
|
365
|
+
await mkdir(this.baseDir, { recursive: true });
|
|
366
|
+
this.index = await this.loadIndex();
|
|
367
|
+
}
|
|
368
|
+
return this.index;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private async requireTopic(idx: IndexFileV1, id: string): Promise<DraftTopic> {
|
|
372
|
+
const entry = idx.entries.find((e) => e.id === id);
|
|
373
|
+
if (!entry) throw new Error(`Topic with id '${id}' not found`);
|
|
374
|
+
const topic = await this.readTopic(entry.fileName);
|
|
375
|
+
if (!topic) throw new Error(`Topic with id '${id}' not found`);
|
|
376
|
+
return topic;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Write a topic file and upsert its index entry. Does not generate ids. */
|
|
380
|
+
private async writeTopic(topic: DraftTopic, idx: IndexFileV1): Promise<void> {
|
|
381
|
+
const fileName = `${sanitizeSegment(topic.id)}.json`;
|
|
382
|
+
const file = join(this.baseDir, fileName);
|
|
383
|
+
await mkdir(dirname(file), { recursive: true });
|
|
384
|
+
const serialized = JSON.stringify(topic, null, 2);
|
|
385
|
+
await writeFile(file, serialized, 'utf8');
|
|
386
|
+
const entry = buildEntry(topic, Buffer.byteLength(serialized, 'utf8'));
|
|
387
|
+
const at = idx.entries.findIndex((e) => e.id === topic.id);
|
|
388
|
+
if (at >= 0) idx.entries[at] = entry;
|
|
389
|
+
else idx.entries.push(entry);
|
|
390
|
+
await this.persistIndex();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private async readTopic(fileName: string): Promise<DraftTopic | null> {
|
|
394
|
+
try {
|
|
395
|
+
const raw = await readFile(join(this.baseDir, fileName), 'utf8');
|
|
396
|
+
return JSON.parse(raw) as DraftTopic;
|
|
397
|
+
} catch (err) {
|
|
398
|
+
console.error('[TopicStore] Failed to read topic', fileName, err);
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private async unlinkRelative(fileName: string): Promise<void> {
|
|
404
|
+
try {
|
|
405
|
+
await unlink(join(this.baseDir, fileName));
|
|
406
|
+
} catch (err) {
|
|
407
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
408
|
+
if (code !== 'ENOENT') {
|
|
409
|
+
console.error('[TopicStore] Failed to unlink', fileName, err);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private async pathExists(path: string): Promise<boolean> {
|
|
415
|
+
try {
|
|
416
|
+
await access(path);
|
|
417
|
+
return true;
|
|
418
|
+
} catch {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private async loadIndex(): Promise<IndexFileV1> {
|
|
424
|
+
try {
|
|
425
|
+
const raw = await readFile(this.indexPath, 'utf8');
|
|
426
|
+
const parsed = JSON.parse(raw) as Partial<IndexFileV1>;
|
|
427
|
+
if (parsed?.version === 1 && Array.isArray(parsed.entries)) {
|
|
428
|
+
return { version: 1, entries: parsed.entries as TopicIndexEntry[] };
|
|
429
|
+
}
|
|
430
|
+
} catch (err) {
|
|
431
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
432
|
+
if (code !== 'ENOENT') {
|
|
433
|
+
console.warn('[TopicStore] _index.json unreadable, rebuilding', err);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// No auto-migration here: the legacy blob is imported only via the
|
|
437
|
+
// explicit migrateFromLegacyBlob() Settings action.
|
|
438
|
+
return this.rebuildIndex();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Rebuild the manifest by scanning every `<id>.json` in the directory. */
|
|
442
|
+
private async rebuildIndex(): Promise<IndexFileV1> {
|
|
443
|
+
const idx = emptyIndex();
|
|
444
|
+
let names: string[];
|
|
445
|
+
try {
|
|
446
|
+
names = await readdir(this.baseDir);
|
|
447
|
+
} catch {
|
|
448
|
+
return idx;
|
|
449
|
+
}
|
|
450
|
+
for (const name of names) {
|
|
451
|
+
if (name === INDEX_FILENAME || !name.endsWith('.json')) continue;
|
|
452
|
+
const topic = await this.readTopic(name);
|
|
453
|
+
if (!topic?.id) continue;
|
|
454
|
+
const createdAt = topic.createdAt ?? nowIso();
|
|
455
|
+
const stamped: DraftTopic = {
|
|
456
|
+
...topic,
|
|
457
|
+
createdAt,
|
|
458
|
+
updatedAt: topic.updatedAt ?? createdAt,
|
|
459
|
+
title: topic.title || 'Untitled topic',
|
|
460
|
+
trailIds: topic.trailIds ?? [],
|
|
461
|
+
};
|
|
462
|
+
const raw = JSON.stringify(stamped, null, 2);
|
|
463
|
+
idx.entries.push(buildEntry(stamped, Buffer.byteLength(raw, 'utf8')));
|
|
464
|
+
}
|
|
465
|
+
this.index = idx;
|
|
466
|
+
await this.persistIndex();
|
|
467
|
+
return idx;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private persistIndex(): Promise<void> {
|
|
471
|
+
if (!this.index) return Promise.resolve();
|
|
472
|
+
const snapshot = this.index;
|
|
473
|
+
this.writeQueue = this.writeQueue
|
|
474
|
+
.catch(() => undefined)
|
|
475
|
+
.then(async () => {
|
|
476
|
+
await mkdir(this.baseDir, { recursive: true });
|
|
477
|
+
await writeFile(this.indexPath, JSON.stringify(snapshot, null, 2), 'utf8');
|
|
478
|
+
});
|
|
479
|
+
return this.writeQueue;
|
|
480
|
+
}
|
|
481
|
+
}
|