Troubleshooting & FAQ
Solutions to common ObjectStack issues — validation failures, query problems, configuration mistakes, and more
Troubleshooting & FAQ
Solutions to the most common issues encountered when working with ObjectStack.
Validation Errors
"Invalid enum value" for field type
Symptom: Zod validation fails with Invalid option: expected one of "text"|"number"|...
Cause: The type value has a typo or uses an unsupported type name.
Fix: Use exact type names from the Field Type Gallery. Common mistakes:
| ❌ Wrong | ✅ Correct |
|---|---|
text_area | textarea |
multi_select | multiselect |
check_boxes | checkboxes |
master-detail | master_detail |
auto_number | autonumber |
rich_text | richtext |
qr_code | qrcode |
The custom error map provides "Did you mean?" suggestions for common typos.
"Field name must be snake_case"
Symptom: Validation fails with a regex error on the name field.
Cause: Field and object names must match the pattern ^[a-z_][a-z0-9_]*$.
Fix:
| ❌ Wrong | ✅ Correct |
|---|---|
firstName | first_name |
Order-Item | order_item |
2ndAddress | second_address |
STATUS | status |
"Required property missing: options"
Symptom: A select, multiselect, radio, or checkboxes field fails validation.
Cause: Selection-type fields require an options array.
Fix:
// ❌ Missing options
{ name: 'status', type: 'select' }
// ✅ With options
{
name: 'status', type: 'select',
options: [
{ label: 'Open', value: 'open' },
{ label: 'Closed', value: 'closed' }
]
}"Required property missing: reference"
Symptom: A lookup, master_detail, or tree field fails validation.
Cause: Relational fields require a reference property pointing to the target object.
Fix:
// ❌ Missing reference
{ name: 'owner', type: 'lookup' }
// ✅ With reference
{ name: 'owner', type: 'lookup', reference: 'user' }Query Issues
"My query returns empty results"
Possible causes:
- Wrong object name — Object names are snake_case (
project_task, notProjectTask) - Filter typo — Check operator spelling (
$contains, not$contain) - Type mismatch — Comparing string to number or vice versa
- Null handling — Use
$null: trueinstead of$eq: null
Debug steps:
// 1. Start with the simplest query
{ object: 'task', limit: 5 }
// 2. Add fields
{ object: 'task', fields: ['id', 'title', 'status'], limit: 5 }
// 3. Add one filter at a time
{ object: 'task', where: { status: { $eq: 'open' } }, limit: 5 }"Invalid filter operator"
Symptom: Query fails with invalid_filter error code.
Fix: Check the operator name. Valid operators:
$eq, $ne, $gt, $gte, $lt, $lte,
$in, $nin, $between,
$contains, $notContains, $startsWith, $endsWith, $icontains,
$like, $ilike,
$null, $exists,
$and, $or, $notCommon mistakes:
| ❌ Wrong | ✅ Correct |
|---|---|
$equal | $eq |
$notEqual | $ne |
$greaterThan | $gt |
$isNull | $null |
$notIn | $nin |
$regex | $icontains |
$like is not a misspelling of $contains — the two mean different things,
and picking the wrong one gives a silently wrong answer rather than an error:
$containstakes text, matched literally as a substring. A%or_in the comparand is an ordinary character.$liketakes a pattern, matched against the whole value, with the wildcards you write:%is any sequence,_is exactly one character, and a backslash escapes either. A pattern with no wildcards is an exact comparison, not a substring search.
$ilike is $like's case-insensitive twin, and $icontains is $contains's.
Both fold ASCII case only, so café does not match CAFÉ.
$like / $ilike are executed by the SQL family (driver-sql,
driver-sqlite-wasm, driver-turso). The other backends refuse them with this
same invalid_filter error — write $contains there.
"Sort field not found or not sortable"
Symptom: Query fails with invalid_sort error.
Fix:
- Verify the field exists on the object
- Check that the field has
sortable: true(or is not explicitly set tosortable: false) - Computed fields (formula, summary) may not be sortable
Configuration Issues
"Object not found" after defining it
Possible causes:
- Name mismatch — The object name in your query does not match the
namein the definition - Not registered — The object is defined but not included in
defineStack({ objects: [...] }) - Case sensitivity — Object names are exact-match snake_case
Fix:
// Ensure the object is included in the stack (array format)
export default defineStack({
objects: [
{ name: 'project_task', /* ... */ }
]
});
// Or use map format (key becomes the name)
export default defineStack({
objects: {
project_task: { /* ... */ }
}
});
// Use the exact same name when querying
client.data.find('project_task', { /* query */ });"Cannot delete record: delete restricted"
Cause: The record has dependent child records via lookup or master_detail fields with deleteBehavior: 'restrict'.
Fix:
- Delete the dependent records first
- Change
deleteBehaviorto'cascade'(deletes children) or'set_null'(clears reference)
// Option A: Cascade delete (children are deleted with parent)
{ name: 'project', type: 'master_detail', reference: 'project', deleteBehavior: 'cascade' }
// Option B: Set null (children keep existing, reference cleared)
{ name: 'project', type: 'lookup', reference: 'project', deleteBehavior: 'set_null' }"defineStack() validation errors"
Symptom: defineStack() in strict mode reports cross-reference errors.
Fix: In strict mode, all references must be valid:
// ❌ View references non-existent object
defineStack({
objects: [{ name: 'task', /* ... */ }],
views: [{ list: { type: 'grid', data: { provider: 'object', object: 'contact' }, columns: ['name'] } }] // 'contact' not defined!
});
// ✅ All references resolve
defineStack({
objects: [
{ name: 'task', /* ... */ },
{ name: 'contact', /* ... */ }
],
views: [{ list: { type: 'grid', data: { provider: 'object', object: 'contact' }, columns: ['name'] } }]
});TypeScript Issues
"Type is not assignable"
Cause: Using a raw object literal where a parsed type is expected, or mixing up input vs. output types.
Fix: Use the define* helpers which parse and return the correct types:
// ❌ Raw object — no type checking
const view = { list: { type: 'grid', columns: ['subject', 'status'] } };
// ✅ Parsed with type checking
import { defineView } from '@objectstack/spec';
const view = defineView({
list: {
type: 'grid',
data: { provider: 'object', object: 'task' },
columns: ['subject', 'status', 'priority'],
},
});"Property does not exist on type"
Cause: Accessing a property that exists on the Zod schema but not on the inferred TypeScript type (usually optional properties that were not provided).
Fix: Use optional chaining or check for undefined:
const field = FieldSchema.parse(data);
// ❌ May be undefined
console.log(field.maxLength.toString());
// ✅ Safe access
console.log(field.maxLength?.toString() ?? 'no limit');Performance Issues
"Query is slow"
Checklist:
- Add indexes — Declare indexes in the object's
indexes[]array (e.g.indexes: [{ fields: ['status'] }]) for fields used inwhereclauses — there is no field-levelindexflag - Limit fields — Only request fields you need (
fields: ['id', 'name']) - Use pagination — Always set
limitto avoid returning all records - Avoid deep nesting — Limit nested
$and/$ordepth - Use keyset pagination — For large datasets, seeking past the last row is
faster than a deep
offset. Express the keyset as awherepredicate on the sort key (thecursorquery property was removed in@objectstack/spec17, #4286 — nothing ever read it)
// Optimized query — next page seeks past the last row instead of offsetting
{
object: 'activity',
fields: ['id', 'type', 'created_at'], // Only needed fields
where: {
type: { $eq: 'login' }, // On indexed field
created_at: { $lt: lastSeenCreatedAt }, // Keyset: past the last row
},
orderBy: [{ field: 'created_at', order: 'desc' }],
limit: 25,
}"Bundle size is too large"
Cause: @objectstack/spec does not re-export domain schemas (like FieldSchema or QuerySchema) from its root — only define* factory functions and a handful of top-level schemas are available there. Each domain (~400 Zod schema closures total) is only reachable through its subpath, so an import that assumes it's on the root will not resolve.
Fix: Import from subpaths instead of the root:
// ❌ Not exported from the root at all
import { FieldSchema, QuerySchema } from '@objectstack/spec';
// ✅ Tree-shakeable subpath imports
import { FieldSchema } from '@objectstack/spec/data';
import { ErrorResponseSchema } from '@objectstack/spec/api';Available subpaths: data, api, ui, system, kernel, ai, automation, contracts, integration, security, studio, cloud, qa, identity, shared.
FAQ
What Node.js version is required?
Node.js 22.0.0 or later. The engines field in package.json enforces this.
What TypeScript version is recommended?
TypeScript 5.3.0 or later for full type inference support.
How do I validate data at runtime?
Use Zod's .parse() or .safeParse() methods:
import { FieldSchema } from '@objectstack/spec/data';
// Throws on invalid data
const field = FieldSchema.parse(input);
// Returns result object (no throw)
const result = FieldSchema.safeParse(input);
if (result.success) {
console.log(result.data);
} else {
console.error(result.error);
}How do I get better error messages?
Use the built-in safeParsePretty() helper:
import { safeParsePretty } from '@objectstack/spec';
const result = safeParsePretty(FieldSchema, input);
// Pretty-printed errors with "Did you mean?" suggestionsCan I use ObjectStack with JavaScript (not TypeScript)?
Yes. All schemas work with plain JavaScript. You lose compile-time type checking but retain runtime validation via Zod's .parse().
How do I extend a built-in schema?
It depends on whether the schema you are extending carries a parse-time
.transform(). A plain schema (FieldSchema, ObjectSchema since protocol 17
retired their alias-lowering transforms, #3855) is a ZodObject and takes
.extend() directly. The composition form works in either case, and is the
safe default when you don't want to track which is which:
import { z } from 'zod';
import { FieldSchema } from '@objectstack/spec/data';
// Plain object schema: .extend() works directly. (This line is what pins the
// claim — if FieldSchema ever grows a transform again and becomes a ZodPipe,
// .extend vanishes and this example fails CI instead of the prose going stale.)
const CustomFieldSchema = FieldSchema.extend({
customProperty: z.string().optional(),
});
CustomFieldSchema.parse({ name: 'code', type: 'text', customProperty: 'x' });
// Composition: parse with the built-in schema, validate additions alongside.
// Shape-agnostic — works for plain schemas AND transform-carrying pipes.
const CustomProps = z.object({ customProperty: z.string().optional() });
function parseCustomField(input: unknown) {
return { ...FieldSchema.parse(input), ...CustomProps.parse(input) };
}
parseCustomField({ name: 'code', type: 'text', customProperty: 'x' });A schema that still lowers author-facing sugar at parse time — ActionSchema
(its requiresFeature → visible lowering) is one, as of protocol 17 — is a
ZodPipe, where .extend does not exist: prefer the composition form.
Reaching the inner object via .in.extend({ … }) builds a merged schema but
skips the transform, so the sugar the pipe would have lowered stays raw.
A quick check when unsure: typeof SomeSchema.extend === 'function' — a pipe
reports undefined. This page once asserted the shapes the other way around;
the schemas moved under it within days (#3890 → #3855), which is why the
load-bearing claim above now lives in a CI-checked block rather than prose.
Where are the JSON Schemas for IDE autocomplete?
The bundled JSON Schema is at packages/spec/json-schema/objectstack.json (its x-schema-count field reports the total number of definitions). Per-domain schemas are in subfolders under packages/spec/json-schema/ (e.g. data/, ui/, api/).