@happyvertical/sql 0.74.8

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.
Files changed (53) hide show
  1. package/AGENT.md +33 -0
  2. package/LICENSE +7 -0
  3. package/README.md +287 -0
  4. package/dist/backup/index.d.ts +119 -0
  5. package/dist/backup/index.d.ts.map +1 -0
  6. package/dist/backup/index.js +212 -0
  7. package/dist/backup/index.js.map +1 -0
  8. package/dist/backup/manifest.d.ts +51 -0
  9. package/dist/backup/manifest.d.ts.map +1 -0
  10. package/dist/chunks/duckdb-Djx8WKZE.js +924 -0
  11. package/dist/chunks/duckdb-Djx8WKZE.js.map +1 -0
  12. package/dist/chunks/index-DL-5nB7l.js +3468 -0
  13. package/dist/chunks/index-DL-5nB7l.js.map +1 -0
  14. package/dist/chunks/postgres-cli-Cr6bBZ3q.js +209 -0
  15. package/dist/chunks/postgres-cli-Cr6bBZ3q.js.map +1 -0
  16. package/dist/chunks/sqlite-DkCGjXmu.js +937 -0
  17. package/dist/chunks/sqlite-DkCGjXmu.js.map +1 -0
  18. package/dist/chunks/sqlite-native-B6q-0nLs.js +1129 -0
  19. package/dist/chunks/sqlite-native-B6q-0nLs.js.map +1 -0
  20. package/dist/cli/claude-context.d.ts +3 -0
  21. package/dist/cli/claude-context.d.ts.map +1 -0
  22. package/dist/cli/claude-context.js +21 -0
  23. package/dist/cli/claude-context.js.map +1 -0
  24. package/dist/doctor.d.ts +69 -0
  25. package/dist/doctor.d.ts.map +1 -0
  26. package/dist/duckdb.d.ts +9 -0
  27. package/dist/duckdb.d.ts.map +1 -0
  28. package/dist/index.d.ts +109 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +34 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/json.d.ts +30 -0
  33. package/dist/json.d.ts.map +1 -0
  34. package/dist/postgres-cli.d.ts +135 -0
  35. package/dist/postgres-cli.d.ts.map +1 -0
  36. package/dist/postgres.d.ts +77 -0
  37. package/dist/postgres.d.ts.map +1 -0
  38. package/dist/schema-manager.d.ts +86 -0
  39. package/dist/schema-manager.d.ts.map +1 -0
  40. package/dist/shared/alter-utils.d.ts +83 -0
  41. package/dist/shared/alter-utils.d.ts.map +1 -0
  42. package/dist/shared/duckdb-schema-utils.d.ts +86 -0
  43. package/dist/shared/duckdb-schema-utils.d.ts.map +1 -0
  44. package/dist/shared/types.d.ts +1016 -0
  45. package/dist/shared/types.d.ts.map +1 -0
  46. package/dist/shared/utils.d.ts +104 -0
  47. package/dist/shared/utils.d.ts.map +1 -0
  48. package/dist/sqlite-native.d.ts +4 -0
  49. package/dist/sqlite-native.d.ts.map +1 -0
  50. package/dist/sqlite.d.ts +54 -0
  51. package/dist/sqlite.d.ts.map +1 -0
  52. package/metadata.json +38 -0
  53. package/package.json +84 -0
package/AGENT.md ADDED
@@ -0,0 +1,33 @@
1
+ # @happyvertical/sql
2
+
3
+ <!-- BEGIN AGENT:GENERATED -->
4
+ ## Purpose
5
+ Database interface with support for SQLite, PostgreSQL, and DuckDB
6
+
7
+ ## Package Map
8
+ - Package: `@happyvertical/sql`
9
+ - Hierarchy path: `@happyvertical/sdk > packages > sql`
10
+ - Workspace position: `26 of 30` local packages
11
+ - Internal dependencies: `@happyvertical/utils`
12
+ - Internal dependents: `@happyvertical/jobs`, `@happyvertical/secrets`
13
+ - Knowledge graph files: `AGENT.md`, `metadata.json`, `ecosystem-manifest.json`
14
+
15
+ ## Build & Test
16
+ ```bash
17
+ pnpm --filter @happyvertical/sql build
18
+ pnpm --filter @happyvertical/sql test
19
+ pnpm --filter @happyvertical/sql clean
20
+ ```
21
+
22
+ ## Agent Correction Loops
23
+ - If module resolution or export errors mention a workspace dependency, build the dependency first (`pnpm --filter @happyvertical/utils build`) and then rerun `pnpm --filter @happyvertical/sql build`.
24
+ - If tests or exports fail after API, type, or bundle changes, run `pnpm --filter @happyvertical/sql clean` followed by `pnpm --filter @happyvertical/sql build` and `pnpm --filter @happyvertical/sql test`.
25
+ - If failures span multiple packages or Turborepo ordering looks wrong, run `pnpm build` and `pnpm typecheck` from the repo root before retrying package-scoped commands.
26
+
27
+ ## Ecosystem Relationships
28
+ - Provides: Database interface with support for SQLite, PostgreSQL, and DuckDB
29
+ - Implements: none
30
+ - Requires: @happyvertical/utils, @duckdb/node-api, @libsql/client, @russellthehippo/honker-node, @sqliteai/sqlite-vector, pg, sqlite-vss
31
+ - Stability: stable (Primary package surface is described as implemented and production-oriented.)
32
+ <!-- END AGENT:GENERATED -->
33
+
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright <2025> <Happy Vertical Corporation>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,287 @@
1
+ # @happyvertical/sql
2
+
3
+ Database interface with support for SQLite (via LibSQL/Turso), PostgreSQL, DuckDB, and a JSON adapter (DuckDB-backed). Provides a unified API across all backends with template literal queries, CRUD helpers, transactions, schema synchronization, and vector search (PostgreSQL via pgvector).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @happyvertical/sql
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Connecting to a Database
14
+
15
+ ```typescript
16
+ import { getDatabase } from '@happyvertical/sql';
17
+
18
+ // SQLite (in-memory)
19
+ const db = await getDatabase({ type: 'sqlite', url: ':memory:' });
20
+
21
+ // SQLite (file)
22
+ const fileDb = await getDatabase({ type: 'sqlite', url: 'file:./app.db' });
23
+
24
+ // LibSQL/Turso (remote)
25
+ const tursoDb = await getDatabase({
26
+ type: 'sqlite',
27
+ url: 'libsql://your-database.turso.io',
28
+ authToken: process.env.TURSO_AUTH_TOKEN,
29
+ });
30
+
31
+ // PostgreSQL
32
+ const pgDb = await getDatabase({
33
+ type: 'postgres',
34
+ url: 'postgresql://user:pass@localhost:5432/dbname',
35
+ });
36
+
37
+ // DuckDB with JSON file auto-registration
38
+ const duckDb = await getDatabase({
39
+ type: 'duckdb',
40
+ url: ':memory:',
41
+ dataDir: './data',
42
+ autoRegisterJSON: true,
43
+ });
44
+
45
+ // JSON adapter (DuckDB-backed, reads/writes JSON files)
46
+ const jsonDb = await getDatabase({
47
+ type: 'json',
48
+ url: './data',
49
+ writeStrategy: 'immediate',
50
+ });
51
+ ```
52
+
53
+ Configuration is also loaded from `HAVE_SQL_*` environment variables (e.g. `HAVE_SQL_TYPE`, `HAVE_SQL_URL`). User-provided options take precedence.
54
+
55
+ ### Template Literal Queries
56
+
57
+ ```typescript
58
+ // Returns all rows
59
+ const posts = await db.many`SELECT * FROM posts WHERE published = ${true}`;
60
+
61
+ // Returns a single row or null
62
+ const post = await db.single`SELECT * FROM posts WHERE id = ${postId}`;
63
+
64
+ // Returns first column of first row
65
+ const count = await db.pluck`SELECT COUNT(*) FROM posts WHERE author = ${name}`;
66
+
67
+ // Executes without returning results
68
+ await db.execute`DELETE FROM posts WHERE id = ${postId}`;
69
+ ```
70
+
71
+ Shorthand aliases: `oo` (many), `oO` (single), `ox` (pluck), `xx` (execute).
72
+
73
+ Interpolated values are always passed as parameterized values (never string-concatenated), with placeholder format handled per adapter (`?` for SQLite/DuckDB, `$1`/`$2` for PostgreSQL).
74
+
75
+ ### Raw Queries
76
+
77
+ ```typescript
78
+ // Raw queries use each adapter's native placeholder syntax.
79
+ await pgDb.query('SELECT * FROM posts WHERE id = $1', postId);
80
+ await pgDb.query('SELECT * FROM posts WHERE id = $1', [postId]);
81
+ await pgDb.query('SELECT * FROM posts WHERE id = ANY($1)', postIds);
82
+
83
+ // Legacy ? placeholders are converted only when unambiguous.
84
+ await pgDb.query('SELECT * FROM posts WHERE id = ?', postId);
85
+
86
+ // Native operators remain safe; prefer $1 placeholders when mixing operators and values.
87
+ await pgDb.query(`SELECT ('{"db":true}'::jsonb ? 'db') AS has_db`);
88
+ ```
89
+
90
+ For PostgreSQL, a single array argument is treated as a values list unless the SQL shows a single array-typed placeholder, such as `$1::text[]`, `CAST($1 AS text[])`, `ANY($1)`, or the equivalent legacy `?` placeholder form. Transaction handles follow the same raw query behavior as the root database handle.
91
+
92
+ ### CRUD Helpers
93
+
94
+ ```typescript
95
+ await db.insert('posts', { id: 'p1', title: 'Hello', author: 'Alice' });
96
+ await db.insert('posts', [{ id: 'p2', title: 'A' }, { id: 'p3', title: 'B' }]);
97
+
98
+ const post = await db.get('posts', { id: 'p1' });
99
+ const recent = await db.list('posts', { author: 'Alice', 'created_at >': '2024-01-01' });
100
+ await db.update('posts', { id: 'p1' }, { title: 'Updated' });
101
+ await db.upsert('posts', ['id'], { id: 'p1', title: 'Upserted' });
102
+ await db.delete('posts', { id: 'p1' });
103
+ const total = await db.count('posts');
104
+ const filtered = await db.count('posts', { published: true });
105
+
106
+ const user = await db.getOrInsert('users', { email: 'a@b.com' }, { id: 'u1', email: 'a@b.com', name: 'A' });
107
+
108
+ // Table-scoped helper
109
+ const postsTable = db.table('posts');
110
+ await postsTable.insert({ id: 'p4', title: 'Scoped' });
111
+ const p = await postsTable.get({ id: 'p4' });
112
+ ```
113
+
114
+ `upsert()` treats `NULL` values in conflict columns as matching existing `NULL`
115
+ values so nullable composite keys update the existing row instead of inserting a
116
+ duplicate. Pass `{ nullsDistinct: true }` as the fourth argument to preserve the
117
+ database-native behavior where `NULL` conflict values are distinct.
118
+
119
+ ### Transactions
120
+
121
+ ```typescript
122
+ // Callback-based (auto commit/rollback)
123
+ await db.transaction(async (tx) => {
124
+ await tx.insert('users', { id: 'u1', name: 'Alice' });
125
+ await tx.insert('profiles', { user_id: 'u1', bio: 'Dev' });
126
+ });
127
+
128
+ // Manual control via beginTransaction()
129
+ const tx = await db.beginTransaction();
130
+ try {
131
+ await tx.insert('orders', { id: 'o1', total: 100 });
132
+ await tx.commit();
133
+ } catch (e) {
134
+ await tx.rollback();
135
+ throw e;
136
+ }
137
+ ```
138
+
139
+ ### WHERE Clause Building
140
+
141
+ ```typescript
142
+ import { buildWhere } from '@happyvertical/sql';
143
+
144
+ const { sql, values } = buildWhere({
145
+ status: 'active',
146
+ 'price >': 100,
147
+ 'category in': ['electronics', 'books'],
148
+ 'name like': '%shirt%',
149
+ deleted_at: null, // IS NULL
150
+ 'updated_at !=': null, // IS NOT NULL
151
+ });
152
+ // Use with raw query: db.query(`SELECT * FROM products ${sql}`, values)
153
+ ```
154
+
155
+ Supports 2D array format for OR/AND compound logic:
156
+ ```typescript
157
+ buildWhere([
158
+ [{ status: 'active' }, { 'price >': 100 }],
159
+ [{ status: 'pending' }, { priority: 'high' }],
160
+ ]);
161
+ // WHERE (status = $1 AND price > $2) OR (status = $3 AND priority = $4)
162
+ ```
163
+
164
+ ### Schema Synchronization
165
+
166
+ ```typescript
167
+ import { syncSchema } from '@happyvertical/sql';
168
+
169
+ await syncSchema({
170
+ db,
171
+ schema: `
172
+ CREATE TABLE IF NOT EXISTS users (
173
+ id TEXT PRIMARY KEY,
174
+ name TEXT NOT NULL,
175
+ email TEXT UNIQUE
176
+ );
177
+ `,
178
+ });
179
+
180
+ const exists = await db.tableExists('users');
181
+ ```
182
+
183
+ ### Vector Search (PostgreSQL)
184
+
185
+ PostgreSQL adapters expose `db.vector` when pgvector is available:
186
+
187
+ ```typescript
188
+ await db.vector.ensureColumn('documents', 'embedding', 1536);
189
+ await db.vector.ensureIndex('documents', 'embedding', { metric: 'cosine' });
190
+ await db.vector.upsertVector('documents', { id: 'doc-1' }, 'embedding', vector);
191
+ const results = await db.vector.search('documents', 'embedding', queryVector, { limit: 10 });
192
+ ```
193
+
194
+ ### Optional SQLite Capabilities
195
+
196
+ SQLite keeps the existing LibSQL adapter path by default. For local development
197
+ and tests, opt into native SQLite capabilities with `capabilities`. This switches
198
+ the adapter to Node's built-in `node:sqlite` for local files or `:memory:` and
199
+ rejects remote `libsql://`, `http://`, and `https://` URLs.
200
+
201
+ ```bash
202
+ pnpm add -D @sqliteai/sqlite-vector @russellthehippo/honker-node
203
+ ```
204
+
205
+ ```typescript
206
+ type SqliteCapabilitiesOptions = {
207
+ notifications?: boolean | {
208
+ watcherBackend?: 'polling' | 'kernel' | 'shm';
209
+ maxReaders?: number;
210
+ };
211
+ vector?: boolean | {
212
+ preload?: boolean;
213
+ quantization?: 'turbo4' | 'turbo3' | 'turbo2' | 'uint8' | 'int8' | '1bit';
214
+ maxMemory?: string;
215
+ };
216
+ };
217
+ ```
218
+
219
+ ```typescript
220
+ const db = await getDatabase({
221
+ type: 'sqlite',
222
+ url: 'file:./dev.db',
223
+ capabilities: {
224
+ vector: { quantization: 'turbo4', preload: true },
225
+ notifications: { watcherBackend: 'polling' },
226
+ },
227
+ });
228
+
229
+ await db.vector?.ensureColumn('documents', 'embedding', 1536);
230
+ await db.vector?.upsertVector('documents', { id: 'doc-1' }, 'embedding', vector);
231
+ const matches = await db.vector?.search('documents', 'embedding', queryVector, {
232
+ limit: 10,
233
+ metric: 'cosine',
234
+ where: 'status = $2',
235
+ params: ['published'],
236
+ });
237
+
238
+ const listener = db.notifications!.listen('jobs');
239
+ await db.notifications!.notify('jobs', { id: 'job-1' });
240
+ for await (const message of listener) {
241
+ console.log(message.channel, message.payload);
242
+ break;
243
+ }
244
+ await db.notifications!.waitForUpdate({ timeoutMs: 5000 });
245
+ await db.close?.();
246
+ ```
247
+
248
+ `@sqliteai/sqlite-vector` is loaded lazily through `getExtensionPath()` and only
249
+ mutates schema when `ensureColumn()` or `ensureIndex()` is called. SQLite vector
250
+ search uses the same `db.vector` API as PostgreSQL. `ensureIndex()` creates a
251
+ quantized sqlite-vector index with `turbo4` by default, and filtered searches can
252
+ keep PostgreSQL-style `$2`, `$3`, etc. placeholders in `VectorSearchOptions.where`.
253
+
254
+ `@russellthehippo/honker-node` is loaded lazily as a sidecar connection to the
255
+ same file. Honker bootstraps its `_honker_*` tables on open and requires a
256
+ file-backed database, so `:memory:` is rejected when notifications are enabled.
257
+ When notifications are enabled, `db.notifications` exposes `notify()`,
258
+ `listen()`, `waitForUpdate()`, and `prune()`; call `db.close?.()` when a test or
259
+ worker is done so watcher handles and sidecar connections are released.
260
+
261
+ Both packages are optional peers. `sqlite-vector` uses a custom license declared
262
+ as `SEE LICENSE IN LICENSE.md`; keep it opt-in and review the upstream
263
+ [license](https://github.com/sqliteai/sqlite-vector/blob/main/LICENSE.md) before
264
+ shipping it beyond development or test environments.
265
+
266
+ ## Adapters
267
+
268
+ | Adapter | `type` | Backend | Notes |
269
+ |---------|--------|---------|-------|
270
+ | SQLite | `'sqlite'` | LibSQL (`@libsql/client`) by default; native `node:sqlite` when capabilities are enabled | Supports `:memory:`, file, and remote Turso URLs by default. Native capabilities are local-only |
271
+ | PostgreSQL | `'postgres'` | `pg` Pool | Connection pooling, pgvector support |
272
+ | DuckDB | `'duckdb'` | `@duckdb/node-api` | JSON file auto-registration, write-back strategies |
273
+ | JSON | `'json'` | DuckDB in-memory | Queries JSON files as tables, connection caching |
274
+
275
+ ## API Overview
276
+
277
+ **Factory**: `getDatabase(options)` — creates or returns a cached database connection.
278
+
279
+ **Interface** (`DatabaseInterface`): `many`, `single`, `pluck`, `execute`, `query`, `insert`, `get`, `list`, `update`, `upsert`, `getOrInsert`, `delete`, `count`, `table`, `tableExists`, `syncSchema`, `transaction`, `beginTransaction`, `vector`, `notifications`, `close`.
280
+
281
+ **Utilities**: `buildWhere`, `syncSchema`, `tableExists`, `escapeSqlValue`, `validateColumnName`, `formatDbError`, `convertUniqueIndexesToInlineConstraints`.
282
+
283
+ **Schema**: `DatabaseSchemaManager` for JSON manifest-based schema initialization with dependency resolution.
284
+
285
+ ## License
286
+
287
+ ISC
@@ -0,0 +1,119 @@
1
+ import { BackupManifest } from './manifest';
2
+ export { BACKUP_KIND, BACKUP_VERSION, type BackupManifest, type BackupManifestDatabase, type BackupManifestFiles, DEFAULT_DUMP_FILE, DEFAULT_FILES_DIR, DEFAULT_MANIFEST_FILE, readBackupManifest, timestampForBackup, writeBackupManifest, } from './manifest';
3
+ export interface ExportBackupContext {
4
+ backupPath: string;
5
+ /**
6
+ * The raw, credential-bearing database URL the caller passed to
7
+ * `exportBackup`. The hook needs the raw form to open its own
8
+ * connection, but **do not log this value verbatim** — it contains
9
+ * credentials. Run it through `redactDatabaseUrl` (exported from
10
+ * `@happyvertical/sql`) before including it in any output. The
11
+ * manifest's `database.url` field is redacted; only `databaseUrl`
12
+ * here is raw.
13
+ */
14
+ databaseUrl: string;
15
+ /**
16
+ * Local directory the caller should copy file storage into.
17
+ * Already exists by the time `onBackup` runs.
18
+ */
19
+ filesDir: string;
20
+ }
21
+ export interface ExportBackupResult<Extra = unknown> {
22
+ /** Absolute path to the backup directory. */
23
+ backupPath: string;
24
+ /** The manifest that was written. */
25
+ manifest: BackupManifest & {
26
+ extra?: Extra;
27
+ };
28
+ }
29
+ export interface ExportBackupOptions<Extra = unknown> {
30
+ databaseUrl: string;
31
+ /** Override the parent dir for the timestamped backup. Defaults to ~/.local/share/happyvertical/backups. */
32
+ backupRoot?: string;
33
+ /** Prefix the timestamp with this label (e.g. 'prod', 'pre-migration'). Defaults to 'backup'. */
34
+ label?: string;
35
+ /** Allow exporting from a non-local DB without --allow-production. */
36
+ allowProduction?: boolean;
37
+ /** Allow exporting from a local DB even though `prod: true` is set. */
38
+ allowLocal?: boolean;
39
+ /** Caller is running a prod-flavored export. */
40
+ prod?: boolean;
41
+ /** Skip the file-storage half entirely (no `onBackup` invoked). */
42
+ skipFiles?: boolean;
43
+ /**
44
+ * Caller-supplied hook for backing up file storage. Receives the
45
+ * already-created backup/files directory; whatever it returns is
46
+ * recorded as `extra` on the manifest.
47
+ */
48
+ onBackup?: (ctx: ExportBackupContext) => Promise<Extra | void>;
49
+ /**
50
+ * Caller-supplied hook for filling the database half of the manifest
51
+ * with diagnostic metadata (row counts, schema count, etc.) — runs
52
+ * after the dump completes. As with `onBackup`, the `databaseUrl`
53
+ * passed in is the raw credential-bearing form — redact via
54
+ * `redactDatabaseUrl` before logging.
55
+ */
56
+ onDatabaseMetadata?: (ctx: {
57
+ /** Raw URL — see warning on {@link ExportBackupContext.databaseUrl}. */
58
+ databaseUrl: string;
59
+ }) => Promise<Record<string, unknown>>;
60
+ /** Recorded as `files.storageConfig` on the manifest after redaction by the caller. */
61
+ filesStorageConfig?: unknown;
62
+ /** Working dir used when discovering the git sha. Defaults to process.cwd(). */
63
+ gitCwd?: string;
64
+ }
65
+ export interface RestoreBackupContext {
66
+ backupPath: string;
67
+ /** Raw URL — see warning on {@link ExportBackupContext.databaseUrl}. */
68
+ databaseUrl: string;
69
+ filesDir: string;
70
+ manifest: BackupManifest;
71
+ }
72
+ export interface RestoreBackupOptions {
73
+ databaseUrl: string;
74
+ backupPath: string;
75
+ allowProduction?: boolean;
76
+ /** Skip the file-storage half (no `onRestore` invoked). */
77
+ skipFiles?: boolean;
78
+ /**
79
+ * Caller-supplied hook for restoring file storage from
80
+ * `backupPath/<files.directory>`. Runs after the pg_restore completes.
81
+ */
82
+ onRestore?: (ctx: RestoreBackupContext) => Promise<void>;
83
+ }
84
+ export interface ResetLocalOptions extends Omit<RestoreBackupOptions, 'allowProduction'> {
85
+ }
86
+ /** Default root for backups. Override via $HAPPYVERTICAL_BACKUP_DIR or `backupRoot`. */
87
+ export declare function defaultBackupRoot(): string;
88
+ /**
89
+ * Take a postgres dump and (optionally) a snapshot of file storage,
90
+ * writing both plus a manifest into a fresh timestamped directory.
91
+ *
92
+ * Returns the directory path and the manifest. The manifest's `extra`
93
+ * field carries whatever `onBackup` returned.
94
+ *
95
+ * **Failure model:** the function is all-or-nothing. If any step after
96
+ * the initial directory creation throws — `dumpPostgresDatabase`,
97
+ * `onBackup`, `onDatabaseMetadata`, `getGitSha`, or the final manifest
98
+ * write — the partial backup directory is removed (best-effort)
99
+ * before the original error is re-thrown. Callers therefore never see
100
+ * a backup directory on disk without a complete manifest, which would
101
+ * otherwise poison later `restoreBackup` calls with confusing
102
+ * "no manifest" errors and leak a partial dump containing sensitive
103
+ * data. If cleanup itself fails, that error is logged via
104
+ * `console.warn` and swallowed so it doesn't mask the original.
105
+ */
106
+ export declare function exportBackup<Extra = unknown>(options: ExportBackupOptions<Extra>): Promise<ExportBackupResult<Extra>>;
107
+ /**
108
+ * Restore a backup directory into `databaseUrl` and, if `onRestore` is
109
+ * provided, run the file-storage half. The caller controls everything
110
+ * after the dump completes (file copy, migrations, doctor) via the hook.
111
+ */
112
+ export declare function restoreBackup(options: RestoreBackupOptions): Promise<BackupManifest>;
113
+ /**
114
+ * Drop-and-recreate the target database, then run `restoreBackup`.
115
+ * Local-only: explicitly refuses non-local URLs without an escape hatch
116
+ * (this is a destructive operation by design).
117
+ */
118
+ export declare function resetLocalDatabaseFromBackup(options: ResetLocalOptions): Promise<BackupManifest>;
119
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/backup/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,212 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile, writeFile, rename, mkdir, rm, access } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { a as assertCanExportDatabase, f as dumpPostgresDatabase, r as redactDatabaseUrl, b as assertCanImportDatabase, e as dropPostgresDatabase, c as createPostgresDatabase, g as restorePostgresDatabase } from "../chunks/postgres-cli-Cr6bBZ3q.js";
6
+ const BACKUP_KIND = "happyvertical-backup";
7
+ const BACKUP_VERSION = 1;
8
+ const DEFAULT_DUMP_FILE = "database.dump";
9
+ const DEFAULT_FILES_DIR = "files";
10
+ const DEFAULT_MANIFEST_FILE = "manifest.json";
11
+ async function readBackupManifest(backupPath) {
12
+ const manifestPath = join(resolve(backupPath), DEFAULT_MANIFEST_FILE);
13
+ const manifest = JSON.parse(
14
+ await readFile(manifestPath, "utf8")
15
+ );
16
+ if (manifest.kind !== BACKUP_KIND || manifest.version !== BACKUP_VERSION) {
17
+ throw new Error(
18
+ `Unsupported backup manifest at ${manifestPath} (kind=${manifest.kind}, version=${manifest.version}).`
19
+ );
20
+ }
21
+ return manifest;
22
+ }
23
+ async function writeBackupManifest(backupPath, manifest) {
24
+ const targetPath = join(backupPath, DEFAULT_MANIFEST_FILE);
25
+ const tempPath = join(
26
+ backupPath,
27
+ `.manifest.${process.pid}.${Date.now().toString(36)}.tmp`
28
+ );
29
+ await writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}
30
+ `, {
31
+ mode: 384
32
+ });
33
+ await rename(tempPath, targetPath);
34
+ }
35
+ function timestampForBackup(date = /* @__PURE__ */ new Date()) {
36
+ return date.toISOString().replace(/[:.]/g, "-");
37
+ }
38
+ function defaultBackupRoot() {
39
+ return process.env.HAPPYVERTICAL_BACKUP_DIR ?? join(homedir(), ".local", "share", "happyvertical", "backups");
40
+ }
41
+ async function exportBackup(options) {
42
+ assertCanExportDatabase(options.databaseUrl, options);
43
+ const label = validateLabel(options.label ?? "backup");
44
+ const backupPath = await createUniqueBackupDir(
45
+ options.backupRoot ?? defaultBackupRoot(),
46
+ label
47
+ );
48
+ try {
49
+ const dumpPath = join(backupPath, DEFAULT_DUMP_FILE);
50
+ await dumpPostgresDatabase(options.databaseUrl, dumpPath);
51
+ const filesDir = join(backupPath, DEFAULT_FILES_DIR);
52
+ let filesInfo = {
53
+ directory: DEFAULT_FILES_DIR,
54
+ exported: false,
55
+ count: 0,
56
+ bytes: 0,
57
+ reason: "Skipped by skipFiles.",
58
+ storageConfig: options.filesStorageConfig
59
+ };
60
+ let extra;
61
+ if (!options.skipFiles && options.onBackup) {
62
+ await mkdir(filesDir, { recursive: true, mode: 448 });
63
+ const result = await options.onBackup({
64
+ backupPath,
65
+ databaseUrl: options.databaseUrl,
66
+ filesDir
67
+ });
68
+ if (result !== void 0 && result !== null) extra = result;
69
+ filesInfo = {
70
+ directory: DEFAULT_FILES_DIR,
71
+ exported: true,
72
+ count: 0,
73
+ bytes: 0,
74
+ storageConfig: options.filesStorageConfig
75
+ };
76
+ }
77
+ const metadata = options.onDatabaseMetadata ? await options.onDatabaseMetadata({ databaseUrl: options.databaseUrl }) : void 0;
78
+ const manifest = {
79
+ kind: BACKUP_KIND,
80
+ version: BACKUP_VERSION,
81
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
82
+ gitSha: await getGitSha(options.gitCwd),
83
+ source: label,
84
+ database: {
85
+ dumpFile: DEFAULT_DUMP_FILE,
86
+ url: redactDatabaseUrl(options.databaseUrl),
87
+ metadata
88
+ },
89
+ files: filesInfo,
90
+ extra
91
+ };
92
+ await writeBackupManifest(backupPath, manifest);
93
+ return { backupPath, manifest };
94
+ } catch (error) {
95
+ try {
96
+ await rm(backupPath, { recursive: true, force: true });
97
+ } catch (cleanupError) {
98
+ console.warn(
99
+ `exportBackup: failed to clean up partial backup at ${backupPath}:`,
100
+ cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
101
+ );
102
+ }
103
+ throw error;
104
+ }
105
+ }
106
+ async function restoreBackup(options) {
107
+ const backupPath = resolve(options.backupPath);
108
+ const manifest = await readBackupManifest(backupPath);
109
+ assertCanImportDatabase(options.databaseUrl, options);
110
+ const dumpPath = join(
111
+ backupPath,
112
+ validateManifestPathSegment(
113
+ manifest.database.dumpFile,
114
+ "database dump file"
115
+ )
116
+ );
117
+ const filesDir = join(
118
+ backupPath,
119
+ validateManifestPathSegment(manifest.files.directory, "files directory")
120
+ );
121
+ await restorePostgresDatabase(options.databaseUrl, dumpPath);
122
+ if (!options.skipFiles && options.onRestore && manifest.files.exported) {
123
+ await options.onRestore({
124
+ backupPath,
125
+ databaseUrl: options.databaseUrl,
126
+ filesDir,
127
+ manifest
128
+ });
129
+ }
130
+ return manifest;
131
+ }
132
+ async function resetLocalDatabaseFromBackup(options) {
133
+ assertCanImportDatabase(options.databaseUrl, { allowProduction: false });
134
+ const backupPath = resolve(options.backupPath);
135
+ const manifest = await readBackupManifest(backupPath);
136
+ const dumpFile = validateManifestPathSegment(
137
+ manifest.database.dumpFile,
138
+ "database dump file"
139
+ );
140
+ validateManifestPathSegment(manifest.files.directory, "files directory");
141
+ await access(join(backupPath, dumpFile));
142
+ await dropPostgresDatabase(options.databaseUrl);
143
+ await createPostgresDatabase(options.databaseUrl);
144
+ return restoreBackup({ ...options, allowProduction: false });
145
+ }
146
+ async function getGitSha(cwd = process.cwd()) {
147
+ try {
148
+ return await new Promise((resolvePromise, reject) => {
149
+ execFile("git", ["rev-parse", "HEAD"], { cwd }, (error, stdout) => {
150
+ if (error) {
151
+ reject(error);
152
+ return;
153
+ }
154
+ resolvePromise(stdout.trim());
155
+ });
156
+ });
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+ function validateLabel(label) {
162
+ if (!label || label === "." || label === ".." || /[\\/]/u.test(label)) {
163
+ throw new Error(
164
+ `Backup label "${label}" is invalid. Labels must be a single path-safe segment.`
165
+ );
166
+ }
167
+ return label;
168
+ }
169
+ function validateManifestPathSegment(value, field) {
170
+ if (!value || value === "." || value === ".." || /[\\/]/u.test(value)) {
171
+ throw new Error(
172
+ `Backup manifest ${field} is invalid: "${value}". It must be a single path segment.`
173
+ );
174
+ }
175
+ return value;
176
+ }
177
+ async function createUniqueBackupDir(backupRoot, label) {
178
+ const root = resolve(backupRoot);
179
+ await mkdir(root, { recursive: true, mode: 448 });
180
+ const baseName = `${label}-${timestampForBackup()}`;
181
+ for (let attempt = 0; attempt < 64; attempt += 1) {
182
+ const suffix = attempt === 0 ? "" : `-${attempt.toString(36)}`;
183
+ const candidate = join(root, `${baseName}${suffix}`);
184
+ try {
185
+ await mkdir(candidate, { mode: 448 });
186
+ return candidate;
187
+ } catch (error) {
188
+ if (error.code === "EEXIST") {
189
+ continue;
190
+ }
191
+ throw error;
192
+ }
193
+ }
194
+ throw new Error(
195
+ `Failed to create a unique backup directory under ${root} for label "${label}".`
196
+ );
197
+ }
198
+ export {
199
+ BACKUP_KIND,
200
+ BACKUP_VERSION,
201
+ DEFAULT_DUMP_FILE,
202
+ DEFAULT_FILES_DIR,
203
+ DEFAULT_MANIFEST_FILE,
204
+ defaultBackupRoot,
205
+ exportBackup,
206
+ readBackupManifest,
207
+ resetLocalDatabaseFromBackup,
208
+ restoreBackup,
209
+ timestampForBackup,
210
+ writeBackupManifest
211
+ };
212
+ //# sourceMappingURL=index.js.map