From 5295d34b9447b774fe434487581d73452c7fdbfa Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Mon, 14 Sep 2026 21:18:13 +1000 Subject: [PATCH] feat(cli): add supabase notebooks push Adds `supabase notebooks push`, the mirror of `notebooks pull`: every file in `supabase/notebooks/` is written to the project notebook of that name, or to a new one when the project has none. Every file is read and decoded before the first upload, so a directory holding one unreadable notebook fails without having half-pushed the rest. Keys a file leaves out stay out of the request rather than going up as explicit nulls, which is how `favorite` keeps whatever the dashboard set on a notebook whose file never mentions it. Project notebooks naming no local file are the divergence this command asks about, the same three answers `pull` offers pointing the other way. Passing a notebook name pushes just that one and reconciles nothing. Co-Authored-By: Claude Opus 5 --- .../commands/notebooks/notebooks.command.ts | 3 +- .../commands/notebooks/notebooks.errors.ts | 14 +- .../notebooks/notebooks.integration.test.ts | 163 ++++++++- .../commands/notebooks/notebooks.shared.ts | 25 +- .../commands/notebooks/push/SIDE_EFFECTS.md | 122 +++++++ .../commands/notebooks/push/push.command.ts | 47 +++ .../commands/notebooks/push/push.handler.ts | 173 ++++++++++ .../notebooks/push/push.integration.test.ts | 326 ++++++++++++++++++ .../telemetry/__fixtures__/error-tags.txt | 1 + apps/cli/tests/helpers/notebooks.ts | 2 +- 10 files changed, 859 insertions(+), 17 deletions(-) create mode 100644 apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md create mode 100644 apps/cli/src/commands/notebooks/push/push.command.ts create mode 100644 apps/cli/src/commands/notebooks/push/push.handler.ts create mode 100644 apps/cli/src/commands/notebooks/push/push.integration.test.ts diff --git a/apps/cli/src/commands/notebooks/notebooks.command.ts b/apps/cli/src/commands/notebooks/notebooks.command.ts index 9d1b200f95..1730291f17 100644 --- a/apps/cli/src/commands/notebooks/notebooks.command.ts +++ b/apps/cli/src/commands/notebooks/notebooks.command.ts @@ -1,10 +1,11 @@ import { Command } from "effect/unstable/cli"; import { notebooksPullCommand } from "./pull/pull.command.ts"; +import { notebooksPushCommand } from "./push/push.command.ts"; export const notebooksCommand = Command.make("notebooks").pipe( Command.withDescription( "Manage Supabase notebooks: SQL and markdown cells stored with your project, kept in supabase/notebooks/.json.", ), Command.withShortDescription("Manage Supabase notebooks"), - Command.withSubcommands([notebooksPullCommand]), + Command.withSubcommands([notebooksPushCommand, notebooksPullCommand]), ); diff --git a/apps/cli/src/commands/notebooks/notebooks.errors.ts b/apps/cli/src/commands/notebooks/notebooks.errors.ts index 99ca97e49e..afc6ff971a 100644 --- a/apps/cli/src/commands/notebooks/notebooks.errors.ts +++ b/apps/cli/src/commands/notebooks/notebooks.errors.ts @@ -8,8 +8,8 @@ import { /** * One network / status pair covers every notebook route rather than one pair - * per call: the notebook commands all walk the same routes, and the failing one - * is already named by the message the caller templates in ("failed to list + * per call: `push` and `pull` each walk the same five routes, and the failing + * one is already named by the message the caller templates in ("failed to list * notebooks", "failed to update notebook ", …). */ export class NotebooksNetworkError extends Data.TaggedError("NotebooksNetworkError")<{ @@ -45,6 +45,16 @@ export class NotebookFileError extends Data.TaggedError("NotebookFileError")<{ } } +/** The name given as an argument names no notebook on either side. */ +export class NotebookNotFoundError extends Data.TaggedError("NotebookNotFoundError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** The single-notebook pull argument is not a Management API notebook UUID. */ export class NotebookIdError extends Data.TaggedError("NotebookIdError")<{ readonly detail: string; diff --git a/apps/cli/src/commands/notebooks/notebooks.integration.test.ts b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts index 2fe5c5bd25..57c3c8cc44 100644 --- a/apps/cli/src/commands/notebooks/notebooks.integration.test.ts +++ b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts @@ -25,12 +25,13 @@ import { NotebooksPaginationError, } from "./notebooks.errors.ts"; import { notebooksPullHandler } from "./pull/pull.command.ts"; +import { notebooksPushHandler } from "./push/push.command.ts"; const temp = useTempWorkdir("supabase-notebooks-regression-"); const ID = "44444444-4444-4444-8444-444444444444"; const OTHER_ID = "55555555-5555-4555-8555-555555555555"; const LOCAL = '{"content":{"cells":[{"type":"markdown","text":"local edits"}]}}'; -const commands = ["pull"] as const; +const commands = ["pull", "push"] as const; type Command = (typeof commands)[number]; const run = Effect.fnUntraced(function* ( @@ -38,9 +39,14 @@ const run = Effect.fnUntraced(function* ( name?: string, projectRef: Option.Option = Option.some(NOTEBOOKS_PROJECT_REF), ) { - return yield* notebooksPullHandler({ + if (command === "pull") + return yield* notebooksPullHandler({ + projectRef, + notebookId: Option.fromUndefinedOr(name), + }); + return yield* notebooksPushHandler({ projectRef, - notebookId: Option.fromUndefinedOr(name), + notebookName: Option.fromUndefinedOr(name), }); }); @@ -79,6 +85,70 @@ function downloaded(name: string, id = ID) { } describe("notebook file preservation", () => { + it.live("round-trips notebook metadata, cell identities, charts, and log ranges", () => { + const cells = [ + { id: "markdown-cell", type: "markdown", text: "# Report", collapsed: true }, + { + id: "database-cell", + type: "database", + sql: "select 1", + view: "chart", + chart: { + cumulative: false, + scale: "linear", + show_labels: true, + type: "line", + x_column: "time", + y_series: [{ column: "count", color: "green" }], + }, + }, + { + id: "log-cell", + type: "log", + sql: "select timestamp from postgres_logs", + time_range: { + type: "absolute", + start: "2026-01-01T00:00:00Z", + end: "2026-01-02T00:00:00Z", + }, + }, + ]; + const pulling = setup("pull", { + routes: { + [`GET ${notebooksRoute()}`]: list([remote("sales")]), + [`GET ${notebooksRoute(`/${ID}`)}`]: { + status: 200, + body: { + data: notebookResource({ + id: ID, + name: "sales", + description: "Shared report", + favorite: true, + cells, + }), + }, + }, + }, + }); + const pushing = setup("push", { + routes: { + [`GET ${notebooksRoute()}`]: list([remote("sales")]), + [`PATCH ${notebooksRoute(`/${ID}`)}`]: downloaded("sales"), + }, + }); + return Effect.gen(function* () { + yield* run("pull").pipe(Effect.provide(pulling.layer)); + yield* run("push", "sales").pipe(Effect.provide(pushing.layer)); + const request = pushing.http.requests.find((entry) => entry.method === "PATCH"); + expect(JSON.parse(request?.body ?? "{}").data.attributes).toEqual({ + name: "sales", + description: "Shared report", + favorite: true, + content: { cells }, + }); + }); + }); + it.live.each([ { local: "Sales", name: "sales" }, { local: "café", name: "cafe\u0301" }, @@ -207,6 +277,24 @@ describe("notebook file preservation", () => { }); describe("notebook reconciliation preflight", () => { + it.live("reports a local read failure without uploading notebooks", () => { + write("sales"); + const { layer, http } = setup("push"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const error = yield* run("push").pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + readFileString: (path) => fs.readFileString(`${path}.missing`), + }), + Effect.flip, + ); + expect(error).toBeInstanceOf(NotebookFileError); + expect(read("sales")).toBe(LOCAL); + expect(http.requests).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + it.live("reports a failed local deletion without losing the notebook", () => { write("sales"); const { layer, cache, telemetry } = setup("pull", { promptSelectResponses: ["delete"] }); @@ -226,6 +314,65 @@ describe("notebook reconciliation preflight", () => { }).pipe(Effect.provide(layer)); }); + it.live.each(["keep", "delete"])( + "can %s remote notebooks with unsupported filenames", + (choice) => { + write("sales"); + const { layer, http } = setup("push", { + promptSelectResponses: [choice], + routes: { + [`GET ${notebooksRoute()}`]: list([remote("sales"), remote("reports/weekly", OTHER_ID)]), + [`PATCH ${notebooksRoute(`/${ID}`)}`]: downloaded("sales"), + [`DELETE ${notebooksRoute(`/${OTHER_ID}`)}`]: { status: 204 }, + }, + }); + return Effect.gen(function* () { + yield* run("push"); + expect(http.requests.filter((request) => request.method === "PATCH")).toHaveLength(1); + expect(http.requests.filter((request) => request.method === "DELETE")).toHaveLength( + choice === "delete" ? 1 : 0, + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live.each(["reports/weekly", "Sales"])( + "validates copying %s before uploading local edits", + (name) => { + write("sales"); + const { layer, http } = setup("push", { + promptSelectResponses: ["copy"], + routes: { [`GET ${notebooksRoute()}`]: list([remote("sales"), remote(name, OTHER_ID)]) }, + }); + return Effect.gen(function* () { + expect(Exit.isFailure(yield* run("push").pipe(Effect.exit))).toBe(true); + expect(read("sales")).toBe(LOCAL); + expect(http.requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("pushes a selected notebook despite unrelated duplicate names", () => { + write("sales"); + const { layer, http } = setup("push", { + routes: { + [`GET ${notebooksRoute()}`]: list([ + remote("sales"), + remote("other", OTHER_ID), + remote("other", "66666666-6666-4666-8666-666666666666"), + ]), + [`PATCH ${notebooksRoute(`/${ID}`)}`]: downloaded("sales"), + }, + }); + return Effect.gen(function* () { + yield* run("push", "sales"); + expect(http.routeKeys).toEqual([ + `GET ${notebooksRoute()}`, + `PATCH ${notebooksRoute(`/${ID}`)}`, + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("validates all local files before creating any during pull reconciliation", () => { write("a-good"); write("z-broken", "{}"); @@ -237,9 +384,9 @@ describe("notebook reconciliation preflight", () => { }); it.live.each(commands)("leaves divergence alone when the %s prompt is cancelled", (command) => { - write("local"); + if (command === "pull") write("local"); const { layer, http, cache, telemetry } = setup(command, { - routes: { [`GET ${notebooksRoute()}`]: list([]) }, + routes: { [`GET ${notebooksRoute()}`]: list(command === "push" ? [remote("remote")] : []) }, }); return Effect.gen(function* () { const output = yield* Output; @@ -298,10 +445,12 @@ describe.each(commands)("notebooks %s command wiring", (command) => { it.live.each([{ interactive: false }, { goOutput: "json" as const }])( "keeps divergence without prompting in unattended text output (%j)", (options) => { - write("local"); + if (command === "pull") write("local"); const { layer, out, http } = setup(command, { ...options, - routes: { [`GET ${notebooksRoute()}`]: list([]) }, + routes: { + [`GET ${notebooksRoute()}`]: list(command === "push" ? [remote("reports/weekly")] : []), + }, }); return Effect.gen(function* () { yield* run(command); diff --git a/apps/cli/src/commands/notebooks/notebooks.shared.ts b/apps/cli/src/commands/notebooks/notebooks.shared.ts index ee39d13d5e..7401f0197f 100644 --- a/apps/cli/src/commands/notebooks/notebooks.shared.ts +++ b/apps/cli/src/commands/notebooks/notebooks.shared.ts @@ -15,11 +15,11 @@ import { } from "./notebooks.errors.ts"; /** - * The shared half of the `supabase notebooks` commands: where notebooks live on - * disk, what a notebook file is, and the Management API routes the commands - * drive. The handlers own the flow — which side is copied where, and what to do - * about the notebooks only one side has. This module provides the shared - * reconciliation prompt. + * The shared half of `supabase notebooks push` / `pull`: where notebooks live on + * disk, what a notebook file is, and the five Management API routes both + * commands drive. The handlers own the flow — which side is copied where, and + * what to do about the notebooks only one side has. This module provides the + * shared reconciliation prompt. * * A notebook's identity across the two sides is its **name**, which is the file * name: the API assigns a uuid, but a checkout is shared through git and a uuid @@ -442,8 +442,21 @@ export const uploadNotebook = Effect.fnUntraced(function* (options: { return "updated" as const; }); +export const deleteRemoteNotebook = Effect.fnUntraced(function* ( + api: ApiClient, + ref: string, + notebook: RemoteNotebook, +) { + yield* api.v2 + .deleteNotebook({ ref, id: notebook.id }) + .pipe( + Effect.catch(mapNotebookHttpError(`delete notebook ${notebook.name}`)), + withNotebookTask(`Deleting notebook ${notebook.name}`), + ); +}); + /** What to do about the notebooks only one of the two sides has. */ -type NotebooksReconcileChoice = "keep" | "delete" | "copy"; +export type NotebooksReconcileChoice = "keep" | "delete" | "copy"; /** * Asks what should happen to the notebooks the other side does not have. diff --git a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md new file mode 100644 index 0000000000..796b9cfd52 --- /dev/null +++ b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md @@ -0,0 +1,122 @@ +# `supabase notebooks push [Notebook name]` + +Writes `supabase/notebooks/.json` into the linked project, one call per +file. A file is matched to a project notebook **by name** — the API assigns a +uuid, but a checkout is shared through git and a uuid in a file name is +unreadable — so a file whose name a project notebook already carries updates that +notebook, and one with no match creates a new notebook. + +## Files Read + +| Path | Format | When | +| ------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/notebooks/` | dir | always — the file names are the notebook names; missing means empty, while any other read failure aborts | +| `/supabase/notebooks/.json` | JSON | one per notebook being pushed; all of them are read and decoded before the first call | +| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | + +Every file is read and decoded **before** the first write, so a directory holding +one unreadable notebook fails without having half-pushed the rest. + +Files under `supabase/notebooks/` that do not end in `.json`, and ones whose name +cannot be a notebook name, are ignored rather than pushed. Reconciliation is chosen before uploads. Names are checked for local-file safety only +when the user selects copying into the directory; unsupported names can still be +kept or deleted remotely. All selected downloads are read before uploads begin. + +## Files Written + +| Path | Format | When | +| ---------------------------------------------------------- | ------ | --------------------------------------------------------------------------- | +| `/supabase/notebooks/.notebook-/` | JSON | scoped temporary file; atomically linked to `.json`, then cleaned | +| `/supabase/notebooks/.json` | JSON | only for a project-only notebook the user chose to write into the directory | +| `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | + +Nothing local is ever removed by `push`. + +## Reconciliation + +With no notebook name given, a project notebook that names no local file is +either one somebody deleted from the checkout or one somebody added in the +dashboard, and neither list says which. So the command lists them and asks, with +three answers: leave them alone, write them into `supabase/notebooks/`, or delete +them from the project. + +`keep` is the answer whenever there is nobody to ask — a non-TTY, `-o json|yaml|toml`, a +`--output-format` other than `text`, or a cancelled prompt. The other answers mutate local or remote state, so an unattended run reports the divergence and leaves both +sides alone rather than resolving it in a direction nobody chose. + +Given a notebook name, nothing is reconciled at all: the argument says which +notebook to act on, so the project's other notebooks are not that invocation's +business. + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------ | -------------------------------------- | --------------------------------------------------- | +| GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` | +| POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` — name plus the file | — | +| PATCH | `/v2/projects/{ref}/notebooks/{id}` | Bearer | `data.attributes` — name plus the file | — | +| DELETE | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | — (only for the delete answer) | +| GET | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | — (only for the write-locally answer) | + +`content` replaces the whole notebook body, and a cell keeps its identity by +echoing back its `id` — which is why a pulled file carries cell ids and a push +sends them as written. A cell with no `id` is added as a new one. + +Attributes the file leaves out stay out of the request, so a notebook whose file +never mentions `favorite` keeps whatever the dashboard set. + +## Exit Codes + +| Code | Condition | +| ---- | -------------------------------------------------------------------------------- | +| `0` | success | +| `1` | no project ref — not linked and no `--project-ref` | +| `1` | the named notebook is not in `supabase/notebooks/` | +| `1` | a file is unreadable, not JSON, or not a notebook — before anything is sent | +| `1` | duplicate remote names (only the selected name for a named push) | +| `1` | the notebooks path exists but cannot be read as a directory | +| `1` | a project notebook name cannot be stored safely as a local file | +| `1` | a Management API call failed (transport, unexpected status, or undecodable body) | +| `1` | `-o env`, which cannot represent the payload | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------- | ------------------------------------------------------ | +| `SUPABASE_ACCESS_TOKEN` | Management API bearer token | no (falls back to the stored login) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ---------------------------------------------- | -------------------------------------------------- | +| `cli_command_executed` | post-handler, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags`, project group | + +`--project-ref` is marked telemetry-safe, so its value is recorded verbatim — +the same call `functions deploy` makes for the same flag. The notebook name +argument is user content and is not. + +## Filename safety and failures + +Filenames must fit within 255 UTF-8 bytes including `.json`. Windows reserved +characters, device names (including extensions), trailing spaces/dots, path +separators, and control characters are unsupported. Broad pulls skip and count +unsupported names; explicit pulls and reconciliation copies reject them. + +Before copying locally, names are compared using Unicode NFC normalization and +lowercasing against both selected notebooks and existing directory entries. +Conflicting filenames fail before writes rather than silently aliasing on another +filesystem. Creation uses an atomic hard link from a scoped temporary file and +fails if the destination appears during the operation. Only an explicit pull by +id replaces an existing file. Temporary files are removed on success, failure, +and interruption. + +A missing, empty, or previously visited pagination cursor fails with a typed API +response error. Partial inventories are never used to reconcile notebooks. + +Network operations show progress in text mode; machine output remains payload-only. +`-o table` and `-o csv` are rejected by command instrumentation before the handler +runs. `-o env` is rejected by the handler before resolving the project. diff --git a/apps/cli/src/commands/notebooks/push/push.command.ts b/apps/cli/src/commands/notebooks/push/push.command.ts new file mode 100644 index 0000000000..3348c0610b --- /dev/null +++ b/apps/cli/src/commands/notebooks/push/push.command.ts @@ -0,0 +1,47 @@ +import { Argument, Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; +import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; +import { notebooksProjectRefSafeFlags } from "../notebooks.shared.ts"; +import { notebooksPush } from "./push.handler.ts"; + +const config = { + notebookName: Argument.string("Notebook name").pipe( + Argument.withDescription("Name of the notebook to push. Pushes all if omitted."), + Argument.optional, + ), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type NotebooksPushFlags = CliCommand.Command.Config.Infer; + +// Exported so integration tests can drive the exact wiring `Command.withHandler` +// uses below, instead of re-asserting the generic instrumentation mechanism. +export const notebooksPushHandler = (flags: NotebooksPushFlags) => + notebooksPush(flags).pipe( + withCommandTelemetry({ flags, safeFlags: notebooksProjectRefSafeFlags }), + withJsonErrorHandling, + ); + +export const notebooksPushCommand = Command.make("push", config).pipe( + Command.withDescription( + "Write supabase/notebooks into the linked Supabase project. If no notebook name is provided, pushes all of them and asks what to do about project notebooks the directory does not have.", + ), + Command.withShortDescription("Push notebooks to Supabase"), + Command.withExamples([ + { + command: "supabase notebooks push", + description: "Push every notebook in supabase/notebooks to the linked project", + }, + { + command: "supabase notebooks push sales-dashboard", + description: "Push a single notebook by name", + }, + ]), + Command.withHandler(notebooksPushHandler), + Command.provide(managementApiRuntimeLayer(["notebooks", "push"])), +); diff --git a/apps/cli/src/commands/notebooks/push/push.handler.ts b/apps/cli/src/commands/notebooks/push/push.handler.ts new file mode 100644 index 0000000000..57bb1d24cd --- /dev/null +++ b/apps/cli/src/commands/notebooks/push/push.handler.ts @@ -0,0 +1,173 @@ +import { Effect, Option } from "effect"; +import { Output } from "../../../shared/output/output.service.ts"; +import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; +import { CommandSettings } from "../../../config/command-settings.service.ts"; +import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; +import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; +import { NotebookNotFoundError } from "../notebooks.errors.ts"; +import { + emitNotebooksMachineOutput, + notebooksMachineOutputRequested, + rejectNotebooksEnvOutput, +} from "../notebooks.output.ts"; +import { + deleteRemoteNotebook, + downloadNotebook, + ensureNotebookDestinationsUnique, + ensureRemoteNotebookNamesUnique, + listLocalNotebooks, + listRemoteNotebooks, + notebooksDir, + promptNotebooksReconcile, + readNotebookFile, + uploadNotebook, + writeNotebookFile, + type NotebooksReconcileChoice, +} from "../notebooks.shared.ts"; +import type { NotebooksPushFlags } from "./push.command.ts"; + +/** + * `supabase notebooks push [name]` — write `supabase/notebooks/` into the + * project. + * + * The mirror of `pull`: the checkout is the source, and every file is written to + * the notebook of that name, or to a new one when the project has none. What is + * left over — a project notebook naming no local file — is the divergence this + * command asks about, for the same reason `pull` asks about its own. + * + * Every file is read and decoded before the first write, so a directory holding + * one unreadable notebook fails without having half-pushed the rest. + */ +export const notebooksPush = Effect.fn("notebooks.push")(function* (flags: NotebooksPushFlags) { + const output = yield* Output; + const api = yield* CommandPlatformApi; + const cliSettings = yield* CommandSettings; + const resolver = yield* ProjectRefResolver; + const linkedProjectCache = yield* LinkedProjectCache; + const telemetryState = yield* TelemetryState; + + const workdir = cliSettings.workdir; + + // The telemetry state file is written on every invocation, success or + // failure, so everything that can fail lives inside the flush. + yield* Effect.gen(function* () { + // Refused before the project is resolved: failing at emit time would mean + // failing after the project has already changed. + yield* rejectNotebooksEnvOutput(); + const machineOutput = yield* notebooksMachineOutputRequested(); + + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const local = yield* listLocalNotebooks(workdir); + const requested = Option.getOrUndefined(flags.notebookName); + + if (requested !== undefined && !local.includes(requested)) { + return yield* new NotebookNotFoundError({ + detail: `${notebooksDir(workdir)} has no notebook named "${requested}".`, + suggestion: + "Run supabase notebooks pull to write the project's copy first.", + }); + } + + const selected = requested === undefined ? local : [requested]; + + // Read before anything is sent: one unreadable file stops the whole push + // rather than leaving the project half-written. + const files = yield* Effect.forEach(selected, (name) => + readNotebookFile(workdir, name).pipe(Effect.map((file) => ({ name, file }))), + ); + + const remote = yield* listRemoteNotebooks(api, ref); + yield* ensureRemoteNotebookNamesUnique( + requested === undefined ? remote : remote.filter((notebook) => notebook.name === requested), + ); + + // Only a whole-directory push reconciles: with a name given, the project's + // other notebooks are not this invocation's business. + const remoteOnly = + requested === undefined ? remote.filter((notebook) => !local.includes(notebook.name)) : []; + + let choice: NotebooksReconcileChoice = "keep"; + if (remoteOnly.length > 0) { + choice = yield* promptNotebooksReconcile({ + summary: `${remoteOnly.length} project notebook(s) are not in ${notebooksDir(workdir)}:`, + names: remoteOnly.map((notebook) => notebook.name), + copyLabel: `Write them into ${notebooksDir(workdir)}`, + deleteLabel: "Delete them from the project", + machineOutput, + }); + } + // Resolve and validate the selected plan before the first upload. + if (choice === "copy") { + yield* ensureNotebookDestinationsUnique( + workdir, + remoteOnly.map((notebook) => notebook.name), + ); + } + const downloads = + choice === "copy" + ? yield* Effect.forEach(remoteOnly, (notebook) => + downloadNotebook(api, ref, notebook).pipe(Effect.map((file) => ({ notebook, file }))), + ) + : []; + + const created: Array = []; + const updated: Array = []; + const remoteByName = new Map(remote.map((notebook) => [notebook.name, notebook])); + for (const { name, file } of files) { + const result = yield* uploadNotebook({ + api, + ref, + name, + file, + existing: remoteByName.get(name), + }); + (result === "created" ? created : updated).push(name); + } + + let deleted: Array = []; + const pulled: Array = []; + if (choice === "delete") { + for (const notebook of remoteOnly) { + yield* deleteRemoteNotebook(api, ref, notebook); + } + deleted = remoteOnly.map((notebook) => notebook.name); + } + for (const { notebook, file } of downloads) { + yield* writeNotebookFile(workdir, notebook.name, file); + pulled.push(notebook.name); + } + + const payload = { + project_ref: ref, + notebooks_dir: notebooksDir(workdir), + created, + updated, + deleted, + pulled, + }; + + if (yield* emitNotebooksMachineOutput(payload)) { + return; + } + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + yield* output.raw( + files.length === 0 + ? `No notebooks in ${notebooksDir(workdir)} to push.\n` + : `Pushed ${files.length} notebook(s) to ${ref} (${created.length} created, ${updated.length} updated)\n`, + ); + if (deleted.length > 0) { + yield* output.raw(`Deleted ${deleted.length} notebook(s) from the project.\n`); + } + if (pulled.length > 0) { + yield* output.raw(`Wrote ${pulled.length} notebook(s) into the notebooks directory.\n`); + } + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); +}); diff --git a/apps/cli/src/commands/notebooks/push/push.integration.test.ts b/apps/cli/src/commands/notebooks/push/push.integration.test.ts new file mode 100644 index 0000000000..1daa462819 --- /dev/null +++ b/apps/cli/src/commands/notebooks/push/push.integration.test.ts @@ -0,0 +1,326 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeNotebooksProject, + notebookListPage, + notebookResource, + notebooksRoute, + setupNotebooks, +} from "../../../../tests/helpers/notebooks.ts"; +import { + NotebookFileError, + NotebookNameConflictError, + NotebookNotFoundError, +} from "../notebooks.errors.ts"; +import { notebooksPushHandler as notebooksPush } from "./push.command.ts"; +import type { NotebooksPushFlags } from "./push.command.ts"; + +const SALES_ID = "44444444-4444-4444-8444-444444444444"; +const ERRORS_ID = "55555555-5555-4555-8555-555555555555"; + +const SALES_FILE = JSON.stringify({ + description: "Weekly revenue", + favorite: true, + content: { cells: [{ id: "cell-1", type: "database", sql: "select 1", row_limit: 10 }] }, +}); + +function flags(overrides: Partial = {}): NotebooksPushFlags { + return { notebookName: Option.none(), projectRef: Option.none(), ...overrides }; +} + +function project(files: Readonly> = {}) { + const created = makeNotebooksProject(files); + return { + dir: created.dir, + read: (name: string) => + readFileSync(join(created.dir, "supabase", "notebooks", `${name}.json`), "utf8"), + exists: (name: string) => + existsSync(join(created.dir, "supabase", "notebooks", `${name}.json`)), + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +describe("notebooks push", () => { + it.live("updates the notebook of that name and creates the ones with no match", () => { + const repo = project({ + "supabase/notebooks/sales-dashboard.json": SALES_FILE, + "supabase/notebooks/brand-new.json": '{"content":{"cells":[]}}', + }); + const { layer, http, out } = setupNotebooks({ + command: "push", + workdir: repo.dir, + routes: { + [`GET ${notebooksRoute()}`]: { + status: 200, + body: notebookListPage({ notebooks: [{ id: SALES_ID, name: "sales-dashboard" }] }), + }, + [`PATCH ${notebooksRoute(`/${SALES_ID}`)}`]: { + status: 200, + body: { data: notebookResource({ id: SALES_ID, name: "sales-dashboard" }) }, + }, + [`POST ${notebooksRoute()}`]: { + status: 201, + body: { data: notebookResource({ id: ERRORS_ID, name: "brand-new" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* notebooksPush(flags()); + + // Matched by name, so the existing notebook is updated in place rather + // than duplicated. + const patched = http.requests.find((request) => request.method === "PATCH"); + expect(JSON.parse(patched?.body ?? "{}")).toEqual({ + data: { + type: "notebook", + attributes: { + name: "sales-dashboard", + description: "Weekly revenue", + favorite: true, + content: { + cells: [{ id: "cell-1", type: "database", sql: "select 1", row_limit: 10 }], + }, + }, + }, + }); + expect(http.requests.filter((request) => request.method === "POST")).toHaveLength(1); + expect(out.stdoutText).toContain("(1 created, 1 updated)"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("pushes a single notebook by name without touching the rest", () => { + const repo = project({ + "supabase/notebooks/sales-dashboard.json": SALES_FILE, + "supabase/notebooks/other.json": '{"content":{"cells":[]}}', + }); + const { layer, http } = setupNotebooks({ + command: "push", + workdir: repo.dir, + routes: { + [`GET ${notebooksRoute()}`]: { + status: 200, + body: notebookListPage({ notebooks: [{ id: SALES_ID, name: "sales-dashboard" }] }), + }, + [`PATCH ${notebooksRoute(`/${SALES_ID}`)}`]: { + status: 200, + body: { data: notebookResource({ id: SALES_ID, name: "sales-dashboard" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* notebooksPush(flags({ notebookName: Option.some("sales-dashboard") })); + + expect(http.routeKeys).toEqual([ + `GET ${notebooksRoute()}`, + `PATCH ${notebooksRoute(`/${SALES_ID}`)}`, + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails when the named notebook is not in the notebooks directory", () => { + const repo = project(); + const { layer, http } = setupNotebooks({ command: "push", workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* notebooksPush(flags({ notebookName: Option.some("nope") })).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(NotebookNotFoundError); + // Refused before the project was read, let alone written. + expect(http.requests).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // One unreadable file stops the whole push: the alternative is a project left + // half-written, with no way to tell how far it got. + it.live("sends nothing when one of the files is not a notebook", () => { + const repo = project({ + "supabase/notebooks/good.json": '{"content":{"cells":[]}}', + "supabase/notebooks/broken.json": "{ not json", + }); + const { layer, http } = setupNotebooks({ command: "push", workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* notebooksPush(flags()).pipe(Effect.flip); + + expect(error).toBeInstanceOf(NotebookFileError); + expect(http.requests).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("deletes the project notebooks the directory does not have when asked to", () => { + const repo = project({ "supabase/notebooks/kept.json": '{"content":{"cells":[]}}' }); + const { layer, http, out } = setupNotebooks({ + command: "push", + workdir: repo.dir, + promptSelectResponses: ["delete"], + routes: { + [`GET ${notebooksRoute()}`]: { + status: 200, + body: notebookListPage({ + notebooks: [ + { id: SALES_ID, name: "kept" }, + { id: ERRORS_ID, name: "stale" }, + ], + }), + }, + [`PATCH ${notebooksRoute(`/${SALES_ID}`)}`]: { + status: 200, + body: { data: notebookResource({ id: SALES_ID, name: "kept" }) }, + }, + [`DELETE ${notebooksRoute(`/${ERRORS_ID}`)}`]: { status: 204 }, + }, + }); + + return Effect.gen(function* () { + yield* notebooksPush(flags()); + + expect(http.routeKeys).toContain(`DELETE ${notebooksRoute(`/${ERRORS_ID}`)}`); + expect(out.stderrText).toContain(" • stale"); + expect(out.stdoutText).toContain("Deleted 1 notebook(s) from the project."); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("writes them into the notebooks directory instead when asked to", () => { + const repo = project(); + const { layer, http } = setupNotebooks({ + command: "push", + workdir: repo.dir, + promptSelectResponses: ["copy"], + routes: { + [`GET ${notebooksRoute()}`]: { + status: 200, + body: notebookListPage({ notebooks: [{ id: ERRORS_ID, name: "error-rates" }] }), + }, + [`GET ${notebooksRoute(`/${ERRORS_ID}`)}`]: { + status: 200, + body: { data: notebookResource({ id: ERRORS_ID, name: "error-rates" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* notebooksPush(flags()); + + expect(repo.exists("error-rates")).toBe(true); + expect(http.routeKeys).not.toContain(`DELETE ${notebooksRoute(`/${ERRORS_ID}`)}`); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("leaves both sides alone when there is nobody to ask", () => { + const repo = project(); + const { layer, http, out } = setupNotebooks({ + command: "push", + workdir: repo.dir, + format: "json", + routes: { + [`GET ${notebooksRoute()}`]: { + status: 200, + body: notebookListPage({ notebooks: [{ id: ERRORS_ID, name: "error-rates" }] }), + }, + }, + }); + + return Effect.gen(function* () { + yield* notebooksPush(flags()); + + expect(out.promptSelectCalls).toHaveLength(0); + expect(http.routeKeys).toEqual([`GET ${notebooksRoute()}`]); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: expect.objectContaining({ created: [], updated: [], deleted: [] }), + }), + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails instead of treating an unreadable notebooks path as empty", () => { + const repo = project({ "supabase/notebooks": "not a directory" }); + const { layer, http } = setupNotebooks({ command: "push", workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* notebooksPush(flags()).pipe(Effect.flip); + + expect(error).toBeInstanceOf(NotebookFileError); + expect(http.requests).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses an unsafe remote name before it can escape the notebooks directory", () => { + const repo = project(); + const { layer, http, out } = setupNotebooks({ + command: "push", + workdir: repo.dir, + promptSelectResponses: ["copy"], + routes: { + [`GET ${notebooksRoute()}`]: { + status: 200, + body: notebookListPage({ notebooks: [{ id: ERRORS_ID, name: "../config" }] }), + }, + }, + }); + + return Effect.gen(function* () { + const error = yield* notebooksPush(flags()).pipe(Effect.flip); + + expect(error).toBeInstanceOf(NotebookFileError); + expect(existsSync(join(repo.dir, "supabase", "config.json"))).toBe(false); + expect(http.routeKeys).toEqual([`GET ${notebooksRoute()}`]); + expect(out.promptSelectCalls).toHaveLength(1); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A directory of files cannot say which of two notebooks of one name it means, + // and guessing would write one user's notebook over another's. + it.live("refuses a project holding two notebooks of the same name", () => { + const repo = project({ "supabase/notebooks/sales-dashboard.json": SALES_FILE }); + const { layer, http } = setupNotebooks({ + command: "push", + workdir: repo.dir, + routes: { + [`GET ${notebooksRoute()}`]: { + status: 200, + body: notebookListPage({ + notebooks: [ + { id: SALES_ID, name: "sales-dashboard" }, + { id: ERRORS_ID, name: "sales-dashboard" }, + ], + }), + }, + }, + }); + + return Effect.gen(function* () { + const error = yield* notebooksPush(flags()).pipe(Effect.flip); + + expect(error).toBeInstanceOf(NotebookNameConflictError); + expect(http.routeKeys).toEqual([`GET ${notebooksRoute()}`]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits the machine payload without text output", () => { + const repo = project(); + const { layer, out } = setupNotebooks({ + command: "push", + workdir: repo.dir, + goOutput: "json", + routes: { + [`GET ${notebooksRoute()}`]: { status: 200, body: notebookListPage({ notebooks: [] }) }, + }, + }); + + return Effect.gen(function* () { + yield* notebooksPush(flags()); + + expect(JSON.parse(out.stdoutText)).toEqual( + expect.objectContaining({ created: [], updated: [], deleted: [], pulled: [] }), + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 7c7e0d68ee..3eebef88e3 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -370,6 +370,7 @@ NotLoggedInError NotebookFileError NotebookIdError NotebookNameConflictError +NotebookNotFoundError NotebooksEnvNotSupportedError NotebooksNetworkError NotebooksPaginationError diff --git a/apps/cli/tests/helpers/notebooks.ts b/apps/cli/tests/helpers/notebooks.ts index c544fb4550..d45be62866 100644 --- a/apps/cli/tests/helpers/notebooks.ts +++ b/apps/cli/tests/helpers/notebooks.ts @@ -241,7 +241,7 @@ export interface NotebooksSetupOptions { readonly routes?: NotebooksHttpRoutes; /** The Go `-o`/`--output` flag, which every command family here honours. */ readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; - readonly command?: "pull"; + readonly command?: "pull" | "push"; readonly args?: ReadonlyArray; }