path-store.mjs
532 lines 17.2 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 12 days ago
1 /**
2 * Learning-path store — dedicated `learning_paths[]` on hub_flow_store.json (KN-WORK-PATH-LIST-b).
3 *
4 * No production starter seed. Empty list is honest. Reads are always authorized (JWT + vault +
5 * scope); writes go through Review-before-write in path-write.mjs.
6 *
7 * @see docs/KN-WORK-PATH-LIST-FREEZE.md
8 */
9
10 import { randomBytes } from 'crypto';
11 import { loadFlowStore, saveFlowStore } from '../flow/flow-store.mjs';
12 import { highestFlowScope } from '../flow/flow-scope.mjs';
13
14 export const LEARNING_PATH_SCHEMA = 'knowtation.learning_path/v0';
15 export const LEARNING_PATH_LIST_SCHEMA = 'knowtation.learning_path_list/v0';
16 export const LEARNING_PATH_GET_SCHEMA = 'knowtation.learning_path_get/v0';
17
18 export const PATH_ID_RE = /^path_[a-z0-9_]{1,48}$/;
19 export const WORKSPACE_ID_RE = /^[A-Za-z0-9._:-]{1,64}$/;
20 export const SOURCE_DOCUMENT_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
21 export const NOTE_PATH_RE = /^[A-Za-z0-9._/-]+\.md$/;
22 export const PATH_EXTERNAL_REF_RE = /^scooling\.path:[A-Za-z0-9._:-]{1,200}$/;
23
24 export const MAX_LEARNING_PATH_SUMMARIES = 200;
25 export const MAX_TITLE = 200;
26 export const MAX_SUMMARY = 2000;
27 export const MAX_GOAL = 180;
28 export const MAX_STEP_TITLE = 240;
29 export const MAX_STEP_OBJECTIVE = 240;
30 export const MAX_ACTIVE_DECISIONS = 240;
31 export const MAX_STEPS = 20;
32 export const MIN_STEPS = 1;
33 export const MAX_SOURCE_DOCUMENT_IDS = 16;
34 export const MAX_NOTE_PATH = 256;
35
36 export const PATH_SCOPES = /** @type {const} */ (['personal', 'project', 'org']);
37 export const PATH_STATUSES = /** @type {const} */ (['active', 'paused', 'archived']);
38 export const PATH_UPDATE_STATUSES = /** @type {const} */ (['active', 'paused']);
39
40 const CONTROL_CHAR_RE = /[\u0000-\u001F]/;
41
42 /** @typedef {'personal'|'project'|'org'} PathScope */
43 /** @typedef {'active'|'paused'|'archived'} PathStatus */
44
45 /**
46 * @typedef {Object} StoredLearningPathStep
47 * @property {string} title
48 * @property {string} objective
49 * @property {string[]} source_document_ids
50 */
51
52 /**
53 * @typedef {Object} StoredLearningPath
54 * @property {'knowtation.learning_path/v0'} schema
55 * @property {string} path_id
56 * @property {PathScope} scope
57 * @property {PathStatus} status
58 * @property {string} title
59 * @property {string} summary
60 * @property {string} goal
61 * @property {StoredLearningPathStep[]} steps
62 * @property {number} current_step_index
63 * @property {number} step_count
64 * @property {string} next_step_title
65 * @property {string} active_decisions
66 * @property {string} workspace_id
67 * @property {string|null} note_path
68 * @property {string} [external_ref]
69 * @property {string} created
70 * @property {string} updated
71 */
72
73 /**
74 * @param {unknown} scope
75 * @returns {scope is PathScope}
76 */
77 export function isPathScope(scope) {
78 return scope === 'personal' || scope === 'project' || scope === 'org';
79 }
80
81 /**
82 * @param {unknown} status
83 * @returns {status is PathStatus}
84 */
85 export function isPathStatus(status) {
86 return status === 'active' || status === 'paused' || status === 'archived';
87 }
88
89 /**
90 * Server-minted path_id: `path_` + 16 lowercase hex from 8 random bytes.
91 *
92 * @returns {string}
93 */
94 export function mintPathId() {
95 return `path_${randomBytes(8).toString('hex')}`;
96 }
97
98 /**
99 * Mint a path_id not already present in `existingIds`.
100 *
101 * @param {Iterable<string>} existingIds
102 * @returns {string}
103 */
104 export function mintUniquePathId(existingIds) {
105 const seen = existingIds instanceof Set ? existingIds : new Set(existingIds);
106 for (let i = 0; i < 32; i += 1) {
107 const id = mintPathId();
108 if (!seen.has(id) && PATH_ID_RE.test(id)) return id;
109 }
110 throw new Error('PATH_ID_MINT_EXHAUSTED');
111 }
112
113 /**
114 * @param {string} value
115 * @returns {boolean}
116 */
117 export function hasControlChars(value) {
118 return typeof value === 'string' && CONTROL_CHAR_RE.test(value);
119 }
120
121 /**
122 * Vault-relative note pointer. Null when absent.
123 *
124 * @param {unknown} raw
125 * @returns {{ ok: true, note_path: string|null } | { ok: false, code: string, reason: string }}
126 */
127 export function validateNotePath(raw) {
128 if (raw == null || raw === '') {
129 return { ok: true, note_path: null };
130 }
131 if (typeof raw !== 'string') {
132 return { ok: false, code: 'PATH_NOTE_PATH_INVALID', reason: 'note_path must be a string or null' };
133 }
134 const notePath = raw.trim();
135 if (!notePath) return { ok: true, note_path: null };
136 if (notePath.length > MAX_NOTE_PATH) {
137 return { ok: false, code: 'PATH_NOTE_PATH_INVALID', reason: 'note_path exceeds 256 chars' };
138 }
139 if (notePath.startsWith('/') || notePath.startsWith('~') || notePath.includes('\\')) {
140 return { ok: false, code: 'PATH_NOTE_PATH_INVALID', reason: 'note_path must be vault-relative' };
141 }
142 if (notePath.includes('://') || /^[A-Za-z]:/.test(notePath)) {
143 return { ok: false, code: 'PATH_NOTE_PATH_INVALID', reason: 'note_path must not be a URL or drive path' };
144 }
145 const segments = notePath.split('/');
146 if (segments.some((seg) => seg === '..' || seg === '.')) {
147 return { ok: false, code: 'PATH_NOTE_PATH_INVALID', reason: 'note_path must not contain .. segments' };
148 }
149 if (!NOTE_PATH_RE.test(notePath)) {
150 return { ok: false, code: 'PATH_NOTE_PATH_INVALID', reason: 'note_path must match vault-relative *.md' };
151 }
152 return { ok: true, note_path: notePath };
153 }
154
155 /**
156 * @param {unknown} raw
157 * @returns {{ ok: true, steps: StoredLearningPathStep[] } | { ok: false, code: string, reason: string }}
158 */
159 export function validateSteps(raw) {
160 if (!Array.isArray(raw) || raw.length < MIN_STEPS || raw.length > MAX_STEPS) {
161 return {
162 ok: false,
163 code: 'BAD_REQUEST',
164 reason: 'steps must be an array of 1–20 items',
165 };
166 }
167 /** @type {StoredLearningPathStep[]} */
168 const steps = [];
169 for (const item of raw) {
170 if (!item || typeof item !== 'object') {
171 return { ok: false, code: 'BAD_REQUEST', reason: 'each step must be an object' };
172 }
173 const row = /** @type {Record<string, unknown>} */ (item);
174 if (typeof row.title !== 'string') {
175 return { ok: false, code: 'BAD_REQUEST', reason: 'step.title is required' };
176 }
177 const title = row.title.trim();
178 if (!title || title.length > MAX_STEP_TITLE || hasControlChars(title)) {
179 return {
180 ok: false,
181 code: hasControlChars(title) ? 'PATH_TEXT_INVALID' : 'BAD_REQUEST',
182 reason: 'invalid step.title',
183 };
184 }
185 if (typeof row.objective !== 'string') {
186 return { ok: false, code: 'BAD_REQUEST', reason: 'step.objective is required' };
187 }
188 const objective = row.objective.trim();
189 if (!objective || objective.length > MAX_STEP_OBJECTIVE || hasControlChars(objective)) {
190 return {
191 ok: false,
192 code: hasControlChars(objective) ? 'PATH_TEXT_INVALID' : 'BAD_REQUEST',
193 reason: 'invalid step.objective',
194 };
195 }
196 const idsRaw = row.source_document_ids;
197 /** @type {string[]} */
198 const sourceDocumentIds = [];
199 if (idsRaw == null) {
200 // omit → []
201 } else if (!Array.isArray(idsRaw)) {
202 return { ok: false, code: 'BAD_REQUEST', reason: 'source_document_ids must be an array' };
203 } else {
204 if (idsRaw.length > MAX_SOURCE_DOCUMENT_IDS) {
205 return { ok: false, code: 'BAD_REQUEST', reason: 'source_document_ids exceeds 16 items' };
206 }
207 for (const id of idsRaw) {
208 if (typeof id !== 'string' || !SOURCE_DOCUMENT_ID_RE.test(id)) {
209 return { ok: false, code: 'BAD_REQUEST', reason: 'invalid source_document_id' };
210 }
211 sourceDocumentIds.push(id);
212 }
213 }
214 steps.push({ title, objective, source_document_ids: sourceDocumentIds });
215 }
216 return { ok: true, steps };
217 }
218
219 /**
220 * @param {unknown} raw
221 * @returns {{ ok: true, path: StoredLearningPath } | { ok: false, code: string, reason: string }}
222 */
223 export function validateLearningPathRecord(raw) {
224 if (!raw || typeof raw !== 'object') {
225 return { ok: false, code: 'BAD_REQUEST', reason: 'learning_path must be an object' };
226 }
227 const row = /** @type {Record<string, unknown>} */ (raw);
228 if (row.schema !== LEARNING_PATH_SCHEMA) {
229 return { ok: false, code: 'BAD_REQUEST', reason: 'schema must be knowtation.learning_path/v0' };
230 }
231 if (typeof row.path_id !== 'string' || !PATH_ID_RE.test(row.path_id)) {
232 return { ok: false, code: 'PATH_NOT_FOUND', reason: 'invalid path_id' };
233 }
234 if (!isPathScope(row.scope)) {
235 return { ok: false, code: 'BAD_REQUEST', reason: 'scope must be personal|project|org' };
236 }
237 if (!isPathStatus(row.status)) {
238 return { ok: false, code: 'BAD_REQUEST', reason: 'status must be active|paused|archived' };
239 }
240
241 const title = typeof row.title === 'string' ? row.title.trim() : '';
242 if (!title || title.length > MAX_TITLE) {
243 return { ok: false, code: 'BAD_REQUEST', reason: 'title must be 1–200 chars' };
244 }
245 if (hasControlChars(title)) {
246 return { ok: false, code: 'PATH_TEXT_INVALID', reason: 'title contains control characters' };
247 }
248
249 const summary = typeof row.summary === 'string' ? row.summary.trim() : '';
250 if (!summary || summary.length > MAX_SUMMARY) {
251 return { ok: false, code: 'BAD_REQUEST', reason: 'summary must be 1–2000 chars' };
252 }
253 if (hasControlChars(summary)) {
254 return { ok: false, code: 'PATH_TEXT_INVALID', reason: 'summary contains control characters' };
255 }
256
257 const goal = typeof row.goal === 'string' ? row.goal.trim() : '';
258 if (!goal || goal.length > MAX_GOAL) {
259 return { ok: false, code: 'BAD_REQUEST', reason: 'goal must be 1–180 chars' };
260 }
261 if (hasControlChars(goal)) {
262 return { ok: false, code: 'PATH_TEXT_INVALID', reason: 'goal contains control characters' };
263 }
264
265 const stepsResult = validateSteps(row.steps);
266 if (!stepsResult.ok) return stepsResult;
267 const { steps } = stepsResult;
268
269 if (
270 typeof row.current_step_index !== 'number' ||
271 !Number.isInteger(row.current_step_index) ||
272 row.current_step_index < 0 ||
273 row.current_step_index >= steps.length
274 ) {
275 return {
276 ok: false,
277 code: 'PATH_STEP_INDEX_INVALID',
278 reason: 'current_step_index must be >= 0 and < steps.length',
279 };
280 }
281
282 const activeDecisions =
283 row.active_decisions == null ? '' : typeof row.active_decisions === 'string' ? row.active_decisions.trim() : null;
284 if (activeDecisions == null || activeDecisions.length > MAX_ACTIVE_DECISIONS) {
285 return { ok: false, code: 'BAD_REQUEST', reason: 'active_decisions must be a string up to 240 chars' };
286 }
287 if (hasControlChars(activeDecisions)) {
288 return { ok: false, code: 'PATH_TEXT_INVALID', reason: 'active_decisions contains control characters' };
289 }
290
291 if (typeof row.workspace_id !== 'string' || !WORKSPACE_ID_RE.test(row.workspace_id)) {
292 return { ok: false, code: 'BAD_REQUEST', reason: 'invalid workspace_id' };
293 }
294
295 const noteResult = validateNotePath(row.note_path);
296 if (!noteResult.ok) return noteResult;
297
298 if (typeof row.created !== 'string' || !row.created.trim()) {
299 return { ok: false, code: 'BAD_REQUEST', reason: 'created must be ISO8601' };
300 }
301 if (typeof row.updated !== 'string' || !row.updated.trim()) {
302 return { ok: false, code: 'BAD_REQUEST', reason: 'updated must be ISO8601' };
303 }
304
305 /** @type {StoredLearningPath} */
306 const path = {
307 schema: LEARNING_PATH_SCHEMA,
308 path_id: row.path_id,
309 scope: row.scope,
310 status: row.status,
311 title,
312 summary,
313 goal,
314 steps,
315 current_step_index: row.current_step_index,
316 step_count: steps.length,
317 next_step_title: steps[row.current_step_index].title,
318 active_decisions: activeDecisions,
319 workspace_id: row.workspace_id,
320 note_path: noteResult.note_path,
321 created: String(row.created),
322 updated: String(row.updated),
323 };
324 if (typeof row.external_ref === 'string' && row.external_ref.trim()) {
325 path.external_ref = row.external_ref.trim();
326 }
327 return { ok: true, path };
328 }
329
330 /**
331 * List summary — no steps, summary, or note_path.
332 *
333 * @param {StoredLearningPath} path
334 */
335 export function learningPathSummaryForClient(path) {
336 return {
337 schema: LEARNING_PATH_SCHEMA,
338 path_id: path.path_id,
339 scope: path.scope,
340 status: path.status,
341 title: path.title,
342 goal: path.goal,
343 current_step_index: path.current_step_index,
344 step_count: path.step_count,
345 next_step_title: path.next_step_title,
346 active_decisions: path.active_decisions,
347 workspace_id: path.workspace_id,
348 updated: path.updated,
349 };
350 }
351
352 /**
353 * Full client projection including steps and note_path.
354 *
355 * @param {StoredLearningPath} path
356 */
357 export function learningPathForClient(path) {
358 return {
359 schema: LEARNING_PATH_SCHEMA,
360 path_id: path.path_id,
361 scope: path.scope,
362 status: path.status,
363 title: path.title,
364 summary: path.summary,
365 goal: path.goal,
366 steps: path.steps.map((s) => ({
367 title: s.title,
368 objective: s.objective,
369 source_document_ids: Array.isArray(s.source_document_ids) ? [...s.source_document_ids] : [],
370 })),
371 current_step_index: path.current_step_index,
372 step_count: path.step_count,
373 next_step_title: path.next_step_title,
374 active_decisions: path.active_decisions,
375 workspace_id: path.workspace_id,
376 note_path: path.note_path ?? null,
377 created: path.created,
378 updated: path.updated,
379 ...(typeof path.external_ref === 'string' ? { external_ref: path.external_ref } : {}),
380 };
381 }
382
383 /**
384 * @param {import('../flow/flow-store.mjs').VaultFlowStore} vault
385 */
386 export function ensureLearningPathBucket(vault) {
387 if (!Array.isArray(vault.learning_paths)) {
388 vault.learning_paths = [];
389 }
390 }
391
392 /**
393 * @param {import('../flow/flow-store.mjs').FlowStoreFile} store
394 * @param {string} vaultId
395 */
396 function ensureVaultInStore(store, vaultId) {
397 if (!store.vaults[vaultId]) {
398 store.vaults[vaultId] = {
399 flows: [],
400 steps: [],
401 runs: [],
402 candidates: [],
403 projections: [],
404 tasks: [],
405 task_loops: [],
406 orchestrator_graphs: [],
407 learning_paths: [],
408 };
409 } else {
410 ensureLearningPathBucket(store.vaults[vaultId]);
411 }
412 return store.vaults[vaultId];
413 }
414
415 /**
416 * @param {string} dataDir
417 * @param {string} vaultId
418 * @returns {StoredLearningPath[]}
419 */
420 export function loadLearningPaths(dataDir, vaultId) {
421 const store = loadFlowStore(dataDir);
422 const vault = store.vaults[vaultId];
423 if (!vault) return [];
424 ensureLearningPathBucket(vault);
425 return /** @type {StoredLearningPath[]} */ (vault.learning_paths);
426 }
427
428 /**
429 * Upsert one path row (last-updated wins on the same path_id). Tests seed via this helper.
430 *
431 * @param {string} dataDir
432 * @param {string} vaultId
433 * @param {StoredLearningPath} record
434 * @returns {StoredLearningPath}
435 */
436 export function upsertLearningPath(dataDir, vaultId, record) {
437 const store = loadFlowStore(dataDir);
438 const vault = ensureVaultInStore(store, vaultId);
439 const idx = vault.learning_paths.findIndex((p) => p.path_id === record.path_id);
440 if (idx >= 0) {
441 vault.learning_paths[idx] = record;
442 } else {
443 vault.learning_paths.push(record);
444 }
445 saveFlowStore(dataDir, store);
446 return record;
447 }
448
449 /**
450 * @param {string} dataDir
451 * @param {string} vaultId
452 * @param {{
453 * visibleScopes?: Set<PathScope>,
454 * filterScopes?: Set<PathScope>,
455 * effectiveScope: PathScope,
456 * workspaceId?: string,
457 * status?: string,
458 * includeArchived?: boolean,
459 * limit?: number,
460 * }} query
461 */
462 export function listLearningPaths(dataDir, vaultId, query) {
463 const filterScopes = query.filterScopes ?? query.visibleScopes ?? new Set(['personal']);
464 const workspaceId =
465 typeof query.workspaceId === 'string' && query.workspaceId.trim() ? query.workspaceId.trim() : '';
466 const statusFilter =
467 typeof query.status === 'string' && query.status.trim() ? query.status.trim() : '';
468
469 let limit = typeof query.limit === 'number' ? query.limit : MAX_LEARNING_PATH_SUMMARIES;
470 if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LEARNING_PATH_SUMMARIES) {
471 limit = MAX_LEARNING_PATH_SUMMARIES;
472 }
473
474 const paths = loadLearningPaths(dataDir, vaultId);
475 let candidates = paths.filter((row) => {
476 if (!filterScopes.has(row.scope)) return false;
477 if (workspaceId && row.workspace_id !== workspaceId) return false;
478 if (statusFilter) {
479 if (row.status !== statusFilter) return false;
480 } else if (row.status === 'archived') {
481 return false;
482 }
483 return true;
484 });
485
486 candidates.sort((a, b) => {
487 const t = Date.parse(b.updated ?? 0) - Date.parse(a.updated ?? 0);
488 if (t !== 0) return t;
489 return a.path_id.localeCompare(b.path_id);
490 });
491
492 const truncated = candidates.length > limit;
493 if (candidates.length > limit) {
494 candidates = candidates.slice(0, limit);
495 }
496
497 return {
498 schema: LEARNING_PATH_LIST_SCHEMA,
499 vault_id: vaultId,
500 effective_scope: query.effectiveScope,
501 paths: candidates.map((row) => learningPathSummaryForClient(row)),
502 truncated,
503 };
504 }
505
506 /**
507 * Get one path when visible; null for missing, invalid id, or out of scope (no leak).
508 *
509 * @param {string} dataDir
510 * @param {string} vaultId
511 * @param {string} pathId
512 * @param {{ visibleScopes?: Set<PathScope> }} query
513 * @returns {StoredLearningPath|null}
514 */
515 export function getLearningPath(dataDir, vaultId, pathId, query) {
516 if (typeof pathId !== 'string' || !PATH_ID_RE.test(pathId)) {
517 return null;
518 }
519 const filterScopes = query.visibleScopes ?? new Set(['personal']);
520 const row = loadLearningPaths(dataDir, vaultId).find((p) => p.path_id === pathId);
521 if (!row) return null;
522 if (!filterScopes.has(row.scope)) return null;
523 return row;
524 }
525
526 /**
527 * @param {Set<PathScope>} visibleScopes
528 * @returns {PathScope}
529 */
530 export function pathGetEffectiveScope(visibleScopes) {
531 return highestFlowScope(visibleScopes);
532 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 12 days ago