diff --git a/docs/reference/api/pages.md b/docs/reference/api/pages.md index 09968f62e..c0c72bd5c 100644 --- a/docs/reference/api/pages.md +++ b/docs/reference/api/pages.md @@ -218,6 +218,8 @@ Imported from `"@void/solid"`. Solid `Link` GET `data` is merged into the rendered `href` query string. Primitive values are serialized with `String(value)`, arrays become repeated keys, `null` and `undefined` are omitted, and nested objects throw. `prefetch` and `reloadDocument` are GET-only and throw for mutation links. +Solid re-creates the page on GET navigation and whenever the route or its params change, so `useForm()` picks up the destination record's URL and defaults; layouts persist. Within the same record, back/forward, mutations, and `router.refresh()` keep the page and update its props in place, as do resolved deferred props, so read props inside JSX or memos instead of copying them in the component body. Set `preserveState` explicitly to override the default for navigation within the same record. + ## Markdown Package {#markdown-package} The optional `@void/md` package adds Markdown pages to Pages Routing. Install it alongside a Pages adapter and add `voidMarkdown()` to your Vite plugins. diff --git a/docs/reference/cli/database.md b/docs/reference/cli/database.md index d47efaf2a..e661da896 100644 --- a/docs/reference/cli/database.md +++ b/docs/reference/cli/database.md @@ -24,15 +24,21 @@ Show migration status. Displays which migrations are applied or pending locally, ## `void db reset` {#void-db-reset} -Drop the development database and re-apply all migrations. D1 resets the local database; PostgreSQL and MySQL drop all tables in the `DATABASE_URL` database from `.env`. Does not affect the deployed database. +``` +void db reset [--yes] +``` + +Drop the development database and re-apply all migrations. D1 resets the local database; PostgreSQL and MySQL drop all tables in the `DATABASE_URL` database from `.env`. + +When that `DATABASE_URL` points to a host other than `localhost`, Void names the database and asks before dropping its tables. Pass `--yes` to skip the prompt in scripts and CI; without a terminal, the command stops unless `--yes` is set. If `.env` uses the same URL as your deployment (for example after `void db connect`), reset drops the deployed tables. ## `void db seed` {#void-db-seed} ``` -void db seed [--file ] +void db seed [--file ] [--yes] ``` -Reset the local database, re-apply all migrations, then execute a seed file. +Reset the local database, re-apply all migrations, then execute a seed file. The reset works like `void db reset`, including the prompt and `--yes`. If `--file` is omitted, Void looks for default seed files in this order: `db/seed.ts`, `db/seed.mts`, `db/seed.js`, `db/seed.mjs`, `db/seed.sql`. diff --git a/packages/react/src/runtime/pages-server.ts b/packages/react/src/runtime/pages-server.ts index 25c09f374..c4392040b 100644 --- a/packages/react/src/runtime/pages-server.ts +++ b/packages/react/src/runtime/pages-server.ts @@ -100,14 +100,28 @@ export async function renderReactPage({ ) : element; + // React calls onError inside its work loop, so it must not throw. The outer + // Suspense turns page errors into a client-render fallback; fail the render + // instead when they happen before the shell, so the caller serves its error page. + let shellError: { error: unknown } | undefined; + let shellSent = false; const appStream = await renderToReadableStream(renderElement, { onError(error) { if (error === DEFERRED_SHELL_CANCEL_REASON) { return; } - throw error; + if (shellSent) { + console.error(error); + return; + } + shellError ??= { error }; }, }); + if (shellError) { + await appStream.cancel(shellError.error); + throw shellError.error; + } + shellSent = true; const renderedAppStream = pageObj.deferredKeys ? readDeferredShell(appStream) : appStream; const pageData = serializePageData(pageObj); const headHtml = pageObj.head ? renderHeadToString(pageObj.head) : ''; diff --git a/packages/react/test/unit/pages-server.test.ts b/packages/react/test/unit/pages-server.test.ts new file mode 100644 index 000000000..7d02bd23c --- /dev/null +++ b/packages/react/test/unit/pages-server.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vite-plus/test'; +import { renderReactPage } from '../../src/runtime/pages-server.ts'; + +it('rejects with the error a page throws while rendering', async () => { + const boom = new TypeError('page render failed'); + function Page(): never { + throw boom; + } + + await expect( + renderReactPage({ + pageObj: { component: 'page', props: {}, url: '/', params: {}, shared: {}, errors: null }, + assetTags: { css: '', preloads: '', body: '' }, + components: { page: async () => ({ default: Page }) }, + layoutTree: {}, + }), + ).rejects.toBe(boom); +}); diff --git a/packages/solid/src/plugin-pages.ts b/packages/solid/src/plugin-pages.ts index 4829dd8dd..5ec15f82a 100644 --- a/packages/solid/src/plugin-pages.ts +++ b/packages/solid/src/plugin-pages.ts @@ -256,7 +256,7 @@ function generateClientEntry( staticPageData?: StaticPageDataBuildOptions, ): string { return ` -import { createSignal, createComponent, batch } from "solid-js"; +import { createSignal, createComponent, createMemo, mergeProps, untrack, batch } from "solid-js"; import { createMutable } from "solid-js/store"; import { hydrate, render } from "solid-js/web"; import { SharedContext, ErrorsContext, ParamsContext, RouterContext, NavigationContext, setActionRouter } from "@void/solid"; @@ -265,7 +265,8 @@ import { parseCacheFor } from "void/pages-prefetch"; ${generateComponentManifest(scan, pagesDir)} -const [component, setComponent] = createSignal(null); +// A new page object re-creates the page; props alone update it in place. +const [page, setPage] = createSignal({ component: null }); const [pageProps, setPageProps] = createSignal({}); const [layouts, setLayouts] = createSignal([]); const shared = createMutable({}); @@ -280,6 +281,11 @@ function replaceShared(next) { Object.assign(shared, next || {}); } +function sameParams(a, b) { + const keys = Object.keys(a); + return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]); +} + const { router, initialPageData, initialComponent, initialLayouts, start, commitInitialRender } = await createVoidRouter({ adapter: { @@ -287,14 +293,19 @@ const { router, initialPageData, initialComponent, initialLayouts, start, commit resolveDeferred(ref, value) { return { value, loading: false, error: null }; }, rejectDeferred(ref, error) { return { value: null, loading: false, error: new Error(error) }; }, isLoading: (ref) => ref.loading, - applyUpdate(pageData, comp, layoutComps) { + applyUpdate(pageData, comp, layoutComps, options) { + const params = pageData.params || {}; + // Back/forward preserves state, but another route or another record + // still needs a new page so useForm() targets that route and record. + const current = page(); + const keep = options?.preserveState && current.id === pageData.component && current.component === comp && sameParams(routeParams(), params); batch(() => { + if (!keep) setPage({ component: comp, id: pageData.component }); setPageProps(pageData.props); - setComponent(() => comp); setLayouts(layoutComps); replaceShared(pageData.shared); setErrors(pageData.errors || {}); - setRouteParams(pageData.params || {}); + setRouteParams(params); }); }, onDeferredUpdate(props) { @@ -320,7 +331,7 @@ const reactiveRouter = createRouterFacade(router, () => routeUrl(), () => routeP setActionRouter(reactiveRouter); batch(() => { - setComponent(() => initialComponent); + setPage({ component: initialComponent, id: initialPageData.component }); setPageProps(initialPageData.props); setLayouts(initialLayouts); replaceShared(initialPageData.shared); @@ -328,6 +339,26 @@ batch(() => { setRouteParams(initialPageData.params || {}); }); +// Each slot re-renders only when its own layout or page changes, so +// layouts above it keep their state. The inner slot is created before its +// layout to match the server render order for hydration. +function renderSlot(depth) { + const entry = createMemo(() => { + const list = layouts(); + return depth < list.length ? list[depth] : page(); + }); + return createMemo(() => { + const current = entry(); + return untrack(() => { + if (typeof current === "function") { + const child = renderSlot(depth + 1); + return createComponent(current, { get children() { return child; } }); + } + return current.component ? createComponent(current.component, mergeProps(pageProps)) : null; + }); + }); +} + function App() { return createComponent(SharedContext.Provider, { value: shared, @@ -344,16 +375,7 @@ function App() { return createComponent(NavigationContext.Provider, { value: navigation, get children() { - const Comp = component(); - if (!Comp) return null; - let element = createComponent(Comp, pageProps()); - const layoutList = layouts(); - for (let i = layoutList.length - 1; i >= 0; i--) { - const Layout = layoutList[i]; - const child = element; - element = createComponent(Layout, { get children() { return child; } }); - } - return element; + return renderSlot(0); } }); } diff --git a/packages/solid/src/runtime/use-form.ts b/packages/solid/src/runtime/use-form.ts index b99672427..39a049431 100644 --- a/packages/solid/src/runtime/use-form.ts +++ b/packages/solid/src/runtime/use-form.ts @@ -92,11 +92,12 @@ export function useForm( const [wasSuccessful, setWasSuccessful] = createSignal(false); const [recentlySuccessful, setRecentlySuccessful] = createSignal(false); - let defaults_ = { ...defaults }; + const [defaults_, setDefaults] = createSignal>({ ...defaults }); let successTimeout: ReturnType | null = null; const hasChanges = createMemo(() => { - return Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])); + const current = defaults_(); + return Object.keys(current).some((key) => !isEqualFormValue(data[key], current[key])); }); function setData(field: string, value: unknown) { @@ -105,10 +106,10 @@ export function useForm( function reset(...fields: ReadonlyArray) { if (fields.length === 0) { - setDataStore(reconcile({ ...defaults_ })); + setDataStore(reconcile({ ...defaults_() })); } else { for (const field of fields) { - setDataStore(field, defaults_[field]); + setDataStore(field, defaults_()[field]); } } } @@ -166,7 +167,7 @@ export function useForm( setWasSuccessful(true); setRecentlySuccessful(true); - defaults_ = { ...data }; + setDefaults({ ...data }); successTimeout = setTimeout(() => { setRecentlySuccessful(false); }, 2000); diff --git a/packages/solid/test/integration/solid-pages-playground.test.ts b/packages/solid/test/integration/solid-pages-playground.test.ts index 48b2f62f3..8377423c2 100644 --- a/packages/solid/test/integration/solid-pages-playground.test.ts +++ b/packages/solid/test/integration/solid-pages-playground.test.ts @@ -25,6 +25,8 @@ declare global { } } +type StampedElement = HTMLElement & { __stamp?: number }; + const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); let DEV_BASE: string; let PREVIEW_BASE: string; @@ -87,6 +89,81 @@ function registerClientTests(getBase: () => string, coverage: 'smoke' | 'full') } }); + it('layout and page state persist across navigation and validation errors', async () => { + const page = await newPage(); + const stampOf = (selector: string) => + page.evaluate((s) => document.querySelector(s)?.__stamp, selector); + try { + await page.goto(BASE + '/'); + await waitForHydration(page); + await page.evaluate(() => { + document.querySelector('nav')!.__stamp = 1; + }); + + await page.click('nav a[href="/users"]'); + await page.waitForFunction(() => + document.querySelector('h1')?.textContent?.includes('Users'), + ); + expect(await stampOf('nav')).toBe(1); + + await page.evaluate(() => { + document.querySelector('h1')!.__stamp = 1; + }); + await page.locator('form input').nth(0).fill('Typed Name'); + await page.click('button[type="submit"]'); + await page.waitForFunction(() => document.body?.textContent?.includes('Email is required')); + + expect(await page.locator('form input').nth(0).inputValue()).toBe('Typed Name'); + expect(await stampOf('h1')).toBe(1); + } finally { + await page.context().close(); + } + }); + + it('back/forward to another record of the same page re-creates the page', async () => { + const page = await newPage(); + try { + await page.goto(BASE + '/users/1'); + await waitForHydration(page); + await page.evaluate(() => { + document.querySelector('h1')!.__stamp = 1; + history.pushState(null, '', '/users/2'); + history.pushState(null, '', '/users'); + history.back(); + }); + await page.waitForFunction(() => document.querySelector('h1')?.textContent?.includes('Bob')); + + expect( + await page.evaluate(() => document.querySelector('h1')?.__stamp), + ).toBeUndefined(); + } finally { + await page.context().close(); + } + }); + + it('back/forward to another route that shares the page component re-creates the page', async () => { + const page = await newPage(); + try { + await page.goto(BASE + '/twins/a'); + await waitForHydration(page); + await page.evaluate(() => { + document.querySelector('h1')!.__stamp = 1; + history.pushState(null, '', '/twins/b'); + history.pushState(null, '', '/'); + history.back(); + }); + await page.waitForFunction( + () => document.querySelector('[data-testid="twin-path"]')?.textContent === '/twins/b', + ); + + expect( + await page.evaluate(() => document.querySelector('h1')?.__stamp), + ).toBeUndefined(); + } finally { + await page.context().close(); + } + }); + it('Link preserves Solid bound onClick handler arguments', async () => { const page = await newPage(); try { diff --git a/packages/svelte/src/plugin-islands.ts b/packages/svelte/src/plugin-islands.ts index 3555654b5..7f29062f1 100644 --- a/packages/svelte/src/plugin-islands.ts +++ b/packages/svelte/src/plugin-islands.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve, relative } from 'pathslash'; +import { preprocess, type CompileResult, type PreprocessorGroup } from 'svelte/compiler'; import { normalizePath, type Plugin, type ResolvedConfig } from 'vite'; import { generateComponentManifest, scanPages, resolveEffectiveLayoutChain } from 'void/pages'; import type { PageScanResult } from 'void/pages'; @@ -31,6 +32,19 @@ type MdPluginWithApi = Plugin & { }; }; type ScannableEntry = { filePath: string; island: boolean | undefined }; +/** The shared API of vite-plugin-svelte, set on its `vite-plugin-svelte:config` plugin. */ +type SveltePluginApi = { + options: { preprocess?: PreprocessorGroup | Array }; + idParser: (id: string, ssr: boolean) => unknown; + compileSvelte: ( + request: unknown, + code: string, + options: unknown, + environment: unknown, + sourcemap?: unknown, + ) => Promise<{ compiled: CompileResult }>; +}; +type SvelteConfigPlugin = Plugin & { api?: SveltePluginApi }; export function islandsPlugin( pagesDir: string, @@ -238,36 +252,60 @@ export function islandsPlugin( }, { name: 'void-svelte:islands-style', - // Only the build's client entry imports `?island-style` (dev CSS comes - // from Void's dev CSS collector), so this raw file reader never needs - // to run on the dev server. + // Only the build's client entry imports `.svelte.css?island-style` + // (dev CSS comes from Void's dev CSS collector). + // - The id stays next to the component, so CSS url() resolves as in the + // component, and it misses vite-plugin-svelte's `.svelte?` filter. + // - The CSS comes from vite-plugin-svelte's own preprocess options and + // compile, so the scope hashes match the SSR markup. + // - The component is compiled here, not loaded with `this.load`: island + // pages and layouts render only on the server, and loading them would + // resolve their server-only imports in the client build. + // - vite-plugin-svelte's `?svelte&type=style` id is not used: it only + // reads CSS already cached on the module, and its cssScopeTo drops the + // CSS when the component is not imported by the client build. apply: 'build', resolveId: { - filter: { id: /\?island-style&/ }, + filter: { id: /\.svelte\.css\?island-style$/ }, handler(source) { return source; }, }, load: { - filter: { id: /\?island-style&/ }, - handler(id) { - // ?island-style&index=N&lang.{ext} — return raw CSS of Nth `; + const page = ` +

{label}

+`; + writeFile('layout.svelte', layout); + writeFile('index.island.svelte', page); + const output = (await build({ + root: TMP, + configFile: false, + logLevel: 'silent', + plugins: [svelte(), ...islandsPlugin(PAGES_DIR, { root: TMP })], + build: { write: false, rollupOptions: { external: ['void/pages-client'] } }, + })) as Rolldown.RolldownOutput; + const css = output.output + .filter((item) => item.type === 'asset' && item.fileName.endsWith('.css')) + .map((item) => String((item as Rolldown.OutputAsset).source)) + .join(''); + + const scopeOf = (source: string, file: string) => + /svelte-[a-z0-9]+/.exec( + compile(source, { filename: normalizePath(join(PAGES_DIR, file)), generate: 'server' }).js + .code, + )![0]; + expect(css).toContain(`nav.${scopeOf(layout, 'layout.svelte')}{padding:8px}`); + expect(css).toContain(`p.${scopeOf(page, 'index.island.svelte')}{color:red}`); + expect(css).toContain('body{margin:0}'); + }); + + it('watches files the style preprocessor reads', async () => { + const partial = join(PAGES_DIR, '_theme.css'); + writeFile('_theme.css', 'nav { padding: 8px; }'); writeFile( - 'Card.island.svelte', - `

Card

- -`, + 'layout.svelte', + ` + +{@render children()} +`, ); - const load = plugin!.load as HookWithHandler<(id: string) => string>; - const file = join(PAGES_DIR, 'Card.island.svelte'); - expect(load.handler(`${file}?island-style&index=1&lang.css`)).toBe('.second { color: blue; }'); + writeFile('index.island.svelte', '

Home

'); + + const watched: Array = []; + const plugins = islandsPlugin(PAGES_DIR, { root: TMP }); + const stylePlugin = plugins.find((plugin) => plugin.name === 'void-svelte:islands-style')!; + const load = stylePlugin.load as HookWithHandler< + (this: Rolldown.PluginContext, id: string) => unknown + >; + const handler = load.handler; + load.handler = function (id) { + const context = new Proxy(this, { + get(target, key) { + if (key === 'addWatchFile') { + return (file: string) => { + watched.push(normalizePath(file)); + target.addWatchFile(file); + }; + } + const value = Reflect.get(target, key, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + return handler.call(context, id); + }; + + await build({ + root: TMP, + configFile: false, + logLevel: 'silent', + plugins: [ + svelte({ + preprocess: { + style: ({ content }) => ({ code: content, dependencies: [partial] }), + }, + }), + ...plugins, + ], + build: { write: false, rollupOptions: { external: ['void/pages-client'] } }, + }); + + expect(watched).toContain(normalizePath(partial)); }); }); diff --git a/packages/void/src/cli/cf-version-api.ts b/packages/void/src/cli/cf-version-api.ts index c0aa2d548..597128db5 100644 --- a/packages/void/src/cli/cf-version-api.ts +++ b/packages/void/src/cli/cf-version-api.ts @@ -8,6 +8,7 @@ import { getWorkerBundleDir, readBuildOutput, } from '@cloudflare/build-output-utils'; +import type { ParsedOutputWorkerConfig } from '@cloudflare/config'; import type { CloudflareCommandResult } from '@void/deploy-cloudflare'; import ignore from 'ignore'; import mime from 'mime'; @@ -390,6 +391,31 @@ function assetConfigFromOutput(value: unknown): Json | undefined { }; } +/** + * The version API placement. Smart placement is normalized the same way as cf's own version + * uploads. Targeted placement is rejected: the Workers beta version endpoint can drop its + * target, and an atomic upload is live before a read-back could catch that. + */ +function placementFromOutput(placement: ParsedOutputWorkerConfig['placement']): Json | undefined { + if (!placement) { + return undefined; + } + const hint = 'hint' in placement ? placement.hint : undefined; + if (hint || placement.mode === 'smart') { + return { mode: 'smart', ...(hint ? { hint } : {}) }; + } + const target = + ('region' in placement && placement.region && 'region') || + ('host' in placement && placement.host && 'host') || + ('hostname' in placement && placement.hostname && 'hostname'); + if (target) { + throw new Error( + `Cloudflare placement.${target} cannot be deployed while the Worker has secrets. Remove cloudflare.placement from void.config.ts, or set it to { mode: 'smart' }.`, + ); + } + return undefined; +} + async function apiResult( url: string, token: string, @@ -1016,6 +1042,13 @@ export async function uploadPinnedCloudflareVersion(options: { 'exports', 'manifest', 'triggers', + 'limits', + 'placement', + 'cache', + // Worker-level settings that version uploads cannot set. Like observability, the + // live Worker keeps its current values. + 'logpush', + 'tailConsumers', ]).has(key), ); if (unsupported.length) { @@ -1023,6 +1056,7 @@ export async function uploadPinnedCloudflareVersion(options: { `Cloudflare Worker setting ${unsupported[0]} cannot be uploaded with pinned secrets.`, ); } + const placement = placementFromOutput(config.placement); const unsafe = object(config.unsafe); const metadata = object(unsafe?.metadata); if ( @@ -1145,6 +1179,18 @@ export async function uploadPinnedCloudflareVersion(options: { compatibility_date: config.compatibilityDate, compatibility_flags: config.compatibilityFlags, bindings, + ...(config.limits + ? { limits: { cpu_ms: config.limits.cpuMs, subrequests: config.limits.subrequests } } + : {}), + ...(placement ? { placement } : {}), + ...(config.cache + ? { + cache_options: { + enabled: config.cache.enabled, + cross_version_cache: config.cache.crossVersionCache, + }, + } + : {}), ...(exports ? { exports } : {}), ...(containers.length ? { containers } : {}), ...(jwt ? { assets: { jwt, ...(assetConfig ? { config: assetConfig } : {}) } } : {}), diff --git a/packages/void/src/cli/cloudflare-link.ts b/packages/void/src/cli/cloudflare-link.ts index 8489a55a7..aeaf8b686 100644 --- a/packages/void/src/cli/cloudflare-link.ts +++ b/packages/void/src/cli/cloudflare-link.ts @@ -225,7 +225,7 @@ export async function maybeLinkCloudflareDeploy( } const errors: Array = []; const configPath = files.length ? join(root, name) : materializeCloudflareConfig(root); - const config = parse(readFileSync(configPath, 'utf8'), errors, { + const config = parse(readFileSync(configPath, 'utf8').replace(/^\uFEFF/, ''), errors, { allowTrailingComma: true, }); if (!files.length && (!config || typeof config.name !== 'string' || !config.name.trim())) { diff --git a/packages/void/src/cli/db-args.ts b/packages/void/src/cli/db-args.ts index 54914903d..dd9ac0de9 100644 --- a/packages/void/src/cli/db-args.ts +++ b/packages/void/src/cli/db-args.ts @@ -57,8 +57,8 @@ export type DbArgs = | { action: 'push' } | { action: 'generate' } | { action: 'status' } - | { action: 'reset' } - | { action: 'seed'; file?: string } + | { action: 'reset'; yes?: boolean } + | { action: 'seed'; file?: string; yes?: boolean } | { action: 'execute'; command?: string; file?: string; remote?: boolean } | { action: 'migrate'; remote?: boolean } | { action: 'studio'; remote?: boolean } @@ -114,6 +114,7 @@ export function parseDbArgs(args: Array): DbArgs { name: { type: 'string' }, region: { type: 'string' }, 'local-only': { type: 'boolean' }, + yes: { type: 'boolean' }, }, allowPositionals: true, strict: true, @@ -134,8 +135,10 @@ export function parseDbArgs(args: Array): DbArgs { } switch (action) { + case 'reset': + return { action, yes: values.yes }; case 'seed': - return { action, file: values.file }; + return { action, file: values.file, yes: values.yes }; case 'execute': { const [command] = rest; if (!command && !values.file) { diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 1167fe17c..abb667084 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -11,6 +11,7 @@ import { import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import { confirm, isCancel, outro, log, text } from '@voidzero-dev/vite-plus-prompts'; +import { createJiti } from 'jiti'; import { basename, dirname, extname, join, relative, resolve } from 'pathslash'; import { readConfig, @@ -192,9 +193,9 @@ export async function runDbCommand(root: string, args: DbArgs): Promise { } else if (args.action === 'status') { await runStatus(root, dialect); } else if (args.action === 'reset') { - await runReset(root, dialect); + await runReset(root, dialect, args.yes); } else if (args.action === 'seed') { - await runSeed(root, args.file, dialect); + await runSeed(root, args.file, dialect, args.yes); } else if (args.action === 'execute') { await runExecute(root, args.command, args.file, dialect, args.remote); } else if (args.action === 'migrate') { @@ -525,7 +526,39 @@ async function runStatus(root: string, dialect: DatabaseDialect): Promise outro(parts.join(', ')); } -async function resetDatabase(root: string, dialect: DatabaseDialect): Promise { +// Hyperdrive reaches only public hosts, so a loopback or socket database is never the deployed one. +const LOCAL_DATABASE_HOSTS = new Set(['', 'localhost', '127.0.0.1', '::1', '[::1]']); + +/** + * `.env` may hold the deployed database URL (`void db connect` writes it there), so ask before + * dropping every table on any other host. Without a TTY, require `--yes`. + */ +async function confirmDropAllTables( + host: string, + target: string, + yes: boolean | undefined, +): Promise { + if (yes || LOCAL_DATABASE_HOSTS.has(host) || host.startsWith('/')) { + return; + } + if (!process.stdin.isTTY) { + log.error( + `db: Refusing to drop every table in ${target} without confirmation. Re-run with --yes.`, + ); + process.exit(1); + } + const ok = await confirm({ message: `This drops every table in ${target}. Continue?` }); + if (isCancel(ok) || !ok) { + log.info('Cancelled.'); + process.exit(0); + } +} + +async function resetDatabase( + root: string, + dialect: DatabaseDialect, + yes?: boolean, +): Promise { // Coherence check must happen before any destructive work. Report a // malformed `_journal.json` like `runStatus`, `runMigrate`, and the deploy // path do, not as a raw stack trace. @@ -540,6 +573,12 @@ async function resetDatabase(root: string, dialect: DatabaseDialect): Promise { const tables = await client.query( @@ -554,9 +593,13 @@ async function resetDatabase(root: string, dialect: DatabaseDialect): Promise { + // MySQL 8 returns an unaliased information_schema column as `TABLE_NAME`. const tables = await client.query( - "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'", + "SELECT table_name AS table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'", ); await client.query('SET FOREIGN_KEY_CHECKS = 0'); try { @@ -586,8 +629,8 @@ async function resetDatabase(root: string, dialect: DatabaseDialect): Promise { - const count = await resetDatabase(root, dialect); +async function runReset(root: string, dialect: DatabaseDialect, yes?: boolean): Promise { + const count = await resetDatabase(root, dialect, yes); if (dialect === 'sqlite') { log.info(dim("Restart your dev server if it's running.")); } @@ -598,9 +641,10 @@ async function runSeed( root: string, file: string | undefined, dialect: DatabaseDialect, + yes?: boolean, ): Promise { // Reset first (drop + re-run migrations) - const migrationCount = await resetDatabase(root, dialect); + const migrationCount = await resetDatabase(root, dialect, yes); log.step(`Reset database. Applied ${formatCount(migrationCount, 'migration')}.`); const seed = resolveSeedTarget(root, file); @@ -1788,12 +1832,16 @@ async function runExport( } tables = [args.table]; } else { + // MySQL 8 returns an unaliased information_schema column as `TABLE_NAME`. const rows = await client.query>( - "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' AND table_name NOT LIKE '\\_%' ORDER BY table_name", + "SELECT table_name AS table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' AND table_name NOT LIKE '\\_%' ORDER BY table_name", ); tables = rows.map((row) => String(row.table_name)); } + // Tables come out in name order with inline foreign keys, so a child + // table can precede its parent; restore with the checks off. + parts.push('SET FOREIGN_KEY_CHECKS = 0;'); for (const table of tables) { const escapedTable = table.replaceAll('`', '``'); if (!args.noSchema) { @@ -1819,6 +1867,7 @@ async function runExport( } parts.push(''); } + parts.push('SET FOREIGN_KEY_CHECKS = 1;', ''); }); const output = parts.join('\n'); @@ -2154,7 +2203,13 @@ function collectSchemaModuleFiles(dir: string): Array { } async function importLocalModule(modulePath: string): Promise> { - return import(`${pathToFileURL(modulePath).href}?t=${Date.now()}`); + // Native import() cannot resolve the extensionless re-exports that `void gen model` writes. + const jiti = createJiti(import.meta.url, { + fsCache: false, + moduleCache: false, + interopDefault: false, + }); + return jiti.import>(modulePath); } function getDrizzleSchemaOptions( diff --git a/packages/void/src/cli/deploy-managed-full.ts b/packages/void/src/cli/deploy-managed-full.ts index 6915dec8e..092efc450 100644 --- a/packages/void/src/cli/deploy-managed-full.ts +++ b/packages/void/src/cli/deploy-managed-full.ts @@ -388,6 +388,7 @@ export async function runFullDeploy( }); const packageStartedAt = Date.now(); const voidPackageOptions = { + bindingNames: analysis.bindingNames, compatibilityDate: wranglerCompat?.compatibilityDate, compatibilityFlags: wranglerCompat?.compatibilityFlags, cpuMs: voidConfig.worker?.limits?.cpu_ms, diff --git a/packages/void/src/cli/gen.ts b/packages/void/src/cli/gen.ts index 2e951d3ec..8396f50d5 100644 --- a/packages/void/src/cli/gen.ts +++ b/packages/void/src/cli/gen.ts @@ -576,7 +576,6 @@ function runMigration(root: string, name: string): void { cwd: root, stdio: 'pipe', encoding: 'utf-8', - shell: process.platform === 'win32', // Yarn PnP projects need the PnP loader to resolve `void/schema-*`. env: buildDrizzleKitSpawnEnv(root), }, diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 833b778bb..0ed8403b7 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -1169,11 +1169,26 @@ const HELP_TREE: HelpTree = { push: basicSubcommand('Push schema to local D1 without migration files.', 'void db push'), generate: basicSubcommand('Generate SQL migrations from schema changes.', 'void db generate'), status: basicSubcommand('Show migration status for local and remote DBs.', 'void db status'), - reset: basicSubcommand('Reset local DB and re-run migrations.', 'void db reset'), + reset: { + summary: 'Reset local DB and re-run migrations.', + usage: ['void db reset [options]'], + options: [ + { + command: '--yes', + description: 'Skip the prompt for a non-local PostgreSQL/MySQL database', + }, + ], + }, seed: { summary: 'Reset local DB, run migrations, and seed data.', usage: ['void db seed [options]'], - options: [{ command: '--file ', description: 'Seed from a specific file' }], + options: [ + { command: '--file ', description: 'Seed from a specific file' }, + { + command: '--yes', + description: 'Skip the prompt for a non-local PostgreSQL/MySQL database', + }, + ], }, execute: { summary: diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index fec669831..3c345c541 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -132,7 +132,15 @@ const EMPTY_PROJECT_ALLOWED_ENTRIES = new Set([ '.zencoder', ]); -const PNPM_ALLOWED_BUILDS = ['esbuild', 'sharp', 'workerd'] as const; +// better-sqlite3 13 already ships its Node-API binaries; no native build is needed. +const PNPM_BUILD_DEFAULTS = [ + ['better-sqlite3', false], + ['esbuild', true], + ['sharp', true], + ['workerd', true], +] as const; +// pnpm writes this value for each blocked build it wants a decision on. +const PNPM_BUILD_PLACEHOLDER = 'set this to true or false'; // Packages a previous Void Pages starter can leave behind, so re-running init // after deleting the app files still takes the starter path. @@ -722,7 +730,6 @@ export function isVoidResolvableFrom(start: string): boolean { const probe = spawnSync(process.execPath, probeArgs, { cwd: start, stdio: 'ignore', - shell: process.platform === 'win32', env: buildDrizzleKitSpawnEnv(start), }); return !probe.error && probe.status === 0; @@ -1883,9 +1890,14 @@ function ensureGitignore(root: string): void { log.step('Updated .gitignore with Void defaults'); } -function removeTopLevelYamlBlock(yaml: string, key: string): string { +/** Removes each block-style top-level `key:` block; `removed` holds its non-blank lines. */ +function removeTopLevelYamlBlock( + yaml: string, + key: string, +): { yaml: string; removed: Array } { const lines = yaml.split(/\r?\n/); const next: Array = []; + const removed: Array = []; for (let i = 0; i < lines.length; i += 1) { const line = lines[i]; if (!new RegExp(`^${key}:\\s*(?:#.*)?$`).test(line)) { @@ -1894,26 +1906,53 @@ function removeTopLevelYamlBlock(yaml: string, key: string): string { } i += 1; - while (i < lines.length && (lines[i].trim() === '' || /^\s/.test(lines[i]))) { + while (i < lines.length && (lines[i].trim() === '' || /^(?:\s|-(?:\s|$))/.test(lines[i]))) { + if (lines[i].trim() !== '') { + removed.push(lines[i]); + } i += 1; } i -= 1; } - return next.join('\n'); + return { yaml: next.join('\n'), removed }; +} + +function pnpmAllowBuildsEntryName(line: string): string | undefined { + return /^\s+(?!#)(["']?)(.+?)\1\s*:(?:\s|$)/.exec(line)?.[2]; } -function pnpmAllowBuildsYaml(): string { - // better-sqlite3 13 already ships its Node-API binaries; no native build is needed. - const lines = ['allowBuilds:', ' better-sqlite3: false']; - for (const dependency of PNPM_ALLOWED_BUILDS) { - lines.push(` ${dependency}: true`); +/** + * Keeps the existing `allowBuilds` entries as written, approves the legacy + * `onlyBuiltDependencies` list, and adds each Void default that is missing. + */ +function pnpmAllowBuildsYaml(entries: Array, legacyList: Array): string { + const legacy = legacyList.flatMap((line) => { + const name = /^\s*-\s+(["']?)(.+?)\1\s*(?:#.*)?$/.exec(line)?.[2]; + return name === undefined ? [] : [name]; + }); + const decided = new Set([...legacy, ...PNPM_BUILD_DEFAULTS.map(([name]) => name)]); + const lines = entries.filter((line) => { + const name = pnpmAllowBuildsEntryName(line); + return !(name !== undefined && decided.has(name) && line.includes(PNPM_BUILD_PLACEHOLDER)); + }); + const names = new Set(lines.map((line) => pnpmAllowBuildsEntryName(line))); + const firstEntry = lines.find((line) => pnpmAllowBuildsEntryName(line) !== undefined); + const indent = /^\s+/.exec(firstEntry ?? '')?.[0] ?? ' '; + const additions: Array = [ + ...legacy.map((name) => [name, true] as const), + ...PNPM_BUILD_DEFAULTS, + ]; + for (const [name, value] of additions) { + if (!names.has(name)) { + names.add(name); + lines.push(`${indent}${/^[\w.-]+$/.test(name) ? name : JSON.stringify(name)}: ${value}`); + } } - return lines.join('\n'); + return ['allowBuilds:', ...lines].join('\n'); } -function insertPnpmAllowBuilds(yaml: string): string { +function insertPnpmAllowBuilds(yaml: string, allowBuilds: string): string { const trimmed = yaml.trim(); - const allowBuilds = pnpmAllowBuildsYaml(); if (trimmed === '') { return `${allowBuilds}\n`; } @@ -1956,19 +1995,33 @@ function resolvePnpmConfigRoot(root: string, pkg: JsonObject): string { function ensurePnpmAllowBuilds(root: string, pkg: JsonObject): void { const configRoot = resolvePnpmConfigRoot(root, pkg); const workspacePath = join(configRoot, 'pnpm-workspace.yaml'); + const displayPath = configRoot === root ? 'pnpm-workspace.yaml' : relative(root, workspacePath); const before = existsSync(workspacePath) ? readFileSync(workspacePath, 'utf-8') : ''; - const withoutLegacy = removeTopLevelYamlBlock( - removeTopLevelYamlBlock(before, 'onlyBuiltDependencies'), - 'allowBuilds', + // A same-line value such as `allowBuilds: {...}` cannot be merged line by + // line, and a second `allowBuilds:` key would make the file invalid. + const inline = /^allowBuilds:[ \t]*([^\s#].*)$/m.exec(before)?.[1]; + if (inline !== undefined) { + const missing = PNPM_BUILD_DEFAULTS.filter( + ([name]) => !new RegExp(`[{,]\\s*["']?${name}["']?\\s*:`).test(inline), + ); + if (missing.length > 0) { + log.warn( + `Add ${missing.map(([name, value]) => `${name}: ${value}`).join(', ')} to allowBuilds in ${displayPath}. ` + + 'Void only edits allowBuilds when each entry is on its own line.', + ); + } + return; + } + + const legacy = removeTopLevelYamlBlock(before, 'onlyBuiltDependencies'); + const current = removeTopLevelYamlBlock(legacy.yaml, 'allowBuilds'); + const after = insertPnpmAllowBuilds( + current.yaml, + pnpmAllowBuildsYaml(current.removed, legacy.removed), ); - const after = insertPnpmAllowBuilds(withoutLegacy); if (after !== before) { writeFileSync(workspacePath, after); - log.step( - configRoot === root - ? 'Updated pnpm-workspace.yaml with approved builds' - : `Updated ${relative(root, workspacePath)} with approved builds`, - ); + log.step(`Updated ${displayPath} with approved builds`); } } @@ -2051,7 +2104,6 @@ export function generateStarterMigration( cwd: root, stdio: 'pipe', encoding: 'utf-8', - shell: process.platform === 'win32', env: buildDrizzleKitSpawnEnv(root), }); diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 06ebd3339..22e4fcfec 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -1771,7 +1771,8 @@ function detectFormattingOptions(text: string): FormattingOptions { function parseJsonc(text: string): Record { const errors: Array = []; - const result = parse(text, errors, { allowTrailingComma: true }); + const source = text.startsWith(UTF8_BOM) ? text.slice(UTF8_BOM.length) : text; + const result = parse(source, errors, { allowTrailingComma: true }); if (errors.length > 0) { throw new SyntaxError('Failed to parse JSONC'); } diff --git a/packages/void/src/migrations/runner-mysql.ts b/packages/void/src/migrations/runner-mysql.ts index ab20c753a..ca130b02d 100644 --- a/packages/void/src/migrations/runner-mysql.ts +++ b/packages/void/src/migrations/runner-mysql.ts @@ -1,4 +1,5 @@ import type { Connection } from 'mysql2/promise'; +import { splitMigrationSql } from '../runtime/migration-handler.ts'; import { collectMigrations } from './collect.ts'; export type MySqlClient = { @@ -64,7 +65,10 @@ export async function applyMySqlMigrations( const pending = migrations.filter((migration) => !appliedSet.has(migration.name)); for (const migration of pending) { - await client.query(migration.sql); + // MySQL does not read drizzle-kit `--> statement-breakpoint` lines as comments. + for (const statement of splitMigrationSql(migration.sql)) { + await client.query(statement); + } await client.query('INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', [ migration.name, new Date().toISOString(), diff --git a/packages/void/src/plugin-framework-triggers.ts b/packages/void/src/plugin-framework-triggers.ts index b7989deed..9f66ab397 100644 --- a/packages/void/src/plugin-framework-triggers.ts +++ b/packages/void/src/plugin-framework-triggers.ts @@ -255,8 +255,8 @@ export async function handleDevTrigger(request, env, ctx) { `; } -/** Append any missing `lines` to the project's `.gitignore`. */ -function ensureGitignoreLines(root: string, lines: ReadonlyArray): void { +/** Append any missing `lines` to the project's `.gitignore` and return the appended ones. */ +function ensureGitignoreLines(root: string, lines: ReadonlyArray): ReadonlyArray { const gitignorePath = join(root, '.gitignore'); let contents = ''; if (existsSync(gitignorePath)) { @@ -265,10 +265,28 @@ function ensureGitignoreLines(root: string, lines: ReadonlyArray): void const existing = new Set(contents.split('\n').map((line) => line.trim())); const missing = lines.filter((line) => !existing.has(line)); if (missing.length === 0) { - return; + return []; } const sep = contents.length === 0 || contents.endsWith('\n') ? '' : '\n'; writeFileSync(gitignorePath, `${contents}${sep}${missing.join('\n')}\n`); + return missing; +} + +/** Remove `lines` from the project's `.gitignore`, if it still exists. */ +function removeGitignoreLines(root: string, lines: ReadonlyArray): void { + const gitignorePath = join(root, '.gitignore'); + if (lines.length === 0 || !existsSync(gitignorePath)) { + return; + } + const contents = readFileSync(gitignorePath, 'utf-8'); + const remove = new Set(lines); + const next = contents + .split('\n') + .filter((line) => !remove.has(line.trim())) + .join('\n'); + if (next !== contents) { + writeFileSync(gitignorePath, next); + } } const NITRO_MIDDLEWARE_FILENAME = '__void-dev-trigger.ts'; @@ -546,12 +564,15 @@ export const onRequest = defineMiddleware(async (context, next) => { * one-shot snippet they can paste into their existing middleware. * * If we wrote the file we remove it on dev-server close so the user's - * source tree stays clean. + * source tree stays clean. `src/middleware.ts` belongs to the user, so the + * `.gitignore` line we add for it is removed too; otherwise a middleware they + * write later would be silently left out of git. */ export function astroDevTriggerInjectPlugin(root: string): Plugin { const middlewarePath = join(root, ASTRO_MIDDLEWARE_RELATIVE); const middlewareDirIndexPath = join(root, 'src', 'middleware', 'index.ts'); let wroteFile = false; + let addedGitignoreLines: ReadonlyArray = []; const ensureFile = () => { if (existsSync(middlewarePath) || existsSync(middlewareDirIndexPath)) { @@ -571,6 +592,8 @@ export function astroDevTriggerInjectPlugin(root: string): Plugin { if (wroteFile) { rmSync(middlewarePath, { force: true }); } + removeGitignoreLines(root, addedGitignoreLines); + addedGitignoreLines = []; }; return { @@ -579,7 +602,7 @@ export function astroDevTriggerInjectPlugin(root: string): Plugin { configureServer(server) { ensureFile(); if (wroteFile) { - ensureGitignoreLines(root, [ASTRO_MIDDLEWARE_RELATIVE]); + addedGitignoreLines = ensureGitignoreLines(root, [ASTRO_MIDDLEWARE_RELATIVE]); } server.httpServer?.once('close', cleanup); process.once('exit', cleanup); diff --git a/packages/void/src/runtime/auth-migrations.ts b/packages/void/src/runtime/auth-migrations.ts index 3d04a2c29..98ab45a93 100644 --- a/packages/void/src/runtime/auth-migrations.ts +++ b/packages/void/src/runtime/auth-migrations.ts @@ -195,7 +195,8 @@ function buildAddColumnSql( fieldName: string, field: VoidAuthMigrationField, ): string { - const ifNotExists = schema.dialect === 'sqlite' ? '' : ' IF NOT EXISTS'; + // Only PostgreSQL accepts ADD COLUMN IF NOT EXISTS; callers check the column exists first. + const ifNotExists = schema.dialect === 'postgresql' ? ' IF NOT EXISTS' : ''; return `ALTER TABLE ${quoteIdentifier(tableName, schema.dialect)} ADD COLUMN${ifNotExists} ${buildColumnDefinition( schema.dialect, schema, @@ -350,8 +351,9 @@ export async function applyMySqlAuthMigrations( 'SELECT DATABASE() AS database_name', ); const databaseName = String(databaseRows[0]?.database_name ?? ''); + // MySQL 8 labels unaliased information_schema columns in uppercase, so alias each one. const tableRows = await runner.all<{ table_name?: string }>( - "SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE'", + "SELECT TABLE_NAME AS table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE'", [databaseName], ); const existingTables = new Set(tableRows.map((row) => String(row.table_name))); @@ -361,12 +363,8 @@ export async function applyMySqlAuthMigrations( await runner.run(buildCreateTableSql(schema, table)); existingTables.add(table.name); } else { - const columnRows = await runner.all<{ - column_name?: string; - data_type?: string; - character_maximum_length?: number | string | null; - }>( - 'SELECT column_name, data_type, character_maximum_length FROM information_schema.columns WHERE table_schema = ? AND table_name = ?', + const columnRows = await runner.all<{ column_name?: string; data_type?: string }>( + 'SELECT COLUMN_NAME AS column_name, DATA_TYPE AS data_type FROM information_schema.columns WHERE table_schema = ? AND table_name = ?', [databaseName, table.name], ); const existingColumns = new Map( @@ -386,7 +384,7 @@ export async function applyMySqlAuthMigrations( } const indexRows = await runner.all<{ index_name?: string }>( - 'SELECT index_name FROM information_schema.statistics WHERE table_schema = ? AND table_name = ?', + 'SELECT INDEX_NAME AS index_name FROM information_schema.statistics WHERE table_schema = ? AND table_name = ?', [databaseName, table.name], ); const existingIndexes = new Set(indexRows.map((row) => String(row.index_name))); diff --git a/packages/void/src/runtime/migration-handler-mysql.ts b/packages/void/src/runtime/migration-handler-mysql.ts index 6a9839c32..e5563f39b 100644 --- a/packages/void/src/runtime/migration-handler-mysql.ts +++ b/packages/void/src/runtime/migration-handler-mysql.ts @@ -1,4 +1,9 @@ -import type { MigrationEntry, MigrationResponse, MigrationResult } from './migration-handler.ts'; +import { + splitMigrationSql, + type MigrationEntry, + type MigrationResponse, + type MigrationResult, +} from './migration-handler.ts'; export function createMigrationHandler( env: Record, @@ -62,7 +67,10 @@ async function runMySqlMigrations( } const start = Date.now(); try { - await connection.query(migration.sql); + // MySQL does not read drizzle-kit `--> statement-breakpoint` lines as comments. + for (const statement of splitMigrationSql(migration.sql)) { + await connection.query(statement); + } await connection.query( 'INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', [migration.name, new Date().toISOString()], diff --git a/packages/void/src/runtime/migration-handler.ts b/packages/void/src/runtime/migration-handler.ts index f815914f3..b12dfc49a 100644 --- a/packages/void/src/runtime/migration-handler.ts +++ b/packages/void/src/runtime/migration-handler.ts @@ -711,7 +711,7 @@ async function recordLegacyMigration(db: D1Database, name: string): Promise { +export function splitMigrationSql(sql: string): Array { const lines = sql.split(/\r?\n/); if ( !lines.some((line) => isStatementBreakpointLine(line) || hasInlineStatementBreakpoint(line)) diff --git a/packages/void/src/runtime/remote/d1.ts b/packages/void/src/runtime/remote/d1.ts index b682ee67a..7f9804f76 100644 --- a/packages/void/src/runtime/remote/d1.ts +++ b/packages/void/src/runtime/remote/d1.ts @@ -47,6 +47,17 @@ export class ProxyD1Database { dump(): Promise { throw new Error('dump() is not supported with remote bindings'); } + + // Each statement is a separate proxy call that the platform runs on the + // deployed binding without a session, so there is no replica or bookmark + // state to carry. The proxy itself serves as the session. + withSession(_constraintOrBookmark?: string): ProxyD1Database { + return this; + } + + getBookmark(): string | null { + return null; + } } /** diff --git a/packages/void/test/integration/full-deploy-manifest.test.ts b/packages/void/test/integration/full-deploy-manifest.test.ts index 13e542ab8..0ef702709 100644 --- a/packages/void/test/integration/full-deploy-manifest.test.ts +++ b/packages/void/test/integration/full-deploy-manifest.test.ts @@ -88,7 +88,9 @@ let originalFetch: typeof globalThis.fetch | undefined; let savedToken: string | undefined; let savedProject: string | undefined; -type Captured = { finalizeBody: { manifest?: { pages?: unknown } } | null }; +type Captured = { + finalizeBody: { manifest?: { pages?: unknown; bindings?: Record } } | null; +}; // Every asset is reported as present, so the deploy skips uploads and goes straight to finalize. function installPlatformMocks(role: ProjectRole = 'owner'): Captured { @@ -292,6 +294,30 @@ describe('runFullDeploy emits manifest.pages (Void pages app, end-to-end)', () = expect(captured.finalizeBody!.manifest?.pages).toBeUndefined(); }); + it('sends custom inference.bindings names in the manifest bindings', async () => { + const configPath = join(fixture, 'void.config.ts'); + const original = readFileSync(configPath, 'utf8'); + writeFileSync( + configPath, + original.replace( + 'export default defineConfig({', + "export default defineConfig({\n inference: { bindings: { kv: 'MY_KV', storage: 'MY_BUCKET' } },", + ), + ); + try { + const captured = installPlatformMocks(); + + await runDeploy(fixture, { skipBuild: true }); + + expect(captured.finalizeBody?.manifest?.bindings).toMatchObject({ + kv: ['MY_KV'], + r2: ['MY_BUCKET'], + }); + } finally { + writeFileSync(configPath, original); + } + }); + it('uses the alternate project for Worker packaging and prerendering while retaining the saved link', async () => { const original = readFileSync(projectJson, 'utf8'); const saved = JSON.stringify({ diff --git a/packages/void/test/unit/auth-migrations.test.ts b/packages/void/test/unit/auth-migrations.test.ts index ee85acf59..28d3c13e3 100644 --- a/packages/void/test/unit/auth-migrations.test.ts +++ b/packages/void/test/unit/auth-migrations.test.ts @@ -222,4 +222,66 @@ describe('auth migration runner', () => { ]); expect(statements.some((sql) => sql.includes('`userId`'))).toBe(false); }); + + it('re-runs MySQL auth migrations against MySQL 8 uppercase information_schema labels', async () => { + const statements: Array = []; + const resolved = resolveVoidAuthMigrationSchema({ dialect: 'mysql', providers: null }); + const user = resolved.tables.find((table) => table.name === 'user')!; + const schema: VoidAuthMigrationSchema = { + ...resolved, + tables: [ + { + ...user, + fields: { + ...user.fields, + nickname: { + type: 'string', + required: false, + unique: false, + index: false, + bigint: false, + }, + }, + }, + ], + }; + // MySQL 8 labels an unaliased information_schema column with its uppercase name. + const row = (sql: string, values: Record) => + Object.fromEntries( + Object.entries(values).map(([column, value]) => [ + new RegExp(`\\bAS ${column}\\b`).test(sql) ? column : column.toUpperCase(), + value, + ]), + ); + + await applyMySqlAuthMigrations( + { + async all>(sql: string) { + if (sql.includes('SELECT DATABASE()')) { + return [row(sql, { database_name: 'app' })] as unknown as Array; + } + if (sql.includes('information_schema.tables')) { + return [row(sql, { table_name: 'user' })] as unknown as Array; + } + if (sql.includes('information_schema.columns')) { + return ['id', ...Object.keys(user.fields)].map((column_name) => + row(sql, { column_name, data_type: 'varchar' }), + ) as unknown as Array; + } + if (sql.includes('information_schema.statistics')) { + return ['PRIMARY', 'user_email_uidx'].map((index_name) => + row(sql, { index_name }), + ) as unknown as Array; + } + return [] as Array; + }, + async run(sql: string) { + statements.push(sql); + }, + }, + schema, + ); + + expect(statements).toEqual(['ALTER TABLE `user` ADD COLUMN `nickname` varchar(255)']); + }); }); diff --git a/packages/void/test/unit/cf-version-runner.test.ts b/packages/void/test/unit/cf-version-runner.test.ts index 72283e421..0e534b39e 100644 --- a/packages/void/test/unit/cf-version-runner.test.ts +++ b/packages/void/test/unit/cf-version-runner.test.ts @@ -931,6 +931,126 @@ it('uploads live secrets and multiple asset batches when the latest upload is in it('uploads live secrets and multiple asset batches for atomic publication', () => verifyPinnedUpload(true)); +it('uploads Worker limits, placement and cache with pinned secrets', async () => { + const prepared = await output({ + secrets: { required: ['AUTH_SECRET'] }, + limits: { cpu_ms: 30_000 }, + placement: { mode: 'smart' }, + cache: { enabled: true, cross_version_cache: true }, + logpush: true, + tail_consumers: [{ service: 'tail-worker' }], + }); + try { + await pinActiveWorkerSecrets({ + output: prepared, + activeVersionId: '11111111-1111-4111-8111-111111111111', + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + requiredNames: ['AUTH_SECRET'], + }); + const uploads: Array> = []; + const server = createServer(async (request, response) => { + const chunks: Array = []; + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)); + } + if (request.method === 'POST') { + uploads.push(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + success: true, + errors: [], + result: + request.method === 'GET' + ? { + id: '11111111-1111-4111-8111-111111111111', + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + } + : { id: '22222222-2222-4222-8222-222222222222', urls: [] }, + }), + ); + }); + const uploaded = await uploadPinnedCloudflareVersion({ + output: prepared, + accountId: 'account-id', + apiBaseUrl: await listen(server), + workerName: 'void-test', + token: 'synthetic-token', + atomic: false, + verifyUploader: () => true, + }); + expect(uploaded.id).toBe('22222222-2222-4222-8222-222222222222'); + expect(uploads).toHaveLength(1); + expect(uploads[0]).toMatchObject({ + limits: { cpu_ms: 30_000 }, + placement: { mode: 'smart' }, + cache_options: { enabled: true, cross_version_cache: true }, + bindings: [ + { + name: 'AUTH_SECRET', + type: 'inherit', + version_id: '11111111-1111-4111-8111-111111111111', + }, + ], + }); + expect(uploads[0]).not.toHaveProperty('logpush'); + expect(uploads[0]).not.toHaveProperty('tail_consumers'); + } finally { + prepared.dispose(); + } +}); + +it.each([ + { region: 'aws:us-east-1' }, + { host: 'db.example.test:5432' }, + { hostname: 'api.example.test' }, +])('rejects targeted placement with pinned secrets before any upload: %j', async (placement) => { + const prepared = await output({ secrets: { required: ['AUTH_SECRET'] }, placement }); + try { + await pinActiveWorkerSecrets({ + output: prepared, + activeVersionId: '11111111-1111-4111-8111-111111111111', + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + requiredNames: ['AUTH_SECRET'], + }); + const requests: Array = []; + const server = createServer((request, response) => { + requests.push(`${request.method} ${request.url}`); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + success: true, + errors: [], + result: + request.method === 'GET' + ? { + id: '11111111-1111-4111-8111-111111111111', + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + } + : { id: '22222222-2222-4222-8222-222222222222', urls: [] }, + }), + ); + }); + await expect( + uploadPinnedCloudflareVersion({ + output: prepared, + accountId: 'account-id', + apiBaseUrl: await listen(server), + workerName: 'void-test', + token: 'synthetic-token', + atomic: true, + verifyUploader: () => true, + }), + ).rejects.toThrow( + `Cloudflare placement.${Object.keys(placement)[0]} cannot be deployed while the Worker has secrets. Remove cloudflare.placement from void.config.ts, or set it to { mode: 'smart' }.`, + ); + expect(requests).toEqual([]); + } finally { + prepared.dispose(); + } +}); + it.each(['missing name', 'duplicate name', 'external collision', 'class collision'] as const)( 'rejects an invalid local Workflow declaration before any provider request: %s', async (mode) => { diff --git a/packages/void/test/unit/cloudflare-link.test.ts b/packages/void/test/unit/cloudflare-link.test.ts index 572c7f3d5..799bb915a 100644 --- a/packages/void/test/unit/cloudflare-link.test.ts +++ b/packages/void/test/unit/cloudflare-link.test.ts @@ -35,6 +35,18 @@ describe('Cloudflare deployment onboarding', () => { expect(readDeploymentPlatform(root)).toBeNull(); expect(readFileSync(join(root, 'wrangler.jsonc'), 'utf8')).toBe(config); }); + it('offers to link a Worker whose config starts with a UTF-8 BOM', async () => { + const bomConfig = `\uFEFF${config}`; + writeFileSync(join(root, 'wrangler.jsonc'), bomConfig); + expect( + await maybeLinkCloudflareDeploy( + root, + {}, + { interactive: true, confirm: vi.fn().mockResolvedValue(true) }, + ), + ).toBe('existing'); + expect(readFileSync(join(root, 'wrangler.jsonc'), 'utf8')).toBe(bomConfig); + }); it('offers to link a Worker configured through a custom Astro JSONC path', async () => { rmSync(join(root, 'wrangler.jsonc')); writeFileSync(join(root, 'wrangler.build.jsonc'), config); diff --git a/packages/void/test/unit/db-args.test.ts b/packages/void/test/unit/db-args.test.ts index 8df712327..7d984cba8 100644 --- a/packages/void/test/unit/db-args.test.ts +++ b/packages/void/test/unit/db-args.test.ts @@ -21,6 +21,11 @@ describe('parseDbArgs', () => { }); }); + it('parses --yes for reset and seed', () => { + expect(parseDbArgs(['reset', '--yes'])).toEqual({ action: 'reset', yes: true }); + expect(parseDbArgs(['seed', '--yes'])).toEqual({ action: 'seed', yes: true }); + }); + it('parses execute with inline SQL', () => { expect(parseDbArgs(['execute', 'SELECT * FROM users'])).toEqual({ action: 'execute', diff --git a/packages/void/test/unit/db-commands.test.ts b/packages/void/test/unit/db-commands.test.ts index 1ed02a7ee..ffbbb0f8f 100644 --- a/packages/void/test/unit/db-commands.test.ts +++ b/packages/void/test/unit/db-commands.test.ts @@ -13,6 +13,8 @@ import { toSlash } from 'pathslash'; import { afterEach, beforeEach, describe, it, expect, vi } from 'vite-plus/test'; const mockExecFileSync = vi.hoisted(() => vi.fn()); +const mockPgQuery = vi.hoisted(() => vi.fn(async (_sql: string) => ({ rows: [] }))); +const mockConfirm = vi.fn(); const mockGetMiniflareDatabasePath = vi.fn(); const mockOpenSqliteDatabase = vi.fn(); const mockGetAppliedMigrations = vi.fn(); @@ -23,6 +25,39 @@ vi.mock('node:child_process', () => ({ execFileSync: (...args: Array) => mockExecFileSync(...args), })); +vi.mock('pg', async (importOriginal) => { + // Keep pg's own connection-string parsing; stub only the network calls. + class Client extends (await importOriginal()).Client {} + Object.assign(Client.prototype, { + async connect() {}, + async end() {}, + query: (sql: string) => mockPgQuery(sql), + }); + return { Client }; +}); + +const mysqlQueries = vi.hoisted((): Array => []); + +vi.mock('mysql2/promise', () => ({ + createConnection: async () => ({ + async end() {}, + async query(sql: string) { + mysqlQueries.push(sql); + // MySQL 8 labels an unaliased information_schema column in upper case. + const tables = /^SELECT table_name(?: AS (\w+))? FROM information_schema\.tables/.exec(sql); + if (tables) { + const key = tables[1] ?? 'TABLE_NAME'; + return [[{ [key]: '_void_migrations' }, { [key]: 'users' }], []]; + } + if (sql === 'SELECT `name` FROM `_void_migrations`') { + const dropped = mysqlQueries.includes('DROP TABLE IF EXISTS `_void_migrations`'); + return [dropped ? [] : [{ name: '0000_init.sql' }], []]; + } + return [[], []]; + }, + }), +})); + vi.mock('../../src/migrations/runner', async () => { const actual = await vi.importActual( '../../src/migrations/runner', @@ -99,6 +134,8 @@ const mockLog = { }; vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ + confirm: (...args: Array) => mockConfirm(...args), + isCancel: () => false, intro: vi.fn(), outro: vi.fn(), log: { @@ -357,6 +394,102 @@ describe('void db reset', () => { expect(existsSync(fakeDbPath)).toBe(true); expect(mockRunMigrations).not.toHaveBeenCalled(); }); + + it('PostgreSQL: asks before dropping every table in a non-local DATABASE_URL', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-reset-pg-remote-')); + rootsToClean.push(root); + mkdirSync(join(root, 'db', 'migrations'), { recursive: true }); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: 'pg' })); + writeFileSync( + join(root, '.env'), + 'DATABASE_URL="postgresql://user:secret@db.example.com:5432/prod"\n', + ); + const stdinTty = process.stdin.isTTY; + try { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }); + await expect(runDbCommand(root, { action: 'reset' })).rejects.toThrow('process.exit:1'); + const message = String(mockLog.error.mock.calls[0][0]); + expect(message).toContain('"prod" on db.example.com:5432'); + expect(message).toContain('--yes'); + expect(message).not.toContain('secret'); + + writeFileSync( + join(root, '.env'), + 'DATABASE_URL="postgresql://user:secret@localhost/prod?host=db.example.com"\n', + ); + await expect(runDbCommand(root, { action: 'reset' })).rejects.toThrow('process.exit:1'); + expect(String(mockLog.error.mock.lastCall?.[0])).toContain('"prod" on db.example.com:5432'); + + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); + mockConfirm.mockResolvedValue(false); + await expect(runDbCommand(root, { action: 'seed' })).rejects.toThrow('process.exit:0'); + expect(mockConfirm).toHaveBeenCalledOnce(); + + expect(mockPgQuery).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: stdinTty }); + } + }); + + it('MySQL: drops every table and re-applies migrations', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-reset-mysql-')); + rootsToClean.push(root); + mkdirSync(join(root, 'db', 'migrations'), { recursive: true }); + writeFileSync( + join(root, 'db', 'migrations', '0000_init.sql'), + 'CREATE TABLE `users` (id INT);', + ); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: 'mysql' })); + writeFileSync(join(root, '.env'), 'DATABASE_URL="mysql://root@127.0.0.1:3306/app"\n'); + mysqlQueries.length = 0; + + await runDbCommand(root, { action: 'reset' }); + + expect(mysqlQueries).toContain('DROP TABLE IF EXISTS `_void_migrations`'); + expect(mysqlQueries).toContain('DROP TABLE IF EXISTS `users`'); + expect(mysqlQueries).toContain('CREATE TABLE `users` (id INT);'); + }); +}); + +describe('void db export', () => { + let runDbCommand: typeof import('../../src/cli/db.ts').runDbCommand; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as never); + runDbCommand = (await import('../../src/cli/db.ts')).runDbCommand; + }); + + afterEach(() => { + vi.restoreAllMocks(); + for (const root of rootsToClean.splice(0, rootsToClean.length)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('MySQL: exports every table with foreign key checks off', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-export-mysql-')); + rootsToClean.push(root); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: 'mysql' })); + writeFileSync(join(root, '.env'), 'DATABASE_URL="mysql://root@127.0.0.1:3306/app"\n'); + mysqlQueries.length = 0; + let stdout = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + stdout += chunk.toString(); + return true; + }); + + await runDbCommand(root, { action: 'export' }); + + expect(mysqlQueries).toContain('SHOW CREATE TABLE `users`'); + expect(mysqlQueries).toContain('SELECT * FROM `users`'); + expect(mysqlQueries.join('\n')).not.toContain('`undefined`'); + const lines = stdout.trim().split('\n'); + expect(lines[0]).toBe('SET FOREIGN_KEY_CHECKS = 0;'); + expect(lines.at(-1)).toBe('SET FOREIGN_KEY_CHECKS = 1;'); + }); }); describe('void db status', () => { diff --git a/packages/void/test/unit/db-seed.test.ts b/packages/void/test/unit/db-seed.test.ts index f1ee2a776..24fd3f149 100644 --- a/packages/void/test/unit/db-seed.test.ts +++ b/packages/void/test/unit/db-seed.test.ts @@ -1,5 +1,7 @@ +import { spawnSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import * as output from '../../src/cli/output.ts'; import { getMiniflareDatabasePath } from '../../src/migrations/runner.ts'; @@ -146,6 +148,46 @@ export const messages = sqliteTable("messages", { expect(output.printSuccess).toHaveBeenCalledWith('Seeded from db/seed.ts.'); }); + it('seeds when db/schema.ts re-exports an extensionless module', async () => { + const root = createProjectRoot({ + schema: 'export * from "./schema/messages";\n', + seedFile: { + name: 'seed.ts', + content: `export default async function seed({ db, schema }) { + await db.insert(schema.messages).values({ text: "from barrel" }); +} +`, + }, + }); + mkdirSync(join(root, 'db', 'schema')); + writeFileSync( + join(root, 'db', 'schema', 'messages.ts'), + `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const messages = sqliteTable("messages", { + id: integer("id").primaryKey({ autoIncrement: true }), + text: text("text").notNull(), +}); +`, + ); + const dbModule = pathToFileURL(join(import.meta.dirname, '../../src/cli/db.ts')).href; + + // Plain Node, because Vitest's module runner resolves extensionless imports. + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `const { runDbCommand } = await import(${JSON.stringify(dbModule)}); +await runDbCommand(${JSON.stringify(root)}, { action: 'seed' });`, + ], + { encoding: 'utf8', timeout: 20_000 }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(await readMessages(root)).toEqual([{ id: 1, text: 'from barrel' }]); + }, 30_000); + it('fails when multiple default seed files exist', async () => { const root = createProjectRoot({ seedFile: { diff --git a/packages/void/test/unit/gen.test.ts b/packages/void/test/unit/gen.test.ts index c60e5dcd2..d3a4bc2c9 100644 --- a/packages/void/test/unit/gen.test.ts +++ b/packages/void/test/unit/gen.test.ts @@ -739,6 +739,20 @@ describe('gen migration', () => { ).toEqual({ fresh: { name: 'fresh' } }); }); + // cmd.exe would split `C:\Program Files\nodejs\node.exe` and paths with spaces. + it('runs drizzle-kit without a shell on Windows', async () => { + seedJournalBackedProject(); + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }); + try { + await runGenCommand(TMP, { subcommand: 'migration', name: 'backfill_users' }); + } finally { + Object.defineProperty(process, 'platform', platform); + } + + expect(findGenerateCall()!.options).not.toHaveProperty('shell', true); + }); + it('refuses when the journal dialect disagrees with the void.json dialect', async () => { const { migrationsDir, journalPath, journal } = seedJournalBackedProject(); writeFileSync(join(TMP, 'void.json'), JSON.stringify({ database: 'pg' })); diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index 401a8d47e..20760122b 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -3596,6 +3596,41 @@ export default config; rmSync(root, { recursive: true, force: true }); } }); + + it('keeps the existing pnpm build approvals when adding the Void defaults', async () => { + const roots: Array = []; + const initWithWorkspaceYaml = async (yaml: string): Promise => { + const root = mkdtempSync(join(tmpdir(), 'void-init-')); + roots.push(root); + writeExistingViteApp(root); + writeFileSync(join(root, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n"); + writeFileSync(join(root, 'pnpm-workspace.yaml'), yaml); + mockSelect.mockResolvedValueOnce('none'); + mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(false); + await runInitCommand(root); + return readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf-8'); + }; + try { + expect( + await initWithWorkspaceYaml( + 'allowBuilds:\n "@parcel/watcher": true\n better-sqlite3: true\nonlyBuiltDependencies:\n - bcrypt\n', + ), + ).toBe( + 'allowBuilds:\n "@parcel/watcher": true\n better-sqlite3: true\n bcrypt: true\n esbuild: true\n sharp: true\n workerd: true\n', + ); + expect(await initWithWorkspaceYaml('onlyBuiltDependencies:\n- bcrypt\n')).toBe( + 'allowBuilds:\n bcrypt: true\n better-sqlite3: false\n esbuild: true\n sharp: true\n workerd: true\n', + ); + + const flow = 'allowBuilds: {"@parcel/watcher": true}\n'; + expect(await initWithWorkspaceYaml(flow)).toBe(flow); + expect(mockLogWarn).toHaveBeenCalledWith(expect.stringContaining('esbuild: true')); + } finally { + for (const root of roots) { + rmSync(root, { recursive: true, force: true }); + } + } + }); }); describe('detectProjectState', () => { @@ -3811,4 +3846,29 @@ describe('generateStarterMigration', () => { expect(existsSync(join(migrationsDir, '0001_new.sql'))).toBe(false); }); }); + + // cmd.exe would split `C:\Program Files\nodejs\node.exe` and paths with spaces. + it('runs drizzle-kit and the Yarn PnP probe without a shell on Windows', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }); + try { + withTempDir((root) => { + seedStarterSchema(root); + writeFileSync(join(root, '.pnp.cjs'), '// pnp stub\n'); + mkdirSync(join(root, '.yarn'), { recursive: true }); + writeFileSync(join(root, '.yarn', 'install-state.gz'), ''); + mockSpawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' }); + + generateStarterMigration(root, 'sqlite'); + expect(isVoidResolvableFrom(root)).toBe(true); + + const nodeSpawns = mockSpawnSync.mock.calls.filter( + ([command]) => command === process.execPath, + ); + expect(nodeSpawns.map(([, , options]) => Boolean(options.shell))).toEqual([false, false]); + }); + } finally { + Object.defineProperty(process, 'platform', platform); + } + }); }); diff --git a/packages/void/test/unit/plugin-framework-triggers.test.ts b/packages/void/test/unit/plugin-framework-triggers.test.ts index 374d2b6d7..2ec2b9822 100644 --- a/packages/void/test/unit/plugin-framework-triggers.test.ts +++ b/packages/void/test/unit/plugin-framework-triggers.test.ts @@ -555,6 +555,28 @@ describe('astroDevTriggerInjectPlugin', () => { 'Astro middleware already exists', ); }); + + it('stops ignoring src/middleware.ts after the dev server closes', () => { + const root = createTempRoot(); + writeFileSync(join(root, '.gitignore'), 'node_modules/\n'); + let onClose: (() => void) | undefined; + // The temp root is removed after each test, so skip the process exit cleanup hook. + vi.spyOn(process, 'once').mockReturnValue(process); + callConfigureServer(astroDevTriggerInjectPlugin(root), { + httpServer: { + once(_event: string, cb: () => void) { + onClose = cb; + }, + }, + }); + + expect(existsSync(join(root, 'src', 'middleware.ts'))).toBe(true); + expect(readFileSync(join(root, '.gitignore'), 'utf-8')).toContain('src/middleware.ts'); + + onClose!(); + expect(existsSync(join(root, 'src', 'middleware.ts'))).toBe(false); + expect(readFileSync(join(root, '.gitignore'), 'utf-8')).toBe('node_modules/\n'); + }); }); describe('analogDevTriggerInjectPlugin', () => { diff --git a/packages/void/test/unit/remote/d1.test.ts b/packages/void/test/unit/remote/d1.test.ts index be3e4908e..96e6b50b0 100644 --- a/packages/void/test/unit/remote/d1.test.ts +++ b/packages/void/test/unit/remote/d1.test.ts @@ -1,4 +1,6 @@ +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { describe, it, expect, vi, afterEach } from 'vite-plus/test'; +import { createDb } from '../../../src/runtime/db.ts'; import { ProxyClient } from '../../../src/runtime/remote/client.ts'; import { ProxyD1Database } from '../../../src/runtime/remote/d1.ts'; @@ -165,6 +167,23 @@ describe('ProxyD1Database', () => { }); }); + describe('createDb()', () => { + it('runs Drizzle queries through the remote binding', async () => { + const users = sqliteTable('users', { id: integer('id').primaryKey(), name: text('name') }); + const fetchMock = mockFetch({ columns: ['id', 'name'], results: [[1, 'Alice']] }); + + const db = createDb(new ProxyD1Database(createClient(), 'DB') as unknown as D1Database); + const rows = await db.select().from(users); + + expect(rows).toEqual([{ id: 1, name: 'Alice' }]); + const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + const body = JSON.parse(String(call[1].body)); + expect(body.binding).toBe('DB'); + expect(body.method).toBe('raw'); + expect(body.sql).toMatch(/^select .* from "users"$/); + }); + }); + describe('bind()', () => { it('replaces params only on the returned statement', async () => { const db = new ProxyD1Database(createClient(), 'DB'); diff --git a/packages/void/test/unit/runner-mysql.test.ts b/packages/void/test/unit/runner-mysql.test.ts index 5356093f2..11031b0db 100644 --- a/packages/void/test/unit/runner-mysql.test.ts +++ b/packages/void/test/unit/runner-mysql.test.ts @@ -1,9 +1,22 @@ -import { describe, expect, it } from 'vite-plus/test'; +import { describe, expect, it, vi } from 'vite-plus/test'; import { applyMySqlMigrations, readMySqlAppliedMigrations, type MySqlClient, } from '../../src/migrations/runner-mysql.ts'; +import { createMigrationHandler } from '../../src/runtime/migration-handler-mysql.ts'; + +const mysqlQueries = vi.hoisted(() => [] as Array); + +vi.mock('mysql2/promise', () => ({ + createConnection: async () => ({ + query: async (sql: string) => { + mysqlQueries.push(sql); + return [[]]; + }, + end: async () => {}, + }), +})); describe('MySQL migration runner', () => { it('applies only pending migrations and records them with MySQL placeholders', async () => { @@ -58,4 +71,37 @@ describe('MySQL migration runner', () => { }; await expect(readMySqlAppliedMigrations(denied)).rejects.toBe(permissionError); }); + + it('runs each drizzle statement-breakpoint chunk as its own query', async () => { + const sql = + 'CREATE TABLE `a` (`id` int);\n--> statement-breakpoint\nCREATE INDEX `a_id_idx` ON `a` (`id`);'; + const statements = ['CREATE TABLE `a` (`id` int);', 'CREATE INDEX `a_id_idx` ON `a` (`id`);']; + const migrationQueries = (queries: Array) => + queries.filter((query) => !query.includes('_void_migrations')); + + const calls: Array = []; + const client: MySqlClient = { + async query> = Array>>( + query: string, + ): Promise { + calls.push(query); + return [] as unknown as T; + }, + }; + await applyMySqlMigrations(client, [{ name: '001_init.sql', sql }]); + expect(migrationQueries(calls)).toEqual(statements); + + const handler = createMigrationHandler( + { HYPERDRIVE: { connectionString: 'mysql://localhost/app' }, __VOID_PROXY_TOKEN: 'token' }, + [{ name: '001_init.sql', sql }], + ); + const response = await handler( + new Request('http://localhost/__void/migrate', { + method: 'POST', + headers: { 'x-void-internal': 'token' }, + }), + ); + expect(await response.json()).toMatchObject({ ok: true }); + expect(migrationQueries(mysqlQueries)).toEqual(statements); + }); }); diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 2c199d322..5c8bdd2a9 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -13,6 +13,7 @@ import { LATEST_KNOWN_COMPAT_DATE, readExistingLiveBindingConfig, readProvisionedBindingEntry, + readRootWranglerConfig, readWranglerCompat, readWranglerManagedQueueCleanupCandidates, readWranglerWebSocketTombstones, @@ -549,6 +550,39 @@ describe('syncWranglerBindings', () => { ]); }); + it('keeps existing keys of a BOM-prefixed config when adding Durable Objects', () => { + setup( + 'wrangler.jsonc', + '' + + `{ + // app + "name": "existing", + "main": "src/index.ts", + "routes": [{ "pattern": "example.com", "custom_domain": true }] +}`, + ); + expect(readRootWranglerConfig(tmpDir)?.config.name).toBe('existing'); + + const counter = { + filePath: join(tmpDir, 'durable-objects', 'counter.ts'), + bindingName: 'COUNTER', + className: 'CounterDurableObject', + migrationTag: 'void-durable-counter-v1', + }; + expect(syncWranglerDurableObjects(tmpDir, [counter])).toBe(true); + const parsed = JSON.parse(readFileSync(join(tmpDir, 'wrangler.jsonc'), 'utf-8')); + + expect(parsed).toMatchObject({ + name: 'existing', + main: 'src/index.ts', + routes: [{ pattern: 'example.com', custom_domain: true }], + durable_objects: { bindings: [{ name: 'COUNTER', class_name: 'CounterDurableObject' }] }, + migrations: [ + { tag: 'void-durable-counter-v1', new_sqlite_classes: ['CounterDurableObject'] }, + ], + }); + }); + it('adds live durable object and SQLite migration metadata', () => { setup('wrangler.jsonc', '{ "name": "test" }'); const result = syncWranglerBindings(tmpDir, { ...noBindings, needsLive: true }); diff --git a/playground/astro/.gitignore b/playground/astro/.gitignore index b580d5b7c..dc1c8fb66 100644 --- a/playground/astro/.gitignore +++ b/playground/astro/.gitignore @@ -3,4 +3,3 @@ dist/ .void/ .wrangler/ node_modules/ -src/middleware.ts diff --git a/playground/solid-pages/pages/deferred.tsx b/playground/solid-pages/pages/deferred.tsx index 0db541e07..b1cc515c7 100644 --- a/playground/solid-pages/pages/deferred.tsx +++ b/playground/solid-pages/pages/deferred.tsx @@ -1,27 +1,16 @@ import type { Props } from './deferred.server.ts'; export default function DeferredPage(props: Props) { - const stats = props.stats; - if (stats.loading) { - return ( -
-

{props.title}

-
Loading stats...
-
- ); - } - if (stats.error) { - return ( -
-

{props.title}

-
Error: {stats.error.message}
-
- ); - } return (

{props.title}

-
Count: {stats.value.count}
+ {props.stats.loading ? ( +
Loading stats...
+ ) : props.stats.error ? ( +
Error: {props.stats.error.message}
+ ) : ( +
Count: {props.stats.value.count}
+ )}
); } diff --git a/playground/solid-pages/pages/twins/_shared.tsx b/playground/solid-pages/pages/twins/_shared.tsx new file mode 100644 index 000000000..538a31b24 --- /dev/null +++ b/playground/solid-pages/pages/twins/_shared.tsx @@ -0,0 +1,12 @@ +import { useRouter } from '@void/solid'; + +// Both twin routes render this one component. +export default function TwinPage() { + const router = useRouter(); + return ( +
+

Twin page

+

{router.path}

+
+ ); +} diff --git a/playground/solid-pages/pages/twins/a.tsx b/playground/solid-pages/pages/twins/a.tsx new file mode 100644 index 000000000..a0240725e --- /dev/null +++ b/playground/solid-pages/pages/twins/a.tsx @@ -0,0 +1 @@ +export { default } from './_shared.tsx'; diff --git a/playground/solid-pages/pages/twins/b.tsx b/playground/solid-pages/pages/twins/b.tsx new file mode 100644 index 000000000..a0240725e --- /dev/null +++ b/playground/solid-pages/pages/twins/b.tsx @@ -0,0 +1 @@ +export { default } from './_shared.tsx';