@medicine-wheel/storage-provider 0.2.2
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/README.md +18 -0
- package/dist/factory.d.ts +18 -0
- package/dist/factory.js +64 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +22 -0
- package/dist/interface.d.ts +36 -0
- package/dist/interface.js +7 -0
- package/dist/jsonl.d.ts +35 -0
- package/dist/jsonl.js +349 -0
- package/dist/neon.d.ts +30 -0
- package/dist/neon.js +196 -0
- package/package.json +24 -0
package/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# @medicine-wheel/storage-provider
|
|
2
|
+
|
|
3
|
+
Abstract storage layer for Medicine Wheel data with JSONL and Neon backends, plus a future Redis path.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @medicine-wheel/storage-provider
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createProvider } from '@medicine-wheel/storage-provider';
|
|
15
|
+
|
|
16
|
+
const store = await createProvider();
|
|
17
|
+
```
|
|
18
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Provider Factory
|
|
3
|
+
*
|
|
4
|
+
* Defaults to JSONL unless the backend is explicitly selected.
|
|
5
|
+
*/
|
|
6
|
+
import type { StorageProvider, ProviderType } from './interface.js';
|
|
7
|
+
/**
|
|
8
|
+
* Create a storage provider instance.
|
|
9
|
+
*
|
|
10
|
+
* @param type - 'jsonl', 'neon', 'redis', or 'auto' (default)
|
|
11
|
+
* @returns Connected StorageProvider instance
|
|
12
|
+
*/
|
|
13
|
+
export declare function createProvider(type?: ProviderType): Promise<StorageProvider>;
|
|
14
|
+
/**
|
|
15
|
+
* Detect which provider is available without connecting.
|
|
16
|
+
* Always returns a concrete non-null provider type; falls back to 'jsonl'.
|
|
17
|
+
*/
|
|
18
|
+
export declare function detectProvider(): ProviderType;
|
package/dist/factory.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Provider Factory
|
|
3
|
+
*
|
|
4
|
+
* Defaults to JSONL unless the backend is explicitly selected.
|
|
5
|
+
*/
|
|
6
|
+
import { JsonlProvider } from './jsonl.js';
|
|
7
|
+
import { NeonProvider } from './neon.js';
|
|
8
|
+
function normalizeProviderType(value) {
|
|
9
|
+
switch (value.toLowerCase()) {
|
|
10
|
+
case 'jsonl':
|
|
11
|
+
case 'local':
|
|
12
|
+
case 'file':
|
|
13
|
+
return 'jsonl';
|
|
14
|
+
case 'neon':
|
|
15
|
+
case 'postgres':
|
|
16
|
+
return 'neon';
|
|
17
|
+
case 'redis':
|
|
18
|
+
case 'upstash':
|
|
19
|
+
return 'redis';
|
|
20
|
+
default:
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function getConfiguredProvider() {
|
|
25
|
+
const raw = process.env.MW_STORAGE_PROVIDER;
|
|
26
|
+
if (!raw)
|
|
27
|
+
return null;
|
|
28
|
+
const normalized = normalizeProviderType(raw);
|
|
29
|
+
if (!normalized) {
|
|
30
|
+
// Truncate raw value to avoid leaking arbitrarily long env content into logs.
|
|
31
|
+
const displayValue = raw.length > 40 ? `${raw.slice(0, 40)}…` : raw;
|
|
32
|
+
throw new Error(`Unsupported MW_STORAGE_PROVIDER value: "${displayValue}". Expected jsonl, neon/postgres, or redis/upstash.`);
|
|
33
|
+
}
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
36
|
+
function instantiateProvider(type) {
|
|
37
|
+
switch (type) {
|
|
38
|
+
case 'jsonl':
|
|
39
|
+
return new JsonlProvider();
|
|
40
|
+
case 'neon':
|
|
41
|
+
return new NeonProvider();
|
|
42
|
+
case 'redis':
|
|
43
|
+
throw new Error('Redis provider not yet implemented. Use jsonl or neon.');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Create a storage provider instance.
|
|
48
|
+
*
|
|
49
|
+
* @param type - 'jsonl', 'neon', 'redis', or 'auto' (default)
|
|
50
|
+
* @returns Connected StorageProvider instance
|
|
51
|
+
*/
|
|
52
|
+
export async function createProvider(type = 'auto') {
|
|
53
|
+
const providerType = type === 'auto' ? getConfiguredProvider() ?? 'jsonl' : type;
|
|
54
|
+
const provider = instantiateProvider(providerType);
|
|
55
|
+
await provider.connect();
|
|
56
|
+
return provider;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Detect which provider is available without connecting.
|
|
60
|
+
* Always returns a concrete non-null provider type; falls back to 'jsonl'.
|
|
61
|
+
*/
|
|
62
|
+
export function detectProvider() {
|
|
63
|
+
return getConfiguredProvider() ?? 'jsonl';
|
|
64
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Medicine Wheel Storage Provider
|
|
3
|
+
*
|
|
4
|
+
* Abstract storage layer supporting multiple backends:
|
|
5
|
+
* - JSONL - Current default/local backend
|
|
6
|
+
* - Neon (Postgres) - First production backend path
|
|
7
|
+
* - Redis (Upstash) - Coming soon
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { createProvider } from '@medicine-wheel/storage-provider';
|
|
12
|
+
*
|
|
13
|
+
* const store = await createProvider(); // Defaults to JSONL unless MW_STORAGE_PROVIDER explicitly selects another backend
|
|
14
|
+
* await store.createNode({ id: 'elder-1', type: 'human', ... });
|
|
15
|
+
* const node = await store.getNode('elder-1');
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export type { StorageProvider, RelationalNode, RelationalEdge, CeremonyLog, ProviderType, } from './interface.js';
|
|
19
|
+
export { createProvider, detectProvider } from './factory.js';
|
|
20
|
+
export { JsonlProvider } from './jsonl.js';
|
|
21
|
+
export { NeonProvider } from './neon.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Medicine Wheel Storage Provider
|
|
3
|
+
*
|
|
4
|
+
* Abstract storage layer supporting multiple backends:
|
|
5
|
+
* - JSONL - Current default/local backend
|
|
6
|
+
* - Neon (Postgres) - First production backend path
|
|
7
|
+
* - Redis (Upstash) - Coming soon
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { createProvider } from '@medicine-wheel/storage-provider';
|
|
12
|
+
*
|
|
13
|
+
* const store = await createProvider(); // Defaults to JSONL unless MW_STORAGE_PROVIDER explicitly selects another backend
|
|
14
|
+
* await store.createNode({ id: 'elder-1', type: 'human', ... });
|
|
15
|
+
* const node = await store.getNode('elder-1');
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
// Factory
|
|
19
|
+
export { createProvider, detectProvider } from './factory.js';
|
|
20
|
+
// Providers (for direct instantiation)
|
|
21
|
+
export { JsonlProvider } from './jsonl.js';
|
|
22
|
+
export { NeonProvider } from './neon.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Provider Interface
|
|
3
|
+
*
|
|
4
|
+
* Abstract interface for Medicine Wheel data persistence.
|
|
5
|
+
* Implementations: JsonlProvider (local/default), NeonProvider (Postgres), RedisProvider (future)
|
|
6
|
+
*/
|
|
7
|
+
import type { DirectionName, NodeType, CeremonyType, RelationalNode as OntologyRelationalNode, RelationalEdge as OntologyRelationalEdge, CeremonyLog as OntologyCeremonyLog } from '@medicine-wheel/ontology-core';
|
|
8
|
+
export interface RelationalNode extends OntologyRelationalNode {
|
|
9
|
+
description?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface RelationalEdge extends Omit<OntologyRelationalEdge, 'id'> {
|
|
12
|
+
id?: string;
|
|
13
|
+
last_ceremony?: string;
|
|
14
|
+
}
|
|
15
|
+
export type CeremonyLog = OntologyCeremonyLog;
|
|
16
|
+
export interface StorageProvider {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
connect(): Promise<void>;
|
|
19
|
+
disconnect(): Promise<void>;
|
|
20
|
+
createNode(node: RelationalNode): Promise<void>;
|
|
21
|
+
getNode(id: string): Promise<RelationalNode | null>;
|
|
22
|
+
getNodesByType(type: NodeType): Promise<RelationalNode[]>;
|
|
23
|
+
getNodesByDirection(direction: DirectionName): Promise<RelationalNode[]>;
|
|
24
|
+
getAllNodes(limit?: number): Promise<RelationalNode[]>;
|
|
25
|
+
createEdge(edge: RelationalEdge): Promise<void>;
|
|
26
|
+
getEdge(fromId: string, toId: string): Promise<RelationalEdge | null>;
|
|
27
|
+
getRelatedNodes(nodeId: string, direction?: 'from' | 'to' | 'both'): Promise<string[]>;
|
|
28
|
+
updateEdgeCeremony(fromId: string, toId: string, ceremonyId: string): Promise<void>;
|
|
29
|
+
logCeremony(ceremony: CeremonyLog): Promise<void>;
|
|
30
|
+
getCeremony(id: string): Promise<CeremonyLog | null>;
|
|
31
|
+
getCeremoniesTimeline(limit?: number): Promise<CeremonyLog[]>;
|
|
32
|
+
getCeremoniesByDirection(direction: DirectionName): Promise<CeremonyLog[]>;
|
|
33
|
+
getCeremoniesByType(type: CeremonyType): Promise<CeremonyLog[]>;
|
|
34
|
+
getAllCeremonies(limit?: number): Promise<CeremonyLog[]>;
|
|
35
|
+
}
|
|
36
|
+
export type ProviderType = 'jsonl' | 'neon' | 'redis' | 'auto';
|
package/dist/jsonl.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DirectionName, NodeType, CeremonyType } from '@medicine-wheel/ontology-core';
|
|
2
|
+
import type { StorageProvider, RelationalNode, RelationalEdge, CeremonyLog } from './interface.js';
|
|
3
|
+
export declare class JsonlProvider implements StorageProvider {
|
|
4
|
+
readonly name = "jsonl";
|
|
5
|
+
readonly dataDir: string;
|
|
6
|
+
private readonly nodesFile;
|
|
7
|
+
private readonly edgesFile;
|
|
8
|
+
private readonly ceremoniesFile;
|
|
9
|
+
constructor(dataDir?: string);
|
|
10
|
+
connect(): Promise<void>;
|
|
11
|
+
disconnect(): Promise<void>;
|
|
12
|
+
createNode(node: RelationalNode): Promise<void>;
|
|
13
|
+
getNode(id: string): Promise<RelationalNode | null>;
|
|
14
|
+
getNodesByType(type: NodeType): Promise<RelationalNode[]>;
|
|
15
|
+
getNodesByDirection(direction: DirectionName): Promise<RelationalNode[]>;
|
|
16
|
+
getAllNodes(limit?: number): Promise<RelationalNode[]>;
|
|
17
|
+
createEdge(edge: RelationalEdge): Promise<void>;
|
|
18
|
+
getEdge(fromId: string, toId: string): Promise<RelationalEdge | null>;
|
|
19
|
+
getRelatedNodes(nodeId: string, direction?: 'from' | 'to' | 'both'): Promise<string[]>;
|
|
20
|
+
updateEdgeCeremony(fromId: string, toId: string, ceremonyId: string): Promise<void>;
|
|
21
|
+
logCeremony(ceremony: CeremonyLog): Promise<void>;
|
|
22
|
+
getCeremony(id: string): Promise<CeremonyLog | null>;
|
|
23
|
+
getCeremoniesTimeline(limit?: number): Promise<CeremonyLog[]>;
|
|
24
|
+
getCeremoniesByDirection(direction: DirectionName): Promise<CeremonyLog[]>;
|
|
25
|
+
getCeremoniesByType(type: CeremonyType): Promise<CeremonyLog[]>;
|
|
26
|
+
getAllCeremonies(limit?: number): Promise<CeremonyLog[]>;
|
|
27
|
+
private readNodes;
|
|
28
|
+
private readEdges;
|
|
29
|
+
private readCeremonies;
|
|
30
|
+
private upsertById;
|
|
31
|
+
private upsertEdge;
|
|
32
|
+
private parseNode;
|
|
33
|
+
private parseEdge;
|
|
34
|
+
private parseCeremony;
|
|
35
|
+
}
|
package/dist/jsonl.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
export class JsonlProvider {
|
|
5
|
+
name = 'jsonl';
|
|
6
|
+
dataDir;
|
|
7
|
+
nodesFile;
|
|
8
|
+
edgesFile;
|
|
9
|
+
ceremoniesFile;
|
|
10
|
+
constructor(dataDir) {
|
|
11
|
+
this.dataDir = path.resolve(dataDir ?? resolveProjectDataDir());
|
|
12
|
+
this.nodesFile = path.join(this.dataDir, 'nodes.jsonl');
|
|
13
|
+
this.edgesFile = path.join(this.dataDir, 'edges.jsonl');
|
|
14
|
+
this.ceremoniesFile = path.join(this.dataDir, 'ceremonies.jsonl');
|
|
15
|
+
}
|
|
16
|
+
async connect() {
|
|
17
|
+
ensureDirectory(this.dataDir);
|
|
18
|
+
}
|
|
19
|
+
async disconnect() {
|
|
20
|
+
// File-backed provider has no long-lived connection to tear down.
|
|
21
|
+
}
|
|
22
|
+
async createNode(node) {
|
|
23
|
+
await this.upsertById(this.nodesFile, node);
|
|
24
|
+
}
|
|
25
|
+
async getNode(id) {
|
|
26
|
+
const node = this.readNodes().find((candidate) => candidate.id === id);
|
|
27
|
+
return node ? this.parseNode(node) : null;
|
|
28
|
+
}
|
|
29
|
+
async getNodesByType(type) {
|
|
30
|
+
return this.readNodes()
|
|
31
|
+
.filter((node) => node.type === type)
|
|
32
|
+
.sort(sortByNewest('created_at'))
|
|
33
|
+
.map((node) => this.parseNode(node));
|
|
34
|
+
}
|
|
35
|
+
async getNodesByDirection(direction) {
|
|
36
|
+
return this.readNodes()
|
|
37
|
+
.filter((node) => node.direction === direction)
|
|
38
|
+
.sort(sortByNewest('created_at'))
|
|
39
|
+
.map((node) => this.parseNode(node));
|
|
40
|
+
}
|
|
41
|
+
async getAllNodes(limit = 100) {
|
|
42
|
+
return this.readNodes()
|
|
43
|
+
.sort(sortByNewest('created_at'))
|
|
44
|
+
.slice(0, limit)
|
|
45
|
+
.map((node) => this.parseNode(node));
|
|
46
|
+
}
|
|
47
|
+
async createEdge(edge) {
|
|
48
|
+
await this.upsertEdge({
|
|
49
|
+
...edge,
|
|
50
|
+
ceremony_id: edge.last_ceremony,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async getEdge(fromId, toId) {
|
|
54
|
+
const edge = this.readEdges().find((candidate) => candidate.from_id === fromId && candidate.to_id === toId);
|
|
55
|
+
return edge ? this.parseEdge(edge) : null;
|
|
56
|
+
}
|
|
57
|
+
async getRelatedNodes(nodeId, direction = 'both') {
|
|
58
|
+
const related = new Set();
|
|
59
|
+
for (const edge of this.readEdges()) {
|
|
60
|
+
if ((direction === 'from' || direction === 'both') && edge.from_id === nodeId) {
|
|
61
|
+
related.add(edge.to_id);
|
|
62
|
+
}
|
|
63
|
+
if ((direction === 'to' || direction === 'both') && edge.to_id === nodeId) {
|
|
64
|
+
related.add(edge.from_id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return Array.from(related);
|
|
68
|
+
}
|
|
69
|
+
async updateEdgeCeremony(fromId, toId, ceremonyId) {
|
|
70
|
+
await withWriteLock(this.edgesFile, () => {
|
|
71
|
+
const nextEdges = this.readEdges().map((edge) => {
|
|
72
|
+
if (edge.from_id !== fromId || edge.to_id !== toId)
|
|
73
|
+
return edge;
|
|
74
|
+
return {
|
|
75
|
+
...edge,
|
|
76
|
+
ceremony_honored: true,
|
|
77
|
+
ceremony_id: ceremonyId,
|
|
78
|
+
last_ceremony: ceremonyId,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
writeJsonl(this.edgesFile, nextEdges);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async logCeremony(ceremony) {
|
|
85
|
+
await this.upsertById(this.ceremoniesFile, ceremony);
|
|
86
|
+
}
|
|
87
|
+
async getCeremony(id) {
|
|
88
|
+
const ceremony = this.readCeremonies().find((candidate) => candidate.id === id);
|
|
89
|
+
return ceremony ? this.parseCeremony(ceremony) : null;
|
|
90
|
+
}
|
|
91
|
+
async getCeremoniesTimeline(limit = 20) {
|
|
92
|
+
return this.readCeremonies()
|
|
93
|
+
.sort(sortByNewest('timestamp'))
|
|
94
|
+
.slice(0, limit)
|
|
95
|
+
.map((ceremony) => this.parseCeremony(ceremony));
|
|
96
|
+
}
|
|
97
|
+
async getCeremoniesByDirection(direction) {
|
|
98
|
+
return this.readCeremonies()
|
|
99
|
+
.filter((ceremony) => ceremony.direction === direction)
|
|
100
|
+
.sort(sortByNewest('timestamp'))
|
|
101
|
+
.map((ceremony) => this.parseCeremony(ceremony));
|
|
102
|
+
}
|
|
103
|
+
async getCeremoniesByType(type) {
|
|
104
|
+
return this.readCeremonies()
|
|
105
|
+
.filter((ceremony) => ceremony.type === type)
|
|
106
|
+
.sort(sortByNewest('timestamp'))
|
|
107
|
+
.map((ceremony) => this.parseCeremony(ceremony));
|
|
108
|
+
}
|
|
109
|
+
async getAllCeremonies(limit = 100) {
|
|
110
|
+
return this.readCeremonies()
|
|
111
|
+
.sort(sortByNewest('timestamp'))
|
|
112
|
+
.slice(0, limit)
|
|
113
|
+
.map((ceremony) => this.parseCeremony(ceremony));
|
|
114
|
+
}
|
|
115
|
+
readNodes() {
|
|
116
|
+
return readJsonl(this.nodesFile);
|
|
117
|
+
}
|
|
118
|
+
readEdges() {
|
|
119
|
+
return readJsonl(this.edgesFile);
|
|
120
|
+
}
|
|
121
|
+
readCeremonies() {
|
|
122
|
+
return readJsonl(this.ceremoniesFile);
|
|
123
|
+
}
|
|
124
|
+
async upsertById(filePath, item) {
|
|
125
|
+
await withWriteLock(filePath, () => {
|
|
126
|
+
const nextRecords = readJsonl(filePath).filter((candidate) => candidate.id !== item.id);
|
|
127
|
+
nextRecords.push(item);
|
|
128
|
+
writeJsonl(filePath, nextRecords);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async upsertEdge(edge) {
|
|
132
|
+
await withWriteLock(this.edgesFile, () => {
|
|
133
|
+
const nextEdges = this.readEdges().filter((candidate) => edgeKey(candidate) !== edgeKey(edge));
|
|
134
|
+
nextEdges.push(edge);
|
|
135
|
+
writeJsonl(this.edgesFile, nextEdges);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
parseNode(node) {
|
|
139
|
+
return {
|
|
140
|
+
id: node.id,
|
|
141
|
+
type: node.type,
|
|
142
|
+
name: node.name,
|
|
143
|
+
description: node.description ?? '',
|
|
144
|
+
direction: node.direction,
|
|
145
|
+
metadata: node.metadata ?? {},
|
|
146
|
+
created_at: node.created_at,
|
|
147
|
+
updated_at: node.updated_at,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
parseEdge(edge) {
|
|
151
|
+
return {
|
|
152
|
+
from_id: edge.from_id,
|
|
153
|
+
to_id: edge.to_id,
|
|
154
|
+
relationship_type: edge.relationship_type,
|
|
155
|
+
strength: Number(edge.strength),
|
|
156
|
+
ceremony_honored: Boolean(edge.ceremony_honored),
|
|
157
|
+
last_ceremony: edge.last_ceremony ?? edge.ceremony_id,
|
|
158
|
+
obligations: Array.isArray(edge.obligations) ? edge.obligations : [],
|
|
159
|
+
created_at: edge.created_at,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
parseCeremony(ceremony) {
|
|
163
|
+
return {
|
|
164
|
+
id: ceremony.id,
|
|
165
|
+
type: ceremony.type,
|
|
166
|
+
direction: ceremony.direction,
|
|
167
|
+
participants: Array.isArray(ceremony.participants) ? ceremony.participants : [],
|
|
168
|
+
medicines_used: Array.isArray(ceremony.medicines_used) ? ceremony.medicines_used : [],
|
|
169
|
+
intentions: Array.isArray(ceremony.intentions) ? ceremony.intentions : [],
|
|
170
|
+
timestamp: ceremony.timestamp,
|
|
171
|
+
research_context: ceremony.research_context,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function ensureDirectory(dirPath) {
|
|
176
|
+
if (!fs.existsSync(dirPath)) {
|
|
177
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function readJsonl(filePath) {
|
|
181
|
+
if (!fs.existsSync(filePath))
|
|
182
|
+
return [];
|
|
183
|
+
const records = [];
|
|
184
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
185
|
+
for (const line of content.split('\n')) {
|
|
186
|
+
const trimmed = line.trim();
|
|
187
|
+
if (!trimmed)
|
|
188
|
+
continue;
|
|
189
|
+
try {
|
|
190
|
+
records.push(JSON.parse(trimmed));
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
194
|
+
console.warn(`[storage-provider/jsonl] Skipping malformed line in ${filePath}: ${message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return records;
|
|
198
|
+
}
|
|
199
|
+
function writeJsonl(filePath, records) {
|
|
200
|
+
ensureDirectory(path.dirname(filePath));
|
|
201
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
202
|
+
const content = records.map((record) => JSON.stringify(record)).join('\n');
|
|
203
|
+
fs.writeFileSync(tmpPath, content.length > 0 ? `${content}\n` : '', 'utf-8');
|
|
204
|
+
fs.renameSync(tmpPath, filePath);
|
|
205
|
+
}
|
|
206
|
+
async function withWriteLock(filePath, fn) {
|
|
207
|
+
const lockPath = `${filePath}.lock`;
|
|
208
|
+
const ownerToken = await acquireWriteLock(lockPath);
|
|
209
|
+
try {
|
|
210
|
+
return await fn();
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
releaseWriteLock(lockPath, ownerToken);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function resolveProjectDataDir(currentDir) {
|
|
217
|
+
if (process.env.MW_DATA_DIR)
|
|
218
|
+
return process.env.MW_DATA_DIR;
|
|
219
|
+
const cwd = currentDir ?? process.cwd();
|
|
220
|
+
if (cwd.endsWith('/mcp') || cwd.endsWith('\\mcp')) {
|
|
221
|
+
return path.join(path.dirname(cwd), '.mw', 'store');
|
|
222
|
+
}
|
|
223
|
+
let dir = cwd;
|
|
224
|
+
for (let depth = 0; depth < 5; depth++) {
|
|
225
|
+
const packageJsonPath = path.join(dir, 'package.json');
|
|
226
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
227
|
+
try {
|
|
228
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
|
229
|
+
if (pkg.name === 'medicine-wheel') {
|
|
230
|
+
return path.join(dir, '.mw', 'store');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Keep walking upward.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const parent = path.dirname(dir);
|
|
238
|
+
if (parent === dir)
|
|
239
|
+
break;
|
|
240
|
+
dir = parent;
|
|
241
|
+
}
|
|
242
|
+
return path.join(cwd, '.mw', 'store');
|
|
243
|
+
}
|
|
244
|
+
function edgeKey(edge) {
|
|
245
|
+
// JSON-array encoding avoids false collisions when IDs contain the delimiter.
|
|
246
|
+
return JSON.stringify([edge.from_id, edge.to_id]);
|
|
247
|
+
}
|
|
248
|
+
function sortByNewest(field) {
|
|
249
|
+
return (left, right) => Date.parse(right[field]) - Date.parse(left[field]);
|
|
250
|
+
}
|
|
251
|
+
function isProcessAlive(pid) {
|
|
252
|
+
try {
|
|
253
|
+
// Signal 0 checks process existence without sending a real signal.
|
|
254
|
+
process.kill(pid, 0);
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function isLockStale(lockPath) {
|
|
262
|
+
try {
|
|
263
|
+
const content = fs.readFileSync(lockPath, 'utf-8').trim();
|
|
264
|
+
const parsed = JSON.parse(content);
|
|
265
|
+
const pid = typeof parsed.pid === 'number' ? parsed.pid : null;
|
|
266
|
+
const createdAt = typeof parsed.created_at === 'string' ? Date.parse(parsed.created_at) : NaN;
|
|
267
|
+
// A lock held by a live process is never stale.
|
|
268
|
+
if (pid !== null && isProcessAlive(pid)) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
// PID is dead or unreadable — treat as stale after a 30-second grace period.
|
|
272
|
+
const lockAgeMs = isNaN(createdAt) ? Infinity : Date.now() - createdAt;
|
|
273
|
+
return lockAgeMs > 30_000;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async function acquireWriteLock(lockPath) {
|
|
280
|
+
const ownerToken = `${process.pid}:${Date.now()}:${randomUUID()}`;
|
|
281
|
+
const payload = JSON.stringify({
|
|
282
|
+
token: ownerToken,
|
|
283
|
+
pid: process.pid,
|
|
284
|
+
created_at: new Date().toISOString(),
|
|
285
|
+
});
|
|
286
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
287
|
+
let fd;
|
|
288
|
+
try {
|
|
289
|
+
fd = fs.openSync(lockPath, 'wx');
|
|
290
|
+
fs.writeFileSync(fd, payload, 'utf-8');
|
|
291
|
+
return ownerToken;
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
if (error?.code !== 'EEXIST') {
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
// Only remove the lock file when we are certain its owner is gone.
|
|
298
|
+
if (isLockStale(lockPath)) {
|
|
299
|
+
try {
|
|
300
|
+
fs.unlinkSync(lockPath);
|
|
301
|
+
console.warn(`[storage-provider/jsonl] Removed stale lock: ${lockPath}`);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Another process may have removed it first — that is fine.
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
await sleep(Math.min(25 * (attempt + 1), 250));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
if (fd !== undefined) {
|
|
313
|
+
fs.closeSync(fd);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
throw new Error(`[storage-provider/jsonl] Failed to acquire write lock: ${lockPath}`);
|
|
318
|
+
}
|
|
319
|
+
function releaseWriteLock(lockPath, ownerToken) {
|
|
320
|
+
try {
|
|
321
|
+
const currentOwner = readLockOwner(lockPath);
|
|
322
|
+
if (currentOwner !== ownerToken) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
fs.unlinkSync(lockPath);
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
if (error?.code !== 'ENOENT') {
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function readLockOwner(lockPath) {
|
|
334
|
+
const content = fs.readFileSync(lockPath, 'utf-8').trim();
|
|
335
|
+
if (!content)
|
|
336
|
+
return null;
|
|
337
|
+
try {
|
|
338
|
+
const parsed = JSON.parse(content);
|
|
339
|
+
return typeof parsed.token === 'string' ? parsed.token : null;
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function sleep(delayMs) {
|
|
346
|
+
return new Promise((resolve) => {
|
|
347
|
+
setTimeout(resolve, delayMs);
|
|
348
|
+
});
|
|
349
|
+
}
|
package/dist/neon.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neon (Postgres) Storage Provider
|
|
3
|
+
*/
|
|
4
|
+
import type { DirectionName, NodeType, CeremonyType } from '@medicine-wheel/ontology-core';
|
|
5
|
+
import type { StorageProvider, RelationalNode, RelationalEdge, CeremonyLog } from './interface.js';
|
|
6
|
+
export declare class NeonProvider implements StorageProvider {
|
|
7
|
+
readonly name = "neon";
|
|
8
|
+
private sql;
|
|
9
|
+
connect(): Promise<void>;
|
|
10
|
+
disconnect(): Promise<void>;
|
|
11
|
+
private get db();
|
|
12
|
+
createNode(node: RelationalNode): Promise<void>;
|
|
13
|
+
getNode(id: string): Promise<RelationalNode | null>;
|
|
14
|
+
getNodesByType(type: NodeType): Promise<RelationalNode[]>;
|
|
15
|
+
getNodesByDirection(direction: DirectionName): Promise<RelationalNode[]>;
|
|
16
|
+
getAllNodes(limit?: number): Promise<RelationalNode[]>;
|
|
17
|
+
private parseNode;
|
|
18
|
+
createEdge(edge: RelationalEdge): Promise<void>;
|
|
19
|
+
getEdge(fromId: string, toId: string): Promise<RelationalEdge | null>;
|
|
20
|
+
getRelatedNodes(nodeId: string, direction?: 'from' | 'to' | 'both'): Promise<string[]>;
|
|
21
|
+
updateEdgeCeremony(fromId: string, toId: string, ceremonyId: string): Promise<void>;
|
|
22
|
+
private parseEdge;
|
|
23
|
+
logCeremony(ceremony: CeremonyLog): Promise<void>;
|
|
24
|
+
getCeremony(id: string): Promise<CeremonyLog | null>;
|
|
25
|
+
getCeremoniesTimeline(limit?: number): Promise<CeremonyLog[]>;
|
|
26
|
+
getCeremoniesByDirection(direction: DirectionName): Promise<CeremonyLog[]>;
|
|
27
|
+
getCeremoniesByType(type: CeremonyType): Promise<CeremonyLog[]>;
|
|
28
|
+
getAllCeremonies(limit?: number): Promise<CeremonyLog[]>;
|
|
29
|
+
private parseCeremony;
|
|
30
|
+
}
|
package/dist/neon.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neon (Postgres) Storage Provider
|
|
3
|
+
*/
|
|
4
|
+
export class NeonProvider {
|
|
5
|
+
name = 'neon';
|
|
6
|
+
sql = null;
|
|
7
|
+
async connect() {
|
|
8
|
+
const url = getConnectionString();
|
|
9
|
+
if (!url)
|
|
10
|
+
throw new Error('DATABASE_URL, POSTGRES_URL, or NEON_DATABASE_URL must be set');
|
|
11
|
+
const { neon } = await import('@neondatabase/serverless');
|
|
12
|
+
this.sql = neon(url);
|
|
13
|
+
}
|
|
14
|
+
async disconnect() {
|
|
15
|
+
this.sql = null;
|
|
16
|
+
}
|
|
17
|
+
get db() {
|
|
18
|
+
if (!this.sql)
|
|
19
|
+
throw new Error('NeonProvider not connected');
|
|
20
|
+
return this.sql;
|
|
21
|
+
}
|
|
22
|
+
// ── Node Operations ──
|
|
23
|
+
async createNode(node) {
|
|
24
|
+
await this.db `
|
|
25
|
+
INSERT INTO nodes (id, type, name, description, direction, metadata, created_at, updated_at)
|
|
26
|
+
VALUES (${node.id}, ${node.type}, ${node.name}, ${node.description},
|
|
27
|
+
${node.direction || null}, ${JSON.stringify(node.metadata)},
|
|
28
|
+
${node.created_at}, ${node.updated_at})
|
|
29
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
30
|
+
type = EXCLUDED.type,
|
|
31
|
+
name = EXCLUDED.name,
|
|
32
|
+
description = EXCLUDED.description,
|
|
33
|
+
direction = EXCLUDED.direction,
|
|
34
|
+
metadata = EXCLUDED.metadata,
|
|
35
|
+
updated_at = EXCLUDED.updated_at
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
async getNode(id) {
|
|
39
|
+
const rows = await this.db `SELECT * FROM nodes WHERE id = ${id}`;
|
|
40
|
+
if (rows.length === 0)
|
|
41
|
+
return null;
|
|
42
|
+
return this.parseNode(rows[0]);
|
|
43
|
+
}
|
|
44
|
+
async getNodesByType(type) {
|
|
45
|
+
const rows = await this.db `SELECT * FROM nodes WHERE type = ${type} ORDER BY created_at DESC`;
|
|
46
|
+
return rows.map((row) => this.parseNode(row));
|
|
47
|
+
}
|
|
48
|
+
async getNodesByDirection(direction) {
|
|
49
|
+
const rows = await this.db `SELECT * FROM nodes WHERE direction = ${direction} ORDER BY created_at DESC`;
|
|
50
|
+
return rows.map((row) => this.parseNode(row));
|
|
51
|
+
}
|
|
52
|
+
async getAllNodes(limit = 100) {
|
|
53
|
+
const rows = await this.db `SELECT * FROM nodes ORDER BY created_at DESC LIMIT ${limit}`;
|
|
54
|
+
return rows.map((row) => this.parseNode(row));
|
|
55
|
+
}
|
|
56
|
+
parseNode(row) {
|
|
57
|
+
return {
|
|
58
|
+
id: row.id,
|
|
59
|
+
type: row.type,
|
|
60
|
+
name: row.name,
|
|
61
|
+
description: row.description || '',
|
|
62
|
+
direction: row.direction || undefined,
|
|
63
|
+
metadata: parseJsonValue(row.metadata, {}),
|
|
64
|
+
created_at: toIsoString(row.created_at),
|
|
65
|
+
updated_at: toIsoString(row.updated_at),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// ── Edge Operations ──
|
|
69
|
+
async createEdge(edge) {
|
|
70
|
+
await this.db `
|
|
71
|
+
INSERT INTO edges (from_id, to_id, relationship_type, strength, ceremony_honored, last_ceremony, obligations, created_at)
|
|
72
|
+
VALUES (${edge.from_id}, ${edge.to_id}, ${edge.relationship_type}, ${edge.strength},
|
|
73
|
+
${edge.ceremony_honored}, ${edge.last_ceremony || null}, ${JSON.stringify(edge.obligations)}, ${edge.created_at})
|
|
74
|
+
ON CONFLICT (from_id, to_id) DO UPDATE SET
|
|
75
|
+
relationship_type = EXCLUDED.relationship_type,
|
|
76
|
+
strength = EXCLUDED.strength,
|
|
77
|
+
ceremony_honored = EXCLUDED.ceremony_honored,
|
|
78
|
+
last_ceremony = EXCLUDED.last_ceremony,
|
|
79
|
+
obligations = EXCLUDED.obligations
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
async getEdge(fromId, toId) {
|
|
83
|
+
const rows = await this.db `SELECT * FROM edges WHERE from_id = ${fromId} AND to_id = ${toId}`;
|
|
84
|
+
if (rows.length === 0)
|
|
85
|
+
return null;
|
|
86
|
+
return this.parseEdge(rows[0]);
|
|
87
|
+
}
|
|
88
|
+
async getRelatedNodes(nodeId, direction = 'both') {
|
|
89
|
+
let rows;
|
|
90
|
+
if (direction === 'from') {
|
|
91
|
+
rows = await this.db `SELECT to_id as id FROM edges WHERE from_id = ${nodeId}`;
|
|
92
|
+
}
|
|
93
|
+
else if (direction === 'to') {
|
|
94
|
+
rows = await this.db `SELECT from_id as id FROM edges WHERE to_id = ${nodeId}`;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
rows = await this.db `
|
|
98
|
+
SELECT to_id as id FROM edges WHERE from_id = ${nodeId}
|
|
99
|
+
UNION
|
|
100
|
+
SELECT from_id as id FROM edges WHERE to_id = ${nodeId}
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
103
|
+
return rows.map(r => r.id);
|
|
104
|
+
}
|
|
105
|
+
async updateEdgeCeremony(fromId, toId, ceremonyId) {
|
|
106
|
+
await this.db `
|
|
107
|
+
UPDATE edges SET ceremony_honored = true, last_ceremony = ${ceremonyId}
|
|
108
|
+
WHERE from_id = ${fromId} AND to_id = ${toId}
|
|
109
|
+
`;
|
|
110
|
+
}
|
|
111
|
+
parseEdge(row) {
|
|
112
|
+
return {
|
|
113
|
+
from_id: row.from_id,
|
|
114
|
+
to_id: row.to_id,
|
|
115
|
+
relationship_type: row.relationship_type,
|
|
116
|
+
strength: Number(row.strength),
|
|
117
|
+
ceremony_honored: row.ceremony_honored,
|
|
118
|
+
last_ceremony: row.last_ceremony || undefined,
|
|
119
|
+
obligations: parseJsonValue(row.obligations, []),
|
|
120
|
+
created_at: toIsoString(row.created_at),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// ── Ceremony Operations ──
|
|
124
|
+
async logCeremony(ceremony) {
|
|
125
|
+
await this.db `
|
|
126
|
+
INSERT INTO ceremonies (id, type, direction, participants, medicines_used, intentions, timestamp, research_context)
|
|
127
|
+
VALUES (${ceremony.id}, ${ceremony.type}, ${ceremony.direction},
|
|
128
|
+
${JSON.stringify(ceremony.participants)}, ${JSON.stringify(ceremony.medicines_used)},
|
|
129
|
+
${JSON.stringify(ceremony.intentions)}, ${ceremony.timestamp}, ${ceremony.research_context || null})
|
|
130
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
131
|
+
type = EXCLUDED.type,
|
|
132
|
+
direction = EXCLUDED.direction,
|
|
133
|
+
participants = EXCLUDED.participants,
|
|
134
|
+
medicines_used = EXCLUDED.medicines_used,
|
|
135
|
+
intentions = EXCLUDED.intentions,
|
|
136
|
+
timestamp = EXCLUDED.timestamp,
|
|
137
|
+
research_context = EXCLUDED.research_context
|
|
138
|
+
`;
|
|
139
|
+
}
|
|
140
|
+
async getCeremony(id) {
|
|
141
|
+
const rows = await this.db `SELECT * FROM ceremonies WHERE id = ${id}`;
|
|
142
|
+
if (rows.length === 0)
|
|
143
|
+
return null;
|
|
144
|
+
return this.parseCeremony(rows[0]);
|
|
145
|
+
}
|
|
146
|
+
async getCeremoniesTimeline(limit = 20) {
|
|
147
|
+
const rows = await this.db `SELECT * FROM ceremonies ORDER BY timestamp DESC LIMIT ${limit}`;
|
|
148
|
+
return rows.map((row) => this.parseCeremony(row));
|
|
149
|
+
}
|
|
150
|
+
async getCeremoniesByDirection(direction) {
|
|
151
|
+
const rows = await this.db `SELECT * FROM ceremonies WHERE direction = ${direction} ORDER BY timestamp DESC`;
|
|
152
|
+
return rows.map((row) => this.parseCeremony(row));
|
|
153
|
+
}
|
|
154
|
+
async getCeremoniesByType(type) {
|
|
155
|
+
const rows = await this.db `SELECT * FROM ceremonies WHERE type = ${type} ORDER BY timestamp DESC`;
|
|
156
|
+
return rows.map((row) => this.parseCeremony(row));
|
|
157
|
+
}
|
|
158
|
+
async getAllCeremonies(limit = 100) {
|
|
159
|
+
return this.getCeremoniesTimeline(limit);
|
|
160
|
+
}
|
|
161
|
+
parseCeremony(row) {
|
|
162
|
+
return {
|
|
163
|
+
id: row.id,
|
|
164
|
+
type: row.type,
|
|
165
|
+
direction: row.direction,
|
|
166
|
+
participants: parseJsonValue(row.participants, []),
|
|
167
|
+
medicines_used: parseJsonValue(row.medicines_used, []),
|
|
168
|
+
intentions: parseJsonValue(row.intentions, []),
|
|
169
|
+
timestamp: toIsoString(row.timestamp),
|
|
170
|
+
research_context: row.research_context || undefined,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function getConnectionString() {
|
|
175
|
+
return process.env.DATABASE_URL ?? process.env.POSTGRES_URL ?? process.env.NEON_DATABASE_URL;
|
|
176
|
+
}
|
|
177
|
+
function toIsoString(value) {
|
|
178
|
+
if (value instanceof Date)
|
|
179
|
+
return value.toISOString();
|
|
180
|
+
if (typeof value === 'string')
|
|
181
|
+
return value;
|
|
182
|
+
return new Date(String(value)).toISOString();
|
|
183
|
+
}
|
|
184
|
+
function parseJsonValue(value, fallback) {
|
|
185
|
+
if (value === undefined || value === null)
|
|
186
|
+
return fallback;
|
|
187
|
+
if (typeof value === 'string') {
|
|
188
|
+
try {
|
|
189
|
+
return JSON.parse(value);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return fallback;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return value;
|
|
196
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@medicine-wheel/storage-provider",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"clean": "rm -rf dist",
|
|
14
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@neondatabase/serverless": "^0.10.0",
|
|
18
|
+
"@upstash/redis": "^1.34.3",
|
|
19
|
+
"@medicine-wheel/ontology-core": "^0.2.2"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^5.7.0"
|
|
23
|
+
}
|
|
24
|
+
}
|