diff --git a/packages/cli/package.json b/packages/cli/package.json index 0483a303..7dbff221 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.8.33", + "version": "0.8.34", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/db/package.json b/packages/db/package.json index 18b8a0fa..cdcb9d7b 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.8.13", + "version": "0.8.14", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index f973e5ea..23130937 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -86,6 +86,40 @@ function hasExecutableSql(sql: string): boolean { return false; } +function additiveColumnTarget(sql: string): { table: string; column: string } | undefined { + const executable = sql + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/--[^\r\n]*/g, " ") + .trim(); + const match = /^ALTER\s+TABLE\s+([A-Za-z_][A-Za-z0-9_]*)\s+ADD\s+COLUMN\s+([A-Za-z_][A-Za-z0-9_]*)\b[\s\S]*;?\s*$/i.exec( + executable, + ); + return match ? { table: match[1]!, column: match[2]! } : undefined; +} + +async function additiveColumnAlreadyExists(db: Db, sql: string): Promise { + const target = additiveColumnTarget(sql); + if (!target) return false; + if (db.driver.dialect === "sqlite") { + const columns = await db.all<{ name: string }>(`PRAGMA table_info(${target.table})`); + return columns.some(({ name }) => name.toLowerCase() === target.column.toLowerCase()); + } + if (db.driver.dialect === "postgres") { + return Boolean( + await db.one( + "SELECT 1 AS present FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?", + [target.table, target.column], + ), + ); + } + return Boolean( + await db.one( + "SELECT 1 AS present FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?", + [target.table, target.column], + ), + ); +} + /** Load and parse all migration files in a directory, sorted by filename. */ export function loadMigrations(dir: string): Migration[] { if (!existsSync(dir)) return []; @@ -171,7 +205,12 @@ export async function applyMigrations( for (const migration of pending.filter(({ name }) => !current.has(name))) { throwIfAborted(options.signal); await db.tx(async (tx) => { - if (hasExecutableSql(migration.up)) await tx.exec(migration.up); + if ( + hasExecutableSql(migration.up) && + !(await additiveColumnAlreadyExists(tx, migration.up)) + ) { + await tx.exec(migration.up); + } await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]); }); done.push(migration.name); diff --git a/packages/db/test/db.test.ts b/packages/db/test/db.test.ts index 325a5217..5ea5ae2c 100644 --- a/packages/db/test/db.test.ts +++ b/packages/db/test/db.test.ts @@ -161,6 +161,24 @@ test("comment-only migrations are recorded without executing empty SQL", async ( await db.close(); }); +test("add-column migrations recover when the column exists but the migration record does not", async () => { + const db = createDb(sqlite()); + await db.exec("CREATE TABLE otp_challenges (id TEXT PRIMARY KEY, purpose TEXT NOT NULL)"); + const migrations = [ + { + name: "0002_otp_purpose", + up: "ALTER TABLE otp_challenges ADD COLUMN purpose TEXT NOT NULL DEFAULT 'verification';", + down: "ALTER TABLE otp_challenges DROP COLUMN purpose;", + }, + ]; + + expect(await applyMigrations(db, migrations)).toEqual(["0002_otp_purpose"]); + expect(await appliedMigrations(db)).toContain("0002_otp_purpose"); + const columns = await db.all<{ name: string }>("PRAGMA table_info(otp_challenges)"); + expect(columns.filter(({ name }) => name === "purpose")).toHaveLength(1); + await db.close(); +}); + test("migration dry-run plans changes without applying schema and honors cancellation", async () => { const db = createDb(sqlite()); const migrations = [ diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 695bda0b..d89b5f6a 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.8.31", + "version": "0.8.32", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/ui/components/input.wrn b/packages/ui/components/input.wrn index b992f18f..7fe0d211 100644 --- a/packages/ui/components/input.wrn +++ b/packages/ui/components/input.wrn @@ -39,15 +39,24 @@ component Input { step: string = "" class: string = "" } + state validationError: string = "" functions { client function detail(sourceEvent) { return { value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent } } - client function handleInput(sourceEvent) { sourceEvent.stopPropagation(); output.input(detail(sourceEvent)) } + client function handleInput(sourceEvent) { + sourceEvent.stopPropagation() + if (sourceEvent.currentTarget.validity.valid) { + validationError = "" + sourceEvent.currentTarget.setAttribute("aria-invalid", error ? "true" : "false") + sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", error ? "true" : "false") + } + output.input(detail(sourceEvent)) + } client function handleChange(sourceEvent) { sourceEvent.stopPropagation(); output.change(detail(sourceEvent)) } client function handleFocus(sourceEvent) { sourceEvent.stopPropagation(); output.focus(detail(sourceEvent)) } client function handleBlur(sourceEvent) { sourceEvent.stopPropagation(); output.blur(detail(sourceEvent)) } - client function handleInvalid(sourceEvent) { sourceEvent.stopPropagation(); output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: sourceEvent.currentTarget.validationMessage, sourceEvent: sourceEvent }) } + client function handleInvalid(sourceEvent) { sourceEvent.stopPropagation(); validationError = sourceEvent.currentTarget.validationMessage; sourceEvent.currentTarget.setAttribute("aria-invalid", "true"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true"); output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: validationError, sourceEvent: sourceEvent }) } client function handleKeydown(sourceEvent) { sourceEvent.stopPropagation(); output.keydown({ key: sourceEvent.key, value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) } client function handleKeyup(sourceEvent) { sourceEvent.stopPropagation(); output.keyup({ key: sourceEvent.key, value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) } } @@ -58,7 +67,7 @@ component Input { data-variant="{variant}" data-inline="{inline}" data-floating="{variant === 'floating'}" - data-invalid="{error ? 'true' : 'false'}" + data-invalid="{error || validationError ? 'true' : 'false'}" >
- {error} + {error || validationError}
} diff --git a/packages/ui/components/select.wrn b/packages/ui/components/select.wrn index c2e7c172..c88967c9 100644 --- a/packages/ui/components/select.wrn +++ b/packages/ui/components/select.wrn @@ -33,6 +33,7 @@ size: string = "default" required: boolean = false class: string = "" } + state validationError: string = "" functions { shared function selected(option) { if (multiple) return values.includes(option.value) @@ -40,34 +41,47 @@ size: string = "default" } client function emitField(nameEvent, sourceEvent) { sourceEvent.stopPropagation() + if (sourceEvent.currentTarget.validity.valid) { validationError = ""; sourceEvent.currentTarget.setAttribute("aria-invalid", error ? "true" : "false"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", error ? "true" : "false") } output[nameEvent]({ value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) } client function handleFocus(sourceEvent) { emitField("focus", sourceEvent); output.open({ name: name, sourceEvent: sourceEvent }) } client function handleBlur(sourceEvent) { emitField("blur", sourceEvent); output.close({ name: name, sourceEvent: sourceEvent }) } - client function handleInvalid(sourceEvent) { output.invalid({ name: name, message: sourceEvent.currentTarget.validationMessage, sourceEvent: sourceEvent }) } + client function handleInvalid(sourceEvent) { validationError = sourceEvent.currentTarget.validationMessage; sourceEvent.currentTarget.setAttribute("aria-invalid", "true"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true"); output.invalid({ name: name, message: validationError, sourceEvent: sourceEvent }) } } view { -
+
{#if cornerHint}{cornerHint}{/if}
{#if icon}{/if} - {#if !multiple}{/if} {#each options as option}{/each} + {#if !multiple}{/if} {#if variant === "floating"}{label}{/if}
{#if helperText}{helperText}{/if} - {error} + {error || validationError}
} style { .wrn-next--select select { + appearance: none; width: 100%; min-height: 2.65rem; margin: 0; + padding-inline-end: 2.5rem; line-height: 1.35; + color: var(--wrn-color-text); + background-color: var(--wrn-color-surface); + } + + .wrn-next__select-indicator { + position: absolute; + right: 0.85rem; + color: var(--wrn-color-muted); + pointer-events: none; } /* Shared component foundation. */ diff --git a/packages/ui/components/textarea.wrn b/packages/ui/components/textarea.wrn index 026fa49b..2dc7e190 100644 --- a/packages/ui/components/textarea.wrn +++ b/packages/ui/components/textarea.wrn @@ -32,25 +32,30 @@ size: string = "default" maxlength: string = "" class: string = "" } + state validationError: string = "" functions { client function emitField(nameEvent, sourceEvent) { sourceEvent.stopPropagation() + if (sourceEvent.currentTarget.validity.valid) { validationError = ""; sourceEvent.currentTarget.setAttribute("aria-invalid", error ? "true" : "false"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", error ? "true" : "false") } output[nameEvent]({ value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) } client function handleInvalid(sourceEvent) { - output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: sourceEvent.currentTarget.validationMessage, sourceEvent: sourceEvent }) + validationError = sourceEvent.currentTarget.validationMessage + sourceEvent.currentTarget.setAttribute("aria-invalid", "true") + sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true") + output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: validationError, sourceEvent: sourceEvent }) } } view { -
+
{#if cornerHint}{cornerHint}{/if}
{#if icon}{/if} - + {#if variant === "floating"}{label}{/if}
{#if helperText}{helperText}{/if} - {error} + {error || validationError}
} diff --git a/packages/ui/package.json b/packages/ui/package.json index 83e3f82c..ebe58ac7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.8.18", + "version": "0.8.19", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts index 279035a9..249331af 100644 --- a/packages/ui/test/ui.test.ts +++ b/packages/ui/test/ui.test.ts @@ -1309,6 +1309,29 @@ test("basic form fields expose shared labels, variants, states, hints, values, a expect(emitted).toEqual(["input", "change", "focus", "blur", "keydown", "keyup"]); }); +test("basic fields render native constraint messages and select matches field surfaces", async () => { + const inputSource = readFileSync(uiComponentPath("Input"), "utf8"); + const dom = mountHtml( + await renderComponent(inputSource, { + id: "required-name", + name: "name", + label: "Name", + required: true, + }), + ); + const input = dom.querySelector("input") as HTMLInputElement; + input.dispatchEvent(new (dom.window as any).Event("invalid", { bubbles: false })); + expect(dom.querySelector("[data-error='name']")?.textContent?.trim()).toBe( + input.validationMessage, + ); + expect(dom.querySelector(".wrn-next--input")?.getAttribute("data-invalid")).toBe("true"); + + const selectSource = readFileSync(uiComponentPath("Select"), "utf8"); + expect(selectSource).toContain("appearance: none"); + expect(selectSource).toContain("background-color: var(--wrn-color-surface)"); + expect(selectSource).toContain("wrn-next__select-indicator"); +}); + test("all basic form components render values and their must-have interaction events", async () => { const components = [ "Checkbox",