@locci/db 1.0.0-alpha-1
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/LICENSE +21 -0
- package/README.md +319 -0
- package/bin/locci-db.js +7 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +55 -0
- package/dist/config.d.ts +74 -0
- package/dist/config.js +90 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +114 -0
- package/dist/pglite-assets.d.ts +10 -0
- package/dist/pglite-assets.js +7 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.js +4 -0
- package/dist/utils.d.ts +21 -0
- package/dist/utils.js +41 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/package.json +84 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 MikeTeddyOmondi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# Locci DB - [@locci/db]()
|
|
2
|
+
|
|
3
|
+
Lightweight PostgreSQL-compatible database server powered by [PGlite](https://pglite.dev/).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Embedded Database**: Full PostgreSQL compatibility with PGlite
|
|
8
|
+
- **Easy to Deploy**: Standalone executable or Docker container
|
|
9
|
+
- **Secure**: Password hashing, connection limiting, IP filtering
|
|
10
|
+
- **Configurable**: Support for config files, environment variables, and CLI flags
|
|
11
|
+
- **CLI Support**: Generate hashes, manage database via command line
|
|
12
|
+
- **Production Ready**: Graceful shutdown, connection pooling, comprehensive logging
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
### As NPM Package
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @locci/db
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Global Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g @locci/db
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Using npx
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx @locci/db start
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
### Start the Server
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
locci-db start
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Options:
|
|
43
|
+
- `-p, --port <port>` - Port to listen on (default: 5432)
|
|
44
|
+
- `-h, --host <host>` - Host to bind to (default: 127.0.0.1)
|
|
45
|
+
- `-d, --data-dir <path>` - Data directory path
|
|
46
|
+
- `-l, --log-level <level>` - Log level: trace, debug, info, warn, error, fatal (default: info)
|
|
47
|
+
|
|
48
|
+
### Generate Password Hash
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
locci-db hash-password "clear-text-password-here"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This generates a secure hash to use in configuration files instead of plaintext passwords.
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
58
|
+
Configuration is loaded from multiple sources in this order:
|
|
59
|
+
1. CLI flags (highest priority)
|
|
60
|
+
2. Environment variables
|
|
61
|
+
3. A `.env` file in the working directory
|
|
62
|
+
4. Configuration files
|
|
63
|
+
5. Built-in defaults (lowest priority)
|
|
64
|
+
|
|
65
|
+
A value set at a higher level overrides the same value below it. Levels are
|
|
66
|
+
merged per field, so a config file can supply `auth` while an environment
|
|
67
|
+
variable overrides only the port.
|
|
68
|
+
|
|
69
|
+
### Environment Variables
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
export LOCCI_DB_PORT=5433
|
|
73
|
+
export LOCCI_DB_HOST=127.0.0.1
|
|
74
|
+
export LOCCI_DB_USERNAME=dbuser
|
|
75
|
+
export LOCCI_DB_PASSWORD=dbpassword
|
|
76
|
+
export LOCCI_DB_PASSWORD_HASH="salt:derivedkey" # If using hash
|
|
77
|
+
export LOCCI_DB_DATA_DIR=/path/to/data
|
|
78
|
+
export LOCCI_DB_LOG_LEVEL=info
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
These can also live in a `.env` file in the working directory, which is read at
|
|
82
|
+
startup. Real environment variables take precedence over the file, so exporting
|
|
83
|
+
a value still overrides it.
|
|
84
|
+
|
|
85
|
+
### Configuration File
|
|
86
|
+
|
|
87
|
+
Create `.locci-db.json` in your project directory:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"port": 5432,
|
|
92
|
+
"host": "127.0.0.1",
|
|
93
|
+
"dataDir": "./my-database",
|
|
94
|
+
"logLevel": "info",
|
|
95
|
+
"auth": {
|
|
96
|
+
"username": "postgres",
|
|
97
|
+
"passwordHash": "salt:derivedkey"
|
|
98
|
+
},
|
|
99
|
+
"security": {
|
|
100
|
+
"allowedHosts": ["127.0.0.1", "localhost"],
|
|
101
|
+
"maxConnections": 10
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Supported file formats:
|
|
107
|
+
- `.locci-db.json`
|
|
108
|
+
- `.locci-db.yaml`
|
|
109
|
+
- `.locci-db.yml`
|
|
110
|
+
- `.locci-db.config.js`
|
|
111
|
+
- `locci-db.config.js`
|
|
112
|
+
- `package.json` (under `locci-db` key)
|
|
113
|
+
|
|
114
|
+
## Single-file binary
|
|
115
|
+
|
|
116
|
+
The server compiles to a standalone executable with Bun. It embeds the Bun
|
|
117
|
+
runtime and PGlite's WASM, so it needs no Node, no Bun, and no `node_modules`
|
|
118
|
+
on the target machine.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
bun run compile # -> dist-bin/locci-db
|
|
122
|
+
./dist-bin/locci-db start
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Every release publishes prebuilt binaries for Linux (x64, arm64, x64-musl),
|
|
126
|
+
macOS (x64, arm64), and Windows (x64), alongside a `SHA256SUMS` file. Verify a
|
|
127
|
+
download before running it:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
sha256sum -c SHA256SUMS --ignore-missing
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
When a signing key is configured for the repository, each artifact also ships a
|
|
134
|
+
detached PGP signature (`.asc`):
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
gpg --verify locci-db-linux-x64.asc locci-db-linux-x64
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Docker
|
|
141
|
+
|
|
142
|
+
The image ships the compiled binary on `distroless/cc`, so it contains no Node,
|
|
143
|
+
no Bun, and no `node_modules`. Images are built for `linux/amd64` and
|
|
144
|
+
`linux/arm64`.
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
### Build
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
docker build -t locci/db:latest .
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Run
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
docker run -d \
|
|
157
|
+
-p 5432:5432 \
|
|
158
|
+
-e LOCCI_DB_USERNAME=postgres \
|
|
159
|
+
-e LOCCI_DB_PASSWORD=postgres \
|
|
160
|
+
-v locci-data:/app/data \
|
|
161
|
+
locci/db:latest
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Docker Compose
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
docker compose up -d
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Security Features
|
|
171
|
+
|
|
172
|
+
- **Password Hashing**: Uses scrypt for secure password hashing
|
|
173
|
+
- **Connection Limiting**: Prevents resource exhaustion
|
|
174
|
+
- **IP Whitelisting**: Restrict connections to authorized hosts
|
|
175
|
+
- **Input Validation**: Zod schema validation for all configurations
|
|
176
|
+
- **Graceful Shutdown**: Properly closes connections on SIGTERM/SIGINT
|
|
177
|
+
|
|
178
|
+
## Development
|
|
179
|
+
|
|
180
|
+
### Prerequisites
|
|
181
|
+
|
|
182
|
+
- [Bun](https://bun.sh/) (package manager, and required to compile binaries)
|
|
183
|
+
- Node.js >= 20.0.0 (the published package targets Node)
|
|
184
|
+
|
|
185
|
+
### Installation
|
|
186
|
+
|
|
187
|
+
Dependencies are managed with Bun (`bun.lock`):
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
bun install
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Building
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
npm run build
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Development Mode
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
npm run dev
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Testing
|
|
206
|
+
|
|
207
|
+
Tests run on [Vitest](https://vitest.dev/) under Node, split into two projects:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
npm test # unit tests
|
|
211
|
+
npm run test:unit # same, explicit
|
|
212
|
+
npm run test:e2e # builds, then boots the real server and connects to it
|
|
213
|
+
npm run test:all # both projects
|
|
214
|
+
npm run test:watch # unit tests in watch mode
|
|
215
|
+
npm run test:coverage
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Unit specs live in `tests/unit`, end-to-end specs in `tests/e2e`. E2E specs bind
|
|
219
|
+
real ports, so they run serially with longer timeouts.
|
|
220
|
+
|
|
221
|
+
### Linting and type checking
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
npm run lint # oxlint, warnings are errors
|
|
225
|
+
npm run lint:fix # apply autofixes
|
|
226
|
+
npm run typecheck # tsc over src and tests
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Compiling
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
npm run compile # single-file binary into dist-bin/
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## API
|
|
236
|
+
|
|
237
|
+
### `startServer(): Promise<net.Server>`
|
|
238
|
+
|
|
239
|
+
Starts the database server with loaded configuration.
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
import { startServer } from '@locci/db';
|
|
243
|
+
|
|
244
|
+
const server = await startServer();
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### `loadConfig(): Config`
|
|
248
|
+
|
|
249
|
+
Loads configuration from files and environment variables.
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
import { loadConfig } from '@locci/db/config';
|
|
253
|
+
|
|
254
|
+
const config = loadConfig();
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### `hashPassword(password: string): Promise<string>`
|
|
258
|
+
|
|
259
|
+
Generates a secure password hash.
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
import { hashPassword } from '@locci/db/config';
|
|
263
|
+
|
|
264
|
+
const hash = await hashPassword('my-password');
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### `verifyPassword(password: string, hash: string): Promise<boolean>`
|
|
268
|
+
|
|
269
|
+
Verifies a password against a hash.
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
import { verifyPassword } from '@locci/db/config';
|
|
273
|
+
|
|
274
|
+
const isValid = await verifyPassword('my-password', hash);
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
## Publishing
|
|
278
|
+
|
|
279
|
+
Releases are automated. Pushing a version tag runs the `Release` workflow,
|
|
280
|
+
which verifies the build, cross-compiles the binaries, creates the GitHub
|
|
281
|
+
release with those binaries and their checksums attached, publishes to npm, and
|
|
282
|
+
pushes multi-arch images to Docker Hub and GHCR.
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
npm version patch|minor|major # updates package.json and creates the tag
|
|
286
|
+
git push --follow-tags
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
The workflow fails if the tag does not match the version in `package.json`. A
|
|
290
|
+
prerelease version (one containing a hyphen, such as `1.0.0-alpha-1`) publishes
|
|
291
|
+
to npm under the `next` dist-tag and does not move the `latest` Docker tag.
|
|
292
|
+
|
|
293
|
+
Required repository secrets: `NPM_TOKEN`, `DOCKER_USERNAME`, `DOCKER_PASSWORD`.
|
|
294
|
+
GHCR uses the built-in `GITHUB_TOKEN`. Set `GPG_PRIVATE_KEY` and
|
|
295
|
+
`GPG_PASSPHRASE` to sign the release binaries; without them the release still
|
|
296
|
+
completes, with a warning, and the binaries are published unsigned.
|
|
297
|
+
|
|
298
|
+
## License
|
|
299
|
+
|
|
300
|
+
MIT - See LICENSE file for details
|
|
301
|
+
|
|
302
|
+
## Dependencies
|
|
303
|
+
|
|
304
|
+
- [PGlite](https://pglite.dev/) - PostgreSQL in WASM
|
|
305
|
+
- [pg-gateway](https://github.com/supabase-community/pg-gateway) - PostgreSQL wire protocol handler
|
|
306
|
+
- [Pino](https://getpino.io/) - Fast logger
|
|
307
|
+
- [Commander](https://github.com/tj/commander.js) - CLI framework
|
|
308
|
+
- [Zod](https://zod.dev/) - TypeScript-first schema validation
|
|
309
|
+
- [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig) - Configuration file discovery
|
|
310
|
+
- [defu](https://github.com/unjs/defu) - Configuration merging
|
|
311
|
+
- [dotenv](https://github.com/motdotla/dotenv) - `.env` file loading
|
|
312
|
+
|
|
313
|
+
## Contributing
|
|
314
|
+
|
|
315
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
316
|
+
|
|
317
|
+
## Support
|
|
318
|
+
|
|
319
|
+
For issues and feature requests, please use the GitHub issues page.
|
package/bin/locci-db.js
ADDED
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Must come before any import that reads process.env. Values already present
|
|
3
|
+
// in the real environment win; .env only fills in what is missing.
|
|
4
|
+
import { config as loadEnvFile } from "dotenv";
|
|
5
|
+
loadEnvFile({ quiet: true });
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import { startServer } from "./index.js";
|
|
8
|
+
import { hashPassword } from "./config.js";
|
|
9
|
+
import { VERSION } from "./version.js";
|
|
10
|
+
import { log } from "./utils.js";
|
|
11
|
+
const program = new Command();
|
|
12
|
+
program
|
|
13
|
+
.name("locci-db")
|
|
14
|
+
.description("Lightweight PostgreSQL-compatible database server")
|
|
15
|
+
.version(VERSION);
|
|
16
|
+
program
|
|
17
|
+
.command("start")
|
|
18
|
+
.description("Start the database server")
|
|
19
|
+
.option("-p, --port <port>", "Port to listen on (default: 5432)")
|
|
20
|
+
.option("-h, --host <host>", "Host to bind to (default: 127.0.0.1)")
|
|
21
|
+
.option("-d, --data-dir <path>", "Data directory path")
|
|
22
|
+
.option("-l, --log-level <level>", "Log level (default: info)")
|
|
23
|
+
.action(async (options) => {
|
|
24
|
+
// Only flags the user actually passed become overrides. Setting these
|
|
25
|
+
// unconditionally would make commander defaults outrank the config file.
|
|
26
|
+
if (options.port)
|
|
27
|
+
process.env.LOCCI_DB_PORT = options.port;
|
|
28
|
+
if (options.host)
|
|
29
|
+
process.env.LOCCI_DB_HOST = options.host;
|
|
30
|
+
if (options.dataDir)
|
|
31
|
+
process.env.LOCCI_DB_DATA_DIR = options.dataDir;
|
|
32
|
+
if (options.logLevel)
|
|
33
|
+
process.env.LOCCI_DB_LOG_LEVEL = options.logLevel;
|
|
34
|
+
try {
|
|
35
|
+
await startServer();
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
log.error({ error }, "Failed to start server:");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
program
|
|
43
|
+
.command("hash-password <password>")
|
|
44
|
+
.description("Generate a password hash for configuration")
|
|
45
|
+
.action(async (password) => {
|
|
46
|
+
try {
|
|
47
|
+
const hash = await hashPassword(password);
|
|
48
|
+
log.info({ hash }, "Password hash (store this in your config) ");
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
log.error({ error }, "Failed to generate password hash:");
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
program.parse();
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Configuration schema with validation
|
|
4
|
+
*/
|
|
5
|
+
export declare const ConfigSchema: z.ZodObject<{
|
|
6
|
+
port: z.ZodDefault<z.ZodNumber>;
|
|
7
|
+
host: z.ZodDefault<z.ZodString>;
|
|
8
|
+
dataDir: z.ZodOptional<z.ZodString>;
|
|
9
|
+
logLevel: z.ZodDefault<z.ZodEnum<["trace", "debug", "info", "warn", "error", "fatal"]>>;
|
|
10
|
+
auth: z.ZodObject<{
|
|
11
|
+
username: z.ZodString;
|
|
12
|
+
password: z.ZodOptional<z.ZodString>;
|
|
13
|
+
passwordHash: z.ZodOptional<z.ZodString>;
|
|
14
|
+
}, "strip", z.ZodTypeAny, {
|
|
15
|
+
username: string;
|
|
16
|
+
password?: string | undefined;
|
|
17
|
+
passwordHash?: string | undefined;
|
|
18
|
+
}, {
|
|
19
|
+
username: string;
|
|
20
|
+
password?: string | undefined;
|
|
21
|
+
passwordHash?: string | undefined;
|
|
22
|
+
}>;
|
|
23
|
+
security: z.ZodOptional<z.ZodObject<{
|
|
24
|
+
allowedHosts: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
25
|
+
maxConnections: z.ZodDefault<z.ZodNumber>;
|
|
26
|
+
}, "strip", z.ZodTypeAny, {
|
|
27
|
+
allowedHosts: string[];
|
|
28
|
+
maxConnections: number;
|
|
29
|
+
}, {
|
|
30
|
+
allowedHosts?: string[] | undefined;
|
|
31
|
+
maxConnections?: number | undefined;
|
|
32
|
+
}>>;
|
|
33
|
+
}, "strip", z.ZodTypeAny, {
|
|
34
|
+
port: number;
|
|
35
|
+
host: string;
|
|
36
|
+
logLevel: "info" | "fatal" | "error" | "warn" | "debug" | "trace";
|
|
37
|
+
auth: {
|
|
38
|
+
username: string;
|
|
39
|
+
password?: string | undefined;
|
|
40
|
+
passwordHash?: string | undefined;
|
|
41
|
+
};
|
|
42
|
+
dataDir?: string | undefined;
|
|
43
|
+
security?: {
|
|
44
|
+
allowedHosts: string[];
|
|
45
|
+
maxConnections: number;
|
|
46
|
+
} | undefined;
|
|
47
|
+
}, {
|
|
48
|
+
auth: {
|
|
49
|
+
username: string;
|
|
50
|
+
password?: string | undefined;
|
|
51
|
+
passwordHash?: string | undefined;
|
|
52
|
+
};
|
|
53
|
+
port?: number | undefined;
|
|
54
|
+
host?: string | undefined;
|
|
55
|
+
dataDir?: string | undefined;
|
|
56
|
+
logLevel?: "info" | "fatal" | "error" | "warn" | "debug" | "trace" | undefined;
|
|
57
|
+
security?: {
|
|
58
|
+
allowedHosts?: string[] | undefined;
|
|
59
|
+
maxConnections?: number | undefined;
|
|
60
|
+
} | undefined;
|
|
61
|
+
}>;
|
|
62
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
63
|
+
/**
|
|
64
|
+
* Hash password securely using scrypt
|
|
65
|
+
*/
|
|
66
|
+
export declare function hashPassword(password: string): Promise<string>;
|
|
67
|
+
/**
|
|
68
|
+
* Verify password against hash
|
|
69
|
+
*/
|
|
70
|
+
export declare function verifyPassword(password: string, hash: string): Promise<boolean>;
|
|
71
|
+
/**
|
|
72
|
+
* Load configuration from multiple sources
|
|
73
|
+
*/
|
|
74
|
+
export declare function loadConfig(): Config;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { cosmiconfigSync } from "cosmiconfig";
|
|
3
|
+
import { defu } from "defu";
|
|
4
|
+
import { randomBytes, scrypt } from "crypto";
|
|
5
|
+
import { promisify } from "util";
|
|
6
|
+
import { log } from "./utils.js";
|
|
7
|
+
const scryptAsync = promisify(scrypt);
|
|
8
|
+
/**
|
|
9
|
+
* Configuration schema with validation
|
|
10
|
+
*/
|
|
11
|
+
export const ConfigSchema = z.object({
|
|
12
|
+
port: z.number().int().min(1024).max(65535).default(5432),
|
|
13
|
+
host: z.string().default("127.0.0.1"),
|
|
14
|
+
dataDir: z.string().optional(),
|
|
15
|
+
logLevel: z
|
|
16
|
+
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
|
17
|
+
.default("info"),
|
|
18
|
+
auth: z.object({
|
|
19
|
+
username: z.string().min(3).max(63),
|
|
20
|
+
password: z.string().min(8).optional(),
|
|
21
|
+
// Optional: password hash instead of plaintext
|
|
22
|
+
passwordHash: z.string().optional(),
|
|
23
|
+
}),
|
|
24
|
+
security: z
|
|
25
|
+
.object({
|
|
26
|
+
allowedHosts: z.array(z.string()).default(["127.0.0.1", "localhost"]),
|
|
27
|
+
maxConnections: z.number().int().positive().default(10),
|
|
28
|
+
})
|
|
29
|
+
.optional(),
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Hash password securely using scrypt
|
|
33
|
+
*/
|
|
34
|
+
export async function hashPassword(password) {
|
|
35
|
+
const salt = randomBytes(16).toString("hex");
|
|
36
|
+
const derivedKey = (await scryptAsync(password, salt, 64));
|
|
37
|
+
return `${salt}:${derivedKey.toString("hex")}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Verify password against hash
|
|
41
|
+
*/
|
|
42
|
+
export async function verifyPassword(password, hash) {
|
|
43
|
+
try {
|
|
44
|
+
const [salt, key] = hash.split(":");
|
|
45
|
+
if (!salt || !key)
|
|
46
|
+
return false;
|
|
47
|
+
const derivedKey = (await scryptAsync(password, salt, 64));
|
|
48
|
+
return key === derivedKey.toString("hex");
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
log.error({ error }, "Error verifying password:");
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Load configuration from multiple sources
|
|
57
|
+
*/
|
|
58
|
+
export function loadConfig() {
|
|
59
|
+
const explorer = cosmiconfigSync("locci-db", {
|
|
60
|
+
searchPlaces: [
|
|
61
|
+
"package.json",
|
|
62
|
+
".locci-db.json",
|
|
63
|
+
".locci-db.yaml",
|
|
64
|
+
".locci-db.yml",
|
|
65
|
+
".locci-db.config.js",
|
|
66
|
+
"locci-db.config.js",
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
const result = explorer.search();
|
|
70
|
+
const config = result?.config || {};
|
|
71
|
+
// Merge with environment variables
|
|
72
|
+
const envConfig = {
|
|
73
|
+
port: process.env.LOCCI_DB_PORT
|
|
74
|
+
? parseInt(process.env.LOCCI_DB_PORT, 10)
|
|
75
|
+
: undefined,
|
|
76
|
+
host: process.env.LOCCI_DB_HOST,
|
|
77
|
+
dataDir: process.env.LOCCI_DB_DATA_DIR,
|
|
78
|
+
logLevel: process.env.LOCCI_DB_LOG_LEVEL,
|
|
79
|
+
auth: {
|
|
80
|
+
username: process.env.LOCCI_DB_USERNAME,
|
|
81
|
+
password: process.env.LOCCI_DB_PASSWORD,
|
|
82
|
+
passwordHash: process.env.LOCCI_DB_PASSWORD_HASH,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
// defu merges right-to-left by falling priority and skips undefined values,
|
|
86
|
+
// so an env var that is not set falls through to the config file.
|
|
87
|
+
const mergedConfig = defu(envConfig, config);
|
|
88
|
+
// Validate with Zod
|
|
89
|
+
return ConfigSchema.parse(mergedConfig);
|
|
90
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
import { mkdir } from "node:fs/promises";
|
|
7
|
+
import { fromNodeSocket } from "pg-gateway/node";
|
|
8
|
+
import { log } from "./utils.js";
|
|
9
|
+
import { loadConfig, verifyPassword } from "./config.js";
|
|
10
|
+
import { getEmbeddedPGliteAssets } from "./pglite-assets.js";
|
|
11
|
+
export async function startServer() {
|
|
12
|
+
const config = loadConfig();
|
|
13
|
+
// Secure data directory (user's home by default)
|
|
14
|
+
const dataDir = config.dataDir || join(homedir(), ".locci/db", "data");
|
|
15
|
+
// Ensure parent directory exists
|
|
16
|
+
await mkdir(dirname(dataDir), { recursive: true });
|
|
17
|
+
log.info(`Initializing PGlite database at ${dataDir}`);
|
|
18
|
+
const db = new PGlite({ dataDir, ...getEmbeddedPGliteAssets() });
|
|
19
|
+
let activeConnections = 0;
|
|
20
|
+
const server = net.createServer(async (socket) => {
|
|
21
|
+
const clientAddress = socket.remoteAddress;
|
|
22
|
+
// Connection limiting
|
|
23
|
+
if (activeConnections >= (config.security?.maxConnections || 10)) {
|
|
24
|
+
log.warn(`Connection rejected: max connections reached (${activeConnections})`);
|
|
25
|
+
socket.destroy();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// IP filtering
|
|
29
|
+
if (config.security?.allowedHosts &&
|
|
30
|
+
!config.security.allowedHosts.includes(clientAddress || "")) {
|
|
31
|
+
log.warn(`Connection rejected from unauthorized host: ${clientAddress}`);
|
|
32
|
+
socket.destroy();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
activeConnections++;
|
|
36
|
+
log.info(`Client connected from ${clientAddress} (${activeConnections} active)`);
|
|
37
|
+
await fromNodeSocket(socket, {
|
|
38
|
+
serverVersion: "16.3 (PGlite 0.2.0)",
|
|
39
|
+
auth: {
|
|
40
|
+
method: "password",
|
|
41
|
+
validateCredentials: async function (credentials) {
|
|
42
|
+
if (credentials.clearTextPassword) {
|
|
43
|
+
const isValidUser = credentials.username === config.auth.username;
|
|
44
|
+
// Use password hash if available, otherwise compare plaintext
|
|
45
|
+
let isValidPassword;
|
|
46
|
+
if (config.auth.passwordHash) {
|
|
47
|
+
isValidPassword = await verifyPassword(credentials.password, config.auth.passwordHash);
|
|
48
|
+
}
|
|
49
|
+
else if (config.auth.password) {
|
|
50
|
+
isValidPassword = credentials.password === config.auth.password;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
isValidPassword = false;
|
|
54
|
+
}
|
|
55
|
+
if (!isValidUser || !isValidPassword) {
|
|
56
|
+
log.warn(`Authentication failed for user: ${credentials.username} from ${clientAddress}`);
|
|
57
|
+
}
|
|
58
|
+
return isValidUser && isValidPassword;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
},
|
|
62
|
+
getClearTextPassword: async function (credentials) {
|
|
63
|
+
return credentials.username;
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
async onStartup() {
|
|
67
|
+
log.debug("Awaiting database to be ready...");
|
|
68
|
+
await db.waitReady;
|
|
69
|
+
},
|
|
70
|
+
async onMessage(data, { isAuthenticated }) {
|
|
71
|
+
if (!isAuthenticated) {
|
|
72
|
+
log.warn("Unauthenticated message rejected");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
return await db.execProtocolRaw(data);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
socket.on("end", () => {
|
|
79
|
+
activeConnections--;
|
|
80
|
+
log.info(`Client disconnected from ${clientAddress} (${activeConnections} active)`);
|
|
81
|
+
});
|
|
82
|
+
socket.on("error", (err) => {
|
|
83
|
+
activeConnections--;
|
|
84
|
+
log.error(`Socket error from ${clientAddress}: ${err.message}`);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
server.on("error", (err) => {
|
|
88
|
+
log.error(`Server error: ${err.message}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
});
|
|
91
|
+
server.listen(config.port, config.host, async () => {
|
|
92
|
+
log.info(`🚀 Server listening on ${config.host}:${config.port}`);
|
|
93
|
+
log.info(`📁 Data directory: ${dataDir}`);
|
|
94
|
+
log.info(`🔐 Authenticated as: ${config.auth.username}`);
|
|
95
|
+
});
|
|
96
|
+
// Graceful shutdown
|
|
97
|
+
process.on("SIGTERM", () => gracefulShutdown(server, db));
|
|
98
|
+
process.on("SIGINT", () => gracefulShutdown(server, db));
|
|
99
|
+
return server;
|
|
100
|
+
}
|
|
101
|
+
async function gracefulShutdown(server, db) {
|
|
102
|
+
log.info("Shutting down gracefully...");
|
|
103
|
+
server.close(() => {
|
|
104
|
+
log.info("Server closed");
|
|
105
|
+
});
|
|
106
|
+
try {
|
|
107
|
+
await db.close();
|
|
108
|
+
log.info("Database closed");
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
log.error({ error }, "Error closing database:");
|
|
112
|
+
}
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PGliteOptions } from "@electric-sql/pglite";
|
|
2
|
+
/**
|
|
3
|
+
* PGlite normally fetches postgres.wasm and postgres.data from disk, relative
|
|
4
|
+
* to its own module URL. That does not work inside a compiled single-file
|
|
5
|
+
* binary, so the Bun entry point embeds both and injects them here before the
|
|
6
|
+
* server starts.
|
|
7
|
+
*/
|
|
8
|
+
export type EmbeddedPGliteAssets = Pick<PGliteOptions, "wasmModule" | "fsBundle">;
|
|
9
|
+
export declare function setEmbeddedPGliteAssets(assets: EmbeddedPGliteAssets): void;
|
|
10
|
+
export declare function getEmbeddedPGliteAssets(): EmbeddedPGliteAssets;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for @locci/db
|
|
3
|
+
*/
|
|
4
|
+
export interface ServerConfig {
|
|
5
|
+
port: number;
|
|
6
|
+
host: string;
|
|
7
|
+
dataDir?: string;
|
|
8
|
+
logLevel: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
|
9
|
+
auth: AuthConfig;
|
|
10
|
+
security?: SecurityConfig;
|
|
11
|
+
}
|
|
12
|
+
export interface AuthConfig {
|
|
13
|
+
username: string;
|
|
14
|
+
password?: string;
|
|
15
|
+
passwordHash?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface SecurityConfig {
|
|
18
|
+
allowedHosts?: string[];
|
|
19
|
+
maxConnections?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface ConnectionInfo {
|
|
22
|
+
clientAddress?: string;
|
|
23
|
+
username: string;
|
|
24
|
+
timestamp: Date;
|
|
25
|
+
authenticated: boolean;
|
|
26
|
+
}
|
package/dist/types.js
ADDED
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
/**
|
|
3
|
+
* Create logger instance
|
|
4
|
+
*/
|
|
5
|
+
export declare const log: pino.Logger<never, boolean>;
|
|
6
|
+
/**
|
|
7
|
+
* Format bytes for human-readable output
|
|
8
|
+
*/
|
|
9
|
+
export declare function formatBytes(bytes: number): string;
|
|
10
|
+
/**
|
|
11
|
+
* Validate port number
|
|
12
|
+
*/
|
|
13
|
+
export declare function isValidPort(port: number): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Validate hostname
|
|
16
|
+
*/
|
|
17
|
+
export declare function isValidHost(host: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Sleep for specified milliseconds
|
|
20
|
+
*/
|
|
21
|
+
export declare function sleep(ms: number): Promise<void>;
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
import pretty from 'pino-pretty';
|
|
3
|
+
/**
|
|
4
|
+
* Create logger instance
|
|
5
|
+
*/
|
|
6
|
+
export const log = pino({
|
|
7
|
+
level: process.env.LOCCI_DB_LOG_LEVEL || 'info',
|
|
8
|
+
}, pretty({
|
|
9
|
+
colorize: true,
|
|
10
|
+
singleLine: false,
|
|
11
|
+
translateTime: 'SYS:standard',
|
|
12
|
+
}));
|
|
13
|
+
/**
|
|
14
|
+
* Format bytes for human-readable output
|
|
15
|
+
*/
|
|
16
|
+
export function formatBytes(bytes) {
|
|
17
|
+
if (bytes === 0)
|
|
18
|
+
return '0 Bytes';
|
|
19
|
+
const k = 1024;
|
|
20
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
21
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
22
|
+
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Validate port number
|
|
26
|
+
*/
|
|
27
|
+
export function isValidPort(port) {
|
|
28
|
+
return port >= 1024 && port <= 65535 && Number.isInteger(port);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Validate hostname
|
|
32
|
+
*/
|
|
33
|
+
export function isValidHost(host) {
|
|
34
|
+
return /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$|^(::)?1?$|^127\.0\.0\.1$|^localhost$|^::$/.test(host);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Sleep for specified milliseconds
|
|
38
|
+
*/
|
|
39
|
+
export function sleep(ms) {
|
|
40
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const VERSION = "1.0.0-alpha-1";
|
package/dist/version.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@locci/db",
|
|
3
|
+
"version": "1.0.0-alpha-1",
|
|
4
|
+
"description": "Lightweight PostgreSQL-compatible database server powered by PGlite",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"locci-db": "./bin/locci-db.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"prepare": "git config core.hooksPath .githooks || true",
|
|
20
|
+
"dev": "tsx ./src/cli.ts",
|
|
21
|
+
"start": "node ./dist/cli.js",
|
|
22
|
+
"prepublishOnly": "npm run build",
|
|
23
|
+
"test": "vitest run --project unit",
|
|
24
|
+
"lint": "oxlint --deny-warnings",
|
|
25
|
+
"docker:build": "docker build -t locci/db:latest .",
|
|
26
|
+
"docker:run": "docker run -p 5432:5432 --env-file .env locci/db:latest",
|
|
27
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
|
|
28
|
+
"test:unit": "vitest run --project unit",
|
|
29
|
+
"test:e2e": "npm run build && vitest run --project e2e",
|
|
30
|
+
"test:all": "npm run build && vitest run",
|
|
31
|
+
"test:watch": "vitest --project unit",
|
|
32
|
+
"test:coverage": "vitest run --project unit --coverage",
|
|
33
|
+
"lint:fix": "oxlint --fix",
|
|
34
|
+
"prebuild": "node scripts/gen-version.mjs",
|
|
35
|
+
"pretypecheck": "node scripts/gen-version.mjs",
|
|
36
|
+
"compile": "bun build ./src/bun.ts --compile --minify --outfile dist-bin/locci-db",
|
|
37
|
+
"precompile": "node scripts/gen-version.mjs"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"postgresql",
|
|
41
|
+
"pglite",
|
|
42
|
+
"database",
|
|
43
|
+
"cli",
|
|
44
|
+
"postgres",
|
|
45
|
+
"embedded-database"
|
|
46
|
+
],
|
|
47
|
+
"author": "MikeTeddyOmondi <contact@miketeddyomondi.dev>",
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "https://github.com/MikeTeddyOmondi/locci-db.git"
|
|
52
|
+
},
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/MikeTeddyOmondi/locci-db/issues"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=20.0.0"
|
|
58
|
+
},
|
|
59
|
+
"files": [
|
|
60
|
+
"dist",
|
|
61
|
+
"bin",
|
|
62
|
+
"README.md",
|
|
63
|
+
"LICENSE"
|
|
64
|
+
],
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@electric-sql/pglite": "^0.2.9",
|
|
67
|
+
"commander": "^12.0.0",
|
|
68
|
+
"cosmiconfig": "^9.0.0",
|
|
69
|
+
"defu": "^6.1.7",
|
|
70
|
+
"dotenv": "^17.4.2",
|
|
71
|
+
"pg-gateway": "0.3.0-beta.3",
|
|
72
|
+
"pino": "^9.5.0",
|
|
73
|
+
"pino-pretty": "^11.3.0",
|
|
74
|
+
"zod": "^3.22.4"
|
|
75
|
+
},
|
|
76
|
+
"devDependencies": {
|
|
77
|
+
"@types/node": "^22.20.1",
|
|
78
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
79
|
+
"oxlint": "^1.82.0",
|
|
80
|
+
"tsx": "^4.16.2",
|
|
81
|
+
"typescript": "^5.5.3",
|
|
82
|
+
"vitest": "^5.0.0"
|
|
83
|
+
}
|
|
84
|
+
}
|