@principal-ai/subsystems-core 0.48.1 → 0.49.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/dist/storage/topic-types.d.ts +10 -19
- package/dist/storage/topic-types.d.ts.map +1 -1
- package/dist/storage/topic-types.js +9 -12
- package/dist/storage/topic-types.js.map +1 -1
- package/dist/storage/topicStore.d.ts +2 -5
- package/dist/storage/topicStore.d.ts.map +1 -1
- package/dist/storage/topicStore.js +43 -19
- package/dist/storage/topicStore.js.map +1 -1
- package/dist/types/subsystem-model.d.ts +35 -10
- package/dist/types/subsystem-model.d.ts.map +1 -1
- package/dist/types/subsystem-model.js.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +23 -0
- package/dist/validation.js.map +1 -1
- package/package.json +1 -1
- package/schemas/subsystem-model.schema.json +19 -27
- package/src/storage/topic-types.ts +13 -31
- package/src/storage/topicStore.test.ts +60 -24
- package/src/storage/topicStore.ts +60 -47
- package/src/types/subsystem-model.test.ts +12 -2
- package/src/types/subsystem-model.ts +35 -10
- package/src/validation.test.ts +67 -4
- package/src/validation.ts +34 -0
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"trails": {
|
|
34
34
|
"type": "array",
|
|
35
|
-
"description": "Ordered runtime trails (one per named behavior). Each step names from/to/mechanism and the concrete file
|
|
35
|
+
"description": "Ordered runtime trails (one per named behavior). Each step names from/to/mechanism and the concrete file where that seam fires; the seam's source span is captured by verification.",
|
|
36
36
|
"items": {
|
|
37
37
|
"$ref": "#/$defs/trail"
|
|
38
38
|
}
|
|
@@ -231,9 +231,9 @@
|
|
|
231
231
|
"$ref": "#/$defs/declToken"
|
|
232
232
|
}
|
|
233
233
|
},
|
|
234
|
-
"
|
|
235
|
-
"$ref": "#/$defs/
|
|
236
|
-
"description": "
|
|
234
|
+
"declarationSpan": {
|
|
235
|
+
"$ref": "#/$defs/sourceSpan",
|
|
236
|
+
"description": "The construct's source span (start/end/content hash) — its anchored identity. Usually populated by verify tooling, not hand-authored. Distinct from `declaration`, which is the structured shape."
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
239
|
},
|
|
@@ -244,7 +244,6 @@
|
|
|
244
244
|
"to",
|
|
245
245
|
"mechanism",
|
|
246
246
|
"file",
|
|
247
|
-
"line",
|
|
248
247
|
"purl",
|
|
249
248
|
"symbol"
|
|
250
249
|
],
|
|
@@ -269,10 +268,9 @@
|
|
|
269
268
|
"pattern": "^(?!.*(^|/)node_modules(/|$)).*$",
|
|
270
269
|
"description": "Repo-root-relative path where the seam fires for this trail."
|
|
271
270
|
},
|
|
272
|
-
"
|
|
273
|
-
"
|
|
274
|
-
"
|
|
275
|
-
"description": "1-based line of the site within `file`."
|
|
271
|
+
"span": {
|
|
272
|
+
"$ref": "#/$defs/sourceSpan",
|
|
273
|
+
"description": "The seam's source span — the call-site range and its content hash. The trail-step counterpart of a component's declarationSpan; captured by verify tooling. `file` + `symbol` locate the frame."
|
|
276
274
|
},
|
|
277
275
|
"purl": {
|
|
278
276
|
"type": "string",
|
|
@@ -352,36 +350,30 @@
|
|
|
352
350
|
}
|
|
353
351
|
}
|
|
354
352
|
},
|
|
355
|
-
"
|
|
353
|
+
"sourceSpan": {
|
|
356
354
|
"type": "object",
|
|
355
|
+
"description": "A content-addressed range of source — the model's identity primitive. startLine/endLine are 1-based inclusive lines within the owner's `file`; contentHash is the hash of the normalized content across that range. `file` / `purl` live on the owner, so a span is a pure range + fingerprint.",
|
|
357
356
|
"required": [
|
|
358
|
-
"file",
|
|
359
357
|
"startLine",
|
|
360
|
-
"
|
|
361
|
-
"
|
|
358
|
+
"endLine",
|
|
359
|
+
"contentHash"
|
|
362
360
|
],
|
|
363
361
|
"additionalProperties": false,
|
|
364
362
|
"properties": {
|
|
365
|
-
"file": {
|
|
366
|
-
"type": "string",
|
|
367
|
-
"description": "Repo-root-relative path (matches component.file)."
|
|
368
|
-
},
|
|
369
363
|
"startLine": {
|
|
370
364
|
"type": "integer",
|
|
371
365
|
"minimum": 1,
|
|
372
|
-
"description": "1-based
|
|
366
|
+
"description": "1-based first line of the construct/seam within the owner's file."
|
|
373
367
|
},
|
|
374
|
-
"
|
|
375
|
-
"type": "
|
|
376
|
-
"
|
|
377
|
-
|
|
378
|
-
"graphifyNodeId": {
|
|
379
|
-
"type": "string",
|
|
380
|
-
"description": "Graphify node id when captured from an exact anchor."
|
|
368
|
+
"endLine": {
|
|
369
|
+
"type": "integer",
|
|
370
|
+
"minimum": 1,
|
|
371
|
+
"description": "1-based last line, inclusive. Always >= startLine (cross-field rule enforced by validation)."
|
|
381
372
|
},
|
|
382
|
-
"
|
|
373
|
+
"contentHash": {
|
|
383
374
|
"type": "string",
|
|
384
|
-
"
|
|
375
|
+
"minLength": 1,
|
|
376
|
+
"description": "SHA-256 of the normalized content across [startLine, endLine], truncated to 32 hex chars. The span's identity."
|
|
385
377
|
}
|
|
386
378
|
}
|
|
387
379
|
},
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* agrees on. Browser-safe (no `node:*`); the file-per-topic store that reads and
|
|
4
4
|
* writes these lives in `./node` (see `topicStore.ts`).
|
|
5
5
|
*
|
|
6
|
-
* A **topic** is a
|
|
7
|
-
* shapes, split by id-namespace / ownership, with an explicit
|
|
8
|
-
* between them — NOT one shared shape.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* A **topic** is a locally authored subject brief that may be published. Two
|
|
7
|
+
* distinct named shapes, split by id-namespace / ownership, with an explicit
|
|
8
|
+
* translation between them — NOT one shared shape. Their `id` fields live in
|
|
9
|
+
* different namespaces (local vs server), so passing one where the other is
|
|
10
|
+
* expected corrupts topic identity.
|
|
11
11
|
*
|
|
12
12
|
* - {@link DraftTopic} — locally-authored, what desktop persists + edits.
|
|
13
13
|
* - {@link PublishedTopic} — server-authoritative, what the server returns and
|
|
@@ -15,10 +15,8 @@
|
|
|
15
15
|
* - {@link publishedFromDraft} — the Draft -> Published projection (one-way;
|
|
16
16
|
* there is no reverse hydration).
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* timestamps are ISO 8601 strings because this contract crosses the desktop/web
|
|
21
|
-
* boundary.
|
|
18
|
+
* All timestamps are ISO 8601 strings because this contract crosses the
|
|
19
|
+
* desktop/web boundary.
|
|
22
20
|
*/
|
|
23
21
|
|
|
24
22
|
/**
|
|
@@ -40,13 +38,7 @@ export interface TopicStatus {
|
|
|
40
38
|
* `new-thought`, so legacy topics and renamed values need no migration.
|
|
41
39
|
*/
|
|
42
40
|
state:
|
|
43
|
-
|
|
44
|
-
| 'working'
|
|
45
|
-
| 'paused'
|
|
46
|
-
| 'waiting'
|
|
47
|
-
| 'done-for-now'
|
|
48
|
-
| 'deprecated'
|
|
49
|
-
| 'abandoned';
|
|
41
|
+
'new-thought' | 'working' | 'paused' | 'waiting' | 'done-for-now' | 'deprecated' | 'abandoned';
|
|
50
42
|
/** Free-form text shown in place of the default label for the state. */
|
|
51
43
|
label?: string;
|
|
52
44
|
/** External blocker description. Meaningful when `state` is `waiting`. */
|
|
@@ -57,7 +49,7 @@ export interface TopicStatus {
|
|
|
57
49
|
until?: string;
|
|
58
50
|
/** Structured pointer to the thing being waited on, for automations. */
|
|
59
51
|
ref?: {
|
|
60
|
-
kind: 'url' | 'pr' | 'issue' | 'topic'
|
|
52
|
+
kind: 'url' | 'pr' | 'issue' | 'topic';
|
|
61
53
|
/** The address/id of the referenced thing. */
|
|
62
54
|
value: string;
|
|
63
55
|
/** Optional human label for display. */
|
|
@@ -122,8 +114,6 @@ export interface DraftTopic {
|
|
|
122
114
|
title: string;
|
|
123
115
|
/** Optional markdown body. */
|
|
124
116
|
description?: string;
|
|
125
|
-
/** Ordered list of LOCAL trail ids — foreign keys into the local trail store. */
|
|
126
|
-
trailIds: string[];
|
|
127
117
|
/**
|
|
128
118
|
* Repositories this topic is about, as PURL strings (e.g.
|
|
129
119
|
* `pkg:github/owner/repo`). The topic's own source of truth for "which repos"
|
|
@@ -160,8 +150,6 @@ export interface PublishedTopic {
|
|
|
160
150
|
title: string;
|
|
161
151
|
/** Markdown body — required on the server. */
|
|
162
152
|
description: string;
|
|
163
|
-
/** Ordered list of REMOTE trail ids. */
|
|
164
|
-
trailIds: string[];
|
|
165
153
|
/**
|
|
166
154
|
* Repositories this topic is about, as PURL strings (e.g.
|
|
167
155
|
* `pkg:github/owner/repo`). Travels with the topic across the wire so a
|
|
@@ -184,13 +172,10 @@ export interface PublishedTopic {
|
|
|
184
172
|
* Inputs the {@link DraftTopic} cannot supply on its own — the namespace
|
|
185
173
|
* crossing and the fields publish must enforce. The caller resolves these
|
|
186
174
|
* before projecting:
|
|
187
|
-
* - `trailIds`: LOCAL trail ids remapped to REMOTE ids (web-ade references
|
|
188
|
-
* trails by their server id; see desktop's `resolveRemoteTrailIds`).
|
|
189
175
|
* - `createdBy`: required on the server; supplied here if the draft lacks it.
|
|
190
176
|
* - `visibility`: a published concept the draft never carries.
|
|
191
177
|
*/
|
|
192
178
|
export interface PublishProjection {
|
|
193
|
-
trailIds: string[];
|
|
194
179
|
createdBy: TopicCreator;
|
|
195
180
|
visibility: 'private' | 'public';
|
|
196
181
|
}
|
|
@@ -199,8 +184,8 @@ export interface PublishProjection {
|
|
|
199
184
|
* Project a {@link DraftTopic} into a {@link PublishedTopic} — the one-way
|
|
200
185
|
* Draft -> Published translation. It does NOT perform any I/O (no id minting,
|
|
201
186
|
* no asset upload, no network): the caller resolves the namespace-crossing and
|
|
202
|
-
* required fields into {@link PublishProjection} first (
|
|
203
|
-
*
|
|
187
|
+
* required fields into {@link PublishProjection} first (uploading assets,
|
|
188
|
+
* choosing visibility), and this enforces the shape.
|
|
204
189
|
*
|
|
205
190
|
* One-way by design: there is no reverse `PublishedTopic -> DraftTopic`
|
|
206
191
|
* hydration. A published topic's edits write through to the server; the local
|
|
@@ -212,19 +197,16 @@ export interface PublishProjection {
|
|
|
212
197
|
*/
|
|
213
198
|
export function publishedFromDraft(
|
|
214
199
|
draft: DraftTopic,
|
|
215
|
-
projection: PublishProjection
|
|
200
|
+
projection: PublishProjection
|
|
216
201
|
): PublishedTopic {
|
|
217
202
|
const description = (draft.description ?? '').trim();
|
|
218
203
|
if (description.length === 0) {
|
|
219
|
-
throw new Error(
|
|
220
|
-
`Topic ${draft.id} has no description; a description is required to publish.`,
|
|
221
|
-
);
|
|
204
|
+
throw new Error(`Topic ${draft.id} has no description; a description is required to publish.`);
|
|
222
205
|
}
|
|
223
206
|
return {
|
|
224
207
|
id: draft.id,
|
|
225
208
|
title: draft.title,
|
|
226
209
|
description,
|
|
227
|
-
trailIds: projection.trailIds,
|
|
228
210
|
...(draft.repos !== undefined ? { repos: draft.repos } : {}),
|
|
229
211
|
createdAt: draft.createdAt,
|
|
230
212
|
updatedAt: draft.updatedAt,
|
|
@@ -28,7 +28,7 @@ afterEach(() => {
|
|
|
28
28
|
describe('CRUD', () => {
|
|
29
29
|
test('create generates an id, persists a file, and reads back', async () => {
|
|
30
30
|
const store = makeStore();
|
|
31
|
-
const created = await store.createTopic({ title: 'Auth flow'
|
|
31
|
+
const created = await store.createTopic({ title: 'Auth flow' });
|
|
32
32
|
expect(created.id).toMatch(/^topic-/);
|
|
33
33
|
expect(existsSync(join(topicsDir, `${created.id}.json`))).toBe(true);
|
|
34
34
|
|
|
@@ -38,20 +38,22 @@ describe('CRUD', () => {
|
|
|
38
38
|
|
|
39
39
|
test('create honors an explicit id and rejects duplicates', async () => {
|
|
40
40
|
const store = makeStore();
|
|
41
|
-
await store.createTopic({ id: 'topic-x', title: 'A'
|
|
42
|
-
await expect(
|
|
43
|
-
|
|
44
|
-
)
|
|
41
|
+
await store.createTopic({ id: 'topic-x', title: 'A' });
|
|
42
|
+
await expect(store.createTopic({ id: 'topic-x', title: 'B' })).rejects.toThrow(
|
|
43
|
+
/already exists/
|
|
44
|
+
);
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
test('update changes fields but never id/createdAt
|
|
47
|
+
test('update changes fields but never id/createdAt', async () => {
|
|
48
48
|
const store = makeStore();
|
|
49
|
-
const t = await store.createTopic({ id: 'topic-u', title: 'A'
|
|
50
|
-
const updated = await store.updateTopic('topic-u', {
|
|
49
|
+
const t = await store.createTopic({ id: 'topic-u', title: 'A' });
|
|
50
|
+
const updated = await store.updateTopic('topic-u', {
|
|
51
|
+
title: 'B',
|
|
52
|
+
description: 'hi',
|
|
53
|
+
});
|
|
51
54
|
expect(updated.title).toBe('B');
|
|
52
55
|
expect(updated.description).toBe('hi');
|
|
53
56
|
expect(updated.createdAt).toBe(t.createdAt);
|
|
54
|
-
expect(updated.trailIds).toEqual(['t1']);
|
|
55
57
|
});
|
|
56
58
|
|
|
57
59
|
test('repos (PURL strings) persist on create and are updatable', async () => {
|
|
@@ -59,7 +61,6 @@ describe('CRUD', () => {
|
|
|
59
61
|
const created = await store.createTopic({
|
|
60
62
|
id: 'topic-repos',
|
|
61
63
|
title: 'Repo-scoped',
|
|
62
|
-
trailIds: [],
|
|
63
64
|
repos: ['pkg:github/acme/web'],
|
|
64
65
|
});
|
|
65
66
|
expect(created.repos).toEqual(['pkg:github/acme/web']);
|
|
@@ -78,22 +79,21 @@ describe('CRUD', () => {
|
|
|
78
79
|
id: 'topic-pub',
|
|
79
80
|
title: 'Pub',
|
|
80
81
|
description: 'has a description',
|
|
81
|
-
trailIds: ['local-1'],
|
|
82
82
|
repos: ['pkg:github/acme/web'],
|
|
83
83
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
84
84
|
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
85
85
|
};
|
|
86
86
|
const published = publishedFromDraft(draft, {
|
|
87
|
-
trailIds: ['remote-1'],
|
|
88
87
|
createdBy: { githubId: 1, githubLogin: 'u1' },
|
|
89
88
|
visibility: 'private',
|
|
90
89
|
});
|
|
91
90
|
expect(published.repos).toEqual(['pkg:github/acme/web']);
|
|
91
|
+
expect(published).not.toHaveProperty('trailIds');
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
test('delete removes the file and the index entry', async () => {
|
|
95
95
|
const store = makeStore();
|
|
96
|
-
await store.createTopic({ id: 'topic-d', title: 'D'
|
|
96
|
+
await store.createTopic({ id: 'topic-d', title: 'D' });
|
|
97
97
|
expect(await store.deleteTopic('topic-d')).toBe(true);
|
|
98
98
|
expect(existsSync(join(topicsDir, 'topic-d.json'))).toBe(false);
|
|
99
99
|
expect(await store.getTopic('topic-d')).toBeNull();
|
|
@@ -103,7 +103,7 @@ describe('CRUD', () => {
|
|
|
103
103
|
test('no eviction cap — many topics all survive', async () => {
|
|
104
104
|
const store = makeStore();
|
|
105
105
|
for (let i = 0; i < 120; i++) {
|
|
106
|
-
await store.createTopic({ id: `topic-${i}`, title: `T${i}
|
|
106
|
+
await store.createTopic({ id: `topic-${i}`, title: `T${i}` });
|
|
107
107
|
}
|
|
108
108
|
expect((await store.getTopics()).length).toBe(120);
|
|
109
109
|
});
|
|
@@ -112,30 +112,60 @@ describe('CRUD', () => {
|
|
|
112
112
|
describe('index rebuild', () => {
|
|
113
113
|
test('a corrupt _index.json is rebuilt by scanning topic files', async () => {
|
|
114
114
|
const store = makeStore();
|
|
115
|
-
await store.createTopic({ id: 'topic-r', title: 'R'
|
|
115
|
+
await store.createTopic({ id: 'topic-r', title: 'R' });
|
|
116
116
|
|
|
117
117
|
// Corrupt the manifest and force a fresh store to rebuild from disk.
|
|
118
118
|
writeFileSync(join(topicsDir, '_index.json'), 'not json', 'utf8');
|
|
119
119
|
const fresh = makeStore();
|
|
120
120
|
const list = await fresh.list();
|
|
121
121
|
expect(list.map((e) => e.id)).toContain('topic-r');
|
|
122
|
-
expect(list.find((e) => e.id === 'topic-r')
|
|
122
|
+
expect(list.find((e) => e.id === 'topic-r')).not.toHaveProperty('trailCount');
|
|
123
|
+
expect(readFileSync(join(topicsDir, '_index.json'), 'utf8')).not.toContain('trailCount');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('legacy topic files drop trailIds but preserve other fields', async () => {
|
|
127
|
+
const store = makeStore();
|
|
128
|
+
await store.createTopic({
|
|
129
|
+
id: 'topic-legacy',
|
|
130
|
+
title: 'Legacy',
|
|
131
|
+
repos: ['pkg:github/a/b'],
|
|
132
|
+
});
|
|
133
|
+
const topicPath = join(topicsDir, 'topic-legacy.json');
|
|
134
|
+
const legacy = {
|
|
135
|
+
...JSON.parse(readFileSync(topicPath, 'utf8')),
|
|
136
|
+
trailIds: ['old-trail'],
|
|
137
|
+
description: 'Keep this description',
|
|
138
|
+
};
|
|
139
|
+
writeFileSync(topicPath, JSON.stringify(legacy), 'utf8');
|
|
140
|
+
|
|
141
|
+
const loaded = await store.getTopic('topic-legacy');
|
|
142
|
+
|
|
143
|
+
expect(loaded).toMatchObject({
|
|
144
|
+
id: 'topic-legacy',
|
|
145
|
+
title: 'Legacy',
|
|
146
|
+
description: 'Keep this description',
|
|
147
|
+
repos: ['pkg:github/a/b'],
|
|
148
|
+
});
|
|
149
|
+
expect(loaded).not.toHaveProperty('trailIds');
|
|
150
|
+
expect(JSON.parse(readFileSync(topicPath, 'utf8'))).not.toHaveProperty('trailIds');
|
|
123
151
|
});
|
|
124
152
|
});
|
|
125
153
|
|
|
126
154
|
describe('migration', () => {
|
|
127
|
-
const seedLegacy = (topics:
|
|
128
|
-
writeFileSync(
|
|
129
|
-
legacyBlob,
|
|
130
|
-
JSON.stringify({ version: '1.0.0', topics }, null, 2),
|
|
131
|
-
'utf8',
|
|
132
|
-
);
|
|
155
|
+
const seedLegacy = (topics: Record<string, unknown>[]) => {
|
|
156
|
+
writeFileSync(legacyBlob, JSON.stringify({ version: '1.0.0', topics }, null, 2), 'utf8');
|
|
133
157
|
};
|
|
134
158
|
|
|
135
159
|
test('fans the blob out to file-per-topic and backs up the blob', async () => {
|
|
136
160
|
seedLegacy([
|
|
137
|
-
{
|
|
138
|
-
|
|
161
|
+
{
|
|
162
|
+
id: 'topic-1',
|
|
163
|
+
title: 'One',
|
|
164
|
+
trailIds: ['a'],
|
|
165
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
166
|
+
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
167
|
+
},
|
|
168
|
+
{ id: 'topic-2', title: 'Two', trailIds: [], repos: ['pkg:github/a/b'] },
|
|
139
169
|
]);
|
|
140
170
|
const store = makeStore();
|
|
141
171
|
const result = await store.migrateFromLegacyBlob();
|
|
@@ -149,6 +179,12 @@ describe('migration', () => {
|
|
|
149
179
|
const one = await store.getTopic('topic-1');
|
|
150
180
|
expect(one?.title).toBe('One');
|
|
151
181
|
expect(one?.createdAt).toBe('2026-01-01T00:00:00.000Z');
|
|
182
|
+
expect(one).not.toHaveProperty('trailIds');
|
|
183
|
+
expect(await store.getTopic('topic-2')).toMatchObject({
|
|
184
|
+
title: 'Two',
|
|
185
|
+
repos: ['pkg:github/a/b'],
|
|
186
|
+
});
|
|
187
|
+
expect(await store.getTopic('topic-2')).not.toHaveProperty('trailIds');
|
|
152
188
|
expect(existsSync(join(topicsDir, 'topic-1.json'))).toBe(true);
|
|
153
189
|
});
|
|
154
190
|
|
|
@@ -26,15 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { homedir } from 'node:os';
|
|
28
28
|
import { dirname, join } from 'node:path';
|
|
29
|
-
import {
|
|
30
|
-
mkdir,
|
|
31
|
-
readFile,
|
|
32
|
-
writeFile,
|
|
33
|
-
unlink,
|
|
34
|
-
readdir,
|
|
35
|
-
rename,
|
|
36
|
-
access,
|
|
37
|
-
} from 'node:fs/promises';
|
|
29
|
+
import { mkdir, readFile, writeFile, unlink, readdir, rename, access } from 'node:fs/promises';
|
|
38
30
|
import type { DraftTopic, TopicStatus } from './topic-types';
|
|
39
31
|
|
|
40
32
|
/** Root of the global, cross-repo principal narration store. */
|
|
@@ -56,7 +48,6 @@ export interface TopicIndexEntry {
|
|
|
56
48
|
id: string;
|
|
57
49
|
title: string;
|
|
58
50
|
descriptionPreview: string;
|
|
59
|
-
trailCount: number;
|
|
60
51
|
/** `status.state` lifted out for filtering/sorting without a file read. */
|
|
61
52
|
state?: TopicStatus['state'];
|
|
62
53
|
createdAt: string;
|
|
@@ -75,8 +66,7 @@ interface IndexFileV1 {
|
|
|
75
66
|
|
|
76
67
|
const emptyIndex = (): IndexFileV1 => ({ version: 1, entries: [] });
|
|
77
68
|
|
|
78
|
-
const sanitizeSegment = (value: string): string =>
|
|
79
|
-
value.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
69
|
+
const sanitizeSegment = (value: string): string => value.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
80
70
|
|
|
81
71
|
const nowIso = (): string => new Date().toISOString();
|
|
82
72
|
|
|
@@ -91,11 +81,16 @@ const descriptionPreview = (description?: string): string => {
|
|
|
91
81
|
return `${trimmed.slice(0, DESCRIPTION_PREVIEW_MAX - 1)}…`;
|
|
92
82
|
};
|
|
93
83
|
|
|
84
|
+
/** Strip the removed topic-to-trail field from data written by older versions. */
|
|
85
|
+
const withoutLegacyTrailIds = (topic: object): Record<string, unknown> => {
|
|
86
|
+
const { trailIds: _trailIds, ...canonical } = topic as Record<string, unknown>;
|
|
87
|
+
return canonical;
|
|
88
|
+
};
|
|
89
|
+
|
|
94
90
|
const buildEntry = (topic: DraftTopic, sizeBytes: number): TopicIndexEntry => ({
|
|
95
91
|
id: topic.id,
|
|
96
92
|
title: topic.title || 'Untitled topic',
|
|
97
93
|
descriptionPreview: descriptionPreview(topic.description),
|
|
98
|
-
trailCount: topic.trailIds.length,
|
|
99
94
|
state: topic.status?.state,
|
|
100
95
|
createdAt: topic.createdAt,
|
|
101
96
|
updatedAt: topic.updatedAt,
|
|
@@ -108,7 +103,7 @@ const buildEntry = (topic: DraftTopic, sizeBytes: number): TopicIndexEntry => ({
|
|
|
108
103
|
/** Shape the legacy `~/.alexandria/topics.json` blob is parsed as. */
|
|
109
104
|
interface LegacyTopicsBlob {
|
|
110
105
|
version?: string;
|
|
111
|
-
topics?:
|
|
106
|
+
topics?: unknown[];
|
|
112
107
|
}
|
|
113
108
|
|
|
114
109
|
export interface MigrationResult {
|
|
@@ -123,10 +118,8 @@ export interface MigrationResult {
|
|
|
123
118
|
}
|
|
124
119
|
|
|
125
120
|
/**
|
|
126
|
-
* Fields a caller may set when updating a topic. `id
|
|
127
|
-
*
|
|
128
|
-
* `trailIds` is foreign-keyed membership owned by the desktop's trail store, so
|
|
129
|
-
* a topic update here can never rewrite it.
|
|
121
|
+
* Fields a caller may set when updating a topic. `id` and `createdAt` are set
|
|
122
|
+
* once at creation and cannot be changed by an update.
|
|
130
123
|
*/
|
|
131
124
|
export type TopicUpdate = Partial<
|
|
132
125
|
Pick<DraftTopic, 'title' | 'description' | 'status' | 'createdBy' | 'assets' | 'repos'>
|
|
@@ -142,10 +135,7 @@ export type TopicUpdate = Partial<
|
|
|
142
135
|
* onto a still-required `id: string` would NOT widen it — an intersection keeps
|
|
143
136
|
* the stricter member — which would leave `id` wrongly required for callers.
|
|
144
137
|
*/
|
|
145
|
-
export type TopicCreate = Omit<
|
|
146
|
-
DraftTopic,
|
|
147
|
-
'id' | 'createdAt' | 'updatedAt'
|
|
148
|
-
> & {
|
|
138
|
+
export type TopicCreate = Omit<DraftTopic, 'id' | 'createdAt' | 'updatedAt'> & {
|
|
149
139
|
id?: string;
|
|
150
140
|
createdAt?: string;
|
|
151
141
|
updatedAt?: string;
|
|
@@ -191,9 +181,7 @@ export class TopicStore {
|
|
|
191
181
|
/** Cheap listing straight off the manifest — no per-topic file read. */
|
|
192
182
|
async list(): Promise<TopicIndexEntry[]> {
|
|
193
183
|
const idx = await this.getIndex();
|
|
194
|
-
return idx.entries
|
|
195
|
-
.slice()
|
|
196
|
-
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
|
184
|
+
return idx.entries.slice().sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
|
197
185
|
}
|
|
198
186
|
|
|
199
187
|
async createTopic(input: TopicCreate): Promise<DraftTopic> {
|
|
@@ -203,14 +191,13 @@ export class TopicStore {
|
|
|
203
191
|
if (idx.entries.some((e) => e.id === id)) {
|
|
204
192
|
throw new Error(`Topic with id '${id}' already exists`);
|
|
205
193
|
}
|
|
206
|
-
const topic
|
|
194
|
+
const topic = {
|
|
207
195
|
...input,
|
|
208
196
|
id,
|
|
209
197
|
title: input.title || 'Untitled topic',
|
|
210
|
-
trailIds: input.trailIds ?? [],
|
|
211
198
|
createdAt: input.createdAt ?? now,
|
|
212
199
|
updatedAt: input.updatedAt ?? now,
|
|
213
|
-
};
|
|
200
|
+
} as DraftTopic;
|
|
214
201
|
await this.writeTopic(topic, idx);
|
|
215
202
|
return topic;
|
|
216
203
|
}
|
|
@@ -222,7 +209,6 @@ export class TopicStore {
|
|
|
222
209
|
...existing,
|
|
223
210
|
...updates,
|
|
224
211
|
id: existing.id,
|
|
225
|
-
trailIds: existing.trailIds,
|
|
226
212
|
createdAt: existing.createdAt,
|
|
227
213
|
updatedAt: nowIso(),
|
|
228
214
|
};
|
|
@@ -267,26 +253,31 @@ export class TopicStore {
|
|
|
267
253
|
blob = JSON.parse(raw) as LegacyTopicsBlob;
|
|
268
254
|
} catch {
|
|
269
255
|
throw new Error(
|
|
270
|
-
`Legacy topics blob at ${this.legacyBlobPath} is not valid JSON; not migrating
|
|
256
|
+
`Legacy topics blob at ${this.legacyBlobPath} is not valid JSON; not migrating.`
|
|
271
257
|
);
|
|
272
258
|
}
|
|
273
259
|
|
|
274
260
|
const idx = await this.getIndex();
|
|
275
261
|
let migrated = 0;
|
|
276
262
|
let skipped = 0;
|
|
277
|
-
|
|
278
|
-
|
|
263
|
+
const legacyTopics = Array.isArray(blob.topics) ? blob.topics : [];
|
|
264
|
+
for (const legacyValue of legacyTopics) {
|
|
265
|
+
if (!legacyValue || typeof legacyValue !== 'object' || Array.isArray(legacyValue)) {
|
|
266
|
+
skipped++;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const legacy = legacyValue as Record<string, unknown>;
|
|
270
|
+
if (typeof legacy.id !== 'string' || !legacy.id) {
|
|
279
271
|
skipped++;
|
|
280
272
|
continue;
|
|
281
273
|
}
|
|
282
274
|
const now = nowIso();
|
|
283
|
-
const topic
|
|
275
|
+
const topic = {
|
|
284
276
|
...legacy,
|
|
285
|
-
title: legacy.title
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
};
|
|
277
|
+
title: typeof legacy.title === 'string' && legacy.title ? legacy.title : 'Untitled topic',
|
|
278
|
+
createdAt: typeof legacy.createdAt === 'string' ? legacy.createdAt : now,
|
|
279
|
+
updatedAt: typeof legacy.updatedAt === 'string' ? legacy.updatedAt : now,
|
|
280
|
+
} as DraftTopic;
|
|
290
281
|
try {
|
|
291
282
|
await this.writeTopic(topic, idx);
|
|
292
283
|
migrated++;
|
|
@@ -334,13 +325,14 @@ export class TopicStore {
|
|
|
334
325
|
|
|
335
326
|
/** Write a topic file and upsert its index entry. Does not generate ids. */
|
|
336
327
|
private async writeTopic(topic: DraftTopic, idx: IndexFileV1): Promise<void> {
|
|
337
|
-
const
|
|
328
|
+
const canonicalTopic = withoutLegacyTrailIds(topic) as unknown as DraftTopic;
|
|
329
|
+
const fileName = `${sanitizeSegment(canonicalTopic.id)}.json`;
|
|
338
330
|
const file = join(this.baseDir, fileName);
|
|
339
331
|
await mkdir(dirname(file), { recursive: true });
|
|
340
|
-
const serialized = JSON.stringify(
|
|
332
|
+
const serialized = JSON.stringify(canonicalTopic, null, 2);
|
|
341
333
|
await writeFile(file, serialized, 'utf8');
|
|
342
|
-
const entry = buildEntry(
|
|
343
|
-
const at = idx.entries.findIndex((e) => e.id ===
|
|
334
|
+
const entry = buildEntry(canonicalTopic, Buffer.byteLength(serialized, 'utf8'));
|
|
335
|
+
const at = idx.entries.findIndex((e) => e.id === canonicalTopic.id);
|
|
344
336
|
if (at >= 0) idx.entries[at] = entry;
|
|
345
337
|
else idx.entries.push(entry);
|
|
346
338
|
await this.persistIndex();
|
|
@@ -349,7 +341,16 @@ export class TopicStore {
|
|
|
349
341
|
private async readTopic(fileName: string): Promise<DraftTopic | null> {
|
|
350
342
|
try {
|
|
351
343
|
const raw = await readFile(join(this.baseDir, fileName), 'utf8');
|
|
352
|
-
|
|
344
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
345
|
+
const topic = withoutLegacyTrailIds(parsed) as unknown as DraftTopic;
|
|
346
|
+
if ('trailIds' in parsed) {
|
|
347
|
+
try {
|
|
348
|
+
await writeFile(join(this.baseDir, fileName), JSON.stringify(topic, null, 2), 'utf8');
|
|
349
|
+
} catch (err) {
|
|
350
|
+
console.warn('[TopicStore] Failed to remove legacy trailIds from', fileName, err);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return topic;
|
|
353
354
|
} catch (err) {
|
|
354
355
|
console.error('[TopicStore] Failed to read topic', fileName, err);
|
|
355
356
|
return null;
|
|
@@ -381,7 +382,20 @@ export class TopicStore {
|
|
|
381
382
|
const raw = await readFile(this.indexPath, 'utf8');
|
|
382
383
|
const parsed = JSON.parse(raw) as Partial<IndexFileV1>;
|
|
383
384
|
if (parsed?.version === 1 && Array.isArray(parsed.entries)) {
|
|
384
|
-
|
|
385
|
+
let hadLegacyTrailCount = false;
|
|
386
|
+
const entries = parsed.entries.map((entry) => {
|
|
387
|
+
hadLegacyTrailCount ||= Boolean(
|
|
388
|
+
entry && typeof entry === 'object' && 'trailCount' in entry
|
|
389
|
+
);
|
|
390
|
+
const { trailCount: _trailCount, ...canonical } = entry as TopicIndexEntry & {
|
|
391
|
+
trailCount?: number;
|
|
392
|
+
};
|
|
393
|
+
return canonical;
|
|
394
|
+
});
|
|
395
|
+
if (hadLegacyTrailCount) {
|
|
396
|
+
await writeFile(this.indexPath, JSON.stringify({ version: 1, entries }, null, 2), 'utf8');
|
|
397
|
+
}
|
|
398
|
+
return { version: 1, entries };
|
|
385
399
|
}
|
|
386
400
|
} catch (err) {
|
|
387
401
|
const code = (err as NodeJS.ErrnoException).code;
|
|
@@ -408,13 +422,12 @@ export class TopicStore {
|
|
|
408
422
|
const topic = await this.readTopic(name);
|
|
409
423
|
if (!topic?.id) continue;
|
|
410
424
|
const createdAt = topic.createdAt ?? nowIso();
|
|
411
|
-
const stamped
|
|
425
|
+
const stamped = {
|
|
412
426
|
...topic,
|
|
413
427
|
createdAt,
|
|
414
428
|
updatedAt: topic.updatedAt ?? createdAt,
|
|
415
429
|
title: topic.title || 'Untitled topic',
|
|
416
|
-
|
|
417
|
-
};
|
|
430
|
+
} as DraftTopic;
|
|
418
431
|
const raw = JSON.stringify(stamped, null, 2);
|
|
419
432
|
idx.entries.push(buildEntry(stamped, Buffer.byteLength(raw, 'utf8')));
|
|
420
433
|
}
|
|
@@ -46,8 +46,18 @@ describe("subsystem-model schema", () => {
|
|
|
46
46
|
expect(schema.properties.verifiedAtCommits).toBeDefined();
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
test("
|
|
50
|
-
|
|
49
|
+
test("declares a content-addressed source span", () => {
|
|
50
|
+
// The identity primitive: start + end + content hash. Replaces the old
|
|
51
|
+
// start-line-only `declarationRef`.
|
|
52
|
+
expect(schema.$defs.sourceSpan?.properties?.startLine).toBeDefined();
|
|
53
|
+
expect(schema.$defs.sourceSpan?.properties?.endLine).toBeDefined();
|
|
54
|
+
expect(schema.$defs.sourceSpan?.properties?.contentHash).toBeDefined();
|
|
55
|
+
expect(schema.$defs.declarationRef).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("trail steps carry a span, not a bare line", () => {
|
|
59
|
+
expect(schema.$defs.trailStep?.properties?.span).toBeDefined();
|
|
60
|
+
expect(schema.$defs.trailStep?.properties?.line).toBeUndefined();
|
|
51
61
|
});
|
|
52
62
|
|
|
53
63
|
test("a store declares its own value type", () => {
|