Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- Preview a single-file data app locally with `altertable app dev --file index.tsx`. The file's `defineDataApp` scope must match the selected profile, and queries run through the CLI's loopback proxy.

### Fixed

- Complete stdin-based credential configuration before exiting on Windows.
Expand All @@ -24,6 +28,8 @@ If credentials were saved inside a project, sign in again after upgrading and re

### Changed

- Use the published 0.72.0 manifest extractor and Bun preview server for single-file data app development; align starter and browser-test packages at 0.72.0.

- Generate data apps with the split core, React, and Bun packages at 0.71.0; keep declared data-app packages aligned during upgrades.

- Update generated data apps and fixtures to `@altertable/data-app` 0.69.0.
Expand Down
9 changes: 6 additions & 3 deletions COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ altertable app create|dev|build|lint|upgrade|validate|publish|update
**Subcommands**

- `create` — Create a local data app project with a live connection check.
- `dev` — Preview a data app locally with lakehouse access.
- `dev` — Preview a local data app directory, or a single-file data app, with lakehouse access.
- `build` — Typecheck and build a local data app without Altertable credentials.
- `lint` — Lint a local data app project: format, lint rules, types, contracts, build, and client credential boundary.
- `upgrade` — Update a local data app's package to the CLI's tested version.
Expand All @@ -436,6 +436,7 @@ altertable app create|dev|build|lint|upgrade|validate|publish|update
```bash
altertable app create my-app
altertable app dev
altertable app dev --file index.tsx
altertable app lint
altertable app build
altertable app upgrade
Expand Down Expand Up @@ -477,7 +478,7 @@ altertable --profile production app create product-pulse

#### `altertable app dev`

Preview a data app locally with lakehouse access.
Preview a local data app directory, or a single-file data app, with lakehouse access.

**Usage**

Expand All @@ -490,14 +491,16 @@ altertable app dev [options]
| Option | Description |
| --- | --- |
| `--dir <DIR>` | App directory (default: current directory). |
| `--port <PORT>` | Local dev server port (1–65535; default: app setting). |
| `--file <FILE>` | Path to a single-file React data app (index.tsx) to preview instead of --dir. Queries are declared in the file via defineDataApp. |
| `--port <PORT>` | Local dev server port (1–65535; default: app setting, or 25837 for --file). |

**Examples**

```bash
altertable app dev
altertable app dev --port 3022
altertable --profile staging app dev --dir ./my-app
altertable app dev --file ./index.tsx
```

#### `altertable app build`
Expand Down
18 changes: 15 additions & 3 deletions cli-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,7 @@
"examples": [
"altertable app create my-app",
"altertable app dev",
"altertable app dev --file index.tsx",
"altertable app lint",
"altertable app build",
"altertable app upgrade",
Expand Down Expand Up @@ -884,7 +885,7 @@
{
"id": "altertable-app-dev",
"command": "altertable app dev",
"description": "Preview a data app locally with lakehouse access.",
"description": "Preview a local data app directory, or a single-file data app, with lakehouse access.",
"usage": [
"altertable app dev [options]"
],
Expand All @@ -901,11 +902,21 @@
"scope": "command",
"values": []
},
{
"name": "file",
"aliases": [],
"type": "string",
"description": "Path to a single-file React data app (index.tsx) to preview instead of --dir. Queries are declared in the file via defineDataApp.",
"required": false,
"repeatable": false,
"scope": "command",
"values": []
},
{
"name": "port",
"aliases": [],
"type": "string",
"description": "Local dev server port (1–65535; default: app setting).",
"description": "Local dev server port (1–65535; default: app setting, or 25837 for --file).",
"required": false,
"repeatable": false,
"scope": "command",
Expand All @@ -915,7 +926,8 @@
"examples": [
"altertable app dev",
"altertable app dev --port 3022",
"altertable --profile staging app dev --dir ./my-app"
"altertable --profile staging app dev --dir ./my-app",
"altertable app dev --file ./index.tsx"
],
"subcommands": []
},
Expand Down
5 changes: 5 additions & 0 deletions cli/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"data-app:test:browser": "cd ../data-app/tests && bun run test"
},
"dependencies": {
"@altertable/data-app-manifest": "0.72.0",
"@clack/prompts": "1.7.0",
"oauth4webapi": "3.8.7",
"yaml": "2.9.0"
Expand Down
2 changes: 1 addition & 1 deletion cli/scripts/smoke-data-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export async function smokeDataApp(command: string[], scaffoldOnly = false): Pro
"@altertable/data-app-react",
"@altertable/data-app-bun",
]) {
if (manifest.dependencies?.[name] !== "0.71.0")
if (manifest.dependencies?.[name] !== "0.72.0")
throw new Error(`Packaged starter must pin ${name} to the tested release`);
}
for (const name of ["@altertable/data-app-embed", "@altertable/data-app-worker"]) {
Expand Down
37 changes: 31 additions & 6 deletions cli/src/commands/app/dev.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { resolve } from "node:path";
import { prepareFilePreview } from "@/commands/app/lib/file-dev.ts";
import { checkAppScope } from "@/commands/app/lib/scope.ts";
import { defineCommand } from "@/lib/command.ts";
import { CliError, EXIT_GENERIC } from "@/lib/errors.ts";
Expand All @@ -12,29 +14,44 @@ import {
export const appDevCommand = defineCommand({
metadata: {
name: "dev",
description: "Preview a data app locally with lakehouse access.",
description:
"Preview a local data app directory, or a single-file data app, with lakehouse access.",
examples: [
"altertable app dev",
"altertable app dev --port 3022",
"altertable --profile staging app dev --dir ./my-app",
"altertable app dev --file ./index.tsx",
],
},
args: {
dir: { type: "string", description: "App directory (default: current directory)." },
port: { type: "string", description: "Local dev server port (1–65535; default: app setting)." },
file: {
type: "string",
description:
"Path to a single-file React data app (index.tsx) to preview instead of --dir. Queries are declared in the file via defineDataApp.",
completion: "file",
},
port: {
type: "string",
description: "Local dev server port (1–65535; default: app setting, or 25837 for --file).",
},
},
async run({ args, execution, runtime }) {
assertAppOutputMode(runtime.context.json, runtime.context.agent);
const port = appPort(args.port);
const directory = appDirectory(args.dir);
const file = args.file === undefined ? undefined : appFile(args.file, args.dir);
const port = appPort(args.port) ?? (file ? "25837" : undefined);
const directory = file
? await prepareFilePreview(file, execution.profile)
: appDirectory(args.dir);
requireAppScripts(directory, ["dev"]);
await checkAppScope(directory, execution.profile);
const installExitCode = await runAppCommand("install", directory);
if (!file) await checkAppScope(directory, execution.profile);
const installExitCode = await runAppCommand(file ? "install-frozen" : "install", directory);
if (installExitCode !== 0) return { exitCode: EXIT_GENERIC };
const proxy = startAppDevProxy(execution);
try {
const environment = {
...proxy.environment,
...(file ? { ALTERTABLE_APP_FILE: file } : {}),
...(port ? { PORT: port } : {}),
};
const exitCode = await runAppCommand("dev", directory, environment);
Expand All @@ -45,6 +62,14 @@ export const appDevCommand = defineCommand({
},
});

function appFile(file: unknown, dir: unknown): string {
if (dir !== undefined) throw new CliError("Pass either --dir or --file.");
if (typeof file !== "string" || file.length === 0) {
throw new CliError("--file requires a file path.");
}
return resolve(file);
}

function appPort(value: unknown): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string" || !/^[1-9]\d{0,4}$/.test(value) || Number(value) > 65535) {
Expand Down
24 changes: 24 additions & 0 deletions cli/src/commands/app/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,28 @@ describe("app commands", () => {
expect(build.exitCode).toBe(1);
expect(existsSync(join(directory, "build.json"))).toBe(false);
});

test("dev validates a single file before preparing its preview", async () => {
const file = workspace.writeFile(
"index.tsx",
`import { defineDataApp } from "@altertable/data-app";
defineDataApp({ scope: { organization: "acme", environment: "staging" } });`,
);
async function failure(args: string[]): Promise<unknown> {
return runAppCommand(["app", "dev", ...args]).catch((error: unknown) => error);
}
function message(text: string) {
return { message: expect.stringContaining(text) };
}

expect(await failure(["--file", file])).toMatchObject(message("data app file scope"));
expect(await failure(["--dir", directory, "--file", file])).toMatchObject(
message("either --dir or --file"),
);
expect(await failure(["--file", ""])).toMatchObject(message("--file requires a file path"));
expect(await failure(["--file", join(directory, "missing.tsx")])).toMatchObject(
message("Could not read source"),
);
expect(await failure(["--file", directory])).toMatchObject(message("Could not read source"));
});
});
1 change: 1 addition & 0 deletions cli/src/commands/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const appCommand = defineCommand({
examples: [
"altertable app create my-app",
"altertable app dev",
"altertable app dev --file index.tsx",
"altertable app lint",
"altertable app build",
"altertable app upgrade",
Expand Down
6 changes: 3 additions & 3 deletions cli/src/commands/app/lib/distribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ test("distribution embeds a deterministic starter with an exact package pin and
recommendedDataAppVersion(),
);
expect(JSON.parse(files["package.json"]!).dependencies).toMatchObject({
"@altertable/data-app": "0.71.0",
"@altertable/data-app-react": "0.71.0",
"@altertable/data-app-bun": "0.71.0",
"@altertable/data-app": "0.72.0",
"@altertable/data-app-react": "0.72.0",
"@altertable/data-app-bun": "0.72.0",
});
expect(files["src/main.tsx"]).toContain('from "@altertable/data-app-react"');
expect(files["src/preview.ts"]).toContain('from "@altertable/data-app-bun/preview"');
Expand Down
17 changes: 17 additions & 0 deletions cli/src/commands/app/lib/file-dev-server.ts.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { localLakehouse } from "@altertable/data-app-bun";
import { serveAppFile } from "@altertable/data-app-bun/preview";

const preview = await serveAppFile({
file: process.env.ALTERTABLE_APP_FILE,
port: Number(process.env.PORT),
lakehouse: localLakehouse(),
resolveDependency: (specifier) => Bun.resolveSync(specifier, import.meta.dir),
});

for (const signal of ["SIGINT", "SIGTERM"]) {
process.once(signal, async () => {
await preview.stop();
process.exit(0);
});
}
console.log(`Data app running at ${preview.url} (${process.env.ALTERTABLE_APP_FILE})`);
64 changes: 64 additions & 0 deletions cli/src/commands/app/lib/file-dev.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { rejects } from "node:assert/strict";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, beforeEach, expect, test } from "bun:test";
import { prepareFilePreview } from "@/commands/app/lib/file-dev.ts";
import { dataAppPayload } from "@/commands/app/lib/payload.ts";
import { FROM_ENV_PSEUDOPROFILE_NAME } from "@/lib/profile-store.ts";
import { createLakehouseTestWorkspace } from "@/test-utils/lakehouse.ts";

let workspace: ReturnType<typeof createLakehouseTestWorkspace>;
let savedEnvironment: Record<string, string | undefined>;
beforeEach(() => {
savedEnvironment = {
ALTERTABLE_CONFIG_HOME: process.env.ALTERTABLE_CONFIG_HOME,
ALTERTABLE_ENV: process.env.ALTERTABLE_ENV,
};
workspace = createLakehouseTestWorkspace("file-dev");
process.env.ALTERTABLE_CONFIG_HOME = workspace.home;
process.env.ALTERTABLE_ENV = "production";
});
afterEach(() => {
workspace.cleanup();
for (const [key, value] of Object.entries(savedEnvironment)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});

function app(scope: string): string {
return `import { defineDataApp as define } from "@altertable/data-app";
const app = define({ scope: ${scope}, queries: {} });`;
}

async function prepare(source: string): Promise<string> {
const file = workspace.writeFile("index.tsx", source);
return prepareFilePreview(file, FROM_ENV_PSEUDOPROFILE_NAME);
}

test("prepares a reusable dev project from the released manifest without executing the app", async () => {
const directory = await prepare(
`${app('{ organization: "acme", environment: "production" }')}
throw new Error("must not execute");`,
);
const manifest = JSON.parse(await readFile(join(directory, "package.json"), "utf8"));
const starter = JSON.parse(dataAppPayload.starter["package.json"]!);
expect(manifest.dependencies).toEqual(starter.dependencies);
expect(manifest.scripts).toEqual({ dev: "bun server.ts" });
expect(await readFile(join(directory, "bun.lock"), "utf8")).toBe(
dataAppPayload.starter["bun.lock"]!,
);
expect(await readFile(join(directory, "server.ts"), "utf8")).toContain(
"@altertable/data-app-bun/preview",
);
expect(await prepare(app('{ organization: "acme", environment: "production" }'))).toBe(directory);
});

test("rejects invalid declarations and mismatched scopes before creating the runtime", async () => {
await rejects(prepare("export const app = 1;"), /Could not extract the data app manifest\.\n1:/);
for (const scope of ["null", "{}", '{ organization: "acme", environment: "staging" }']) {
await rejects(prepare(app(scope)), /data app file scope does not match/);
}
expect(existsSync(join(workspace.home, "data-app-dev"))).toBe(false);
});
Loading
Loading