@medicine-wheel/storage-provider 0.4.12 → 0.4.13

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/index.d.ts CHANGED
@@ -15,7 +15,8 @@
15
15
  * const node = await store.getNode('elder-1');
16
16
  * ```
17
17
  */
18
- export type { StorageProvider, RelationalNode, RelationalEdge, CeremonyLog, WeaveRecord, WeaveSyncState, InquiryWeaveFilters, ProviderType, } from './interface.js';
18
+ export type { StorageProvider, RelationalNode, RelationalEdge, CeremonyLog, WeaveRecord, WeaveSyncState, InquiryWeaveFilters, PlanPerspectiveRecord, PlanPerspectiveEpisode, PlanPerspectiveFilters, ProviderType, } from './interface.js';
19
+ export { mergePlanPerspectiveRecords, unionPlanPerspectiveEpisodes, matchesPlanPerspectiveFilters, } from './plan-perspectives.js';
19
20
  export { createProvider, detectProvider } from './factory.js';
20
21
  export { JsonlProvider } from './jsonl.js';
21
22
  export { NeonProvider } from './neon.js';
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@
15
15
  * const node = await store.getNode('elder-1');
16
16
  * ```
17
17
  */
18
+ // Plan Perspective merge/filter semantics (shared across providers)
19
+ export { mergePlanPerspectiveRecords, unionPlanPerspectiveEpisodes, matchesPlanPerspectiveFilters, } from './plan-perspectives.js';
18
20
  // Factory
19
21
  export { createProvider, detectProvider } from './factory.js';
20
22
  // Providers (for direct instantiation)
@@ -51,6 +51,56 @@ export interface InquiryWeaveFilters {
51
51
  issue?: string;
52
52
  artefact?: string;
53
53
  }
54
+ export interface PlanPerspectiveEpisode {
55
+ path: string;
56
+ number?: number;
57
+ [key: string]: unknown;
58
+ }
59
+ export interface PlanPerspectiveRecord extends Record<string, unknown> {
60
+ id: string;
61
+ perspective: 1;
62
+ plan: {
63
+ session_id: string;
64
+ plan_path?: string;
65
+ plan_filename: string;
66
+ plan_sha256: string;
67
+ captured_at?: string;
68
+ [key: string]: unknown;
69
+ };
70
+ narrative: {
71
+ title: string;
72
+ body_markdown: string;
73
+ mia_context?: string;
74
+ [key: string]: unknown;
75
+ };
76
+ lineage?: {
77
+ user_inputs_path?: string;
78
+ input_count?: number;
79
+ first_input_at?: string;
80
+ last_input_at?: string;
81
+ excerpts?: string[];
82
+ [key: string]: unknown;
83
+ };
84
+ episodes: PlanPerspectiveEpisode[];
85
+ source: {
86
+ package?: string;
87
+ generator?: {
88
+ system?: string;
89
+ agent?: string;
90
+ model?: string;
91
+ producer_session_id?: string;
92
+ [key: string]: unknown;
93
+ };
94
+ registered_at: string;
95
+ updated_at: string;
96
+ [key: string]: unknown;
97
+ };
98
+ }
99
+ export interface PlanPerspectiveFilters {
100
+ episode_path?: string;
101
+ session_id?: string;
102
+ id?: string;
103
+ }
54
104
  export interface StorageProvider {
55
105
  readonly name: string;
56
106
  connect(): Promise<void>;
@@ -74,5 +124,8 @@ export interface StorageProvider {
74
124
  registerInquiryWeave(record: WeaveRecord): Promise<void>;
75
125
  getInquiryWeave(id: string): Promise<WeaveRecord | null>;
76
126
  listInquiryWeaves(filters?: InquiryWeaveFilters): Promise<WeaveRecord[]>;
127
+ registerPlanPerspective(record: PlanPerspectiveRecord): Promise<PlanPerspectiveRecord>;
128
+ getPlanPerspective(id: string): Promise<PlanPerspectiveRecord | null>;
129
+ listPlanPerspectives(filters?: PlanPerspectiveFilters): Promise<PlanPerspectiveRecord[]>;
77
130
  }
78
131
  export type ProviderType = 'jsonl' | 'neon' | 'redis' | 'auto';
package/dist/jsonl.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { DirectionName, NodeType, CeremonyType } from '@medicine-wheel/ontology-core';
2
- import type { StorageProvider, RelationalNode, RelationalEdge, CeremonyLog, WeaveRecord, InquiryWeaveFilters } from './interface.js';
2
+ import type { StorageProvider, RelationalNode, RelationalEdge, CeremonyLog, WeaveRecord, InquiryWeaveFilters, PlanPerspectiveRecord, PlanPerspectiveFilters } from './interface.js';
3
3
  export declare class JsonlProvider implements StorageProvider {
4
4
  readonly name = "jsonl";
5
5
  readonly dataDir: string;
@@ -7,6 +7,7 @@ export declare class JsonlProvider implements StorageProvider {
7
7
  private readonly edgesFile;
8
8
  private readonly ceremoniesFile;
9
9
  private readonly inquiryWeavesFile;
10
+ private readonly planPerspectivesFile;
10
11
  constructor(dataDir?: string);
11
12
  connect(): Promise<void>;
12
13
  disconnect(): Promise<void>;
@@ -29,10 +30,14 @@ export declare class JsonlProvider implements StorageProvider {
29
30
  registerInquiryWeave(record: WeaveRecord): Promise<void>;
30
31
  getInquiryWeave(id: string): Promise<WeaveRecord | null>;
31
32
  listInquiryWeaves(filters?: InquiryWeaveFilters): Promise<WeaveRecord[]>;
33
+ registerPlanPerspective(record: PlanPerspectiveRecord): Promise<PlanPerspectiveRecord>;
34
+ getPlanPerspective(id: string): Promise<PlanPerspectiveRecord | null>;
35
+ listPlanPerspectives(filters?: PlanPerspectiveFilters): Promise<PlanPerspectiveRecord[]>;
32
36
  private readNodes;
33
37
  private readEdges;
34
38
  private readCeremonies;
35
39
  private readInquiryWeaves;
40
+ private readPlanPerspectives;
36
41
  private upsertById;
37
42
  private upsertEdge;
38
43
  private parseNode;
package/dist/jsonl.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { randomUUID } from 'crypto';
4
+ import { mergePlanPerspectiveRecords, matchesPlanPerspectiveFilters, } from './plan-perspectives.js';
4
5
  export class JsonlProvider {
5
6
  name = 'jsonl';
6
7
  dataDir;
@@ -8,12 +9,14 @@ export class JsonlProvider {
8
9
  edgesFile;
9
10
  ceremoniesFile;
10
11
  inquiryWeavesFile;
12
+ planPerspectivesFile;
11
13
  constructor(dataDir) {
12
14
  this.dataDir = path.resolve(dataDir ?? resolveProjectDataDir());
13
15
  this.nodesFile = path.join(this.dataDir, 'nodes.jsonl');
14
16
  this.edgesFile = path.join(this.dataDir, 'edges.jsonl');
15
17
  this.ceremoniesFile = path.join(this.dataDir, 'ceremonies.jsonl');
16
18
  this.inquiryWeavesFile = path.join(this.dataDir, 'inquiry-weaves.jsonl');
19
+ this.planPerspectivesFile = path.join(this.dataDir, 'plan-perspectives.jsonl');
17
20
  }
18
21
  async connect() {
19
22
  ensureDirectory(this.dataDir);
@@ -129,6 +132,24 @@ export class JsonlProvider {
129
132
  async listInquiryWeaves(filters = {}) {
130
133
  return this.readInquiryWeaves().filter((record) => matchesInquiryWeaveFilters(record, filters));
131
134
  }
135
+ async registerPlanPerspective(record) {
136
+ let merged = record;
137
+ await withWriteLock(this.planPerspectivesFile, () => {
138
+ const records = readJsonl(this.planPerspectivesFile);
139
+ const existing = records.find((candidate) => candidate.id === record.id);
140
+ merged = existing ? mergePlanPerspectiveRecords(existing, record) : record;
141
+ const nextRecords = records.filter((candidate) => candidate.id !== record.id);
142
+ nextRecords.push(merged);
143
+ writeJsonl(this.planPerspectivesFile, nextRecords);
144
+ });
145
+ return merged;
146
+ }
147
+ async getPlanPerspective(id) {
148
+ return this.readPlanPerspectives().find((record) => record.id === id) ?? null;
149
+ }
150
+ async listPlanPerspectives(filters = {}) {
151
+ return this.readPlanPerspectives().filter((record) => matchesPlanPerspectiveFilters(record, filters));
152
+ }
132
153
  readNodes() {
133
154
  return readJsonl(this.nodesFile);
134
155
  }
@@ -141,6 +162,9 @@ export class JsonlProvider {
141
162
  readInquiryWeaves() {
142
163
  return readJsonl(this.inquiryWeavesFile);
143
164
  }
165
+ readPlanPerspectives() {
166
+ return readJsonl(this.planPerspectivesFile);
167
+ }
144
168
  async upsertById(filePath, item) {
145
169
  await withWriteLock(filePath, () => {
146
170
  const nextRecords = readJsonl(filePath).filter((candidate) => candidate.id !== item.id);
package/dist/neon.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Neon (Postgres) Storage Provider
3
3
  */
4
4
  import type { DirectionName, NodeType, CeremonyType } from '@medicine-wheel/ontology-core';
5
- import type { StorageProvider, RelationalNode, RelationalEdge, CeremonyLog, WeaveRecord, InquiryWeaveFilters } from './interface.js';
5
+ import type { StorageProvider, RelationalNode, RelationalEdge, CeremonyLog, WeaveRecord, InquiryWeaveFilters, PlanPerspectiveRecord, PlanPerspectiveFilters } from './interface.js';
6
6
  export declare class NeonProvider implements StorageProvider {
7
7
  readonly name = "neon";
8
8
  private sql;
@@ -33,4 +33,8 @@ export declare class NeonProvider implements StorageProvider {
33
33
  listInquiryWeaves(filters?: InquiryWeaveFilters): Promise<WeaveRecord[]>;
34
34
  private getAllInquiryWeaves;
35
35
  private ensureInquiryWeavesTable;
36
+ registerPlanPerspective(record: PlanPerspectiveRecord): Promise<PlanPerspectiveRecord>;
37
+ getPlanPerspective(id: string): Promise<PlanPerspectiveRecord | null>;
38
+ listPlanPerspectives(filters?: PlanPerspectiveFilters): Promise<PlanPerspectiveRecord[]>;
39
+ private ensurePlanPerspectivesTable;
36
40
  }
package/dist/neon.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Neon (Postgres) Storage Provider
3
3
  */
4
+ import { mergePlanPerspectiveRecords, matchesPlanPerspectiveFilters, } from './plan-perspectives.js';
4
5
  export class NeonProvider {
5
6
  name = 'neon';
6
7
  sql = null;
@@ -11,6 +12,7 @@ export class NeonProvider {
11
12
  const { neon } = await import('@neondatabase/serverless');
12
13
  this.sql = neon(url);
13
14
  await this.ensureInquiryWeavesTable();
15
+ await this.ensurePlanPerspectivesTable();
14
16
  }
15
17
  async disconnect() {
16
18
  this.sql = null;
@@ -228,6 +230,73 @@ export class NeonProvider {
228
230
  await this.db `CREATE INDEX IF NOT EXISTS idx_inquiry_weaves_issue ON inquiry_weaves(issue)`;
229
231
  await this.db `CREATE INDEX IF NOT EXISTS idx_inquiry_weaves_artefact_id ON inquiry_weaves(artefact_id)`;
230
232
  }
233
+ // ── Plan Perspective Operations ──
234
+ async registerPlanPerspective(record) {
235
+ const existing = await this.getPlanPerspective(record.id);
236
+ const merged = existing ? mergePlanPerspectiveRecords(existing, record) : record;
237
+ const episodePaths = merged.episodes.map((episode) => episode.path);
238
+ await this.db `
239
+ INSERT INTO plan_perspectives (id, payload, session_id, episode_paths, updated_at)
240
+ VALUES (
241
+ ${merged.id},
242
+ ${JSON.stringify(merged)},
243
+ ${merged.plan.session_id},
244
+ ${episodePaths},
245
+ NOW()
246
+ )
247
+ ON CONFLICT (id) DO UPDATE SET
248
+ payload = EXCLUDED.payload,
249
+ session_id = EXCLUDED.session_id,
250
+ episode_paths = EXCLUDED.episode_paths,
251
+ updated_at = NOW()
252
+ `;
253
+ return merged;
254
+ }
255
+ async getPlanPerspective(id) {
256
+ const rows = await this.db `SELECT payload FROM plan_perspectives WHERE id = ${id}`;
257
+ if (rows.length === 0)
258
+ return null;
259
+ return parseJsonValue(rows[0].payload, null);
260
+ }
261
+ async listPlanPerspectives(filters = {}) {
262
+ let rows;
263
+ if (filters.id !== undefined) {
264
+ rows = await this.db `SELECT payload FROM plan_perspectives WHERE id = ${filters.id}`;
265
+ }
266
+ else if (filters.session_id !== undefined) {
267
+ rows = await this.db `
268
+ SELECT payload FROM plan_perspectives
269
+ WHERE session_id = ${filters.session_id}
270
+ ORDER BY updated_at DESC
271
+ `;
272
+ }
273
+ else if (filters.episode_path !== undefined) {
274
+ rows = await this.db `
275
+ SELECT payload FROM plan_perspectives
276
+ WHERE episode_paths @> ARRAY[${filters.episode_path}]
277
+ ORDER BY updated_at DESC
278
+ `;
279
+ }
280
+ else {
281
+ rows = await this.db `SELECT payload FROM plan_perspectives ORDER BY updated_at DESC`;
282
+ }
283
+ return rows
284
+ .map((row) => parseJsonValue(row.payload, null))
285
+ .filter((record) => record !== null && matchesPlanPerspectiveFilters(record, filters));
286
+ }
287
+ async ensurePlanPerspectivesTable() {
288
+ await this.db `
289
+ CREATE TABLE IF NOT EXISTS plan_perspectives (
290
+ id TEXT PRIMARY KEY,
291
+ payload JSONB NOT NULL,
292
+ session_id TEXT NOT NULL,
293
+ episode_paths TEXT[] NOT NULL DEFAULT '{}',
294
+ updated_at TIMESTAMPTZ DEFAULT NOW()
295
+ )
296
+ `;
297
+ await this.db `CREATE INDEX IF NOT EXISTS idx_plan_perspectives_session_id ON plan_perspectives(session_id)`;
298
+ await this.db `CREATE INDEX IF NOT EXISTS idx_plan_perspectives_episode_paths ON plan_perspectives USING GIN(episode_paths)`;
299
+ }
231
300
  }
232
301
  function getConnectionString() {
233
302
  return process.env.DATABASE_URL ?? process.env.POSTGRES_URL ?? process.env.NEON_DATABASE_URL;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Plan Perspective merge and filter semantics shared by every provider.
3
+ *
4
+ * Medicine-wheel stores relational projections of Miette perspectives; the
5
+ * session file remains authoritative. Nothing here generates or rewrites
6
+ * narrative prose — records pass through untouched apart from the episode
7
+ * union and source timestamps pinned by @miadi/plan-insight's
8
+ * mergePerspectiveRecords contract.
9
+ */
10
+ import type { PlanPerspectiveEpisode, PlanPerspectiveFilters, PlanPerspectiveRecord } from './interface.js';
11
+ /** Union episode relations by exact path; a known number backfills an entry missing one. */
12
+ export declare function unionPlanPerspectiveEpisodes(episodes: readonly PlanPerspectiveEpisode[]): PlanPerspectiveEpisode[];
13
+ /**
14
+ * Upsert merge: the incoming projection wins, episodes union by path, and the
15
+ * original registration timestamp survives while updated_at refreshes.
16
+ */
17
+ export declare function mergePlanPerspectiveRecords(existing: PlanPerspectiveRecord, incoming: PlanPerspectiveRecord): PlanPerspectiveRecord;
18
+ export declare function matchesPlanPerspectiveFilters(record: PlanPerspectiveRecord, filters: PlanPerspectiveFilters): boolean;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Plan Perspective merge and filter semantics shared by every provider.
3
+ *
4
+ * Medicine-wheel stores relational projections of Miette perspectives; the
5
+ * session file remains authoritative. Nothing here generates or rewrites
6
+ * narrative prose — records pass through untouched apart from the episode
7
+ * union and source timestamps pinned by @miadi/plan-insight's
8
+ * mergePerspectiveRecords contract.
9
+ */
10
+ /** Union episode relations by exact path; a known number backfills an entry missing one. */
11
+ export function unionPlanPerspectiveEpisodes(episodes) {
12
+ const byPath = new Map();
13
+ for (const episode of episodes) {
14
+ if (typeof episode?.path !== 'string' || episode.path.length === 0)
15
+ continue;
16
+ const existing = byPath.get(episode.path);
17
+ if (!existing) {
18
+ byPath.set(episode.path, { ...episode });
19
+ }
20
+ else if (existing.number === undefined && typeof episode.number === 'number') {
21
+ existing.number = episode.number;
22
+ }
23
+ }
24
+ return [...byPath.values()];
25
+ }
26
+ /**
27
+ * Upsert merge: the incoming projection wins, episodes union by path, and the
28
+ * original registration timestamp survives while updated_at refreshes.
29
+ */
30
+ export function mergePlanPerspectiveRecords(existing, incoming) {
31
+ return {
32
+ ...incoming,
33
+ episodes: unionPlanPerspectiveEpisodes([...existing.episodes, ...incoming.episodes]),
34
+ source: {
35
+ ...incoming.source,
36
+ registered_at: existing.source.registered_at,
37
+ updated_at: incoming.source.updated_at,
38
+ },
39
+ };
40
+ }
41
+ export function matchesPlanPerspectiveFilters(record, filters) {
42
+ if (filters.id !== undefined && record.id !== filters.id) {
43
+ return false;
44
+ }
45
+ if (filters.session_id !== undefined && record.plan?.session_id !== filters.session_id) {
46
+ return false;
47
+ }
48
+ if (filters.episode_path !== undefined &&
49
+ !record.episodes?.some((episode) => episode?.path === filters.episode_path)) {
50
+ return false;
51
+ }
52
+ return true;
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medicine-wheel/storage-provider",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -16,7 +16,7 @@
16
16
  "dependencies": {
17
17
  "@neondatabase/serverless": "^0.10.0",
18
18
  "@upstash/redis": "^1.34.3",
19
- "@medicine-wheel/ontology-core": "^0.4.12"
19
+ "@medicine-wheel/ontology-core": "^0.4.13"
20
20
  },
21
21
  "devDependencies": {
22
22
  "typescript": "^5.7.0"