@theholocron/holocron-plugin-neon 2.0.0-alpha.9 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/dist/index.d.mts +8 -56
- package/dist/index.mjs +42 -116
- package/package.json +22 -10
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<!-- editorconfig-checker-disable-file -->
|
|
2
|
+
|
|
1
3
|
# `@theholocron/holocron-plugin-neon`
|
|
2
4
|
|
|
3
5
|
Neon plugin for [Holocron](../cli). Implements the `storage`
|
|
@@ -5,8 +7,10 @@ capability against [Neon's REST API](https://api-docs.neon.tech/reference/gettin
|
|
|
5
7
|
|
|
6
8
|
## Install
|
|
7
9
|
|
|
10
|
+
<!-- prettier-ignore -->
|
|
8
11
|
```bash
|
|
9
12
|
pnpm add -D @theholocron/holocron-plugin-neon@alpha
|
|
13
|
+
|
|
10
14
|
```
|
|
11
15
|
|
|
12
16
|
## Auth
|
|
@@ -19,16 +23,18 @@ Token resolution order:
|
|
|
19
23
|
|
|
20
24
|
## Config
|
|
21
25
|
|
|
26
|
+
<!-- prettier-ignore -->
|
|
22
27
|
```jsonc
|
|
23
28
|
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
"providers": {
|
|
30
|
+
"storage": ["neon", { "projectId": "ancient-resonance-…" }],
|
|
31
|
+
},
|
|
27
32
|
}
|
|
33
|
+
|
|
28
34
|
```
|
|
29
35
|
|
|
30
36
|
- `projectId` (required) — the Neon project id. The plugin binds to
|
|
31
|
-
|
|
37
|
+
this project; every method operates within it.
|
|
32
38
|
|
|
33
39
|
## What's implemented
|
|
34
40
|
|
package/dist/index.d.mts
CHANGED
|
@@ -1,67 +1,19 @@
|
|
|
1
|
-
import { ConnectionStringOptions, Storage, StorageBranch } from "@theholocron/cli";
|
|
1
|
+
import { AuthError, ConnectionStringOptions, ResolveTokenInput, Storage, StorageBranch } from "@theholocron/cli";
|
|
2
|
+
import { NeonClient, NeonClient as NeonClient$1, createNeonClient } from "@theholocron/neon-client";
|
|
2
3
|
|
|
3
4
|
//#region src/auth.d.ts
|
|
4
|
-
|
|
5
|
-
* Token resolution for the Neon plugin.
|
|
6
|
-
*
|
|
7
|
-
* Resolution order (matches the standard 4-step precedence set by
|
|
8
|
-
* `.notes/tech-auth-bootstrap.spec.md`):
|
|
9
|
-
* 1. explicit `cliToken` argument (from `--token` flag)
|
|
10
|
-
* 2. HOLOCRON_NEON_API_KEY env var (preferred — explicit intent)
|
|
11
|
-
* 3. NEON_API_KEY env var (the default Neon CLI reads)
|
|
12
|
-
* 4. keyring (com.theholocron.cli / "neon")
|
|
13
|
-
* 5. AuthError naming all four options + the bootstrap hint
|
|
14
|
-
*/
|
|
15
|
-
declare class AuthError extends Error {
|
|
16
|
-
name: string;
|
|
17
|
-
}
|
|
18
|
-
interface ResolveTokenInput {
|
|
19
|
-
/** From `--token` CLI flag. */
|
|
20
|
-
cliToken?: string;
|
|
21
|
-
/** Env vars; passed in for testability. Defaults to `process.env`. */
|
|
22
|
-
env?: NodeJS.ProcessEnv;
|
|
23
|
-
/** Keyring lookup fn; passed in for testability. Defaults to `getToken(provider)`. */
|
|
24
|
-
keyring?: (provider: string) => string | null;
|
|
25
|
-
}
|
|
26
|
-
declare function resolveToken(input?: ResolveTokenInput): string;
|
|
27
|
-
//#endregion
|
|
28
|
-
//#region src/rest.d.ts
|
|
29
|
-
/**
|
|
30
|
-
* Thin REST wrapper around console.neon.tech/api/v2.
|
|
31
|
-
*
|
|
32
|
-
* Same pattern as the GitHub + Vercel REST clients — bearer auth,
|
|
33
|
-
* JSON-only bodies, transport-failure wrapping with status: 0 so the
|
|
34
|
-
* orchestrator's soft-skip path sees a clear "Neon GET /path failed"
|
|
35
|
-
* message instead of a generic `TypeError: fetch failed`.
|
|
36
|
-
*/
|
|
37
|
-
interface RestClientOptions {
|
|
38
|
-
token: string;
|
|
39
|
-
fetch?: typeof fetch;
|
|
40
|
-
baseUrl?: string;
|
|
41
|
-
}
|
|
42
|
-
interface RequestOptions {
|
|
43
|
-
method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
|
44
|
-
body?: unknown;
|
|
45
|
-
query?: Record<string, string>;
|
|
46
|
-
}
|
|
47
|
-
declare class NeonRestClient {
|
|
48
|
-
private readonly token;
|
|
49
|
-
private readonly fetchImpl;
|
|
50
|
-
readonly baseUrl: string;
|
|
51
|
-
constructor(opts: RestClientOptions);
|
|
52
|
-
request<T>(path: string, opts?: RequestOptions): Promise<T>;
|
|
53
|
-
}
|
|
5
|
+
declare const resolveToken: (input?: import("@theholocron/http-client").ResolveTokenInput) => string;
|
|
54
6
|
//#endregion
|
|
55
7
|
//#region src/capabilities/storage.d.ts
|
|
56
8
|
interface StorageOptions {
|
|
57
9
|
projectId: string;
|
|
58
10
|
}
|
|
59
11
|
declare class NeonStorage implements Storage {
|
|
60
|
-
private readonly
|
|
12
|
+
private readonly client;
|
|
13
|
+
private readonly opts;
|
|
61
14
|
readonly key: "storage";
|
|
62
15
|
readonly providerName = "neon";
|
|
63
|
-
|
|
64
|
-
constructor(rest: NeonRestClient, opts: StorageOptions);
|
|
16
|
+
constructor(client: NeonClient$1, opts: StorageOptions);
|
|
65
17
|
getConnectionString(scope: string, options?: ConnectionStringOptions): Promise<string>;
|
|
66
18
|
listBranches(): Promise<StorageBranch[]>;
|
|
67
19
|
createBranch(input: {
|
|
@@ -118,7 +70,7 @@ interface NeonPluginOptions extends ResolveTokenInput {
|
|
|
118
70
|
}
|
|
119
71
|
interface PluginContext {
|
|
120
72
|
options: NeonPluginOptions;
|
|
121
|
-
|
|
73
|
+
client: NeonClient;
|
|
122
74
|
}
|
|
123
75
|
declare function createContext(options: NeonPluginOptions): PluginContext;
|
|
124
76
|
declare function storage(ctx: PluginContext): Storage;
|
|
@@ -136,4 +88,4 @@ declare function createPlugin(options: NeonPluginOptions): {
|
|
|
136
88
|
*/
|
|
137
89
|
declare const AUTH_HINT: string;
|
|
138
90
|
//#endregion
|
|
139
|
-
export { AUTH_HINT, AuthError,
|
|
91
|
+
export { AUTH_HINT, AuthError, type NeonClient, NeonPluginOptions, NeonStorage, PluginContext, type ResolveTokenInput, type VerifyTokenFailure, type VerifyTokenResult, type VerifyTokenSuccess, createContext, createNeonClient, createPlugin, resolveToken, storage, verifyToken };
|
package/dist/index.mjs
CHANGED
|
@@ -1,26 +1,12 @@
|
|
|
1
|
-
import { ProviderApiError,
|
|
1
|
+
import { AuthError, ProviderApiError, createResolveToken } from "@theholocron/cli";
|
|
2
|
+
import { createNeonClient } from "@theholocron/neon-client";
|
|
2
3
|
//#region src/auth.ts
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* 2. HOLOCRON_NEON_API_KEY env var (preferred — explicit intent)
|
|
10
|
-
* 3. NEON_API_KEY env var (the default Neon CLI reads)
|
|
11
|
-
* 4. keyring (com.theholocron.cli / "neon")
|
|
12
|
-
* 5. AuthError naming all four options + the bootstrap hint
|
|
13
|
-
*/
|
|
14
|
-
var AuthError = class extends Error {
|
|
15
|
-
name = "AuthError";
|
|
16
|
-
};
|
|
17
|
-
function resolveToken(input = {}) {
|
|
18
|
-
const env = input.env ?? process.env;
|
|
19
|
-
const keyring = input.keyring ?? getToken;
|
|
20
|
-
const token = input.cliToken || env["HOLOCRON_NEON_API_KEY"] || env["NEON_API_KEY"] || keyring("neon");
|
|
21
|
-
if (!token) throw new AuthError("no Neon API key found. Pass --token <KEY>, set HOLOCRON_NEON_API_KEY / NEON_API_KEY, or run: holocron auth set neon <KEY>");
|
|
22
|
-
return token;
|
|
23
|
-
}
|
|
4
|
+
const resolveToken = createResolveToken({
|
|
5
|
+
envName: "HOLOCRON_NEON_API_KEY",
|
|
6
|
+
vendorEnvName: "NEON_API_KEY",
|
|
7
|
+
keyringService: "neon",
|
|
8
|
+
errorMessage: "no Neon API key found. Pass --token <KEY>, set HOLOCRON_NEON_API_KEY / NEON_API_KEY, or run: holocron auth set neon <KEY>"
|
|
9
|
+
});
|
|
24
10
|
//#endregion
|
|
25
11
|
//#region src/capabilities/storage.ts
|
|
26
12
|
/**
|
|
@@ -42,55 +28,46 @@ function resolveToken(input = {}) {
|
|
|
42
28
|
* via `projectId` in options.
|
|
43
29
|
*/
|
|
44
30
|
var NeonStorage = class {
|
|
45
|
-
|
|
31
|
+
client;
|
|
32
|
+
opts;
|
|
46
33
|
key = "storage";
|
|
47
34
|
providerName = "neon";
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
this.
|
|
35
|
+
constructor(client, opts) {
|
|
36
|
+
this.client = client;
|
|
37
|
+
this.opts = opts;
|
|
51
38
|
if (!opts.projectId) throw new Error("NeonStorage requires `projectId` in options");
|
|
52
|
-
this.base = `/projects/${opts.projectId}`;
|
|
53
39
|
}
|
|
54
40
|
async getConnectionString(scope, options = {}) {
|
|
55
41
|
const db = await this.firstDatabase(scope);
|
|
56
|
-
const
|
|
42
|
+
const { uri } = await this.client.connection.uri(this.opts.projectId, {
|
|
57
43
|
branch_id: scope,
|
|
58
44
|
database_name: db.name,
|
|
59
45
|
role_name: db.owner_name,
|
|
60
46
|
pooled: options.pooled ? "true" : "false"
|
|
61
|
-
};
|
|
62
|
-
return
|
|
47
|
+
});
|
|
48
|
+
return uri;
|
|
63
49
|
}
|
|
64
50
|
async listBranches() {
|
|
65
|
-
|
|
51
|
+
const { branches } = await this.client.branches.list(this.opts.projectId);
|
|
52
|
+
return branches.map(mapBranch);
|
|
66
53
|
}
|
|
67
54
|
async createBranch(input) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
},
|
|
75
|
-
endpoints: [{ type: "read_write" }]
|
|
76
|
-
}
|
|
77
|
-
})).branch);
|
|
55
|
+
const { branch } = await this.client.branches.create(this.opts.projectId, {
|
|
56
|
+
name: input.name,
|
|
57
|
+
...input.from ? { parent_id: input.from } : {},
|
|
58
|
+
endpoints: [{ type: "read_write" }]
|
|
59
|
+
});
|
|
60
|
+
return mapBranch(branch);
|
|
78
61
|
}
|
|
79
62
|
async destroyBranch(branch) {
|
|
80
|
-
await this.
|
|
63
|
+
await this.client.branches.destroy(this.opts.projectId, branch);
|
|
81
64
|
}
|
|
82
65
|
async resetBranch(input) {
|
|
83
|
-
await this.
|
|
84
|
-
method: "POST",
|
|
85
|
-
body: { source_branch_id: input.from }
|
|
86
|
-
});
|
|
66
|
+
await this.client.branches.restore(this.opts.projectId, input.branch, input.from);
|
|
87
67
|
}
|
|
88
68
|
async enableExtension(input) {
|
|
89
69
|
const db = await this.firstDatabase(input.branch);
|
|
90
|
-
await this.
|
|
91
|
-
method: "POST",
|
|
92
|
-
body: { query: `CREATE EXTENSION IF NOT EXISTS "${input.extension}"` }
|
|
93
|
-
});
|
|
70
|
+
await this.client.databases.runSql(this.opts.projectId, input.branch, db.name, `CREATE EXTENSION IF NOT EXISTS "${input.extension}"`);
|
|
94
71
|
}
|
|
95
72
|
/**
|
|
96
73
|
* The first database on a branch — the "default" for connection-string
|
|
@@ -98,7 +75,8 @@ var NeonStorage = class {
|
|
|
98
75
|
* means it was created without an endpoint and needs initialization).
|
|
99
76
|
*/
|
|
100
77
|
async firstDatabase(branchId) {
|
|
101
|
-
const
|
|
78
|
+
const { databases } = await this.client.databases.list(this.opts.projectId, branchId);
|
|
79
|
+
const db = databases[0];
|
|
102
80
|
if (!db) throw new ProviderApiError(`Neon branch ${branchId} has no databases — initialize the branch first`, 404, void 0);
|
|
103
81
|
return db;
|
|
104
82
|
}
|
|
@@ -112,60 +90,6 @@ function mapBranch(raw) {
|
|
|
112
90
|
};
|
|
113
91
|
}
|
|
114
92
|
//#endregion
|
|
115
|
-
//#region src/rest.ts
|
|
116
|
-
/**
|
|
117
|
-
* Thin REST wrapper around console.neon.tech/api/v2.
|
|
118
|
-
*
|
|
119
|
-
* Same pattern as the GitHub + Vercel REST clients — bearer auth,
|
|
120
|
-
* JSON-only bodies, transport-failure wrapping with status: 0 so the
|
|
121
|
-
* orchestrator's soft-skip path sees a clear "Neon GET /path failed"
|
|
122
|
-
* message instead of a generic `TypeError: fetch failed`.
|
|
123
|
-
*/
|
|
124
|
-
var NeonRestClient = class {
|
|
125
|
-
token;
|
|
126
|
-
fetchImpl;
|
|
127
|
-
baseUrl;
|
|
128
|
-
constructor(opts) {
|
|
129
|
-
this.token = opts.token;
|
|
130
|
-
this.fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
131
|
-
let url = opts.baseUrl ?? "https://console.neon.tech/api/v2";
|
|
132
|
-
while (url.endsWith("/")) url = url.slice(0, -1);
|
|
133
|
-
this.baseUrl = url;
|
|
134
|
-
}
|
|
135
|
-
async request(path, opts = {}) {
|
|
136
|
-
const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : "/" + path}`);
|
|
137
|
-
for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);
|
|
138
|
-
const fullUrl = url.toString();
|
|
139
|
-
const headers = {
|
|
140
|
-
authorization: `Bearer ${this.token}`,
|
|
141
|
-
accept: "application/json"
|
|
142
|
-
};
|
|
143
|
-
const init = {
|
|
144
|
-
method: opts.method ?? "GET",
|
|
145
|
-
headers
|
|
146
|
-
};
|
|
147
|
-
if (opts.body !== void 0) {
|
|
148
|
-
headers["content-type"] = "application/json";
|
|
149
|
-
init.body = JSON.stringify(opts.body);
|
|
150
|
-
}
|
|
151
|
-
let res;
|
|
152
|
-
try {
|
|
153
|
-
res = await this.fetchImpl(fullUrl, init);
|
|
154
|
-
} catch (err) {
|
|
155
|
-
const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
156
|
-
throw new ProviderApiError(`Neon ${init.method} ${path} failed: ${detail}`, 0, void 0);
|
|
157
|
-
}
|
|
158
|
-
if (!res.ok) {
|
|
159
|
-
const body = await res.text().catch(() => "");
|
|
160
|
-
throw new ProviderApiError(`Neon ${init.method} ${path} → ${res.status}`, res.status, body);
|
|
161
|
-
}
|
|
162
|
-
if (res.status === 204) return void 0;
|
|
163
|
-
const text = await res.text();
|
|
164
|
-
if (!text) return void 0;
|
|
165
|
-
return JSON.parse(text);
|
|
166
|
-
}
|
|
167
|
-
};
|
|
168
|
-
//#endregion
|
|
169
93
|
//#region src/verify-token.ts
|
|
170
94
|
/**
|
|
171
95
|
* `verifyToken` — plugin-level export used by `holocron auth set` +
|
|
@@ -174,12 +98,13 @@ var NeonRestClient = class {
|
|
|
174
98
|
* shape.
|
|
175
99
|
*/
|
|
176
100
|
async function verifyToken(token, opts = {}) {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
101
|
+
const client = createNeonClient({
|
|
102
|
+
token,
|
|
103
|
+
baseUrl: opts.baseUrl,
|
|
104
|
+
fetch: opts.fetch
|
|
105
|
+
});
|
|
181
106
|
try {
|
|
182
|
-
const me = await
|
|
107
|
+
const me = await client.users.me();
|
|
183
108
|
return {
|
|
184
109
|
ok: true,
|
|
185
110
|
subject: `user @ ${me?.email ?? me?.login ?? me?.name ?? me?.id ?? "unknown"}`
|
|
@@ -195,16 +120,17 @@ async function verifyToken(token, opts = {}) {
|
|
|
195
120
|
//#region src/index.ts
|
|
196
121
|
function createContext(options) {
|
|
197
122
|
if (!options.projectId) throw new Error("@theholocron/holocron-plugin-neon requires `projectId` in options");
|
|
198
|
-
const restOpts = { token: resolveToken(options) };
|
|
199
|
-
if (options.baseUrl !== void 0) restOpts.baseUrl = options.baseUrl;
|
|
200
|
-
if (options.fetch !== void 0) restOpts.fetch = options.fetch;
|
|
201
123
|
return {
|
|
202
124
|
options,
|
|
203
|
-
|
|
125
|
+
client: createNeonClient({
|
|
126
|
+
token: resolveToken(options),
|
|
127
|
+
baseUrl: options.baseUrl,
|
|
128
|
+
fetch: options.fetch
|
|
129
|
+
})
|
|
204
130
|
};
|
|
205
131
|
}
|
|
206
132
|
function storage(ctx) {
|
|
207
|
-
return new NeonStorage(ctx.
|
|
133
|
+
return new NeonStorage(ctx.client, { projectId: ctx.options.projectId });
|
|
208
134
|
}
|
|
209
135
|
function createPlugin(options) {
|
|
210
136
|
const ctx = createContext(options);
|
|
@@ -221,4 +147,4 @@ function createPlugin(options) {
|
|
|
221
147
|
*/
|
|
222
148
|
const AUTH_HINT = "generate a Neon API key at https://console.neon.tech/app/settings/api-keys, then run: holocron auth set neon <KEY>";
|
|
223
149
|
//#endregion
|
|
224
|
-
export { AUTH_HINT, AuthError,
|
|
150
|
+
export { AUTH_HINT, AuthError, NeonStorage, createContext, createNeonClient, createPlugin, resolveToken, storage, verifyToken };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theholocron/holocron-plugin-neon",
|
|
3
|
-
"version": "2.0.0
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Holocron plugin for Neon. Implements the storage capability against Neon's REST API — branch ops, connection strings, extensions.",
|
|
5
5
|
"homepage": "https://github.com/theholocron/holocron/tree/main/packages/holocron-plugin-neon#readme",
|
|
6
6
|
"bugs": "https://github.com/theholocron/holocron/issues",
|
|
@@ -21,19 +21,31 @@
|
|
|
21
21
|
}
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
|
-
"@theholocron/
|
|
24
|
+
"@theholocron/neon-client": "^1.1.0",
|
|
25
|
+
"@theholocron/cli": "2.0.0"
|
|
26
|
+
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"@theholocron/neon-client": {
|
|
29
|
+
"optional": false
|
|
30
|
+
}
|
|
25
31
|
},
|
|
26
32
|
"devDependencies": {
|
|
27
|
-
"@theholocron/
|
|
28
|
-
"@
|
|
29
|
-
"@
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"vitest": "^
|
|
33
|
+
"@theholocron/eslint-config": "^7.3.0",
|
|
34
|
+
"@theholocron/neon-client": "^1.1.0",
|
|
35
|
+
"@theholocron/tsconfig": "^7.3.0",
|
|
36
|
+
"@theholocron/tsdown-config": "^7.3.0",
|
|
37
|
+
"@theholocron/vitest-config": "^7.3.0",
|
|
38
|
+
"@types/node": "^26",
|
|
39
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
40
|
+
"@vitest/eslint-plugin": "^1.6.23",
|
|
41
|
+
"eslint": "^10.7.0",
|
|
42
|
+
"eslint-plugin-n": "^18.2.2",
|
|
43
|
+
"globals": "^17.7.0",
|
|
34
44
|
"tsdown": "^0.22.3",
|
|
35
45
|
"tsx": "^4.22.4",
|
|
36
|
-
"
|
|
46
|
+
"typescript": "^5.9.3",
|
|
47
|
+
"vitest": "^4.1.10",
|
|
48
|
+
"@theholocron/cli": "2.0.0"
|
|
37
49
|
},
|
|
38
50
|
"publishConfig": {
|
|
39
51
|
"access": "public"
|