diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index 76945eb208..2e9358a471 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -26,6 +26,7 @@ import { logoutCommand } from "../commands/logout/logout.command.ts"; import { migrationCommand } from "../commands/migration/migration.command.ts"; import { networkBansCommand } from "../commands/network-bans/network-bans.command.ts"; import { networkRestrictionsCommand } from "../commands/network-restrictions/network-restrictions.command.ts"; +import { notebooksCommand } from "../commands/notebooks/notebooks.command.ts"; import { orgsCommand } from "../commands/orgs/orgs.command.ts"; import { postgresConfigCommand } from "../commands/postgres-config/postgres-config.command.ts"; import { projectsCommand } from "../commands/projects/projects.command.ts"; @@ -112,6 +113,7 @@ export const rootCommandForFeatures = ( migrationCommand, networkBansCommand, networkRestrictionsCommand, + notebooksCommand, orgsCommand, postgresConfigCommand, projectsCommand, diff --git a/apps/cli/src/commands/notebooks/notebooks.command.ts b/apps/cli/src/commands/notebooks/notebooks.command.ts new file mode 100644 index 0000000000..9d1b200f95 --- /dev/null +++ b/apps/cli/src/commands/notebooks/notebooks.command.ts @@ -0,0 +1,10 @@ +import { Command } from "effect/unstable/cli"; +import { notebooksPullCommand } from "./pull/pull.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]), +); diff --git a/apps/cli/src/commands/notebooks/notebooks.errors.ts b/apps/cli/src/commands/notebooks/notebooks.errors.ts new file mode 100644 index 0000000000..99ca97e49e --- /dev/null +++ b/apps/cli/src/commands/notebooks/notebooks.errors.ts @@ -0,0 +1,89 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; + +/** + * 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 + * notebooks", "failed to update notebook ", …). + */ +export class NotebooksNetworkError extends Data.TaggedError("NotebooksNetworkError")<{ + readonly message: string; + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} + +export class NotebooksUnexpectedStatusError extends Data.TaggedError( + "NotebooksUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} + +/** A file under `supabase/notebooks/` is not readable, not JSON, or not a notebook. */ +export class NotebookFileError extends Data.TaggedError("NotebookFileError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +/** The single-notebook pull argument is not a Management API notebook UUID. */ +export class NotebookIdError extends Data.TaggedError("NotebookIdError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** + * Two project notebooks share one name. The API does not require notebook names + * to be unique, but a directory of files does — so there is no way to say which + * of them a local file corresponds to, and guessing would write one user's + * notebook over another's. + */ +export class NotebookNameConflictError extends Data.TaggedError("NotebookNameConflictError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +export class NotebooksEnvNotSupportedError extends Data.TaggedError( + "NotebooksEnvNotSupportedError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +export class NotebooksPaginationError extends Data.TaggedError("NotebooksPaginationError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } +} diff --git a/apps/cli/src/commands/notebooks/notebooks.integration.test.ts b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts new file mode 100644 index 0000000000..2fe5c5bd25 --- /dev/null +++ b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts @@ -0,0 +1,427 @@ +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Fiber, FileSystem, Option } from "effect"; +import { + notebookListPage, + notebookResource, + notebooksRoute, + NOTEBOOKS_PROJECT_REF, + setupNotebooks, + type NotebooksSetupOptions, +} from "../../../tests/helpers/notebooks.ts"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import { NonInteractiveError } from "../../shared/output/errors.ts"; +import { + EventCommandExecuted, + PropExitCode, + PropFlags, +} from "../../shared/telemetry/event-catalog.ts"; +import { + NotebookFileError, + NotebookNameConflictError, + NotebooksNetworkError, + NotebooksPaginationError, +} from "./notebooks.errors.ts"; +import { notebooksPullHandler } from "./pull/pull.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; +type Command = (typeof commands)[number]; + +const run = Effect.fnUntraced(function* ( + command: Command, + name?: string, + projectRef: Option.Option = Option.some(NOTEBOOKS_PROJECT_REF), +) { + return yield* notebooksPullHandler({ + projectRef, + notebookId: Option.fromUndefinedOr(name), + }); +}); + +function setup(command: Command, options: Omit = {}) { + return setupNotebooks({ + workdir: temp.current, + command, + args: ["notebooks", command, "--project-ref", NOTEBOOKS_PROJECT_REF], + routes: { + [`GET ${notebooksRoute()}`]: { status: 200, body: notebookListPage({ notebooks: [] }) }, + }, + ...options, + }); +} + +function write(name: string, contents = LOCAL) { + const dir = join(temp.current, "supabase", "notebooks"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${name}.json`), contents); +} + +function read(name: string) { + return readFileSync(join(temp.current, "supabase", "notebooks", `${name}.json`), "utf8"); +} + +function remote(name: string, id = ID) { + return { id, name }; +} + +function list(notebooks: ReadonlyArray<{ id: string; name: string }>) { + return { status: 200, body: notebookListPage({ notebooks }) }; +} + +function downloaded(name: string, id = ID) { + return { status: 200, body: { data: notebookResource({ id, name }) } }; +} + +describe("notebook file preservation", () => { + it.live.each([ + { local: "Sales", name: "sales" }, + { local: "café", name: "cafe\u0301" }, + ])("refuses local filename aliases $local / $name before downloading", ({ local, name }) => { + write(local); + const { layer, http } = setup("pull", { + routes: { [`GET ${notebooksRoute()}`]: list([remote(name)]) }, + }); + return Effect.gen(function* () { + const error = yield* run("pull").pipe(Effect.flip); + expect(error).toBeInstanceOf(NotebookNameConflictError); + expect(read(local)).toBe(LOCAL); + expect(http.requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("refuses colliding remote filenames before writing any notebook", () => { + const { layer, http } = setup("pull", { + routes: { [`GET ${notebooksRoute()}`]: list([remote("Sales"), remote("sales", OTHER_ID)]) }, + }); + return Effect.gen(function* () { + expect(yield* run("pull").pipe(Effect.flip)).toBeInstanceOf(NotebookNameConflictError); + expect(http.requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live.each(["a".repeat(210), "b".repeat(250), "é".repeat(125)])( + "pulls long filenames and removes temporary artifacts (%s)", + (name) => { + const { layer } = setup("pull", { + routes: { + [`GET ${notebooksRoute()}`]: list([remote(name)]), + [`GET ${notebooksRoute(`/${ID}`)}`]: downloaded(name), + }, + }); + return Effect.gen(function* () { + yield* run("pull"); + expect(JSON.parse(read(name)).content.cells).toHaveLength(1); + expect(readdirSync(join(temp.current, "supabase", "notebooks"))).toEqual([`${name}.json`]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("skips unsupported portable filenames without attempting downloads", () => { + const names = [ + "CON", + "nul.backup", + "LPT1", + "COM¹", + "report:2026", + "report?", + "trailing.", + "trailing ", + "a".repeat(251), + "é".repeat(126), + ]; + const { layer, http, out } = setup("pull", { + format: "json", + routes: { + [`GET ${notebooksRoute()}`]: list(names.map((name, index) => remote(name, String(index)))), + }, + }); + return Effect.gen(function* () { + yield* run("pull"); + expect(http.requests).toHaveLength(1); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: expect.objectContaining({ skipped: names.length }), + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("preserves a file created after the pull inventory was read", () => { + const { layer, cache, telemetry } = setup("pull", { + routes: { + [`GET ${notebooksRoute()}`]: list([remote("sales")]), + [`GET ${notebooksRoute(`/${ID}`)}`]: downloaded("sales"), + }, + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const error = yield* run("pull").pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + link: (source, destination) => + fs + .writeFileString(destination, LOCAL) + .pipe(Effect.andThen(fs.link(source, destination))), + }), + Effect.flip, + ); + expect(error).toBeInstanceOf(NotebookFileError); + expect(read("sales")).toBe(LOCAL); + expect(readdirSync(join(temp.current, "supabase", "notebooks"))).toEqual(["sales.json"]); + expect(cache.cacheCount).toBe(1); + expect(telemetry.flushCount).toBe(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("cleans temporary files when a pull is interrupted before publication", () => { + const { layer, cache, telemetry } = setup("pull", { + routes: { + [`GET ${notebooksRoute()}`]: list([remote("sales")]), + [`GET ${notebooksRoute(`/${ID}`)}`]: downloaded("sales"), + }, + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const publishing = yield* Deferred.make(); + const fiber = yield* run("pull").pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + link: () => Deferred.succeed(publishing, undefined).pipe(Effect.andThen(Effect.never)), + }), + Effect.forkChild, + ); + yield* Deferred.await(publishing); + yield* Fiber.interrupt(fiber); + expect(readdirSync(join(temp.current, "supabase", "notebooks"))).toEqual([]); + expect(cache.cacheCount).toBe(1); + expect(telemetry.flushCount).toBe(1); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("notebook reconciliation preflight", () => { + it.live("reports a failed local deletion without losing the notebook", () => { + write("sales"); + const { layer, cache, telemetry } = setup("pull", { promptSelectResponses: ["delete"] }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const error = yield* run("pull").pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + remove: (path) => fs.remove(join(path, "not-a-directory")), + }), + Effect.flip, + ); + expect(error).toBeInstanceOf(NotebookFileError); + expect(read("sales")).toBe(LOCAL); + expect(cache.cacheCount).toBe(1); + expect(telemetry.flushCount).toBe(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("validates all local files before creating any during pull reconciliation", () => { + write("a-good"); + write("z-broken", "{}"); + const { layer, http } = setup("pull", { promptSelectResponses: ["copy"] }); + return Effect.gen(function* () { + expect(yield* run("pull").pipe(Effect.flip)).toBeInstanceOf(NotebookFileError); + expect(http.requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live.each(commands)("leaves divergence alone when the %s prompt is cancelled", (command) => { + write("local"); + const { layer, http, cache, telemetry } = setup(command, { + routes: { [`GET ${notebooksRoute()}`]: list([]) }, + }); + return Effect.gen(function* () { + const output = yield* Output; + yield* run(command).pipe( + Effect.provideService(Output, { + ...output, + promptSelect: () => + Effect.fail(new NonInteractiveError({ detail: "context canceled", suggestion: "" })), + }), + ); + expect(http.requests).toHaveLength(1); + expect(cache.cacheCount).toBe(1); + expect(telemetry.flushCount).toBe(1); + }).pipe(Effect.provide(layer)); + }); +}); + +describe.each(commands)("notebooks %s command wiring", (command) => { + it.live.each([ + { status: 200, transportError: "ECONNRESET" }, + { status: 200, body: { invalid: "response" } }, + ])("reports transport and response failures and stops progress", (response) => { + const { layer, cache, telemetry, analytics } = setup(command, { + routes: { [`GET ${notebooksRoute()}`]: response }, + }); + return Effect.gen(function* () { + const output = yield* Output; + let failed = 0; + const error = yield* run(command).pipe( + Effect.provideService(Output, { + ...output, + task: (message) => + output.task(message).pipe( + Effect.map((task) => ({ + ...task, + fail: () => + Effect.sync(() => { + failed += 1; + }), + })), + ), + }), + Effect.flip, + ); + expect(error).toBeInstanceOf(NotebooksNetworkError); + expect(failed).toBe(1); + expect(cache.cacheCount).toBe(1); + expect(telemetry.flushCount).toBe(1); + expect(analytics.captured).toContainEqual({ + event: EventCommandExecuted, + properties: expect.objectContaining({ [PropExitCode]: 1 }), + }); + }).pipe(Effect.provide(layer)); + }); + + it.live.each([{ interactive: false }, { goOutput: "json" as const }])( + "keeps divergence without prompting in unattended text output (%j)", + (options) => { + write("local"); + const { layer, out, http } = setup(command, { + ...options, + routes: { [`GET ${notebooksRoute()}`]: list([]) }, + }); + return Effect.gen(function* () { + yield* run(command); + expect(out.promptSelectCalls).toEqual([]); + expect(http.requests).toHaveLength(1); + expect(out.stderrText).toContain("Left alone"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("flushes telemetry when project resolution fails", () => { + const { layer, cache, telemetry, http } = setup(command, { linked: false }); + return Effect.gen(function* () { + const exit = yield* run(command, undefined, Option.none()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(cache.cacheCount).toBe(0); + expect(telemetry.flushCount).toBe(1); + expect(http.requests).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live.each(["json", "stream-json"] as const)( + "emits %s failures with a nonzero exit code and telemetry", + (format) => { + const { layer, out, process, analytics, cache, telemetry } = setup(command, { + format, + routes: { [`GET ${notebooksRoute()}`]: { status: 403, body: { message: "denied" } } }, + }); + return Effect.gen(function* () { + yield* run(command); + expect(process.exitCode).toBe(1); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "fail", message: expect.stringContaining("403") }), + ); + expect(out.progressEvents).toEqual([]); + expect(cache.cacheCount).toBe(1); + expect(telemetry.flushCount).toBe(1); + expect(analytics.captured).toContainEqual({ + event: EventCommandExecuted, + properties: expect.objectContaining({ + [PropExitCode]: 1, + [PropFlags]: { "project-ref": NOTEBOOKS_PROJECT_REF }, + command: `notebooks ${command}`, + }), + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live.each(["json", "stream-json"] as const)( + "emits %s success without prompts or progress", + (format) => { + const { layer, out, analytics } = setup(command, { format }); + return Effect.gen(function* () { + yield* run(command); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: expect.objectContaining({ project_ref: NOTEBOOKS_PROJECT_REF }), + }), + ); + expect(out.promptSelectCalls).toEqual([]); + expect(out.progressEvents).toEqual([]); + expect(analytics.captured).toContainEqual({ + event: EventCommandExecuted, + properties: expect.objectContaining({ [PropExitCode]: 0 }), + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live.each(["json", "yaml", "toml"] as const)( + "honors -o %s before --output-format and keeps stdout clean", + (goOutput) => { + const { layer, out } = setup(command, { goOutput, format: "stream-json" }); + return Effect.gen(function* () { + yield* run(command); + expect(out.stdoutText).toContain(NOTEBOOKS_PROJECT_REF); + expect(out.messages).toEqual([]); + expect(out.progressEvents).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live.each(["table", "csv", "env"] as const)( + "rejects -o %s without reading or changing notebooks", + (goOutput) => { + const { layer, http, cache } = setup(command, { goOutput }); + return Effect.gen(function* () { + expect(Exit.isFailure(yield* run(command).pipe(Effect.exit))).toBe(true); + expect(http.requests).toEqual([]); + expect(cache.cacheCount).toBe(0); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live.each([undefined, "", "cursor-a", "cycle"])( + "fails closed for a missing or cyclic pagination cursor (%s)", + (cursor) => { + const next = (value: string | undefined) => + value === undefined ? notebooksRoute() : `${notebooksRoute()}?page[after]=${value}`; + const cursors = + cursor === "cycle" + ? ["cursor-a", "cursor-b", "cursor-a"] + : cursor === "cursor-a" + ? [cursor, cursor] + : [cursor]; + const { layer, http, out } = setup(command, { + routes: { + [`GET ${notebooksRoute()}`]: cursors.map((value) => ({ + status: 200, + body: notebookListPage({ notebooks: [], next: next(value) }), + })), + }, + }); + return Effect.gen(function* () { + expect(yield* run(command).pipe(Effect.flip)).toBeInstanceOf(NotebooksPaginationError); + expect(http.requests).toHaveLength(cursors.length); + expect(out.promptSelectCalls).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); +}); diff --git a/apps/cli/src/commands/notebooks/notebooks.output.ts b/apps/cli/src/commands/notebooks/notebooks.output.ts new file mode 100644 index 0000000000..69cdda975e --- /dev/null +++ b/apps/cli/src/commands/notebooks/notebooks.output.ts @@ -0,0 +1,92 @@ +import { Effect, Option } from "effect"; +import { OutputFlag } from "../../command-internal/global-flags.ts"; +import { encodeGoJson, encodeToml, encodeYaml } from "../../command-internal/go-output.encoders.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import { NotebooksEnvNotSupportedError } from "./notebooks.errors.ts"; + +/** + * The `-o`/`--output` policy for notebooks payloads: which formats answer with + * a payload, how that payload is encoded, and an up-front refusal of `-o env`. + * + * Knowingly duplicated from `commands/experimental/compute/compute.output.ts`, + * which carries the same policy for the compute family. None of it is + * notebooks-specific, so the honest shape is one `command-internal` helper + * parameterized by each family's env-not-supported error. + * + * It is kept as a copy so adding `notebooks` does not edit a command family + * that already ships — the two copies are independent, and a bug introduced + * here cannot reach `compute`. Fold them together when a third family wants the + * same policy, at which point the shared version can be reviewed on its own + * rather than inside a feature PR. + * + * Until then the copies are expected to agree: a change to the allowlist or to + * the encoding here almost certainly belongs in `compute.output.ts` too. + */ + +/** + * Which `-o` values these commands answer with a payload. + * + * An allowlist, not a denylist, so an unrecognized future `-o` value falls + * through to text instead of silently serializing as TOML. `env` is included + * so it reaches the refusal below rather than being treated as unrecognized. + */ +const PAYLOAD_FORMATS = new Set(["json", "yaml", "toml", "env"]); + +function emitsPayloadFor(goFormat: string | undefined): boolean { + return goFormat !== undefined && PAYLOAD_FORMATS.has(goFormat); +} + +export const emitNotebooksMachineOutput = Effect.fnUntraced(function* ( + payload: Record, +) { + const output = yield* Output; + const goFormat = Option.getOrUndefined(yield* OutputFlag); + + if (!emitsPayloadFor(goFormat)) { + return false; + } + + if (goFormat === "env") { + // Unreachable when the command called `rejectNotebooksEnvOutput` first, + // which is where the refusal belongs; here as the backstop that stops a new + // command silently emitting TOML for `-o env`. + return yield* new NotebooksEnvNotSupportedError({ + message: "--output env flag is not supported", + }); + } + + if (goFormat === "json") { + yield* output.raw(encodeGoJson(payload)); + return true; + } + if (goFormat === "yaml") { + yield* output.raw(encodeYaml(payload)); + return true; + } + yield* output.raw(encodeToml(payload)); + return true; +}); + +/** + * Whether a machine-readable stdout was requested via `-o`. Callers that emit + * human lines *before* their payload need this: the `-o` branch runs at the end, + * by which point those lines would already be on stdout. + */ +export const notebooksMachineOutputRequested = Effect.fnUntraced(function* () { + return emitsPayloadFor(Option.getOrUndefined(yield* OutputFlag)); +}); + +/** + * Refuses `-o env` before the command does anything. + * + * Every notebooks payload has structure a flat `KEY=value` list cannot hold. + * Refused up front rather than at emit time, since for `push` that would mean + * failing only after the remote project has already changed. + */ +export const rejectNotebooksEnvOutput = Effect.fnUntraced(function* () { + if (Option.getOrUndefined(yield* OutputFlag) === "env") { + return yield* new NotebooksEnvNotSupportedError({ + message: "--output env flag is not supported", + }); + } +}); diff --git a/apps/cli/src/commands/notebooks/notebooks.shared.ts b/apps/cli/src/commands/notebooks/notebooks.shared.ts new file mode 100644 index 0000000000..ee39d13d5e --- /dev/null +++ b/apps/cli/src/commands/notebooks/notebooks.shared.ts @@ -0,0 +1,499 @@ +import { join } from "node:path"; +import type { ApiClient } from "@supabase/api/effect"; +import { V2GetNotebookInput, V2UpdateNotebookInput } from "@supabase/api/effect"; +import { Effect, Exit, FileSystem, Predicate, Schema } from "effect"; +import { Output } from "../../shared/output/output.service.ts"; +import { notebooksMachineOutputRequested } from "./notebooks.output.ts"; +import { sanitizeInlineName, mapHttpError } from "../../command-internal/http-errors.ts"; +import { + NotebookFileError, + NotebookIdError, + NotebookNameConflictError, + NotebooksNetworkError, + NotebooksPaginationError, + NotebooksUnexpectedStatusError, +} 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. + * + * 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 + * in a filename is unreadable, so the name is what a push matches on. Names are + * not unique in the API, so a project holding two notebooks of one name is + * refused rather than guessed at (`NotebookNameConflictError`). + */ + +/** `supabase/notebooks/`, alongside `supabase/functions/` and `supabase/workers/`. */ +export function notebooksDir(workdir: string): string { + return join(workdir, "supabase", "notebooks"); +} + +const notebookFileExtension = ".json"; + +/** + * `--project-ref` is a project identifier, not user content, so its value is + * logged verbatim — the same call `functions download` / `functions deploy` make + * for the same flag. + */ +export const notebooksProjectRefSafeFlags = ["project-ref"] as const; + +/** Page size for the list walk — the API's maximum, so the walk is one call in practice. */ +const NOTEBOOK_PAGE_SIZE = 100; + +const updateAttributes = V2UpdateNotebookInput.fields.data.fields.attributes.fields; +const notebookIdSchema = V2GetNotebookInput.fields.id; + +/** + * A notebook file is the notebook's API attributes minus the ones the file + * cannot own: `name` is the file name, and the server owns the timestamps and + * the `owner` / `updated_by` identities. Deriving the fields from the generated + * update input keeps the cell union in one place — the file format changes when + * the API's does, with no schema here to fall behind it. + * + * `content` is required: a notebook file without cells is not a notebook. Cell + * `id`s are written back out on pull and echoed on push, which is how a cell + * keeps its identity across an update instead of being replaced by a copy. + */ +const NotebookFileSchema = Schema.Struct({ + description: updateAttributes.description, + favorite: updateAttributes.favorite, + content: Schema.requiredKey(updateAttributes.content), +}); + +export type NotebookFile = typeof NotebookFileSchema.Type; + +/** A project notebook as the list route describes it — no cells. */ +export interface RemoteNotebook { + readonly id: string; + readonly name: string; +} + +/** + * Whether a notebook name can be a file name in `supabase/notebooks/`. A + * notebook name is free text up to 255 characters, so it can hold a path + * separator, and joining that onto the notebooks directory would write outside + * it. Pull reports the ones it skipped rather than sanitising them into a name + * that would then push back as a rename. + */ +export function isNotebookNameWritable(name: string): boolean { + if (name.length === 0 || name === "." || name === ".." || /[. ]$/.test(name)) return false; + // Keep committed filenames portable, including Windows device names with extensions. + if (/^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(name)) return false; + if (new TextEncoder().encode(`${name}${notebookFileExtension}`).length > 255) return false; + return !name.split("").some((char) => { + const code = char.charCodeAt(0); + return '<>:"/\\|?*'.includes(char) || code < 0x20 || code === 0x7f; + }); +} + +function notebookFilePath(workdir: string, name: string): string { + return join(notebooksDir(workdir), `${name}${notebookFileExtension}`); +} + +export const validateNotebookId = Effect.fnUntraced(function* (value: string) { + return yield* Schema.decodeUnknownEffect(notebookIdSchema)(value).pipe( + Effect.mapError( + () => + new NotebookIdError({ + detail: `${JSON.stringify(value)} is not a notebook id.`, + suggestion: "Pass the UUID shown in the notebook's dashboard URL.", + }), + ), + ); +}); + +const ensureNotebookNameWritable = Effect.fnUntraced(function* (name: string) { + if (!isNotebookNameWritable(name)) { + return yield* new NotebookFileError({ + detail: `The project notebook name ${JSON.stringify(name)} cannot be stored as a file name.`, + suggestion: "Rename the notebook in the dashboard, then run the command again.", + }); + } + return name; +}); + +/** + * The notebook names `supabase/notebooks/` holds, sorted, so both commands walk + * them in a stable order rather than whatever the filesystem returned. A + * missing directory is an empty project, not a failure — `pull` creates it. + */ +const readNotebookDirectory = Effect.fnUntraced(function* (workdir: string) { + const fs = yield* FileSystem.FileSystem; + const dir = notebooksDir(workdir); + const entries = yield* fs.readDirectory(dir).pipe( + Effect.catchTag("PlatformError", (cause) => + Predicate.isTagged(cause.reason, "NotFound") + ? Effect.succeed>([]) + : Effect.fail( + new NotebookFileError({ + detail: `Cannot read ${dir}: ${cause.message}`, + suggestion: "Check the notebooks path is a readable directory.", + }), + ), + ), + ); + + return entries; +}); + +export const listLocalNotebooks = Effect.fnUntraced(function* (workdir: string) { + return (yield* readNotebookDirectory(workdir)) + .filter((entry) => entry.endsWith(notebookFileExtension)) + .map((entry) => entry.slice(0, -notebookFileExtension.length)) + .filter((name) => isNotebookNameWritable(name)) + .sort(); +}); + +/** Reject aliases on every platform so a checkout can safely move between filesystems. */ +export const ensureNotebookDestinationsUnique = Effect.fnUntraced(function* ( + workdir: string, + names: ReadonlyArray, +) { + const key = (name: string) => name.normalize("NFC").toLowerCase(); + const destinations = new Map(); + const existing = yield* readNotebookDirectory(workdir); + for (const name of names) { + yield* ensureNotebookNameWritable(name); + const filename = `${name}${notebookFileExtension}`; + const normalized = key(filename); + const collision = + destinations.get(normalized) ?? + existing.find((entry) => key(entry) === normalized && entry !== filename); + if (collision !== undefined) { + return yield* new NotebookNameConflictError({ + detail: `${JSON.stringify(filename)} and ${JSON.stringify(collision)} refer to the same portable filename.`, + suggestion: "Rename the conflicting notebooks or local files before pulling them.", + }); + } + destinations.set(normalized, filename); + } +}); + +export const readNotebookFile = Effect.fnUntraced(function* (workdir: string, name: string) { + const fs = yield* FileSystem.FileSystem; + const path = notebookFilePath(workdir, name); + + const contents = yield* fs.readFileString(path).pipe( + Effect.catch( + (cause) => + new NotebookFileError({ + detail: `Cannot read ${path}: ${cause.message}`, + suggestion: "Check the file exists and is readable.", + }), + ), + ); + + const parsed = yield* Effect.try({ + try: (): unknown => JSON.parse(contents), + catch: (cause) => + new NotebookFileError({ + detail: `${path} is not valid JSON: ${String(cause)}`, + suggestion: + "Run supabase notebooks pull to replace it with the project's copy.", + }), + }); + + return yield* Schema.decodeUnknownEffect(NotebookFileSchema)(parsed).pipe( + Effect.catch( + (cause) => + new NotebookFileError({ + detail: `${path} is not a notebook: ${cause.message}`, + suggestion: + "Run supabase notebooks pull to replace it with the project's copy.", + }), + ), + ); +}); + +/** + * Writes one notebook file, creating `supabase/notebooks/` if this is the first. + * Trailing newline and two-space indent so a pulled notebook is a normal + * committed JSON file rather than one line the next diff cannot be read. + */ +export const writeNotebookFile = Effect.fnUntraced(function* ( + workdir: string, + name: string, + notebook: NotebookFile, + mode: "create" | "replace" = "create", +) { + const fs = yield* FileSystem.FileSystem; + yield* ensureNotebookNameWritable(name); + const dir = notebooksDir(workdir); + const path = notebookFilePath(workdir, name); + + yield* Effect.gen(function* () { + yield* fs.makeDirectory(dir, { recursive: true }); + const temporaryPath = yield* fs.makeTempFileScoped({ directory: dir, prefix: ".notebook-" }); + yield* fs.writeFileString(temporaryPath, `${JSON.stringify(notebook, null, 2)}\n`); + if (mode === "replace") { + yield* fs.rename(temporaryPath, path); + } else { + // A hard link publishes the complete file atomically and refuses existing destinations. + yield* fs.link(temporaryPath, path); + } + }).pipe( + Effect.scoped, + Effect.catchTag( + "PlatformError", + (cause) => + new NotebookFileError({ + detail: `Cannot write ${path}: ${cause.message}`, + suggestion: "Check the notebooks directory is writable.", + }), + ), + ); +}); + +export const removeNotebookFile = Effect.fnUntraced(function* (workdir: string, name: string) { + const fs = yield* FileSystem.FileSystem; + const path = notebookFilePath(workdir, name); + yield* fs.remove(path).pipe( + Effect.mapError( + (cause) => + new NotebookFileError({ + detail: `Cannot remove ${path}: ${cause.message}`, + suggestion: "Check the notebook file and directory permissions.", + }), + ), + ); +}); + +const mapNotebookHttpError = (subject: string) => { + const label = sanitizeInlineName(subject); + return mapHttpError({ + networkError: NotebooksNetworkError, + statusError: NotebooksUnexpectedStatusError, + networkMessage: (cause) => `failed to ${label}: ${cause}`, + statusMessage: (status, body) => `unexpected ${label} status ${status}: ${body}`, + }); +}; + +const withNotebookTask = + (subject: string) => + (self: Effect.Effect) => + Effect.gen(function* () { + const output = yield* Output; + if (output.format !== "text" || (yield* notebooksMachineOutputRequested())) + return yield* self; + return yield* Effect.acquireUseRelease( + output.task(`${sanitizeInlineName(subject)}...`), + () => self, + (task, exit) => (Exit.isFailure(exit) ? task.fail() : task.clear()), + ); + }); + +/** + * Every notebook in the project. The list route is cursor-paginated and its + * `links.next` carries the cursor for the following page, so the walk follows + * that rather than counting rows: `next` is null exactly when the page was the + * last one, which a length comparison cannot tell on an exact multiple. + */ +export const listRemoteNotebooks = Effect.fnUntraced(function* (api: ApiClient, ref: string) { + const notebooks: Array = []; + let after: string | undefined; + const visited = new Set(); + + for (;;) { + const page = yield* api.v2 + .listNotebooks({ + ref, + page: { size: NOTEBOOK_PAGE_SIZE, ...(after === undefined ? {} : { after }) }, + sort: "name", + }) + .pipe( + Effect.catch(mapNotebookHttpError("list notebooks")), + withNotebookTask("Listing notebooks"), + ); + + for (const resource of page.data) { + notebooks.push({ id: resource.id, name: resource.attributes.name }); + } + + const next = page.links.next; + if (next === null) { + return notebooks; + } + // The cursor is opaque, so it is read back out of the link the server built + // rather than derived from the rows. `links.next` is a path, so its query is + // taken from the string directly rather than through a URL and a made-up base. + const cursor = new URLSearchParams(next.split("?")[1] ?? "").get("page[after]"); + // A link with no cursor, or one repeating the cursor already walked, would + // loop forever. Neither is a page this command can make progress on. + if (cursor === null || cursor.length === 0 || visited.has(cursor)) { + return yield* new NotebooksPaginationError({ + message: + "The notebook list returned a missing or repeated pagination cursor. No reconciliation was performed.", + }); + } + visited.add(cursor); + after = cursor; + } +}); + +export const ensureRemoteNotebookNamesUnique = Effect.fnUntraced(function* ( + remote: ReadonlyArray, +) { + const counts = new Map(); + for (const notebook of remote) { + counts.set(notebook.name, (counts.get(notebook.name) ?? 0) + 1); + } + for (const [name, count] of counts) { + if (count > 1) { + return yield* new NotebookNameConflictError({ + detail: `The project has ${count} notebooks named ${JSON.stringify(name)}.`, + suggestion: + "Rename them in the dashboard so each notebook has its own name, then run the command again.", + }); + } + } +}); + +const readRemoteNotebook = Effect.fnUntraced(function* ( + api: ApiClient, + ref: string, + id: string, + subject: string, +) { + const response = yield* api.v2 + .getNotebook({ ref, id }) + .pipe( + Effect.catch(mapNotebookHttpError(`read notebook ${subject}`)), + withNotebookTask(`Downloading notebook ${subject}`), + ); + + const { description, favorite, content, name } = response.data.attributes; + return { + notebook: { id: response.data.id, name } satisfies RemoteNotebook, + file: { + ...(description === null ? {} : { description }), + favorite, + content: { cells: content.cells }, + } satisfies NotebookFile, + }; +}); + +/** Reads one project notebook and returns it in the shape a notebook file holds. */ +export const downloadNotebook = Effect.fnUntraced(function* ( + api: ApiClient, + ref: string, + notebook: RemoteNotebook, +) { + // `schema_version` is dropped: the server owns it, and the update route's + // `content` does not accept it back. A null `description` becomes an absent + // key rather than `"description": null` — the update route takes a string or + // nothing, so a written null would not push back. + return (yield* readRemoteNotebook(api, ref, notebook.id, notebook.name)).file; +}); + +export const downloadNotebookById = Effect.fnUntraced(function* ( + api: ApiClient, + ref: string, + id: string, +) { + return yield* readRemoteNotebook(api, ref, id, id); +}); + +/** + * Writes one notebook file to the project — updating the notebook of that name + * when there is one, and creating it otherwise. Returns which of the two + * happened so the caller can report it. + */ +export const uploadNotebook = Effect.fnUntraced(function* (options: { + readonly api: ApiClient; + readonly ref: string; + readonly name: string; + readonly file: NotebookFile; + readonly existing: RemoteNotebook | undefined; +}) { + // Keys the file left 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. + const data = { + type: "notebook" as const, + attributes: { + name: options.name, + ...(options.file.description === undefined ? {} : { description: options.file.description }), + ...(options.file.favorite === undefined ? {} : { favorite: options.file.favorite }), + content: options.file.content, + }, + }; + + if (options.existing === undefined) { + yield* options.api.v2 + .createNotebook({ ref: options.ref, data }) + .pipe( + Effect.catch(mapNotebookHttpError(`create notebook ${options.name}`)), + withNotebookTask(`Creating notebook ${options.name}`), + ); + return "created" as const; + } + + yield* options.api.v2 + .updateNotebook({ ref: options.ref, id: options.existing.id, data }) + .pipe( + Effect.catch(mapNotebookHttpError(`update notebook ${options.name}`)), + withNotebookTask(`Updating notebook ${options.name}`), + ); + return "updated" as const; +}); + +/** What to do about the notebooks only one of the two sides has. */ +type NotebooksReconcileChoice = "keep" | "delete" | "copy"; + +/** + * Asks what should happen to the notebooks the other side does not have. + * + * Three answers rather than a confirmation, because the two useful ones point + * opposite ways: a notebook missing from a checkout is either one somebody + * deleted and the project has not caught up with, or one somebody added on the + * other side that the checkout has not caught up with. Nothing in either list + * says which, so the command asks instead of picking. + * + * `keep` is the answer whenever there is nobody to ask — a non-TTY, a machine + * output format, or a cancelled prompt. The other answers mutate local or remote + * state, so the unattended run reports the divergence and leaves both sides alone rather + * than resolving it in a direction nobody chose. + */ +export const promptNotebooksReconcile = Effect.fnUntraced(function* (options: { + readonly summary: string; + readonly names: ReadonlyArray; + readonly deleteLabel: string; + readonly copyLabel: string; + /** `-o json|yaml|toml|env` — stdout belongs to the payload, so do not prompt. */ + readonly machineOutput: boolean; +}) { + const output = yield* Output; + + const listing = `${options.summary}\n${options.names.map((name) => ` • ${sanitizeInlineName(name)}`).join("\n")}\n`; + yield* output.raw(listing, "stderr"); + + if (output.format !== "text" || !output.interactive || options.machineOutput) { + yield* output.raw("Left alone — rerun interactively to resolve.\n", "stderr"); + return "keep"; + } + + const answer = yield* output + .promptSelect( + "What should happen to them?", + [ + { value: "keep", label: "Leave them alone" }, + { value: "copy", label: options.copyLabel }, + { value: "delete", label: options.deleteLabel }, + ], + // The listing above is on stderr, and clack renders to stdout by default, + // which would split one question across two streams. + { stream: "stderr" }, + ) + .pipe(Effect.orElseSucceed(() => "keep")); + + // `promptSelect` hands back a bare string, so the three answers are narrowed + // rather than trusted — an unrecognised one leaves both sides alone. + return answer === "copy" || answer === "delete" + ? answer + : ("keep" satisfies NotebooksReconcileChoice); +}); diff --git a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md new file mode 100644 index 0000000000..7b5d249675 --- /dev/null +++ b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md @@ -0,0 +1,119 @@ +# `supabase notebooks pull [Notebook id]` + +Writes the linked project's notebooks into `supabase/notebooks/.json`, one +file per notebook. A notebook file holds the notebook's API attributes minus the +ones a file cannot own: `name` is carried by the file name, and the server owns +the timestamps, the `owner` / `updated_by` identities, and `content.schema_version`. + +## Files Read + +| Path | Format | When | +| ------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/notebooks/` | dir | when no notebook id is given — to preserve existing notebooks and find local-only notebooks | +| `/supabase/notebooks/.json` | JSON | only for a local-only notebook the user chose to create in the project | +| `/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 | + +## Files Written + +| Path | Format | When | +| ---------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------- | +| `/supabase/notebooks/` | dir | created if absent, before the first notebook is written | +| `/supabase/notebooks/.notebook-/` | JSON | scoped temporary file; linked for creation or renamed for explicit replacement, then cleaned | +| `/supabase/notebooks/.json` | JSON | only when absent during a broad pull, or replaced when its notebook id was explicitly given | +| `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | + +Files are **removed** only when the user picks the delete answer at the +reconciliation prompt: a local notebook naming no project notebook is deleted +from `supabase/notebooks/` and nowhere else. + +A project notebook whose name cannot be a file name — one holding `/`, `\`, a +control character, or the name `.` / `..` — is skipped and counted on stderr, +not sanitised: a sanitised name would push back as a rename of somebody's +notebook. Nothing outside `supabase/notebooks/` is ever written. + +## Reconciliation + +With no notebook id given, a project notebook whose file already exists locally +is left unchanged. Only notebooks missing from `supabase/notebooks/` are +downloaded. A local notebook that names no project notebook is either one +somebody deleted in the dashboard or one somebody added locally and never +pushed, and neither list says which. So the command lists them and asks, with +three answers: leave them alone, create them in the project, or delete the local +files. + +`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 id, that notebook is fetched directly and its returned name +selects the destination file. An existing file of that name is replaced, and +nothing else is reconciled. + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------ | ------------------------------- | --------------------------------------------------------------------- | +| GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` — broad pull only | +| GET | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | `data.{id,attributes.{name,description,favorite,content}}` | +| POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` from the file | — (only for the create answer) | + +The list route is walked by following `links.next` until it is null, rather than +by counting rows: a short page cannot be told from an exact multiple of the page +size. + +## Exit Codes + +| Code | Condition | +| ---- | -------------------------------------------------------------------------------- | +| `0` | success | +| `1` | no project ref — not linked and no `--project-ref` | +| `1` | the supplied notebook id is not a UUID | +| `1` | the project holds two notebooks of one name during a broad pull | +| `1` | the notebooks path exists but cannot be read as a directory | +| `1` | an explicitly requested notebook name cannot be stored safely as a local file | +| `1` | a Management API call failed (transport, unexpected status, or undecodable body) | +| `1` | a local file the user chose to create in the project is not a readable notebook | +| `1` | a local notebook file cannot be written | +| `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 download` makes for the same flag. The notebook id +argument is not recorded. + +## 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/pull/pull.command.ts b/apps/cli/src/commands/notebooks/pull/pull.command.ts new file mode 100644 index 0000000000..90accb45cd --- /dev/null +++ b/apps/cli/src/commands/notebooks/pull/pull.command.ts @@ -0,0 +1,49 @@ +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 { notebooksPull } from "./pull.handler.ts"; + +const config = { + notebookId: Argument.string("Notebook id").pipe( + Argument.withDescription( + "UUID of the notebook to replace locally. Pulls only locally missing notebooks if omitted.", + ), + Argument.optional, + ), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type NotebooksPullFlags = 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 notebooksPullHandler = (flags: NotebooksPullFlags) => + notebooksPull(flags).pipe( + withCommandTelemetry({ flags, safeFlags: notebooksProjectRefSafeFlags }), + withJsonErrorHandling, + ); + +export const notebooksPullCommand = Command.make("pull", config).pipe( + Command.withDescription( + "Write the linked Supabase project's notebooks into supabase/notebooks. Without a notebook id, existing local files are preserved and only missing notebooks are written.", + ), + Command.withShortDescription("Pull notebooks from Supabase"), + Command.withExamples([ + { + command: "supabase notebooks pull", + description: "Pull every notebook from the linked project", + }, + { + command: "supabase notebooks pull 44444444-4444-4444-8444-444444444444", + description: "Replace the local copy of one notebook by id", + }, + ]), + Command.withHandler(notebooksPullHandler), + Command.provide(managementApiRuntimeLayer(["notebooks", "pull"])), +); diff --git a/apps/cli/src/commands/notebooks/pull/pull.handler.ts b/apps/cli/src/commands/notebooks/pull/pull.handler.ts new file mode 100644 index 0000000000..b1153913c7 --- /dev/null +++ b/apps/cli/src/commands/notebooks/pull/pull.handler.ts @@ -0,0 +1,174 @@ +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 { + emitNotebooksMachineOutput, + notebooksMachineOutputRequested, + rejectNotebooksEnvOutput, +} from "../notebooks.output.ts"; +import { + isNotebookNameWritable, + downloadNotebook, + downloadNotebookById, + ensureNotebookDestinationsUnique, + ensureRemoteNotebookNamesUnique, + listLocalNotebooks, + listRemoteNotebooks, + notebooksDir, + promptNotebooksReconcile, + readNotebookFile, + removeNotebookFile, + uploadNotebook, + validateNotebookId, + writeNotebookFile, +} from "../notebooks.shared.ts"; +import type { NotebooksPullFlags } from "./pull.command.ts"; + +/** + * `supabase notebooks pull [id]` — write the project's notebooks into + * `supabase/notebooks/`. + * + * The checkout is the source: a whole-directory pull only writes notebooks that + * are not present locally. An explicit notebook id is the opt-in overwrite path. + * What is left over — a local file naming no project notebook — is the divergence + * this command asks about, because the file is either one somebody deleted in the + * dashboard or one somebody added locally and never pushed. + * + * Given an id, only that notebook is pulled and nothing is reconciled: the + * argument says which notebook to replace, so the rest of the directory is not + * this invocation's business. + */ +export const notebooksPull = Effect.fn("notebooks.pull")(function* (flags: NotebooksPullFlags) { + 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: `-o env` cannot hold the payload, + // and failing at emit time would mean failing after the files are written. + yield* rejectNotebooksEnvOutput(); + const machineOutput = yield* notebooksMachineOutputRequested(); + + const ref = yield* resolver.resolve(flags.projectRef); + + yield* Effect.gen(function* () { + const pulled: Array = []; + const preserved: Array = []; + let skipped = 0; + let localOnly: Array = []; + const requestedId = Option.getOrUndefined(flags.notebookId); + + if (requestedId !== undefined) { + const id = yield* validateNotebookId(requestedId); + const { notebook, file } = yield* downloadNotebookById(api, ref, id); + yield* ensureNotebookDestinationsUnique(workdir, [notebook.name]); + yield* writeNotebookFile(workdir, notebook.name, file, "replace"); + pulled.push(notebook.name); + } else { + const remote = yield* listRemoteNotebooks(api, ref); + yield* ensureRemoteNotebookNamesUnique(remote); + const local = yield* listLocalNotebooks(workdir); + const localNames = new Set(local); + + // Names that cannot be file names are reported rather than sanitised: a + // sanitised name would push back as a rename of somebody's notebook. + const writable = remote.filter((notebook) => isNotebookNameWritable(notebook.name)); + skipped = remote.length - writable.length; + yield* ensureNotebookDestinationsUnique( + workdir, + writable.map((notebook) => notebook.name), + ); + + for (const notebook of writable) { + if (localNames.has(notebook.name)) { + preserved.push(notebook.name); + continue; + } + const file = yield* downloadNotebook(api, ref, notebook); + yield* writeNotebookFile(workdir, notebook.name, file); + pulled.push(notebook.name); + } + + localOnly = local.filter((name) => !remote.some((notebook) => notebook.name === name)); + } + + let deleted: Array = []; + let pushed: Array = []; + if (localOnly.length > 0) { + const choice = yield* promptNotebooksReconcile({ + summary: `${localOnly.length} local notebook(s) are not in the project:`, + names: localOnly, + copyLabel: "Create them in the project", + deleteLabel: `Delete them from ${notebooksDir(workdir)}`, + machineOutput, + }); + + if (choice === "delete") { + for (const name of localOnly) { + yield* removeNotebookFile(workdir, name); + } + deleted = [...localOnly]; + } + if (choice === "copy") { + const files = yield* Effect.forEach(localOnly, (name) => + readNotebookFile(workdir, name).pipe(Effect.map((file) => ({ name, file }))), + ); + for (const { name, file } of files) { + yield* uploadNotebook({ api, ref, name, file, existing: undefined }); + } + pushed = [...localOnly]; + } + } + + const payload = { + project_ref: ref, + notebooks_dir: notebooksDir(workdir), + pulled, + preserved_locally: preserved, + created: pushed, + deleted_locally: deleted, + skipped, + }; + + if (yield* emitNotebooksMachineOutput(payload)) { + return; + } + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + yield* output.raw( + pulled.length === 0 + ? "No notebooks to pull.\n" + : `Pulled ${pulled.length} notebook(s) into ${notebooksDir(workdir)}\n`, + ); + if (skipped > 0) { + yield* output.raw( + `Skipped ${skipped} notebook(s) whose name cannot be a file name.\n`, + "stderr", + ); + } + if (preserved.length > 0) { + yield* output.raw(`Kept ${preserved.length} existing local notebook(s) unchanged.\n`); + } + if (deleted.length > 0) { + yield* output.raw(`Deleted ${deleted.length} local notebook(s).\n`); + } + if (pushed.length > 0) { + yield* output.raw(`Created ${pushed.length} notebook(s) in the project.\n`); + } + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + }).pipe(Effect.ensuring(telemetryState.flush)); +}); diff --git a/apps/cli/src/commands/notebooks/pull/pull.integration.test.ts b/apps/cli/src/commands/notebooks/pull/pull.integration.test.ts new file mode 100644 index 0000000000..f61c21527e --- /dev/null +++ b/apps/cli/src/commands/notebooks/pull/pull.integration.test.ts @@ -0,0 +1,321 @@ +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 { NotebookIdError, NotebookNameConflictError } from "../notebooks.errors.ts"; +import { notebooksPullHandler as notebooksPull } from "./pull.command.ts"; +import type { NotebooksPullFlags } from "./pull.command.ts"; + +const SALES_ID = "44444444-4444-4444-8444-444444444444"; +const ERRORS_ID = "55555555-5555-4555-8555-555555555555"; + +function flags(overrides: Partial = {}): NotebooksPullFlags { + return { notebookId: 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 pull", () => { + it.live("writes missing project notebooks without replacing local notebooks", () => { + const localSales = '{"content":{"cells":[{"type":"markdown","text":"# Local"}]}}'; + const repo = project({ "supabase/notebooks/sales-dashboard.json": localSales }); + const { layer, http, out } = setupNotebooks({ + workdir: repo.dir, + routes: { + [`GET ${notebooksRoute()}`]: { + status: 200, + body: notebookListPage({ + notebooks: [ + { id: SALES_ID, name: "sales-dashboard" }, + { id: ERRORS_ID, name: "error-rates" }, + ], + }), + }, + [`GET ${notebooksRoute(`/${ERRORS_ID}`)}`]: { + status: 200, + body: { + data: notebookResource({ + id: ERRORS_ID, + name: "error-rates", + cells: [{ id: "cell-1", type: "database", sql: "select 1", row_limit: 100 }], + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* notebooksPull(flags()); + + expect(repo.read("sales-dashboard")).toBe(localSales); + // A newly downloaded file is the notebook's attributes minus its name and + // the server-owned `schema_version`. + expect(JSON.parse(repo.read("error-rates"))).toEqual({ + favorite: false, + content: { cells: [{ id: "cell-1", type: "database", sql: "select 1", row_limit: 100 }] }, + }); + expect(http.routeKeys).toEqual([ + `GET ${notebooksRoute()}`, + `GET ${notebooksRoute(`/${ERRORS_ID}`)}`, + ]); + expect(out.stdoutText).toContain("Pulled 1 notebook(s)"); + expect(out.stdoutText).toContain("Kept 1 existing local notebook(s) unchanged."); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("replaces a single local notebook by id without listing or reconciling", () => { + const repo = project({ + "supabase/notebooks/sales-dashboard.json": + '{"content":{"cells":[{"type":"markdown","text":"# Local"}]}}', + "supabase/notebooks/leftover.json": '{"content":{"cells":[]}}', + }); + const { layer, http } = setupNotebooks({ + workdir: repo.dir, + routes: { + [`GET ${notebooksRoute(`/${SALES_ID}`)}`]: { + status: 200, + body: { data: notebookResource({ id: SALES_ID, name: "sales-dashboard" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* notebooksPull(flags({ notebookId: Option.some(SALES_ID) })); + + expect(JSON.parse(repo.read("sales-dashboard"))).toEqual({ + favorite: false, + content: { cells: [{ id: "cell-1", type: "markdown", text: "# Hello" }] }, + }); + expect(repo.exists("leftover")).toBe(true); + expect(http.routeKeys).toEqual([`GET ${notebooksRoute(`/${SALES_ID}`)}`]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("rejects a non-UUID notebook id before calling the API", () => { + const repo = project(); + const { layer, http } = setupNotebooks({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* notebooksPull(flags({ notebookId: Option.some("nope") })).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(NotebookIdError); + expect(http.requests).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("deletes the local notebooks the project does not have when asked to", () => { + const repo = project({ "supabase/notebooks/gone.json": '{"content":{"cells":[]}}' }); + const { layer, out } = setupNotebooks({ + workdir: repo.dir, + promptSelectResponses: ["delete"], + routes: { + [`GET ${notebooksRoute()}`]: { status: 200, body: notebookListPage({ notebooks: [] }) }, + }, + }); + + return Effect.gen(function* () { + yield* notebooksPull(flags()); + + expect(repo.exists("gone")).toBe(false); + expect(out.stderrText).toContain("1 local notebook(s) are not in the project:"); + expect(out.stderrText).toContain(" • gone"); + expect(out.promptSelectCalls.map((call) => call.message)).toEqual([ + "What should happen to them?", + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("creates the local notebooks in the project instead when asked to", () => { + const repo = project({ + "supabase/notebooks/new-one.json": + '{"content":{"cells":[{"type":"markdown","text":"# New"}]}}', + }); + const { layer, http } = setupNotebooks({ + workdir: repo.dir, + promptSelectResponses: ["copy"], + routes: { + [`GET ${notebooksRoute()}`]: { status: 200, body: notebookListPage({ notebooks: [] }) }, + [`POST ${notebooksRoute()}`]: { + status: 201, + body: { data: notebookResource({ id: SALES_ID, name: "new-one" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* notebooksPull(flags()); + + expect(repo.exists("new-one")).toBe(true); + const created = http.requests.find((request) => request.method === "POST"); + // The name goes up from the file name, and the cells go up as written. + expect(JSON.parse(created?.body ?? "{}")).toEqual({ + data: { + type: "notebook", + attributes: { + name: "new-one", + content: { cells: [{ type: "markdown", text: "# New" }] }, + }, + }, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Both other answers delete something, so an unattended run reports the + // divergence and resolves nothing. + it.live("leaves both sides alone when there is nobody to ask", () => { + const repo = project({ "supabase/notebooks/gone.json": '{"content":{"cells":[]}}' }); + const { layer, out } = setupNotebooks({ + workdir: repo.dir, + format: "json", + routes: { + [`GET ${notebooksRoute()}`]: { status: 200, body: notebookListPage({ notebooks: [] }) }, + }, + }); + + return Effect.gen(function* () { + yield* notebooksPull(flags()); + + expect(repo.exists("gone")).toBe(true); + expect(out.promptSelectCalls).toHaveLength(0); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: expect.objectContaining({ pulled: [], deleted_locally: [], created: [] }), + }), + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // `page[after]` has to reach the wire as its own query parameter, or the + // second page is the first one again and the walk never ends. + it.live("follows the cursor the list route hands back", () => { + const repo = project(); + const { layer, http } = setupNotebooks({ + workdir: repo.dir, + routes: { + [`GET ${notebooksRoute()}`]: [ + { + status: 200, + body: notebookListPage({ + notebooks: [{ id: SALES_ID, name: "sales-dashboard" }], + next: `${notebooksRoute()}?page[size]=100&page[after]=cursor-1`, + }), + }, + { + status: 200, + body: notebookListPage({ notebooks: [{ id: ERRORS_ID, name: "error-rates" }] }), + }, + ], + [`GET ${notebooksRoute(`/${SALES_ID}`)}`]: { + status: 200, + body: { data: notebookResource({ id: SALES_ID, name: "sales-dashboard" }) }, + }, + [`GET ${notebooksRoute(`/${ERRORS_ID}`)}`]: { + status: 200, + body: { data: notebookResource({ id: ERRORS_ID, name: "error-rates" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* notebooksPull(flags()); + + expect(repo.exists("sales-dashboard")).toBe(true); + expect(repo.exists("error-rates")).toBe(true); + const listCalls = http.requests.filter( + (request) => new URL(request.url).pathname === notebooksRoute(), + ); + expect(listCalls).toHaveLength(2); + // `page` is a `style: deepObject` parameter, so it has to arrive expanded + // rather than as one JSON blob. + expect(listCalls[0]!.query.get("page[size]")).toBe("100"); + expect(listCalls[0]!.query.get("page[after]")).toBeNull(); + expect(listCalls[1]!.query.get("page[after]")).toBe("cursor-1"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("skips a notebook whose name cannot be a file name", () => { + const repo = project(); + const { layer, out } = setupNotebooks({ + workdir: repo.dir, + routes: { + [`GET ${notebooksRoute()}`]: { + status: 200, + body: notebookListPage({ notebooks: [{ id: SALES_ID, name: "reports/weekly" }] }), + }, + }, + }); + + return Effect.gen(function* () { + yield* notebooksPull(flags()); + + expect(out.stderrText).toContain("Skipped 1 notebook(s)"); + // Nothing was read, so nothing could have been written outside the dir. + expect(out.stdoutText).toContain("No notebooks to pull."); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses duplicate remote names before writing either notebook", () => { + const repo = project(); + const { layer, http } = setupNotebooks({ + 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* notebooksPull(flags()).pipe(Effect.flip); + + expect(error).toBeInstanceOf(NotebookNameConflictError); + expect(repo.exists("sales-dashboard")).toBe(false); + 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({ + workdir: repo.dir, + goOutput: "json", + routes: { + [`GET ${notebooksRoute()}`]: { status: 200, body: notebookListPage({ notebooks: [] }) }, + }, + }); + + return Effect.gen(function* () { + yield* notebooksPull(flags()); + + expect(JSON.parse(out.stdoutText)).toEqual( + expect.objectContaining({ pulled: [], preserved_locally: [], skipped: 0 }), + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/docs/docs-spec.tables.ts b/apps/cli/src/docs/docs-spec.tables.ts index 3a6f32e421..760bf0c1ce 100644 --- a/apps/cli/src/docs/docs-spec.tables.ts +++ b/apps/cli/src/docs/docs-spec.tables.ts @@ -42,6 +42,7 @@ export const DOCS_TAGS: Readonly>> = { "supabase-migration": ["local-dev"], "supabase-network-bans": ["management-api"], "supabase-network-restrictions": ["management-api"], + "supabase-notebooks": ["management-api"], "supabase-orgs": ["management-api"], "supabase-postgres-config": ["management-api"], "supabase-projects": ["management-api"], diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index b03bd1f596..7c7e0d68ee 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -367,6 +367,13 @@ NoComputeToDeployError NoFunctionsToDeployError NonInteractiveError NotLoggedInError +NotebookFileError +NotebookIdError +NotebookNameConflictError +NotebooksEnvNotSupportedError +NotebooksNetworkError +NotebooksPaginationError +NotebooksUnexpectedStatusError OperationCanceledError OrgsCreateNetworkError OrgsCreateUnexpectedStatusError diff --git a/apps/cli/tests/helpers/notebooks.ts b/apps/cli/tests/helpers/notebooks.ts new file mode 100644 index 0000000000..c544fb4550 --- /dev/null +++ b/apps/cli/tests/helpers/notebooks.ts @@ -0,0 +1,288 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { makeApiClient } from "@supabase/api/effect"; +import { Effect, Layer, Option, Predicate, Stdio } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import type * as HttpClientError from "effect/unstable/http/HttpClientError"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as UrlParams from "effect/unstable/http/UrlParams"; +import { CommandPlatformApi } from "../../src/auth/command-platform-api.service.ts"; +import { ProjectRefNotLinkedError } from "../../src/config/project-ref.errors.ts"; +import { ProjectRefResolver } from "../../src/config/project-ref.service.ts"; +import { OutputFlag } from "../../src/command-internal/global-flags.ts"; +import { + mockLinkedProjectCacheTracked, + mockTelemetryStateTracked, + mockCommandSettings, + transportFailure, +} from "./command-mocks.ts"; +import { mockOutput, mockContextualAnalytics, mockProcessControl } from "./mocks.ts"; +import { commandRuntimeLayer } from "../../src/shared/runtime/command-runtime.layer.ts"; + +/** + * Shared scaffolding for the `supabase notebooks` command integration tests. + * + * Both commands read and write real files under `supabase/notebooks/`, so these + * tests run against a per-test temp project rather than a mocked filesystem — + * what ends up on disk and what goes up to the project is most of what is worth + * asserting. Only the network is faked. + */ + +export const NOTEBOOKS_PROJECT_REF = "abcdefghijklmnopqrst"; + +export interface RecordedRequest { + readonly method: string; + readonly url: string; + /** + * The query string, which `url` does not carry: an `HttpClientRequest` keeps + * its parameters separate until the transport merges them, and these tests + * intercept the request before that happens. + */ + readonly query: URLSearchParams; + /** The request body decoded as UTF-8. */ + readonly body: string; +} + +export interface StubResponse { + readonly status: number; + readonly body?: unknown; + readonly transportError?: string; +} + +/** How a test answers one request; sequential entries reply to repeated calls. */ +export type RouteHandler = StubResponse | ReadonlyArray; + +export interface NotebooksHttpRoutes { + /** Keyed `" "`, e.g. `"GET /v2/projects/abc.../notebooks"`. */ + readonly [route: string]: RouteHandler; +} + +function respond( + request: HttpClientRequest.HttpClientRequest, + stub: StubResponse, +): HttpClientResponse.HttpClientResponse { + const hasBody = stub.body !== undefined; + return HttpClientResponse.fromWeb( + request, + new Response(hasBody ? JSON.stringify(stub.body) : null, { + status: stub.status, + headers: hasBody ? { "content-type": "application/json" } : { "content-type": "text/plain" }, + }), + ); +} + +export function mockNotebooksHttp(routes: NotebooksHttpRoutes) { + const requests: Array = []; + const remaining = new Map>( + Object.entries(routes).map(([route, handler]) => [ + route, + "status" in handler ? [handler] : [...handler], + ]), + ); + + const handle = ( + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect => + Effect.gen(function* () { + const body = Predicate.isTagged(request.body, "Uint8Array") + ? new TextDecoder().decode(request.body.body) + : ""; + const url = new URL(request.url); + requests.push({ + method: request.method, + url: request.url, + query: new URLSearchParams(UrlParams.toString(request.urlParams)), + body, + }); + + const key = `${request.method} ${url.pathname}`; + const queue = remaining.get(key); + if (queue === undefined || queue.length === 0) { + return respond(request, { status: 599, body: { error: `unstubbed route: ${key}` } }); + } + // The last stub for a route keeps answering, so a repeated call does not + // have to be stubbed a fixed number of times. + const stub = queue.length === 1 ? queue[0]! : queue.shift()!; + if (stub.transportError !== undefined) + return yield* Effect.fail(transportFailure(request, stub.transportError)); + return respond(request, stub); + }); + + const httpClientLayer = Layer.succeed(HttpClient.HttpClient, HttpClient.make(handle)); + + const apiLayer = Layer.effect( + CommandPlatformApi, + makeApiClient( + { + baseUrl: "https://api.supabase.com", + accessToken: "test-token", + userAgent: "supabase", + }, + { retry: { maxRetries: 0 } }, + ), + ).pipe(Layer.provide(httpClientLayer)); + + return { + layer: Layer.mergeAll(apiLayer, httpClientLayer), + requests, + get routeKeys(): Array { + return requests.map((request) => `${request.method} ${new URL(request.url).pathname}`); + }, + }; +} + +export const notebooksRoute = (suffix = "") => + `/v2/projects/${NOTEBOOKS_PROJECT_REF}/notebooks${suffix}`; + +/** A notebook's metadata resource, as the list route's JSON:API envelope wraps it. */ +export function notebookMetadata(options: { readonly id: string; readonly name: string }) { + return { + type: "notebook", + id: options.id, + attributes: { + name: options.name, + description: null, + favorite: false, + inserted_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + owner: null, + updated_by: null, + }, + }; +} + +/** One page of the list route, with `next` null unless a cursor is given. */ +export function notebookListPage(options: { + readonly notebooks: ReadonlyArray<{ readonly id: string; readonly name: string }>; + readonly next?: string; +}) { + return { + data: options.notebooks.map(notebookMetadata), + links: { + first: null, + last: null, + prev: null, + next: options.next === undefined ? null : options.next, + }, + }; +} + +export type NotebookCell = Readonly>; + +/** A single notebook, cells included, as the read route returns it. */ +export function notebookResource(options: { + readonly id: string; + readonly name: string; + readonly cells?: ReadonlyArray; + readonly description?: string | null; + readonly favorite?: boolean; +}) { + const metadata = notebookMetadata(options); + return { + ...metadata, + attributes: { + ...metadata.attributes, + ...(options.description === undefined ? {} : { description: options.description }), + ...(options.favorite === undefined ? {} : { favorite: options.favorite }), + content: { + schema_version: 1, + cells: options.cells ?? [{ id: "cell-1", type: "markdown", text: "# Hello" }], + }, + }, + }; +} + +/** A per-test temp project, optionally pre-seeded with files. */ +export function makeNotebooksProject(files: Readonly> = {}): { + readonly dir: string; +} { + const dir = mkdtempSync(join(tmpdir(), "supabase-notebooks-")); + for (const [relativePath, contents] of Object.entries(files)) { + const absolutePath = join(dir, relativePath); + mkdirSync(dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, contents); + } + return { dir }; +} + +/** A complete resolver mock; only resolution is replaced, never the service type. */ +function testProjectRefLayer(linked: boolean) { + const optional = (flag: Option.Option) => + Option.orElse(flag, () => (linked ? Option.some(NOTEBOOKS_PROJECT_REF) : Option.none())); + const resolve = (flag: Option.Option) => + Option.match(optional(flag), { + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new ProjectRefNotLinkedError({ + message: "Cannot find project ref. Have you run supabase link?", + }), + ), + }); + return Layer.succeed(ProjectRefResolver, { + resolve, + resolveForLink: resolve, + resolveOptional: (flag) => Effect.succeed(optional(flag)), + loadProjectRef: resolve, + promptProjectRef: () => resolve(Option.none()), + }); +} + +export interface NotebooksSetupOptions { + readonly workdir: string; + readonly format?: "text" | "json" | "stream-json"; + readonly interactive?: boolean; + readonly linked?: boolean; + /** Answers the reconciliation prompt: `"keep"`, `"copy"` or `"delete"`. */ + readonly promptSelectResponses?: ReadonlyArray; + 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 args?: ReadonlyArray; +} + +export function setupNotebooks(options: NotebooksSetupOptions) { + const out = mockOutput({ + format: options.format ?? "text", + interactive: options.interactive ?? (options.format ?? "text") === "text", + ...(options.promptSelectResponses === undefined + ? {} + : { promptSelectResponses: options.promptSelectResponses }), + }); + const http = mockNotebooksHttp(options.routes ?? {}); + + const telemetry = mockTelemetryStateTracked(); + const cache = mockLinkedProjectCacheTracked(); + const analytics = mockContextualAnalytics(); + const process = mockProcessControl(); + const command = options.command ?? "pull"; + return { + out, + http, + telemetry, + cache, + analytics, + process, + layer: Layer.mergeAll( + out.layer, + http.layer, + mockCommandSettings({ workdir: options.workdir }), + testProjectRefLayer(options.linked !== false), + telemetry.layer, + cache.layer, + analytics.layer, + process.layer, + commandRuntimeLayer(["notebooks", command]), + Layer.succeed( + OutputFlag, + options.goOutput === undefined ? Option.none() : Option.some(options.goOutput), + ), + BunServices.layer, + Stdio.layerTest({ args: Effect.succeed(options.args ?? ["notebooks", command]) }), + ), + }; +}