Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/field-type-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/driver-sql": minor
"@objectstack/objectql": patch
---

fix: array-valued field types persist, and `Field.time` accepts time-of-day — two field-type runtime gaps found driving the showcase field-zoo (which had no seed data, so neither was ever exercised).

**Array/object fields broke every write (driver-sql).** `multiselect` / `checkboxes` / `tags` / `repeater` / `vector` were absent from the SQL driver's JSON-field classification, so their array values reached the better-sqlite3 binder un-serialized and threw *"SQLite3 can only bind numbers, strings, bigints, buffers, and null"* — a 500 on insert/update for common field types (even `task.labels` on a normal object). The DDL column-type switch and `isJsonField` had drifted into two separate lists; they now share one `JSON_COLUMN_TYPES` source that includes the array/object types, so these columns are created as JSON and round-trip as arrays/objects. A `formatInput` safety net additionally serializes any stray array/object value so an unclassified field degrades to a stored string instead of crashing.

**`Field.time` rejected every valid value (objectql).** The validator reused the date/datetime branch (`Date.parse`), which is `NaN` for any bare time string — so a `time` field could never accept `14:30` or `09:05:30`. `time` now validates a time-of-day (`HH:MM` / `HH:MM:SS`, optional fractional seconds and `Z`/offset) and still accepts a full ISO datetime; `date`/`datetime` are unchanged.

Verified live on app-showcase: the full field-zoo specimen (all input-able field types) now persists and round-trips. Regression tests added for both.
32 changes: 32 additions & 0 deletions packages/objectql/src/validation/record-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,35 @@ describe('validateRecord — required + autonumber exemption', () => {
).not.toThrow();
});
});

/**
* `Field.time` is a wall-clock time-of-day, not an instant. The old validator
* reused the date/datetime branch (`Date.parse`), which is `NaN` for every
* bare time string — so a `time` field rejected ALL valid values (found
* driving the showcase field-zoo). It must accept `HH:MM` / `HH:MM:SS`.
*/
describe('validateRecord — time field accepts time-of-day', () => {
const schema = { fields: { at: { type: 'time' } } };

for (const v of ['14:30', '09:05:30', '23:59', '00:00:00', '14:30:00Z', '14:30:00.500', '08:15:00+02:00']) {
it(`accepts ${v}`, () => {
expect(() => validateRecord(schema, { at: v }, 'insert')).not.toThrow();
});
}

it('accepts a full ISO datetime for a time field (lenient)', () => {
expect(() => validateRecord(schema, { at: '2026-06-17T14:30:00Z' }, 'insert')).not.toThrow();
});

for (const v of ['25:00', '14:60', 'not-a-time', '14']) {
it(`rejects ${v}`, () => {
expect(() => validateRecord(schema, { at: v }, 'insert')).toThrow(/invalid_time/i);
});
}

it('does NOT regress date/datetime (still ISO-parsed)', () => {
const ds = { fields: { d: { type: 'date' }, dt: { type: 'datetime' } } };
expect(() => validateRecord(ds, { d: '2026-06-17', dt: '2026-06-17T10:00:00Z' }, 'insert')).not.toThrow();
expect(() => validateRecord(ds, { d: 'not-a-date' }, 'insert')).toThrow(/invalid_date/i);
});
});
22 changes: 21 additions & 1 deletion packages/objectql/src/validation/record-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export interface FieldValidationError {
| 'invalid_number'
| 'invalid_boolean'
| 'invalid_date'
| 'invalid_time'
| 'invalid_option'
// Object-level validation rules (ADR-0020, see rule-validator.ts)
| 'invalid_transition'
Expand Down Expand Up @@ -173,12 +174,31 @@ function validateOne(name: string, def: FieldDef, value: unknown): FieldValidati
}

// ── date/datetime ───────────────────────────────────────────────
if (t === 'date' || t === 'datetime' || t === 'time') {
if (t === 'date' || t === 'datetime') {
if (value instanceof Date) return null;
if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return null;
return { field: name, code: 'invalid_date', message: `${name} must be a valid ${t} (ISO-8601)` };
}

// ── time (time-of-day) ──────────────────────────────────────────
// A `Field.time` is a wall-clock time, NOT an instant — `Date.parse('14:30')`
// is NaN, so reusing the date branch rejected every valid time. Accept
// `HH:MM`, `HH:MM:SS`, optional fractional seconds and an optional Z/offset;
// also accept a Date or a full ISO datetime (callers that send a timestamp
// for a time field).
if (t === 'time') {
if (value instanceof Date) return null;
if (typeof value === 'string') {
const timeOfDay = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]([01]\d|2[0-3]):?[0-5]\d)?$/;
// Accept a valid time-of-day, OR a full datetime that carries a real date
// component. NOT a bare `Date.parse` check — `Date.parse('14:60')` returns
// a (bogus) number in Node, which would let malformed times through.
const hasDate = /\d{4}-\d{2}-\d{2}/.test(value);
if (timeOfDay.test(value.trim()) || (hasDate && !Number.isNaN(Date.parse(value)))) return null;
}
return { field: name, code: 'invalid_time', message: `${name} must be a valid time (HH:MM or HH:MM:SS)` };
}

// ── select / multiselect / radio ────────────────────────────────
if (t === 'select' || t === 'radio') {
const allowed = optionValues(def.options);
Expand Down
88 changes: 88 additions & 0 deletions packages/plugins/driver-sql/src/sql-driver-array-fields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Array/object-valued field types must be stored as JSON columns and
* round-tripped as arrays/objects. Regression: `multiselect`/`checkboxes`/
* `tags`/`repeater`/`vector` were absent from the driver's JSON-field
* classification, so their array values reached the better-sqlite3 binder
* un-serialized and crashed with "SQLite3 can only bind numbers, strings,
* bigints, buffers, and null" — breaking common field types on every write
* (found driving the showcase field-zoo, which had no seed data to surface it).
*
* The classification (`isJsonField`) and the DDL column-type switch now share
* one `JSON_COLUMN_TYPES` source, plus a `formatInput` safety net stringifies
* any stray array/object so an unclassified field degrades to a stored string
* instead of a 500.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { SqlDriver } from '../src/index.js';

describe('SqlDriver array/object field persistence', () => {
let driver: SqlDriver;

beforeEach(async () => {
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.initObjects([
{
name: 'zoo',
fields: {
name: { type: 'string' },
tags: { type: 'tags' },
ms: { type: 'multiselect' },
cbs: { type: 'checkboxes' },
rep: { type: 'repeater' },
vec: { type: 'vector' },
comp: { type: 'composite' },
loc: { type: 'location' },
},
},
]);
});

afterEach(async () => {
await driver.disconnect();
});

it('persists and round-trips array-valued fields as arrays', async () => {
await driver.create(
'zoo',
{
id: 'z1', name: 'Specimen',
tags: ['x', 'y'],
ms: ['red', 'green'],
cbs: ['email', 'push'],
rep: [{ a: 1 }, { a: 2 }],
vec: [0.1, 0.2, 0.3],
comp: { k: 'v' },
loc: { lat: 1.5, lng: 2.5 },
},
{ bypassTenantAudit: true },
);
const row = await driver.findOne('zoo', 'z1', { bypassTenantAudit: true });
expect(row.tags).toEqual(['x', 'y']);
expect(row.ms).toEqual(['red', 'green']);
expect(row.cbs).toEqual(['email', 'push']);
expect(row.rep).toEqual([{ a: 1 }, { a: 2 }]);
expect(row.vec).toEqual([0.1, 0.2, 0.3]);
expect(row.comp).toEqual({ k: 'v' });
expect(row.loc).toEqual({ lat: 1.5, lng: 2.5 });
});

it('updates an array field to a new array', async () => {
await driver.create('zoo', { id: 'z2', name: 'B', tags: ['a'] }, { bypassTenantAudit: true });
await driver.update('zoo', 'z2', { tags: ['a', 'b', 'c'] }, { bypassTenantAudit: true });
const row = await driver.findOne('zoo', 'z2', { bypassTenantAudit: true });
expect(row.tags).toEqual(['a', 'b', 'c']);
});

it('does not crash on an empty array', async () => {
await driver.create('zoo', { id: 'z3', name: 'C', ms: [] }, { bypassTenantAudit: true });
const row = await driver.findOne('zoo', 'z3', { bypassTenantAudit: true });
expect(row.ms).toEqual([]);
});
});
49 changes: 43 additions & 6 deletions packages/plugins/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ const SEQUENCES_TABLE = '_objectstack_sequences';
*/
const GLOBAL_TENANT = '__global__';

/**
* Field types whose value is an array or object and must be stored as a JSON
* column (and JSON-(de)serialized at the driver boundary). SINGLE SOURCE for
* both the DDL column-type switch and `isJsonField` so the two can't drift —
* the drift between them is exactly what let array-valued fields (multiselect/
* checkboxes/tags/repeater/vector) reach the SQLite binder un-serialized and
* crash with "SQLite3 can only bind numbers, strings, bigints, buffers, and
* null" (#field-zoo). `image`/`file`/`avatar` hold structured upload metadata;
* `composite`/`address`/`location` are objects; the rest are arrays.
*/
const JSON_COLUMN_TYPES = new Set<string>([
'json', 'object', 'array',
'image', 'file', 'avatar',
'location', 'address', 'composite',
'multiselect', 'checkboxes', 'tags', 'repeater', 'vector',
]);

// ── Introspection Types ──────────────────────────────────────────────────────

export interface IntrospectedColumn {
Expand Down Expand Up @@ -1845,6 +1862,13 @@ export class SqlDriver implements IDataDriver {
case 'file':
case 'avatar':
case 'location':
case 'address':
case 'composite':
case 'multiselect':
case 'checkboxes':
case 'tags':
case 'repeater':
case 'vector':
col = table.json(name);
break;
case 'lookup':
Expand Down Expand Up @@ -1976,7 +2000,7 @@ export class SqlDriver implements IDataDriver {
}

protected isJsonField(type: string, field: any): boolean {
return ['json', 'object', 'array', 'image', 'file', 'avatar', 'location'].includes(type) || field.multiple;
return JSON_COLUMN_TYPES.has(type) || !!field.multiple;
}

// ── SQLite serialisation ────────────────────────────────────────────────────
Expand Down Expand Up @@ -2022,12 +2046,25 @@ export class SqlDriver implements IDataDriver {
if (!this.isSqlite) return copy;

const fields = this.jsonFields[object];
if (!fields || fields.length === 0) return copy;
if (fields && fields.length > 0) {
if (!copied) { copy = { ...copy }; copied = true; }
for (const field of fields) {
if (copy[field] !== undefined && typeof copy[field] === 'object' && copy[field] !== null) {
copy[field] = JSON.stringify(copy[field]);
}
}
}

if (!copied) { copy = { ...copy }; copied = true; }
for (const field of fields) {
if (copy[field] !== undefined && typeof copy[field] === 'object' && copy[field] !== null) {
copy[field] = JSON.stringify(copy[field]);
// Safety net: better-sqlite3 can only bind numbers/strings/bigints/buffers/
// null. Any value still an array or plain object here (a field type not
// classified as JSON, a `Field.multiple` we didn't catch, or an ad-hoc
// payload) would otherwise throw a raw TypeError mid-insert. Serialize it
// to JSON so the write degrades to a stored string instead of a 500.
for (const key of Object.keys(copy)) {
const v = copy[key];
if (v !== null && typeof v === 'object' && !(v instanceof Date) && !Buffer.isBuffer(v)) {
if (!copied) { copy = { ...copy }; copied = true; }
copy[key] = JSON.stringify(v);
}
}
return copy;
Expand Down
Loading