QUESTPIE
SchemaFields

URL field

`f.url()` is a varchar whose value has to parse as a URL. The call picks the width, the width becomes the Zod cap, and the field filters with the string set plus host and protocol.

View markdown
CallColumnDerived schema
f.url()varchar(2048)z.string().url().max(2048)
f.url(n)varchar(n)z.string().url().max(n)

The argument is a number and nothing else. There is no options object. There is no unbounded mode either. The constructor argument is the only thing that sets the column width. Only .array() and the .drizzle() escape hatch replace the column.

src/questpie/server/collections/links.ts
import { collection } from "#questpie/factories";

export const links = collection("links").fields(({ f }) => ({
	website: f.url().required(), // varchar(2048), NOT NULL
	webhook: f.url(500), // varchar(500)
}));

Without .required() the field selects as string | null. With it, string.

What passes

The check hands the value to new URL(). Anything that parses passes. QUESTPIE adds no scheme or hostname constraint, so the field is not limited to http and https.

ValueResult
https://example.com/a?bPasses
mailto:hi@example.comPasses, any scheme parses
ftp://host/filePasses
example.comRejected, no scheme
//example.comRejected, no scheme
" https://x.dev "Passes, stored trimmed

That last row is a write, not just a check. The parser trims the value and hands the trimmed string back. create() and update() then store what the schema returned. Nothing else is normalized. Case, port and trailing slash are stored exactly as you typed them.

Refine with .zod() to narrow the scheme. The field type carries no protocol option.

endpoint: f.url().zod((s) => s.refine((v) => String(v).startsWith("https://"))),

.zod() receives the schema typed as ZodType. So the refinement's argument arrives as unknown. Narrow it yourself. The refinement runs after the format and length checks, never instead of them.

Methods

Two methods come with the type. The base chain works here as on any field, so .required(), .default(), .label() and .localized() all apply.

MethodEffect
.min(n)Adds z.string().min(n), a character count
.max(n)Adds z.string().max(n), a character count
endpoint: f.url(500).required().min(12),

`.max()` only tightens

The constructor bakes its width into the schema. .max(n) adds a second check beside it. f.url(500).max(2000) still rejects 600 characters. The column stays varchar(500). To widen, change the constructor argument.

Filtering

URL carries urlOps, the fourteen string operators plus three of its own.

OperatorOperandMatches
eq / nestringEqual, not equal
notstring, or nullNot equal, or IS NOT NULL when given null
in / notInstring[]In the list, not in the list
like / notLikestringLIKE, case-sensitive, you write the %
ilike / notIlikestringThe same, case-insensitive
containsstringSubstring, the % are added for you
startsWithstringPrefix
endsWithstringSuffix
isNull / isNotNullbooleanPass false and each inverts

The three additions:

OperatorOperandCompiles to
hoststringcol ILIKE '%://value%'
hostInstring[]The same, one arm per value, joined with OR
protocolstringcol LIKE 'value://%', case-sensitive, anchored
const { docs } = await app.collections.links.find({
	where: { website: { host: "questpie.com" } },
});

`host` and `protocol` are patterns, not a parsed URL

host looks for :// plus your value anywhere in the string. So example.com also matches https://example.com.evil.net. It matches any URL carrying that text in its query too. protocol needs the //, so mailto never matches. An empty hostIn compiles to FALSE.

In the admin

The form control is TextField. The list cell is TextCell. It truncates the text and puts the full value in a title attribute. Neither renders a link.

What crosses to the browser:

  • The input is type="text". The primitive does support type="url" and the URL touch keyboard that goes with it. The registration asks for neither.
  • maxLength becomes the input's maxLength attribute. Typing stops there. .max(n) overwrites that number, even when it sits above the constructor width.
  • The admin rebuilds its own schema for the form. It uses z.url() and maxLength only. It never reads minLength, so .min(n) is a server-side check. Text takes the generic path instead, which applies both bounds.

Configure the rest with .admin(). @questpie/admin registers that field extension.

KeyTypeEffect
placeholderstringThe input's placeholder
showProtocolDropdownbooleanNothing. Declared only
defaultProtocol"http" | "https"Nothing. Declared only

The last two type-check and reach the component as props. No component under packages/ reads either one. The shared .admin() keys behave as they do on any field. That covers hidden, readOnly, group, colspan and the rest.

Lists

.array() replaces the column with jsonb. It also swaps urlOps for the multi-value set. So host and protocol are gone from the filter. Each element is still parsed as a URL on write.

mirrors: f.url().array().maxItems(5),

The admin's item-type allowlist has no url in it. Items get a plain text input. The form runs no per-item check. The write catches a bad element.

Text is the plain string. It owns the stringOps set that URL extends.

Email is the other format-checked varchar. It has domain and domainIn in place of host, hostIn and protocol.

Arrays covers what .array() does to reads, writes and filters.

Uploads is for a file you store rather than a link you point at.

Validation covers the derived schema and .zod().

On this page