neon 2.1.1 → 2.29.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.
Files changed (166) hide show
  1. package/LICENSE.md +178 -0
  2. package/README.md +601 -0
  3. package/dist/analytics.js +156 -0
  4. package/dist/api.js +665 -0
  5. package/dist/auth.js +127 -0
  6. package/dist/callback.html +51 -0
  7. package/dist/cli.js +9 -0
  8. package/dist/commands/auth.js +214 -0
  9. package/dist/commands/bootstrap.js +481 -0
  10. package/dist/commands/branches.js +481 -0
  11. package/dist/commands/bucket.js +543 -0
  12. package/dist/commands/checkout.js +289 -0
  13. package/dist/commands/config.js +544 -0
  14. package/dist/commands/connection_string.js +172 -0
  15. package/dist/commands/data_api.js +285 -0
  16. package/dist/commands/databases.js +82 -0
  17. package/dist/commands/deploy.js +26 -0
  18. package/dist/commands/dev.js +698 -0
  19. package/dist/commands/env.js +166 -0
  20. package/dist/commands/functions.js +373 -0
  21. package/dist/commands/index.js +54 -0
  22. package/dist/commands/init.js +73 -0
  23. package/dist/commands/ip_allow.js +137 -0
  24. package/dist/commands/link.js +1121 -0
  25. package/dist/commands/neon_auth.js +1028 -0
  26. package/dist/commands/operations.js +28 -0
  27. package/dist/commands/orgs.js +24 -0
  28. package/dist/commands/projects.js +372 -0
  29. package/dist/commands/psql.js +62 -0
  30. package/dist/commands/roles.js +65 -0
  31. package/dist/commands/schema_diff.js +151 -0
  32. package/dist/commands/set_context.js +29 -0
  33. package/dist/commands/status.js +40 -0
  34. package/dist/commands/user.js +15 -0
  35. package/dist/commands/vpc_endpoints.js +134 -0
  36. package/dist/config.js +11 -0
  37. package/dist/config_format.js +72 -0
  38. package/dist/context.js +177 -0
  39. package/dist/current_branch_fast_path.js +55 -0
  40. package/dist/dev/env.js +240 -0
  41. package/dist/dev/functions.js +70 -0
  42. package/dist/dev/inputs.js +63 -0
  43. package/dist/dev/runtime.js +146 -0
  44. package/dist/env.js +36 -0
  45. package/dist/env_file.js +159 -0
  46. package/dist/errors.js +80 -0
  47. package/dist/functions_api.js +44 -0
  48. package/dist/help.js +146 -0
  49. package/dist/index.js +234 -0
  50. package/dist/log.js +18 -0
  51. package/dist/parameters.gen.js +480 -0
  52. package/dist/pkg.js +25 -0
  53. package/dist/psql/cli.js +53 -0
  54. package/dist/psql/command/cmd_cond.js +437 -0
  55. package/dist/psql/command/cmd_connect.js +820 -0
  56. package/dist/psql/command/cmd_copy.js +1035 -0
  57. package/dist/psql/command/cmd_describe.js +1815 -0
  58. package/dist/psql/command/cmd_format.js +922 -0
  59. package/dist/psql/command/cmd_io.js +2193 -0
  60. package/dist/psql/command/cmd_lo.js +393 -0
  61. package/dist/psql/command/cmd_meta.js +970 -0
  62. package/dist/psql/command/cmd_misc.js +187 -0
  63. package/dist/psql/command/cmd_pipeline.js +1148 -0
  64. package/dist/psql/command/cmd_restrict.js +171 -0
  65. package/dist/psql/command/cmd_show.js +766 -0
  66. package/dist/psql/command/dispatch.js +343 -0
  67. package/dist/psql/command/inputQueue.js +42 -0
  68. package/dist/psql/command/shared.js +71 -0
  69. package/dist/psql/complete/filenames.js +139 -0
  70. package/dist/psql/complete/index.js +104 -0
  71. package/dist/psql/complete/matcher.js +315 -0
  72. package/dist/psql/complete/psqlVars.js +247 -0
  73. package/dist/psql/complete/queries.js +493 -0
  74. package/dist/psql/complete/rules.js +2424 -0
  75. package/dist/psql/core/common.js +1253 -0
  76. package/dist/psql/core/help.js +576 -0
  77. package/dist/psql/core/mainloop.js +1360 -0
  78. package/dist/psql/core/prompt.js +440 -0
  79. package/dist/psql/core/settings.js +684 -0
  80. package/dist/psql/core/sqlHelp.js +1066 -0
  81. package/dist/psql/core/startup.js +846 -0
  82. package/dist/psql/core/syncVars.js +116 -0
  83. package/dist/psql/core/variables.js +287 -0
  84. package/dist/psql/describe/formatters.js +1290 -0
  85. package/dist/psql/describe/processNamePattern.js +270 -0
  86. package/dist/psql/describe/queries.js +2378 -0
  87. package/dist/psql/describe/versionGate.js +43 -0
  88. package/dist/psql/index.js +2030 -0
  89. package/dist/psql/io/history.js +299 -0
  90. package/dist/psql/io/input.js +120 -0
  91. package/dist/psql/io/lineEditor/buffer.js +325 -0
  92. package/dist/psql/io/lineEditor/complete.js +227 -0
  93. package/dist/psql/io/lineEditor/filename.js +159 -0
  94. package/dist/psql/io/lineEditor/index.js +894 -0
  95. package/dist/psql/io/lineEditor/keymap.js +745 -0
  96. package/dist/psql/io/lineEditor/vt100.js +363 -0
  97. package/dist/psql/io/pgpass.js +202 -0
  98. package/dist/psql/io/pgservice.js +194 -0
  99. package/dist/psql/io/psqlrc.js +422 -0
  100. package/dist/psql/print/aligned.js +1765 -0
  101. package/dist/psql/print/asciidoc.js +248 -0
  102. package/dist/psql/print/crosstab.js +463 -0
  103. package/dist/psql/print/csv.js +95 -0
  104. package/dist/psql/print/html.js +258 -0
  105. package/dist/psql/print/json.js +96 -0
  106. package/dist/psql/print/latex.js +396 -0
  107. package/dist/psql/print/pager.js +267 -0
  108. package/dist/psql/print/troff.js +258 -0
  109. package/dist/psql/print/unaligned.js +119 -0
  110. package/dist/psql/print/units.js +135 -0
  111. package/dist/psql/scanner/slash.js +515 -0
  112. package/dist/psql/scanner/sql.js +914 -0
  113. package/dist/psql/scanner/stringutils.js +394 -0
  114. package/dist/psql/types/backslash.js +1 -0
  115. package/dist/psql/types/connection.js +1 -0
  116. package/dist/psql/types/index.js +7 -0
  117. package/dist/psql/types/printer.js +1 -0
  118. package/dist/psql/types/repl.js +1 -0
  119. package/dist/psql/types/scanner.js +24 -0
  120. package/dist/psql/types/settings.js +1 -0
  121. package/dist/psql/types/variables.js +1 -0
  122. package/dist/psql/wire/connection.js +2858 -0
  123. package/dist/psql/wire/copy.js +108 -0
  124. package/dist/psql/wire/notify.js +59 -0
  125. package/dist/psql/wire/pipeline.js +521 -0
  126. package/dist/psql/wire/protocol.js +466 -0
  127. package/dist/psql/wire/sasl.js +296 -0
  128. package/dist/psql/wire/tls.js +602 -0
  129. package/dist/storage_api.js +147 -0
  130. package/dist/test_utils/fixtures.js +122 -0
  131. package/dist/test_utils/oauth_server.js +9 -0
  132. package/dist/types.js +1 -0
  133. package/dist/utils/api_enums.js +33 -0
  134. package/dist/utils/auth.js +5 -0
  135. package/dist/utils/branch_notice.js +22 -0
  136. package/dist/utils/branch_picker.js +103 -0
  137. package/dist/utils/compute_units.js +28 -0
  138. package/dist/utils/enrichers.js +161 -0
  139. package/dist/utils/esbuild.js +158 -0
  140. package/dist/utils/formats.js +18 -0
  141. package/dist/utils/middlewares.js +20 -0
  142. package/dist/utils/package_manager.js +68 -0
  143. package/dist/utils/point_in_time.js +56 -0
  144. package/dist/utils/psql.js +120 -0
  145. package/dist/utils/string.js +5 -0
  146. package/dist/utils/ui.js +59 -0
  147. package/dist/utils/zip.js +4 -0
  148. package/dist/writer.js +97 -0
  149. package/package.json +117 -14
  150. package/.jshintrc +0 -3
  151. package/CHANGELOG.markdown +0 -22
  152. package/LICENSE +0 -21
  153. package/README.markdown +0 -76
  154. package/bower.json +0 -23
  155. package/license.txt +0 -9
  156. package/neon.js +0 -234
  157. package/stdlib/bubbling_support.js +0 -32
  158. package/stdlib/custom_event.js +0 -54
  159. package/stdlib/custom_event_support.js +0 -195
  160. package/stdlib/index.js +0 -7
  161. package/stdlib/node_support.js +0 -123
  162. package/stdlib/widget.js +0 -340
  163. package/test/neon_browser.html +0 -11
  164. package/test/neon_stdlib_browser.html +0 -15
  165. package/test/neon_stdlib_test.js +0 -84
  166. package/test/neon_test.js +0 -64
@@ -0,0 +1,543 @@
1
+ import { createReadStream, createWriteStream } from "node:fs";
2
+ import { stat, unlink } from "node:fs/promises";
3
+ import { basename } from "node:path";
4
+ import { pipeline } from "node:stream/promises";
5
+ import { isNeonApiError, retryOnLock } from "../api.js";
6
+ import { log } from "../log.js";
7
+ import { createProjectBranchBucket, deleteProjectBranchBucket, deleteProjectBranchBucketObject, deleteProjectBranchBucketObjectsByPrefix, getProjectBranchBucketObject, listProjectBranchBucketObjects, listProjectBranchBuckets, presignUpload, } from "../storage_api.js";
8
+ import { branchIdFromProps, fillSingleProject } from "../utils/enrichers.js";
9
+ import { writer } from "../writer.js";
10
+ const OBJECT_FIELDS = ["key", "size", "last_modified", "etag"];
11
+ const BUCKET_FIELDS = ["name", "access_level"];
12
+ const ACCESS_LEVELS = ["private", "public_read"];
13
+ // Single-PUT upload cap. Objects larger than this must use multipart upload,
14
+ // which is out of scope for v1; we reject them client-side before any HTTP so
15
+ // the user gets an immediate, clear error rather than a server-side rejection
16
+ // part-way through a large transfer.
17
+ const MAX_OBJECT_BYTES = 100 * 1024 * 1024; // 100 MB
18
+ // Ambient scope shared by every bucket sub-command. The bucket name (and the
19
+ // object key/prefix) is always a positional, never a flag.
20
+ const scopeOptions = {
21
+ "project-id": {
22
+ describe: "Project ID",
23
+ type: "string",
24
+ },
25
+ branch: {
26
+ describe: "Branch ID or name",
27
+ type: "string",
28
+ },
29
+ };
30
+ // Split an object target into its bucket and the remainder (key or prefix) on
31
+ // the FIRST `/`. Bucket names are DNS-safe so they never contain a slash; the
32
+ // remainder may contain further slashes and is returned verbatim. When the
33
+ // target has no slash, `rest` is the empty string.
34
+ export const splitBucketTarget = (target) => {
35
+ const slash = target.indexOf("/");
36
+ if (slash === -1) {
37
+ return { bucket: target, rest: "" };
38
+ }
39
+ return {
40
+ bucket: target.slice(0, slash),
41
+ rest: target.slice(slash + 1),
42
+ };
43
+ };
44
+ export const command = "buckets";
45
+ export const describe = "Manage branch object-storage buckets and their objects";
46
+ export const aliases = ["bucket"];
47
+ export const builder = (argv) => argv
48
+ .usage("$0 bucket <sub-command> [options]")
49
+ .options({
50
+ "project-id": {
51
+ describe: "Project ID",
52
+ type: "string",
53
+ },
54
+ })
55
+ .middleware(fillSingleProject)
56
+ .command("create <name>", "Create a bucket on a branch", (yargs) => yargs
57
+ .usage("$0 bucket create <name> [options]")
58
+ .positional("name", {
59
+ describe: "The bucket name to create",
60
+ type: "string",
61
+ demandOption: true,
62
+ })
63
+ .options({
64
+ ...scopeOptions,
65
+ "access-level": {
66
+ describe: "The visibility of the bucket",
67
+ type: "string",
68
+ choices: ACCESS_LEVELS,
69
+ default: "private",
70
+ },
71
+ }), (args) => createBucket(args))
72
+ .command({
73
+ command: "list",
74
+ aliases: ["ls"],
75
+ describe: "List the buckets on a branch",
76
+ builder: (yargs) => yargs.usage("$0 bucket list [options]").options(scopeOptions),
77
+ handler: (args) => listBuckets(args),
78
+ })
79
+ .command({
80
+ command: "delete <name>",
81
+ aliases: ["rm"],
82
+ describe: "Delete a bucket from a branch",
83
+ builder: (yargs) => yargs
84
+ .usage("$0 bucket delete <name> [options]")
85
+ .positional("name", {
86
+ describe: "The bucket name to delete",
87
+ type: "string",
88
+ demandOption: true,
89
+ })
90
+ .options(scopeOptions),
91
+ handler: (args) => deleteBucket(args),
92
+ })
93
+ .command("object <sub-command>", "List, download, upload or delete objects in a bucket", (yargs) => yargs
94
+ .usage("$0 bucket object <sub-command> [options]")
95
+ .command({
96
+ command: "list <target>",
97
+ aliases: ["ls"],
98
+ describe: 'List objects in a bucket. By default folders are collapsed (like "aws s3 ls"); pass --recursive for a flat listing of every key',
99
+ builder: (yargs) => yargs
100
+ .usage("$0 bucket object list <bucket>[/<prefix>] [options]")
101
+ .positional("target", {
102
+ describe: "The bucket to list, optionally with a key prefix: <bucket>[/<prefix>]",
103
+ type: "string",
104
+ demandOption: true,
105
+ })
106
+ .options({
107
+ ...scopeOptions,
108
+ recursive: {
109
+ describe: 'List every key flat, descending into nested folders (no delimiter). Mutually exclusive with --delimiter. Mirrors "aws s3 ls --recursive"',
110
+ type: "boolean",
111
+ default: false,
112
+ },
113
+ delimiter: {
114
+ describe: 'Collapse keys sharing this prefix separator into folders. Defaults to "/" (folder view); ignored when --recursive is set',
115
+ type: "string",
116
+ },
117
+ cursor: {
118
+ describe: "Pagination cursor returned as next_cursor by a previous call",
119
+ type: "string",
120
+ },
121
+ limit: {
122
+ describe: "Maximum number of items (objects + folders) to return",
123
+ type: "number",
124
+ },
125
+ }),
126
+ handler: (args) => listObjects(args),
127
+ })
128
+ .command("get <target>", "Download an object from a bucket to a local file", (yargs) => yargs
129
+ .usage("$0 bucket object get <bucket>/<key> [options]")
130
+ .positional("target", {
131
+ describe: "The object to download: <bucket>/<key>",
132
+ type: "string",
133
+ demandOption: true,
134
+ })
135
+ .options({
136
+ ...scopeOptions,
137
+ file: {
138
+ describe: "Path to write the downloaded object to (defaults to the object filename in the current directory)",
139
+ type: "string",
140
+ },
141
+ }), (args) => getObject(args))
142
+ .command("put <target>", "Upload a local file to a bucket as an object", (yargs) => yargs
143
+ .usage("$0 bucket object put <bucket>/<key> [options]")
144
+ .positional("target", {
145
+ describe: "The object to upload to: <bucket>/<key>",
146
+ type: "string",
147
+ demandOption: true,
148
+ })
149
+ .options({
150
+ ...scopeOptions,
151
+ file: {
152
+ describe: "Path to the local file to upload",
153
+ type: "string",
154
+ demandOption: true,
155
+ },
156
+ "content-type": {
157
+ describe: "Content-Type to store the object with (e.g. text/plain)",
158
+ type: "string",
159
+ },
160
+ }), (args) => putObject(args))
161
+ .command({
162
+ command: "delete <target>",
163
+ aliases: ["rm"],
164
+ describe: "Delete an object, or every object under a prefix",
165
+ builder: (yargs) => yargs
166
+ .usage("$0 bucket object delete <bucket>/<key> [options]")
167
+ .positional("target", {
168
+ describe: "The object to delete: <bucket>/<key>, or <bucket>/<prefix>/ with --recursive",
169
+ type: "string",
170
+ demandOption: true,
171
+ })
172
+ .options({
173
+ ...scopeOptions,
174
+ recursive: {
175
+ describe: 'Delete every object under the given prefix. The prefix must end with "/"',
176
+ type: "boolean",
177
+ default: false,
178
+ },
179
+ }),
180
+ handler: (args) => deleteObject(args),
181
+ })
182
+ .demandCommand(1, "")
183
+ .strictCommands())
184
+ .demandCommand(1, "");
185
+ export const handler = (args) => {
186
+ return args;
187
+ };
188
+ const createBucket = async (props) => {
189
+ const branchId = await branchIdFromProps(props);
190
+ const { data } = await retryOnLock(() => createProjectBranchBucket(props.apiClient, {
191
+ projectId: props.projectId,
192
+ branchId,
193
+ name: props.name,
194
+ accessLevel: props.accessLevel,
195
+ }));
196
+ log.info(`Bucket "${data.bucket.name}" (${data.bucket.access_level}) created on branch ${branchId}`);
197
+ };
198
+ const listBuckets = async (props) => {
199
+ const branchId = await branchIdFromProps(props);
200
+ const { data } = await listProjectBranchBuckets(props.apiClient, {
201
+ projectId: props.projectId,
202
+ branchId,
203
+ });
204
+ if (props.output === "json" || props.output === "yaml") {
205
+ writer(props).end(data.buckets, { fields: BUCKET_FIELDS });
206
+ return;
207
+ }
208
+ writer(props).end(data.buckets, {
209
+ fields: BUCKET_FIELDS,
210
+ title: "buckets",
211
+ emptyMessage: "No buckets found.",
212
+ });
213
+ };
214
+ const deleteBucket = async (props) => {
215
+ const branchId = await branchIdFromProps(props);
216
+ try {
217
+ await retryOnLock(() => deleteProjectBranchBucket(props.apiClient, {
218
+ projectId: props.projectId,
219
+ branchId,
220
+ bucketName: props.name,
221
+ }));
222
+ }
223
+ catch (err) {
224
+ if (isNeonApiError(err) && err.status === 404) {
225
+ throw new Error(`Bucket "${props.name}" not found on branch ${branchId}.`);
226
+ }
227
+ throw err;
228
+ }
229
+ log.info(`Bucket "${props.name}" deleted from branch ${branchId}`);
230
+ };
231
+ // Resolve the delimiter to send to the backend, mirroring `aws s3 ls`:
232
+ // - default (neither flag): "/" so the listing is folder-collapsed;
233
+ // - --recursive: no delimiter, so every nested key is returned flat;
234
+ // - explicit --delimiter <x>: that value (an empty string lists flat too).
235
+ // `--recursive` together with an explicit `--delimiter` is nonsensical and is
236
+ // rejected client-side before any HTTP request is made.
237
+ export const resolveListDelimiter = (props) => {
238
+ if (props.recursive && props.delimiter !== undefined) {
239
+ throw new Error("--recursive and --delimiter cannot be used together. Use --recursive for a flat listing, or --delimiter to collapse on a separator.");
240
+ }
241
+ if (props.recursive) {
242
+ return undefined;
243
+ }
244
+ if (props.delimiter !== undefined) {
245
+ return props.delimiter;
246
+ }
247
+ return "/";
248
+ };
249
+ const listObjects = async (props) => {
250
+ const delimiter = resolveListDelimiter(props);
251
+ const branchId = await branchIdFromProps(props);
252
+ const { bucket, rest } = splitBucketTarget(props.target);
253
+ const { data } = await listProjectBranchBucketObjects(props.apiClient, {
254
+ projectId: props.projectId,
255
+ branchId,
256
+ bucketName: bucket,
257
+ prefix: rest === "" ? undefined : rest,
258
+ delimiter,
259
+ cursor: props.cursor,
260
+ limit: props.limit,
261
+ });
262
+ if (props.output === "json" || props.output === "yaml") {
263
+ writer(props).end(data, {
264
+ fields: [
265
+ "folders",
266
+ "objects",
267
+ "prefix",
268
+ "next_cursor",
269
+ "is_truncated",
270
+ ],
271
+ });
272
+ return;
273
+ }
274
+ const w = writer(props);
275
+ if (data.folders.length > 0) {
276
+ w.write(data.folders.map((name) => ({ name })), { fields: ["name"], title: "folders" });
277
+ }
278
+ w.write(data.objects, {
279
+ fields: OBJECT_FIELDS,
280
+ title: "objects",
281
+ emptyMessage: "No objects found.",
282
+ });
283
+ w.end();
284
+ if (data.is_truncated && data.next_cursor) {
285
+ log.info(`More results available. Re-run with --cursor ${data.next_cursor} to fetch the next page.`);
286
+ }
287
+ };
288
+ // Pull a filename out of a `Content-Disposition` header, falling back to the
289
+ // last segment of the object key. Handles the plain and RFC 5987 (`filename*=`)
290
+ // forms the download endpoint may emit.
291
+ const filenameFromContentDisposition = (contentDisposition, key) => {
292
+ if (contentDisposition) {
293
+ const extended = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(contentDisposition);
294
+ if (extended?.[1]) {
295
+ try {
296
+ return basename(decodeURIComponent(extended[1].trim()));
297
+ }
298
+ catch {
299
+ // Fall through to the plain form / key on malformed encoding.
300
+ }
301
+ }
302
+ const plain = /filename="?([^";]+)"?/i.exec(contentDisposition);
303
+ if (plain?.[1]) {
304
+ return basename(plain[1].trim());
305
+ }
306
+ }
307
+ return basename(key) || key;
308
+ };
309
+ // Pull the `message` field out of a server error body, returning undefined when
310
+ // the body is absent, not an object, or carries no usable message.
311
+ const serverErrorMessage = (body) => {
312
+ const message = body?.message;
313
+ return typeof message === "string" && message.trim() !== ""
314
+ ? message
315
+ : undefined;
316
+ };
317
+ // Drain a streamed error body (the form an `octet-stream` download 404 takes)
318
+ // and parse its `message`. Returns undefined on any read/parse failure so the
319
+ // caller falls back to its default message.
320
+ const streamErrorMessage = async (stream) => {
321
+ if (typeof stream?.[Symbol.asyncIterator] !== "function") {
322
+ return undefined;
323
+ }
324
+ try {
325
+ const chunks = [];
326
+ for await (const chunk of stream) {
327
+ chunks.push(Buffer.from(chunk));
328
+ }
329
+ return serverErrorMessage(JSON.parse(Buffer.concat(chunks).toString()));
330
+ }
331
+ catch {
332
+ return undefined;
333
+ }
334
+ };
335
+ const objectNotFoundFallback = (key, bucket, branchId) => `Object "${key}" not found in bucket "${bucket}" on branch ${branchId}.`;
336
+ // Prefer the server's error message when present so a missing bucket is not
337
+ // misreported as a missing object; otherwise fall back to a clean default. Used
338
+ // for the JSON (non-streamed) endpoints where the body is already parsed.
339
+ const objectNotFoundMessage = (err, key, bucket, branchId) => {
340
+ if (isNeonApiError(err)) {
341
+ const serverMessage = serverErrorMessage(err.data);
342
+ if (serverMessage !== undefined) {
343
+ return serverMessage;
344
+ }
345
+ }
346
+ return objectNotFoundFallback(key, bucket, branchId);
347
+ };
348
+ // Stream a file from disk as a WHATWG `ReadableStream` suitable for a `fetch`
349
+ // request body, applying backpressure so we never read faster than the upload
350
+ // drains. Uses the global `ReadableStream` (what `fetch` expects) directly, so
351
+ // there's no Node-vs-DOM stream type bridging.
352
+ const fileToWebStream = (path) => {
353
+ const source = createReadStream(path);
354
+ return new ReadableStream({
355
+ start(controller) {
356
+ source.on("data", (chunk) => {
357
+ controller.enqueue(new Uint8Array(chunk));
358
+ if ((controller.desiredSize ?? 0) <= 0)
359
+ source.pause();
360
+ });
361
+ source.on("end", () => {
362
+ controller.close();
363
+ });
364
+ source.on("error", (err) => {
365
+ controller.error(err instanceof Error ? err : new Error(String(err)));
366
+ });
367
+ },
368
+ pull() {
369
+ source.resume();
370
+ },
371
+ cancel() {
372
+ source.destroy();
373
+ },
374
+ });
375
+ };
376
+ const getObject = async (props) => {
377
+ const branchId = await branchIdFromProps(props);
378
+ const { bucket, rest: key } = splitBucketTarget(props.target);
379
+ if (key === "") {
380
+ throw new Error("Object target must be in the form <bucket>/<key>.");
381
+ }
382
+ let response;
383
+ try {
384
+ response = await getProjectBranchBucketObject(props.apiClient, {
385
+ projectId: props.projectId,
386
+ branchId,
387
+ bucketName: bucket,
388
+ objectKey: key,
389
+ });
390
+ }
391
+ catch (err) {
392
+ if (isNeonApiError(err) && err.status === 404) {
393
+ // The download response is a stream, so a 404 body arrives as a stream
394
+ // too; drain and parse it to recover the server's message (which
395
+ // distinguishes a missing bucket from a missing object).
396
+ const serverMessage = await streamErrorMessage(err.data);
397
+ throw new Error(serverMessage ?? objectNotFoundFallback(key, bucket, branchId));
398
+ }
399
+ throw err;
400
+ }
401
+ const contentDisposition = response.headers["content-disposition"];
402
+ const destination = props.file ?? filenameFromContentDisposition(contentDisposition, key);
403
+ try {
404
+ await pipeline(response.data, createWriteStream(destination));
405
+ }
406
+ catch (err) {
407
+ // Best-effort cleanup of the partial file before rethrowing.
408
+ await unlink(destination).catch(() => undefined);
409
+ throw err;
410
+ }
411
+ log.info(`Object "${key}" downloaded from bucket "${bucket}" on branch ${branchId} to ${destination}`);
412
+ };
413
+ const putObject = async (props) => {
414
+ const branchId = await branchIdFromProps(props);
415
+ const { bucket, rest: key } = splitBucketTarget(props.target);
416
+ if (bucket === "" || key === "") {
417
+ throw new Error("Object target must be in the form <bucket>/<key>.");
418
+ }
419
+ // Stat the file first so we fail fast on a missing/unreadable file and can
420
+ // enforce the single-PUT size cap BEFORE any network round-trip. We also
421
+ // reuse the byte count as the PUT Content-Length so the stream is uploaded
422
+ // without buffering the whole file in memory.
423
+ let fileSize;
424
+ try {
425
+ const fileStat = await stat(props.file);
426
+ if (!fileStat.isFile()) {
427
+ throw new Error(`"${props.file}" is not a regular file.`);
428
+ }
429
+ fileSize = fileStat.size;
430
+ }
431
+ catch (err) {
432
+ if (err?.code === "ENOENT") {
433
+ throw new Error(`File "${props.file}" does not exist.`);
434
+ }
435
+ throw err;
436
+ }
437
+ if (fileSize > MAX_OBJECT_BYTES) {
438
+ throw new Error(`File "${props.file}" is ${fileSize} bytes, which exceeds the ${MAX_OBJECT_BYTES}-byte (100 MB) single-upload limit. Larger objects are not supported yet.`);
439
+ }
440
+ // Ask the console for a presigned PUT URL plus the headers that must travel
441
+ // with the upload for the signature to verify. No SigV4 happens in neonctl.
442
+ let presign;
443
+ try {
444
+ ({ data: presign } = await presignUpload(props.apiClient, {
445
+ projectId: props.projectId,
446
+ branchId,
447
+ bucketName: bucket,
448
+ objectKey: key,
449
+ contentType: props.contentType,
450
+ }));
451
+ }
452
+ catch (err) {
453
+ if (isNeonApiError(err)) {
454
+ const status = err.status;
455
+ if (status === 404) {
456
+ throw new Error(objectNotFoundMessage(err, key, bucket, branchId));
457
+ }
458
+ // Any other HTTP error from the console (e.g. 403 when the caller lacks
459
+ // write permission on the bucket) carries the same JSON `{ message }`
460
+ // body, so surface that rather than a bare error. When the body has no
461
+ // usable message, fall back to a clean status-bearing error.
462
+ const serverMessage = serverErrorMessage(err.data);
463
+ throw new Error(serverMessage ??
464
+ `Failed to presign upload for "${key}" in bucket "${bucket}" on branch ${branchId}${status !== undefined ? ` (HTTP ${status})` : ""}: ${err.message}`);
465
+ }
466
+ throw err;
467
+ }
468
+ // Stream the file straight into the PUT body via `fetch`; never buffer the
469
+ // whole file. The presigned URL targets the branch S3 data-plane endpoint
470
+ // directly, so this PUT bypasses the console API entirely.
471
+ //
472
+ // `presign.headers` carries the signature-relevant headers (e.g. host,
473
+ // content-type); the server does not sign Content-Length, so we set it
474
+ // ourselves from the stat'd size. `redirect: 'error'` ensures we never resend
475
+ // the file bytes and signed headers to a different host if the data-plane
476
+ // endpoint were to answer with a redirect. `duplex: 'half'` is required by
477
+ // fetch when streaming a request body.
478
+ const upload = {
479
+ method: "PUT",
480
+ headers: {
481
+ ...presign.headers,
482
+ "Content-Length": String(fileSize),
483
+ },
484
+ body: fileToWebStream(props.file),
485
+ redirect: "error",
486
+ duplex: "half",
487
+ };
488
+ let uploadResponse;
489
+ try {
490
+ uploadResponse = await fetch(presign.url, upload);
491
+ }
492
+ catch (err) {
493
+ // A transport-level failure (DNS, connection reset, redirect when none is
494
+ // allowed). Surface a clean message without leaking the signed URL.
495
+ throw new Error(`Failed to upload "${props.file}" to "${key}" in bucket "${bucket}" on branch ${branchId}: ${err instanceof Error ? err.message : String(err)}`);
496
+ }
497
+ if (!uploadResponse.ok) {
498
+ // The upload targets the S3 data plane, whose error bodies are XML rather
499
+ // than the JSON `{ message }` the console returns, so surface the status
500
+ // rather than the body. Never include the presigned URL, which carries the
501
+ // signature.
502
+ throw new Error(`Failed to upload "${props.file}" to "${key}" in bucket "${bucket}" on branch ${branchId} (HTTP ${uploadResponse.status}): Request failed with status code ${uploadResponse.status}`);
503
+ }
504
+ log.info(`File "${props.file}" uploaded to "${key}" in bucket "${bucket}" on branch ${branchId}`);
505
+ };
506
+ const deleteObject = async (props) => {
507
+ const branchId = await branchIdFromProps(props);
508
+ const { bucket, rest } = splitBucketTarget(props.target);
509
+ if (props.recursive) {
510
+ if (rest === "") {
511
+ throw new Error('Recursive delete requires a non-empty prefix ending in "/".');
512
+ }
513
+ if (!rest.endsWith("/")) {
514
+ throw new Error(`Recursive delete requires a prefix ending in "/" (got "${rest}").`);
515
+ }
516
+ const { data } = await retryOnLock(() => deleteProjectBranchBucketObjectsByPrefix(props.apiClient, {
517
+ projectId: props.projectId,
518
+ branchId,
519
+ bucketName: bucket,
520
+ prefix: rest,
521
+ }));
522
+ log.info(`Deleted ${data.deleted} object(s) under prefix "${rest}" from bucket "${bucket}" on branch ${branchId}`);
523
+ return;
524
+ }
525
+ if (rest === "") {
526
+ throw new Error("Object target must be in the form <bucket>/<key>.");
527
+ }
528
+ try {
529
+ await retryOnLock(() => deleteProjectBranchBucketObject(props.apiClient, {
530
+ projectId: props.projectId,
531
+ branchId,
532
+ bucketName: bucket,
533
+ objectKey: rest,
534
+ }));
535
+ }
536
+ catch (err) {
537
+ if (isNeonApiError(err) && err.status === 404) {
538
+ throw new Error(objectNotFoundMessage(err, rest, bucket, branchId));
539
+ }
540
+ throw err;
541
+ }
542
+ log.info(`Object "${rest}" deleted from bucket "${bucket}" on branch ${branchId}`);
543
+ };