Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/1-framework/3-tooling/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,33 @@ prisma-next migration status [--db <url>] [--ref <name>] [--config <path>] [--js

**Branched graphs:** When the migration graph has multiple branches (divergence), status reports an `AMBIGUOUS_TARGET` error with the divergence point and branch details. Use `--ref` to target a specific branch.

### `prisma-next repl`

Interactive query console — the Prisma Next replacement for `psql`. Connects to your database through the project's own runtime packages and evaluates Prisma Next queries and plain TypeScript.

```bash
prisma-next repl [--db <url>] [--config <path>]
```

**Options:**
- `--db <url>`: Database connection string (optional; defaults to `config.db.connection`)
- `--config <path>`: Path to `prisma-next.config.ts`

**What it does:**
1. Loads the config and the emitted `contract.json`, then resolves the target facade runtime (e.g. `@prisma-next/postgres/runtime`) and any required extension packs from the project's `node_modules`
2. Exposes `db`, `sql`, `orm`, `enums`, and `raw` in a persistent evaluation context — `const`/`let` bindings survive across submissions and top-level `await` works
3. Auto-executes queries: submitting a builder, plan, or ORM collection runs it immediately — no `.build()`, `execute()`, or `await` needed — and renders rows as a psql-style table with timing
4. Autocompletes from the contract: a dropdown menu (Tab or as-you-type) offers tables, columns, models, relations, and builder methods with context sensitivity (columns inside `select('…')`, fields after where-lambda params, etc.); inline ghost text suggests previous inputs fish-style (accept with `→`)
5. Supports meta commands with psql aliases: `.help` (`\?`), `.tables` (`\dt`), `.schema [table]` (`\d`), `.models`, `.clear`, `.exit` (`\q`)

**Non-interactive mode:** when stdin is piped, each line is evaluated in order and results stream to stdout:

```bash
echo "db.sql.public.user.select('id','email').limit(5)" | prisma-next repl
```

The interactive editor lives in `src/repl/`: a pure reducer (`editor-state.ts`) drives the raw-mode terminal shell (`line-editor.ts`), with the completion engine (`completion.ts`), evaluator (`evaluator.ts`), and renderers unit-tested in `test/repl/`.

### `prisma-next migrate`

Apply planned migrations to the database. Executes previously planned migrations (created by `migration plan`). Compares the database marker against the migration graph to determine which migrations are pending, then executes them sequentially. Each migration runs in its own transaction. Does not plan new migrations — run `migration plan` first.
Expand Down
4 changes: 4 additions & 0 deletions packages/1-framework/3-tooling/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@
"types": "./dist/commands/migrate.d.mts",
"import": "./dist/commands/migrate.mjs"
},
"./commands/repl": {
"types": "./dist/commands/repl.d.mts",
"import": "./dist/commands/repl.mjs"
},
"./commands/ref": {
"types": "./dist/commands/ref.d.mts",
"import": "./dist/commands/ref.mjs"
Expand Down
6 changes: 2 additions & 4 deletions packages/1-framework/3-tooling/cli/recordings/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
mp4/
tapes/
.bin/
.cache.json
mp4/*
!mp4/repl-demo.mp4
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion packages/1-framework/3-tooling/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { createMigrationPlanCommand } from './commands/migration-plan';
import { createMigrationShowCommand } from './commands/migration-show';
import { createMigrationStatusCommand } from './commands/migration-status';
import { createRefCommand } from './commands/ref';
import { createReplCommand } from './commands/repl';
import { createTelemetryCommand } from './commands/telemetry';
import { setCommandDescriptions } from './utils/command-helpers';
import { formatCommandHelp, formatRootHelp } from './utils/formatters/help';
Expand Down Expand Up @@ -314,14 +315,18 @@ const initCommand = createInitCommand();

const formatCommand = createFormatCommand();
const lspCommand = createLspCommand();
const replCommand = createReplCommand();

// Register top-level commands in the order the spec's intended-surface
// diagram lists them: verbs (init, migrate) first, then subject
// namespaces (contract, db, migration, ref). The order shows up in
// `prisma-next --help` and is the first thing a new user sees, so it
// matches the order spec.md uses to introduce the surface.
// matches the order spec.md uses to introduce the surface. Commands
// added after that spec (repl, format, lsp, telemetry) slot in with the
// verbs they belong beside rather than appearing in the diagram.
program.addCommand(initCommand);
program.addCommand(migrateCommand);
program.addCommand(replCommand);
program.addCommand(formatCommand);
program.addCommand(lspCommand);
program.addCommand(contractCommand);
Expand Down
103 changes: 103 additions & 0 deletions packages/1-framework/3-tooling/cli/src/commands/repl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Command } from 'commander';
import {
addGlobalOptions,
setCommandDescriptions,
setCommandExamples,
} from '../utils/command-helpers';
import type { CommonCommandOptions } from '../utils/global-flags';
import { parseGlobalFlagsOrExit } from '../utils/global-flags';
import { handleResult } from '../utils/result-handler';
import { createTerminalUI } from '../utils/terminal-ui';

interface ReplCommandOptions extends CommonCommandOptions {
readonly db?: string;
readonly config?: string;
}

/** Resolves once queued stdout writes have reached the OS, then exits. */
function flushAndExit(code: number): void {
process.exitCode = code;
process.stdout.write('', () => process.exit(code));
}

export function createReplCommand(): Command {
const command = new Command('repl');
setCommandDescriptions(
command,
'Interactive query console',
'Starts an interactive console connected to your database. Type any Prisma Next\n' +
'query — SQL lane or ORM lane — and it executes on Enter; builders and plans run\n' +
'without .build() or execute(). Tab completes tables, columns, and methods from\n' +
'your contract. Plain TypeScript works too. When stdin is piped, each line is\n' +
'evaluated in order, results stream to stdout, and the exit code is 1 when any\n' +
'line fails.',
);
setCommandExamples(command, [
'prisma-next repl',
'prisma-next repl --db $DATABASE_URL',
`echo "db.sql.public.user.select('id').limit(5)" | prisma-next repl`,
]);
addGlobalOptions(command)
.option('--db <url>', 'Database connection string')
.option('--config <path>', 'Path to prisma-next.config.ts')
.action(async (options: ReplCommandOptions) => {
const flags = parseGlobalFlagsOrExit(options);
const ui = createTerminalUI(flags);

// Loaded lazily so the repl's heavier dependencies (esbuild for TS
// stripping, the line editor) never tax the startup time of other
// commands bundled into the same CLI entry.
const [{ loadReplContext }, { runInteractiveSession }, { runBatchSession }] =
await Promise.all([
import('../repl/load-repl-context'),
import('../repl/session'),
import('../repl/batch'),
]);

const result = await loadReplContext({
...(options.db !== undefined ? { db: options.db } : {}),
...(options.config !== undefined ? { config: options.config } : {}),
});

if (!result.ok) {
const exitCode = handleResult(result, flags, ui, () => undefined);
process.exit(exitCode);
}

const context = result.value;
const interactive =
flags.interactive !== false &&
process.stdin.isTTY === true &&
process.stdout.isTTY === true;
const color = flags.color === true;

let exitCode = 0;
try {
if (interactive) {
await runInteractiveSession({
context,
input: process.stdin,
output: process.stdout,
color,
});
} else {
const { failures } = await runBatchSession({
context,
input: process.stdin,
output: process.stdout,
color,
echo: true,
});
if (failures > 0) exitCode = 1;
}
} catch (error) {
ui.error(error instanceof Error ? error.message : String(error));
exitCode = 1;
} finally {
await context.close();
}
Comment on lines +74 to +98

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Cleanup failure in context.close() bypasses exit handling.

If context.close() throws inside finally, the error escapes uncaught past this whole try/catch/finally, skipping ui.error(...) and flushAndExit(exitCode) entirely — the process's exit code/message reporting becomes inconsistent with every other failure path in this function.

🛡️ Proposed fix
       } finally {
-        await context.close();
+        try {
+          await context.close();
+        } catch (closeError) {
+          ui.error(closeError instanceof Error ? closeError.message : String(closeError));
+          exitCode = exitCode || 1;
+        }
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let exitCode = 0;
try {
if (interactive) {
await runInteractiveSession({
context,
input: process.stdin,
output: process.stdout,
color,
});
} else {
const { failures } = await runBatchSession({
context,
input: process.stdin,
output: process.stdout,
color,
echo: true,
});
if (failures > 0) exitCode = 1;
}
} catch (error) {
ui.error(error instanceof Error ? error.message : String(error));
exitCode = 1;
} finally {
await context.close();
}
let exitCode = 0;
try {
if (interactive) {
await runInteractiveSession({
context,
input: process.stdin,
output: process.stdout,
color,
});
} else {
const { failures } = await runBatchSession({
context,
input: process.stdin,
output: process.stdout,
color,
echo: true,
});
if (failures > 0) exitCode = 1;
}
} catch (error) {
ui.error(error instanceof Error ? error.message : String(error));
exitCode = 1;
} finally {
try {
await context.close();
} catch (closeError) {
ui.error(closeError instanceof Error ? closeError.message : String(closeError));
exitCode = exitCode || 1;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/1-framework/3-tooling/cli/src/commands/repl.ts` around lines 74 -
98, The `repl` command’s `try/catch/finally` around
`runInteractiveSession`/`runBatchSession` lets a throwing `context.close()`
escape, bypassing `ui.error(...)` and `flushAndExit(exitCode)`. Wrap
`context.close()` in its own error handling inside the `finally` block (or a
nested try/catch) so cleanup failures are reported through `ui.error` and the
existing exit-code flow in `runRepl` still always reaches `flushAndExit`.

flushAndExit(exitCode);
});

return command;
}
149 changes: 149 additions & 0 deletions packages/1-framework/3-tooling/cli/src/repl/batch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Shared evaluate-and-print pipeline plus the non-interactive (piped stdin)
* batch mode. Stream-parameterized so unit tests can drive it with
* PassThrough streams and a stubbed context.
*/
import { createReplEvaluator, type ReplEvaluator } from './evaluator';
import type { ReplContext } from './load-repl-context';
import { materializeResult } from './materialize';
import { runMetaCommand } from './meta-commands';
import { replPalette } from './palette';
import { renderResultValue } from './render';

/** Thrown by the print pipeline when a meta command requests exit. */
export class ExitSignal extends Error {}

export interface EvaluatePrintOptions {
readonly context: ReplContext;
readonly output: NodeJS.WritableStream;
readonly color: boolean;
/** Gates terminal-only behavior like the .clear escape sequence. */
readonly interactive: boolean;
}

export function createSessionEvaluator(context: ReplContext): ReplEvaluator {
return createReplEvaluator({
db: context.db,
sql: context.db.sql,
orm: context.db.orm,
enums: context.db.enums,
raw: context.db.raw,
});
}

export function formatError(error: unknown, color: boolean): string {
const palette = replPalette(color);
if (typeof error === 'object' && error !== null) {
const structured = error as { code?: unknown; message?: unknown };
if (typeof structured.code === 'string' && typeof structured.message === 'string') {
return palette.red(`✗ ${structured.code}: ${structured.message}`);
}
}
if (
error instanceof Error ||
(typeof error === 'object' && error !== null && 'message' in error)
) {
const err = error as { name?: string; message?: string };
return palette.red(`✗ ${err.name ?? 'Error'}: ${err.message ?? String(error)}`);
}
return palette.red(`✗ ${String(error)}`);
}
Comment on lines +34 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Bare as casts violate the repo's no-bare-casts rule.

error as { code?: unknown; message?: unknown } and error as { name?: string; message?: string } are bare casts in production code.

As per coding guidelines: "No bare as in production code. Use blindCast<T, "Reason"> or castAs<T> from @prisma-next/utils/casts; see the no-bare-casts skill for the decision tree."

♻️ Proposed fix using `castAs`
+import { castAs } from '`@prisma-next/utils/casts`';
+
 export function formatError(error: unknown, color: boolean): string {
   const palette = replPalette(color);
   if (typeof error === 'object' && error !== null) {
-    const structured = error as { code?: unknown; message?: unknown };
+    const structured = castAs<{ code?: unknown; message?: unknown }>(error);
     if (typeof structured.code === 'string' && typeof structured.message === 'string') {
       return palette.red(`✗ ${structured.code}: ${structured.message}`);
     }
   }
   if (
     error instanceof Error ||
     (typeof error === 'object' && error !== null && 'message' in error)
   ) {
-    const err = error as { name?: string; message?: string };
+    const err = castAs<{ name?: string; message?: string }>(error);
     return palette.red(`✗ ${err.name ?? 'Error'}: ${err.message ?? String(error)}`);
   }
   return palette.red(`✗ ${String(error)}`);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function formatError(error: unknown, color: boolean): string {
const palette = replPalette(color);
if (typeof error === 'object' && error !== null) {
const structured = error as { code?: unknown; message?: unknown };
if (typeof structured.code === 'string' && typeof structured.message === 'string') {
return palette.red(`✗ ${structured.code}: ${structured.message}`);
}
}
if (
error instanceof Error ||
(typeof error === 'object' && error !== null && 'message' in error)
) {
const err = error as { name?: string; message?: string };
return palette.red(`✗ ${err.name ?? 'Error'}: ${err.message ?? String(error)}`);
}
return palette.red(`✗ ${String(error)}`);
}
import { castAs } from '`@prisma-next/utils/casts`';
export function formatError(error: unknown, color: boolean): string {
const palette = replPalette(color);
if (typeof error === 'object' && error !== null) {
const structured = castAs<{ code?: unknown; message?: unknown }>(error);
if (typeof structured.code === 'string' && typeof structured.message === 'string') {
return palette.red(`✗ ${structured.code}: ${structured.message}`);
}
}
if (
error instanceof Error ||
(typeof error === 'object' && error !== null && 'message' in error)
) {
const err = castAs<{ name?: string; message?: string }>(error);
return palette.red(`✗ ${err.name ?? 'Error'}: ${err.message ?? String(error)}`);
}
return palette.red(`✗ ${String(error)}`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/1-framework/3-tooling/cli/src/repl/batch.ts` around lines 34 - 50,
The `formatError` helper uses bare `as` casts, which violates the repo’s
no-bare-casts rule. Replace the inline object casts for `structured` and `err`
with `castAs<T>` or `blindCast<T, "Reason">` from `@prisma-next/utils/casts`,
keeping the same `code`, `message`, and `name` handling logic. Make the change
inside `formatError` so the error formatting behavior stays the same while
removing bare casts.

Source: Coding guidelines


/**
* Evaluates one submission and writes the outcome. Returns true when the
* submission failed (evaluation or execution error). Throws {@link ExitSignal}
* when a meta command requests exit.
*/
export async function evaluateAndPrint(
input: string,
evaluator: ReplEvaluator,
options: EvaluatePrintOptions,
): Promise<boolean> {
const { context, output, color, interactive } = options;

const meta = runMetaCommand(input, context.schema, { color });
if (meta.handled) {
if (meta.clear && interactive) output.write('\x1b[2J\x1b[H');
if (meta.output) output.write(`${meta.output}\n`);
if (meta.exit) throw new ExitSignal();
return false;
}

const result = await evaluator.evaluate(input);
if (!result.ok) {
output.write(`${formatError(result.error, color)}\n`);
return true;
}

try {
// Timed around materialization only, so the figure reflects query
// execution rather than esbuild/vm overhead.
const started = performance.now();
const materialized = await materializeResult(result.value, context.executePlan);
const elapsedMs = performance.now() - started;
const rendered = materialized.executed
? renderResultValue(materialized.value, { color, elapsedMs })
: renderResultValue(materialized.value, { color });
output.write(`${rendered}\n`);
return false;
} catch (error) {
output.write(`${formatError(error, color)}\n`);
return true;
}
}

export interface BatchSessionOptions {
readonly context: ReplContext;
readonly input: NodeJS.ReadStream;
readonly output: NodeJS.WriteStream;
readonly color: boolean;
/** Echo inputs before results. */
readonly echo?: boolean;
}

export interface BatchSessionResult {
readonly failures: number;
}

/**
* Batch mode: reads full stdin, evaluates statement per line (blank lines
* and `//` comments skipped), and prints each result. Powers piping:
* `echo "db.sql.public.user.select('id')" | prisma-next repl`.
*/
export async function runBatchSession(options: BatchSessionOptions): Promise<BatchSessionResult> {
const { context, input, output, color } = options;
const evaluator = createSessionEvaluator(context);
const palette = replPalette(color);

if (input.isTTY) {
process.stderr.write('reading input from stdin — end with Ctrl+D\n');
}

const chunks: Buffer[] = [];
for await (const chunk of input) {
chunks.push(Buffer.from(chunk));
}
const source = Buffer.concat(chunks).toString('utf8');

let failures = 0;
for (const line of source.split('\n')) {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith('//')) continue;
if (options.echo) {
output.write(`${palette.dim(`› ${trimmed}`)}\n`);
}
try {
const failed = await evaluateAndPrint(trimmed, evaluator, {
context,
output,
color,
interactive: false,
});
if (failed) failures++;
} catch (error) {
if (error instanceof ExitSignal) return { failures };
throw error;
}
}
return { failures };
}
Loading
Loading