@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,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safety regression test.
|
|
3
|
+
*
|
|
4
|
+
* The package has two entry points:
|
|
5
|
+
* - `@principal-ai/principal-view-core` (src/index.ts) — must work in any
|
|
6
|
+
* environment, including browsers. It must not transitively import any
|
|
7
|
+
* Node.js-only built-in module.
|
|
8
|
+
* - `@principal-ai/principal-view-core/node` (src/node.ts) — Node-only.
|
|
9
|
+
*
|
|
10
|
+
* This test walks the import graph starting from src/index.ts and fails if
|
|
11
|
+
* any reachable source file pulls in a forbidden Node built-in. `import type`
|
|
12
|
+
* lines are erased at compile time and are ignored.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { readFileSync, statSync } from 'fs';
|
|
17
|
+
import { dirname, resolve } from 'path';
|
|
18
|
+
|
|
19
|
+
const ENTRY = resolve(import.meta.dir, 'index.ts');
|
|
20
|
+
const SRC_ROOT = resolve(import.meta.dir);
|
|
21
|
+
|
|
22
|
+
// Node built-ins that don't exist in the browser. `node:` prefix and
|
|
23
|
+
// submodules like `fs/promises` are matched separately.
|
|
24
|
+
const FORBIDDEN_BARE = new Set([
|
|
25
|
+
'fs',
|
|
26
|
+
'path',
|
|
27
|
+
'os',
|
|
28
|
+
'child_process',
|
|
29
|
+
'crypto',
|
|
30
|
+
'http',
|
|
31
|
+
'https',
|
|
32
|
+
'net',
|
|
33
|
+
'tls',
|
|
34
|
+
'stream',
|
|
35
|
+
'zlib',
|
|
36
|
+
'url',
|
|
37
|
+
'worker_threads',
|
|
38
|
+
'cluster',
|
|
39
|
+
'dgram',
|
|
40
|
+
'dns',
|
|
41
|
+
'readline',
|
|
42
|
+
'v8',
|
|
43
|
+
'vm',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
function isForbidden(spec: string): boolean {
|
|
47
|
+
if (spec.startsWith('node:')) return true;
|
|
48
|
+
const root = spec.split('/')[0];
|
|
49
|
+
return FORBIDDEN_BARE.has(root);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveRelative(from: string, spec: string): string | null {
|
|
53
|
+
const base = resolve(dirname(from), spec);
|
|
54
|
+
const candidates = [
|
|
55
|
+
base,
|
|
56
|
+
`${base}.ts`,
|
|
57
|
+
`${base}.tsx`,
|
|
58
|
+
`${base}/index.ts`,
|
|
59
|
+
`${base}/index.tsx`,
|
|
60
|
+
];
|
|
61
|
+
for (const c of candidates) {
|
|
62
|
+
try {
|
|
63
|
+
const s = statSync(c);
|
|
64
|
+
if (s.isFile()) return c;
|
|
65
|
+
} catch {
|
|
66
|
+
// not a file; keep trying
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ImportRef {
|
|
73
|
+
spec: string;
|
|
74
|
+
typeOnly: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Strip /* ... */ and // ... so commented-out imports don't trip the regex.
|
|
78
|
+
function stripComments(src: string): string {
|
|
79
|
+
return src
|
|
80
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
81
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractImports(src: string): ImportRef[] {
|
|
85
|
+
const cleaned = stripComments(src);
|
|
86
|
+
const refs: ImportRef[] = [];
|
|
87
|
+
|
|
88
|
+
// import ... from 'spec' / import 'spec'
|
|
89
|
+
const importRe = /^\s*import\s+(type\s+)?(?:[^'"]*?from\s+)?['"]([^'"]+)['"]/gm;
|
|
90
|
+
let m: RegExpExecArray | null;
|
|
91
|
+
while ((m = importRe.exec(cleaned))) {
|
|
92
|
+
refs.push({ spec: m[2], typeOnly: Boolean(m[1]) });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// export ... from 'spec' (re-exports — runtime-relevant unless "export type")
|
|
96
|
+
const exportRe = /^\s*export\s+(type\s+)?(?:\*|\{[^}]*\})\s+from\s+['"]([^'"]+)['"]/gm;
|
|
97
|
+
while ((m = exportRe.exec(cleaned))) {
|
|
98
|
+
refs.push({ spec: m[2], typeOnly: Boolean(m[1]) });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return refs;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface LeakReport {
|
|
105
|
+
chain: string[];
|
|
106
|
+
forbiddenSpec: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function findLeaks(entry: string): LeakReport[] {
|
|
110
|
+
const visited = new Set<string>();
|
|
111
|
+
const leaks: LeakReport[] = [];
|
|
112
|
+
|
|
113
|
+
function walk(file: string, chain: string[]): void {
|
|
114
|
+
if (visited.has(file)) return;
|
|
115
|
+
visited.add(file);
|
|
116
|
+
|
|
117
|
+
const rel = file.startsWith(SRC_ROOT) ? file.slice(SRC_ROOT.length + 1) : file;
|
|
118
|
+
const nextChain = [...chain, rel];
|
|
119
|
+
|
|
120
|
+
let src: string;
|
|
121
|
+
try {
|
|
122
|
+
src = readFileSync(file, 'utf-8');
|
|
123
|
+
} catch {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const ref of extractImports(src)) {
|
|
128
|
+
if (ref.typeOnly) continue;
|
|
129
|
+
|
|
130
|
+
// Forbidden Node built-in?
|
|
131
|
+
if (isForbidden(ref.spec)) {
|
|
132
|
+
leaks.push({ chain: nextChain, forbiddenSpec: ref.spec });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Relative import — recurse.
|
|
137
|
+
if (ref.spec.startsWith('.')) {
|
|
138
|
+
const resolved = resolveRelative(file, ref.spec);
|
|
139
|
+
if (resolved) walk(resolved, nextChain);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// External package — out of scope. We trust the package author to have
|
|
144
|
+
// shipped a browser-safe entry; if they didn't, that's a separate bug.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
walk(entry, []);
|
|
149
|
+
return leaks;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
describe('browser-safe entry (src/index.ts)', () => {
|
|
153
|
+
test('does not transitively import any Node.js built-in', () => {
|
|
154
|
+
const leaks = findLeaks(ENTRY);
|
|
155
|
+
|
|
156
|
+
if (leaks.length > 0) {
|
|
157
|
+
const lines = leaks.map((l) => {
|
|
158
|
+
const arrow = l.chain.join('\n → ');
|
|
159
|
+
return ` ✗ imports "${l.forbiddenSpec}" via:\n → ${arrow}`;
|
|
160
|
+
});
|
|
161
|
+
const msg =
|
|
162
|
+
`Found ${leaks.length} Node.js built-in import(s) reachable from src/index.ts.\n` +
|
|
163
|
+
`These must move to src/node.ts (or be replaced with a FileSystemAdapter):\n\n` +
|
|
164
|
+
lines.join('\n\n');
|
|
165
|
+
throw new Error(msg);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
expect(leaks).toHaveLength(0);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
|
|
|
2
2
|
import { mkdtempSync, mkdirSync, rmSync } from 'fs';
|
|
3
3
|
import { tmpdir } from 'os';
|
|
4
4
|
import { join } from 'path';
|
|
5
|
+
import { NodeFileSystemAdapter } from '@principal-ai/repository-abstraction/node';
|
|
5
6
|
import { EventsCanvasValidator } from './EventsCanvasValidator';
|
|
6
7
|
import type { ExtendedCanvas } from '../types/canvas';
|
|
7
8
|
|
|
@@ -50,7 +51,7 @@ function canvas(nodes: any[]): ExtendedCanvas {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
async function runValidator(nodes: any[]) {
|
|
53
|
-
const validator = new EventsCanvasValidator();
|
|
54
|
+
const validator = new EventsCanvasValidator(new NodeFileSystemAdapter());
|
|
54
55
|
return validator.validate({
|
|
55
56
|
eventsCanvas: canvas(nodes),
|
|
56
57
|
eventsCanvasPath: 'test.events.canvas',
|
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
* and their events, with correct namespace extraction and node structure.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { FileSystemAdapter } from '@principal-ai/repository-abstraction';
|
|
8
9
|
import type { ExtendedCanvas, ExtendedCanvasNode } from '../types/canvas';
|
|
9
|
-
import { existsSync } from 'fs';
|
|
10
|
-
import { resolve } from 'path';
|
|
11
10
|
import { pathsOverlap } from './path-helpers';
|
|
12
11
|
|
|
13
12
|
/**
|
|
@@ -128,6 +127,8 @@ export interface EventsCanvasValidationResult {
|
|
|
128
127
|
* Validates events canvas files
|
|
129
128
|
*/
|
|
130
129
|
export class EventsCanvasValidator {
|
|
130
|
+
constructor(private fsAdapter: FileSystemAdapter) {}
|
|
131
|
+
|
|
131
132
|
/**
|
|
132
133
|
* Extract namespace from event name (all segments except last)
|
|
133
134
|
*
|
|
@@ -376,8 +377,8 @@ export class EventsCanvasValidator {
|
|
|
376
377
|
});
|
|
377
378
|
|
|
378
379
|
// Warn when a declared path does not exist relative to the repo root.
|
|
379
|
-
const resolved =
|
|
380
|
-
if (!
|
|
380
|
+
const resolved = this.fsAdapter.join(basePath, p);
|
|
381
|
+
if (!(await this.fsAdapter.exists(resolved))) {
|
|
381
382
|
violations.push({
|
|
382
383
|
ruleId: 'events-namespace-paths-missing',
|
|
383
384
|
severity: 'warn',
|
package/src/index.ts
CHANGED
|
@@ -532,6 +532,19 @@ export {
|
|
|
532
532
|
export type { FileSystemAdapter } from '@principal-ai/repository-abstraction';
|
|
533
533
|
export { InMemoryFileSystemAdapter } from '@principal-ai/repository-abstraction';
|
|
534
534
|
|
|
535
|
+
// Canonical topic storage contract (browser-safe types + Draft->Published
|
|
536
|
+
// projection). The file-per-topic store that reads/writes these is node-only;
|
|
537
|
+
// import it from '@principal-ai/principal-view-core/node'.
|
|
538
|
+
export { publishedFromDraft } from './storage/topic-types';
|
|
539
|
+
export type {
|
|
540
|
+
DraftTopic,
|
|
541
|
+
PublishedTopic,
|
|
542
|
+
PublishProjection,
|
|
543
|
+
TopicStatus,
|
|
544
|
+
TopicAsset,
|
|
545
|
+
TopicCreator,
|
|
546
|
+
} from './storage/topic-types';
|
|
547
|
+
|
|
535
548
|
// NOTE: The following require Node.js dependencies and are NOT exported in the main bundle.
|
|
536
549
|
// Use '@principal-ai/principal-view-core/node' for Node.js-specific functionality:
|
|
537
550
|
//
|
package/src/node.ts
CHANGED
|
@@ -13,6 +13,30 @@
|
|
|
13
13
|
// Export all types (safe in all environments)
|
|
14
14
|
export * from './types';
|
|
15
15
|
|
|
16
|
+
// File-per-topic store (~/.principal/topics) — node:fs only. The browser-safe
|
|
17
|
+
// topic types it reads/writes are also re-exported here for node consumers.
|
|
18
|
+
export {
|
|
19
|
+
TopicStore,
|
|
20
|
+
PRINCIPAL_DIR,
|
|
21
|
+
TOPICS_DIR,
|
|
22
|
+
LEGACY_TOPICS_BLOB,
|
|
23
|
+
} from './storage/topicStore';
|
|
24
|
+
export type {
|
|
25
|
+
TopicIndexEntry,
|
|
26
|
+
TopicCreate,
|
|
27
|
+
TopicUpdate,
|
|
28
|
+
MigrationResult,
|
|
29
|
+
} from './storage/topicStore';
|
|
30
|
+
export { publishedFromDraft } from './storage/topic-types';
|
|
31
|
+
export type {
|
|
32
|
+
DraftTopic,
|
|
33
|
+
PublishedTopic,
|
|
34
|
+
PublishProjection,
|
|
35
|
+
TopicStatus,
|
|
36
|
+
TopicAsset,
|
|
37
|
+
TopicCreator,
|
|
38
|
+
} from './storage/topic-types';
|
|
39
|
+
|
|
16
40
|
// Export core classes (Node.js processing)
|
|
17
41
|
export { EventProcessor } from './EventProcessor';
|
|
18
42
|
export type { ProcessingResult } from './EventProcessor';
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { tmpdir } from 'os';
|
|
5
|
+
import { NodeFileSystemAdapter } from '@principal-ai/repository-abstraction/node';
|
|
5
6
|
import { ScopesCanvasValidator } from './ScopesCanvasValidator';
|
|
6
7
|
import type { ExtendedCanvas } from '../types/canvas';
|
|
7
8
|
|
|
@@ -46,7 +47,7 @@ function withTempRepo(layout: string[], fn: (root: string) => Promise<void>): Pr
|
|
|
46
47
|
describe('ScopesCanvasValidator scope paths', () => {
|
|
47
48
|
test('no paths declared → no path violations', async () => {
|
|
48
49
|
const canvas = makeCanvas([makeScopeNode({ id: 's1', scope: 'a.b' })]);
|
|
49
|
-
const v = new ScopesCanvasValidator();
|
|
50
|
+
const v = new ScopesCanvasValidator(new NodeFileSystemAdapter());
|
|
50
51
|
const r = await v.validate({
|
|
51
52
|
scopesCanvas: canvas,
|
|
52
53
|
ownedScopes: ['a.b'],
|
|
@@ -64,7 +65,7 @@ describe('ScopesCanvasValidator scope paths', () => {
|
|
|
64
65
|
paths: ['packages/a/src', 'packages/a/generated'],
|
|
65
66
|
}),
|
|
66
67
|
]);
|
|
67
|
-
const v = new ScopesCanvasValidator();
|
|
68
|
+
const v = new ScopesCanvasValidator(new NodeFileSystemAdapter());
|
|
68
69
|
const r = await v.validate({ scopesCanvas: canvas, ownedScopes: ['a'], basePath: root });
|
|
69
70
|
const w = r.violations.find(x => x.ruleId === 'scopes-paths-multiple');
|
|
70
71
|
expect(w).toBeDefined();
|
|
@@ -77,7 +78,7 @@ describe('ScopesCanvasValidator scope paths', () => {
|
|
|
77
78
|
const canvas = makeCanvas([
|
|
78
79
|
makeScopeNode({ id: 's1', scope: 'a', paths: ['packages/a/missing'] }),
|
|
79
80
|
]);
|
|
80
|
-
const v = new ScopesCanvasValidator();
|
|
81
|
+
const v = new ScopesCanvasValidator(new NodeFileSystemAdapter());
|
|
81
82
|
const r = await v.validate({ scopesCanvas: canvas, ownedScopes: ['a'], basePath: root });
|
|
82
83
|
const w = r.violations.find(x => x.ruleId === 'scopes-paths-missing');
|
|
83
84
|
expect(w).toBeDefined();
|
|
@@ -91,7 +92,7 @@ describe('ScopesCanvasValidator scope paths', () => {
|
|
|
91
92
|
makeScopeNode({ id: 's1', scope: 'a', paths: ['packages/shared/src'] }),
|
|
92
93
|
makeScopeNode({ id: 's2', scope: 'b', paths: ['packages/shared/src'] }),
|
|
93
94
|
]);
|
|
94
|
-
const v = new ScopesCanvasValidator();
|
|
95
|
+
const v = new ScopesCanvasValidator(new NodeFileSystemAdapter());
|
|
95
96
|
const r = await v.validate({
|
|
96
97
|
scopesCanvas: canvas,
|
|
97
98
|
ownedScopes: ['a', 'b'],
|
|
@@ -114,7 +115,7 @@ describe('ScopesCanvasValidator scope paths', () => {
|
|
|
114
115
|
paths: ['packages/core/src/validation'],
|
|
115
116
|
}),
|
|
116
117
|
]);
|
|
117
|
-
const v = new ScopesCanvasValidator();
|
|
118
|
+
const v = new ScopesCanvasValidator(new NodeFileSystemAdapter());
|
|
118
119
|
const r = await v.validate({
|
|
119
120
|
scopesCanvas: canvas,
|
|
120
121
|
ownedScopes: ['principal-ai.core', 'principal-ai.core.validation'],
|
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
* all instrumentation scopes declared in library.yaml.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import { resolve } from 'path';
|
|
8
|
+
import type { FileSystemAdapter } from '@principal-ai/repository-abstraction';
|
|
10
9
|
import type { ExtendedCanvas, ExtendedCanvasNode, OtelScopeNode, isOtelScopeNode } from '../types/canvas';
|
|
11
10
|
import { isOtelScopeNode as checkOtelScopeNode } from '../types/canvas';
|
|
12
11
|
import { pathsOverlap } from '../events/path-helpers';
|
|
@@ -81,6 +80,8 @@ export interface ScopesCanvasValidationResult {
|
|
|
81
80
|
* Validates scopes canvas files
|
|
82
81
|
*/
|
|
83
82
|
export class ScopesCanvasValidator {
|
|
83
|
+
constructor(private fsAdapter: FileSystemAdapter) {}
|
|
84
|
+
|
|
84
85
|
/**
|
|
85
86
|
* Validate a scopes canvas against library.yaml owned-scopes
|
|
86
87
|
*/
|
|
@@ -224,8 +225,8 @@ export class ScopesCanvasValidator {
|
|
|
224
225
|
declaredPaths.push({ scope, nodeId, path: p });
|
|
225
226
|
|
|
226
227
|
// Warn when a declared path does not exist relative to the repo root.
|
|
227
|
-
const resolved =
|
|
228
|
-
if (!
|
|
228
|
+
const resolved = this.fsAdapter.join(basePath, p);
|
|
229
|
+
if (!(await this.fsAdapter.exists(resolved))) {
|
|
229
230
|
violations.push({
|
|
230
231
|
ruleId: 'scopes-paths-missing',
|
|
231
232
|
severity: 'warn',
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical topic contract — the shapes every consumer of `~/.principal/topics`
|
|
3
|
+
* agrees on. Browser-safe (no `node:*`); the file-per-topic store that reads and
|
|
4
|
+
* writes these lives in `./node` (see `topicStore.ts`).
|
|
5
|
+
*
|
|
6
|
+
* A **topic** is a curated bundle of trails on one subject. Two distinct named
|
|
7
|
+
* shapes, split by id-namespace / ownership, with an explicit translation
|
|
8
|
+
* between them — NOT one shared shape. They share most field names, but `id`
|
|
9
|
+
* and `trailIds` live in different namespaces (local vs server), so passing one
|
|
10
|
+
* where the other is expected corrupts the trail foreign-keys.
|
|
11
|
+
*
|
|
12
|
+
* - {@link DraftTopic} — locally-authored, what desktop persists + edits.
|
|
13
|
+
* - {@link PublishedTopic} — server-authoritative, what the server returns and
|
|
14
|
+
* mobile reads.
|
|
15
|
+
* - {@link publishedFromDraft} — the Draft -> Published projection (one-way;
|
|
16
|
+
* there is no reverse hydration).
|
|
17
|
+
*
|
|
18
|
+
* The shapes mirror the over-the-wire form used by the sharing API (web-ade) so
|
|
19
|
+
* a published topic and a locally stored one stay interchangeable on read. All
|
|
20
|
+
* timestamps are ISO 8601 strings because this contract crosses the desktop/web
|
|
21
|
+
* boundary.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Status of a topic — a small structured axis plus optional human nuance.
|
|
26
|
+
*
|
|
27
|
+
* `state` is the machine-meaningful signal (drives badge color, sorting, and
|
|
28
|
+
* filtering). `label` is free-form text shown in place of the default per-state
|
|
29
|
+
* label. `waitingOn` describes an external blocker and is meaningful when
|
|
30
|
+
* `state` is `waiting`.
|
|
31
|
+
*
|
|
32
|
+
* The structured shape is deliberate: it lets automations check and resolve a
|
|
33
|
+
* blocker without parsing prose — e.g. flip `waiting` -> `paused` once `until`
|
|
34
|
+
* passes, or once a referenced PR merges.
|
|
35
|
+
*/
|
|
36
|
+
export interface TopicStatus {
|
|
37
|
+
/**
|
|
38
|
+
* Structured lifecycle axis, ordered by a feature's "aliveness" from nascent
|
|
39
|
+
* to retired. Readers treat an absent OR unrecognized `state` as
|
|
40
|
+
* `new-thought`, so legacy topics and renamed values need no migration.
|
|
41
|
+
*/
|
|
42
|
+
state:
|
|
43
|
+
| 'new-thought'
|
|
44
|
+
| 'working'
|
|
45
|
+
| 'paused'
|
|
46
|
+
| 'waiting'
|
|
47
|
+
| 'done-for-now'
|
|
48
|
+
| 'deprecated'
|
|
49
|
+
| 'abandoned';
|
|
50
|
+
/** Free-form text shown in place of the default label for the state. */
|
|
51
|
+
label?: string;
|
|
52
|
+
/** External blocker description. Meaningful when `state` is `waiting`. */
|
|
53
|
+
waitingOn?: {
|
|
54
|
+
/** Human description of the blocker, e.g. "design sign-off". */
|
|
55
|
+
note?: string;
|
|
56
|
+
/** ISO 8601 — when the hold is expected to lift. */
|
|
57
|
+
until?: string;
|
|
58
|
+
/** Structured pointer to the thing being waited on, for automations. */
|
|
59
|
+
ref?: {
|
|
60
|
+
kind: 'url' | 'pr' | 'issue' | 'topic' | 'trail';
|
|
61
|
+
/** The address/id of the referenced thing. */
|
|
62
|
+
value: string;
|
|
63
|
+
/** Optional human label for display. */
|
|
64
|
+
title?: string;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* An image attached to a topic — primarily a screenshot dragged into the
|
|
71
|
+
* description. Bytes live on the topic (referenced from the description via an
|
|
72
|
+
* `asset://<id>` markdown link), not inlined into the description string. A
|
|
73
|
+
* render-time resolver swaps `asset://<id>` for `url` (preferred) or a data-URL
|
|
74
|
+
* built from `data`, keeping a local (data-only) and a published (url-bearing)
|
|
75
|
+
* topic interchangeable on read.
|
|
76
|
+
*
|
|
77
|
+
* Local-only on a {@link DraftTopic}: assets are uploaded/resolved at publish
|
|
78
|
+
* time, so a {@link PublishedTopic} carries the resolved `url`, not raw `data`.
|
|
79
|
+
*/
|
|
80
|
+
export interface TopicAsset {
|
|
81
|
+
/** Content hash — free dedup and the stable `asset://` target. */
|
|
82
|
+
id: string;
|
|
83
|
+
/** MIME type, e.g. "image/png". */
|
|
84
|
+
mime: string;
|
|
85
|
+
/** Base64-encoded bytes. Present locally and on publish. */
|
|
86
|
+
data?: string;
|
|
87
|
+
/** Resolvable URL, when the bytes have been offloaded (e.g. to S3). */
|
|
88
|
+
url?: string;
|
|
89
|
+
/** Markdown alt text. */
|
|
90
|
+
alt?: string;
|
|
91
|
+
/** Origin metadata, for future re-capture / open-live affordances. */
|
|
92
|
+
source?: { storyId?: string; storybookUrl?: string };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* GitHub identity of a topic's creator. Populated when a signed-in user
|
|
97
|
+
* authors a topic; left undefined for local-only topics created before
|
|
98
|
+
* sign-in. The server requires this on publish.
|
|
99
|
+
*/
|
|
100
|
+
export interface TopicCreator {
|
|
101
|
+
githubId: number;
|
|
102
|
+
githubLogin: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Locally-authored topic — the shape desktop persists to
|
|
107
|
+
* `~/.principal/topics/<id>.json` and edits.
|
|
108
|
+
*
|
|
109
|
+
* Local id namespace. `description` / `createdBy` are optional (a Draft may be
|
|
110
|
+
* authored before sign-in). Carries local-only {@link TopicAsset}s
|
|
111
|
+
* (`asset://<id>` screenshots, raw `data`). Has NO `visibility` — that's a
|
|
112
|
+
* published concept.
|
|
113
|
+
*
|
|
114
|
+
* A Draft keeps existing after publish (it can be in a "published state"); its
|
|
115
|
+
* link to the server identity lives in the desktop's `topics-sync.json`
|
|
116
|
+
* sidecar, not in this shape.
|
|
117
|
+
*/
|
|
118
|
+
export interface DraftTopic {
|
|
119
|
+
/** Unique identifier in the LOCAL id namespace. */
|
|
120
|
+
id: string;
|
|
121
|
+
/** Display title. */
|
|
122
|
+
title: string;
|
|
123
|
+
/** Optional markdown body. */
|
|
124
|
+
description?: string;
|
|
125
|
+
/** Ordered list of LOCAL trail ids — foreign keys into the local trail store. */
|
|
126
|
+
trailIds: string[];
|
|
127
|
+
/** ISO 8601. */
|
|
128
|
+
createdAt: string;
|
|
129
|
+
/** ISO 8601. */
|
|
130
|
+
updatedAt: string;
|
|
131
|
+
/** Creator identity; undefined for topics authored before sign-in. */
|
|
132
|
+
createdBy?: TopicCreator;
|
|
133
|
+
/** Optional workflow status. Absent reads as `new-thought`. */
|
|
134
|
+
status?: TopicStatus;
|
|
135
|
+
/** Local image attachments, referenced from the description via `asset://<id>`. */
|
|
136
|
+
assets?: TopicAsset[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Server-authoritative topic — the shape the server returns and mobile reads.
|
|
141
|
+
*
|
|
142
|
+
* Remote id namespace. `description` / `createdBy` are required (publish is the
|
|
143
|
+
* validation gate). Carries `visibility`; assets have been resolved to `url`s.
|
|
144
|
+
*
|
|
145
|
+
* CAVEAT: "Published" means "has a server identity", NOT "public" — a
|
|
146
|
+
* PublishedTopic can be `visibility: 'private'`.
|
|
147
|
+
*/
|
|
148
|
+
export interface PublishedTopic {
|
|
149
|
+
/** Unique identifier in the REMOTE (server) id namespace. */
|
|
150
|
+
id: string;
|
|
151
|
+
/** Display title. */
|
|
152
|
+
title: string;
|
|
153
|
+
/** Markdown body — required on the server. */
|
|
154
|
+
description: string;
|
|
155
|
+
/** Ordered list of REMOTE trail ids. */
|
|
156
|
+
trailIds: string[];
|
|
157
|
+
/** ISO 8601. */
|
|
158
|
+
createdAt: string;
|
|
159
|
+
/** ISO 8601. */
|
|
160
|
+
updatedAt: string;
|
|
161
|
+
/** Creator identity — required on the server. */
|
|
162
|
+
createdBy: TopicCreator;
|
|
163
|
+
/** Optional workflow status, travels with the topic when published. */
|
|
164
|
+
status?: TopicStatus;
|
|
165
|
+
/** Server visibility. "private" is still a PublishedTopic. */
|
|
166
|
+
visibility: 'private' | 'public';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Inputs the {@link DraftTopic} cannot supply on its own — the namespace
|
|
171
|
+
* crossing and the fields publish must enforce. The caller resolves these
|
|
172
|
+
* before projecting:
|
|
173
|
+
* - `trailIds`: LOCAL trail ids remapped to REMOTE ids (web-ade references
|
|
174
|
+
* trails by their server id; see desktop's `resolveRemoteTrailIds`).
|
|
175
|
+
* - `createdBy`: required on the server; supplied here if the draft lacks it.
|
|
176
|
+
* - `visibility`: a published concept the draft never carries.
|
|
177
|
+
*/
|
|
178
|
+
export interface PublishProjection {
|
|
179
|
+
trailIds: string[];
|
|
180
|
+
createdBy: TopicCreator;
|
|
181
|
+
visibility: 'private' | 'public';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Project a {@link DraftTopic} into a {@link PublishedTopic} — the one-way
|
|
186
|
+
* Draft -> Published translation. It does NOT perform any I/O (no id minting,
|
|
187
|
+
* no asset upload, no network): the caller resolves the namespace-crossing and
|
|
188
|
+
* required fields into {@link PublishProjection} first (remapping trail ids,
|
|
189
|
+
* uploading assets, choosing visibility), and this enforces the shape.
|
|
190
|
+
*
|
|
191
|
+
* One-way by design: there is no reverse `PublishedTopic -> DraftTopic`
|
|
192
|
+
* hydration. A published topic's edits write through to the server; the local
|
|
193
|
+
* Draft is reconciled from the server's response field-by-field, never by
|
|
194
|
+
* rebuilding a Draft from a Published shape.
|
|
195
|
+
*
|
|
196
|
+
* Throws if `description` is empty — publish is also the validation gate, and
|
|
197
|
+
* the server rejects a description-less topic.
|
|
198
|
+
*/
|
|
199
|
+
export function publishedFromDraft(
|
|
200
|
+
draft: DraftTopic,
|
|
201
|
+
projection: PublishProjection,
|
|
202
|
+
): PublishedTopic {
|
|
203
|
+
const description = (draft.description ?? '').trim();
|
|
204
|
+
if (description.length === 0) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Topic ${draft.id} has no description; a description is required to publish.`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
id: draft.id,
|
|
211
|
+
title: draft.title,
|
|
212
|
+
description,
|
|
213
|
+
trailIds: projection.trailIds,
|
|
214
|
+
createdAt: draft.createdAt,
|
|
215
|
+
updatedAt: draft.updatedAt,
|
|
216
|
+
createdBy: projection.createdBy,
|
|
217
|
+
...(draft.status !== undefined ? { status: draft.status } : {}),
|
|
218
|
+
visibility: projection.visibility,
|
|
219
|
+
};
|
|
220
|
+
}
|