@cloud-cli/d0 1.8.0 → 1.9.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 CHANGED
@@ -15,6 +15,13 @@ console.log(schema.tables, schema.statements);
15
15
 
16
16
  **GET /api** returns an OpenAPI 3.1 description of the HTTP API.
17
17
 
18
+ **POST /clone** creates a copy of the selected database. The target name must contain only letters, numbers, and hyphens. An existing target returns `409` until overwrite is explicitly confirmed.
19
+
20
+ ```js
21
+ await db.clone('test-copy');
22
+ await db.clone('test-copy', true); // overwrite an existing copy
23
+ ```
24
+
18
25
  The web console uses an explicit method selector instead of guessing from SQL text. Use `all` or `get` for reads, `run` for one prepared statement, `exec` for DDL or multiple statements, and `transaction` to run the entered SQL atomically.
19
26
 
20
27
  **POST /query**
package/client.mjs CHANGED
@@ -30,6 +30,16 @@ export async function schema({ internal = false } = {}) {
30
30
  throw new Error(await req.text());
31
31
  }
32
32
 
33
+ export async function clone(name, overwrite = false) {
34
+ const req = await fetch(new URL('/clone', baseURL), {
35
+ method: 'POST',
36
+ body: JSON.stringify({ name, overwrite }),
37
+ });
38
+
39
+ if (req.ok) return req.json();
40
+ throw new Error(await req.text());
41
+ }
42
+
33
43
  export async function transaction(statements, pragma = pragmas) {
34
44
  return query('transaction', undefined, undefined, pragma, statements);
35
45
  }
@@ -45,4 +55,4 @@ export function pragma(p) {
45
55
  }
46
56
  }
47
57
 
48
- export default { query, get, run, all, exec, transaction, schema, pragma };
58
+ export default { query, get, run, all, exec, transaction, schema, clone, pragma };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createServer } from 'node:http';
2
- import { mkdirSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, rmSync } from 'node:fs';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import SQLite from 'better-sqlite3';
5
5
  import { join } from 'node:path';
@@ -12,6 +12,7 @@ const maxDatabases = Math.max(1, Number.parseInt(process.env.MAX_DATABASES || '3
12
12
  const maxBodyBytes = Math.max(1, Number.parseInt(process.env.MAX_BODY_BYTES || '1048576', 10) || 1048576);
13
13
  const slowQueryMs = Math.max(0, Number.parseInt(process.env.SLOW_QUERY_MS || '1000', 10) || 1000);
14
14
  const databases = new Map();
15
+ const cloneLocks = new Set();
15
16
  mkdirSync(dataPath, { recursive: true });
16
17
  export function getDatabase(file) {
17
18
  const fullPath = join(dataPath, file);
@@ -89,6 +90,8 @@ export async function handleRequest(request, response, db) {
89
90
  return onSchema(response, db, url.searchParams.get('internal') === '1');
90
91
  case 'POST /query':
91
92
  return onQuery(request, response, db);
93
+ case 'POST /clone':
94
+ return onClone(request, response, db);
92
95
  default:
93
96
  response.writeHead(404).end();
94
97
  }
@@ -136,8 +139,21 @@ function onApi(request, response) {
136
139
  },
137
140
  },
138
141
  '/index.mjs': { get: { summary: 'Get the consumer ES module', responses: { '200': { description: 'JavaScript module.' } } } },
139
- '/console.html': { get: { summary: 'Get the web console', responses: { '200': { description: 'HTML console.' } } } },
140
- '/logo.svg': { get: { summary: 'Get the d0 logo', responses: { '200': { description: 'SVG image.' } } } },
142
+ '/clone': {
143
+ post: {
144
+ summary: 'Clone the selected database',
145
+ requestBody: {
146
+ required: true,
147
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/CloneRequest' } } },
148
+ },
149
+ responses: {
150
+ '201': { description: 'Database cloned.' },
151
+ '400': { description: 'Invalid database name.' },
152
+ '409': { description: 'Target exists and overwrite was not confirmed.' },
153
+ '503': { description: 'Another clone is already running for the target.' },
154
+ },
155
+ },
156
+ },
141
157
  },
142
158
  components: {
143
159
  schemas: {
@@ -161,11 +177,71 @@ function onApi(request, response) {
161
177
  m: { type: 'string', enum: ['all', 'get', 'run', 'exec'], default: 'run' },
162
178
  },
163
179
  },
180
+ CloneRequest: {
181
+ type: 'object',
182
+ required: ['name'],
183
+ properties: {
184
+ name: { type: 'string', pattern: '^[a-zA-Z0-9][a-zA-Z0-9-]*$', description: 'Name of the cloned database.' },
185
+ overwrite: { type: 'boolean', default: false, description: 'Replace the target if it already exists.' },
186
+ },
187
+ },
164
188
  },
165
189
  },
166
190
  };
167
191
  sendJson(response, 200, document);
168
192
  }
193
+ async function onClone(request, response, source) {
194
+ const body = await readBody(request);
195
+ if (!body) {
196
+ sendError(response, 413, new Error('Request body too large.'));
197
+ return;
198
+ }
199
+ try {
200
+ const { name: requestedName, overwrite = false } = JSON.parse(body.toString('utf-8'));
201
+ const name = String(requestedName || '').replace(/\.sqlite3$/i, '');
202
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(name)) {
203
+ sendError(response, 400, new Error('Invalid database name.'));
204
+ return;
205
+ }
206
+ const target = join(dataPath, `${name}.sqlite3`);
207
+ const sourcePath = join(dataPath, source);
208
+ if (target === sourcePath) {
209
+ sendError(response, 400, new Error('The clone must have a different name.'));
210
+ return;
211
+ }
212
+ if (cloneLocks.has(target)) {
213
+ sendError(response, 503, new Error('A clone is already running for this database.'));
214
+ return;
215
+ }
216
+ const exists = existsSync(target);
217
+ if (exists && overwrite !== true) {
218
+ sendJson(response, 409, { exists: true, name, requiresOverwrite: true });
219
+ return;
220
+ }
221
+ cloneLocks.add(target);
222
+ try {
223
+ const targetDatabase = databases.get(target);
224
+ if (targetDatabase) {
225
+ targetDatabase.close();
226
+ databases.delete(target);
227
+ }
228
+ if (exists) {
229
+ rmSync(target, { force: true });
230
+ rmSync(`${target}-wal`, { force: true });
231
+ rmSync(`${target}-shm`, { force: true });
232
+ }
233
+ await getDatabase(source).backup(target);
234
+ sendJson(response, 201, { name, overwritten: exists });
235
+ }
236
+ finally {
237
+ cloneLocks.delete(target);
238
+ }
239
+ }
240
+ catch (error) {
241
+ DEBUG && console.error(error);
242
+ sendError(response, 400, error);
243
+ }
244
+ }
169
245
  async function onSchema(response, db, includeInternal) {
170
246
  try {
171
247
  const sqlite = getDatabase(db);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud-cli/d0",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "packageManager": "pnpm@12.3.4",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",