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
43 changes: 43 additions & 0 deletions .changeset/user-field-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@objectstack/spec': minor
'@objectstack/objectql': minor
'@objectstack/driver-sql': minor
'@objectstack/driver-mongodb': minor
'@objectstack/cli': patch
'@objectstack/plugin-approvals': patch
---

feat: add a first-class `user` field type (person picker)

A new `user` field type — the equivalent of Airtable's Collaborator / Notion's
Person / Salesforce's `Lookup(User)`. Authored as `Field.user({ ... })`; use
`{ multiple: true }` for collaborators/watchers and `{ defaultValue: 'current_user' }`
to auto-fill the acting user on create.

**Why a distinct type rather than telling authors to `Field.lookup('sys_user')`:**
selecting a person is table-stakes, but the value is in *modelling
discoverability* — a "User" entry in the Studio/AI field palette instead of
requiring authors (and AI) to know to reference the internal `sys_user` system
object — plus `current_user` defaults and a user-search picker. Storage and
runtime are unchanged.

**Deliberately NOT a new storage primitive.** `user` is a *semantic
specialization of `lookup`* with the target fixed to `sys_user`: it shares the
exact lookup code path — same FK string column (`multiple` ⇒ JSON), same
`$expand` resolution, same indexing — so referential integrity and fresh display
names come for free, and nothing is re-implemented. An existing
`Field.lookup('sys_user')` is therefore equivalent at the storage layer (zero
data migration to adopt `Field.user`).

Ownership semantics are **unchanged**: the existing `owner_id` convention +
`plugin-security` auto-stamp/RLS still apply. A declarative `owner` flag is a
possible future follow-up; intentionally not added here to avoid a second
field type for what is a system role (rationale: keep the `FieldType` surface
lean — see related ADR-0059 freeze discipline).

Changes: `FieldType` gains `'user'` + `Field.user()` builder; the SQL/Mongo
drivers treat `user` exactly like `lookup`; the engine resolves `$expand` for
`user` fields and honours a new `defaultValue: 'current_user'` token (resolved
app-side from the execution context, mirroring the `NOW()` convention); kanban
group-by and symbolic seed references accept `user`; approvals enrich `user`
references. The public API surface is unchanged (additive enum member).
5 changes: 5 additions & 0 deletions examples/app-showcase/src/objects/field-zoo.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ export const FieldZoo = ObjectSchema.create({
f_master_detail: Field.masterDetail('showcase_project', { label: 'Master-Detail → Project' }),
f_tree: { type: 'tree', label: 'Tree (self/category)', reference: 'showcase_category' },

// ── User (lookup specialized to sys_user) ────────────────────────────
f_user: Field.user({ label: 'User → sys_user (single)' }),
f_users: Field.user({ label: 'Users (multiple)', multiple: true }),
f_owner: Field.user({ label: 'Owner (current_user default)', defaultValue: 'current_user' }),

// ── Media ────────────────────────────────────────────────────────────
f_image: Field.image({ label: 'Image' }),
f_file: Field.file({ label: 'File' }),
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,11 @@ function generateMigrationTs(config: Record<string, unknown>): string {
case 'uuid': case 'lookup': case 'master_detail':
colMethod = `table.uuid('${fieldName}')`;
break;
// `user` references sys_user, whose id is a text identifier (not a uuid),
// so store it as a string column — consistent with the runtime sql-driver.
case 'user':
colMethod = `table.string('${fieldName}')`;
break;
default:
colMethod = `table.text('${fieldName}')`;
}
Expand Down
77 changes: 77 additions & 0 deletions packages/objectql/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,47 @@ describe('ObjectQL Engine', () => {
expect(result).toEqual({ id: '1', success: true });
});

it('stamps a `current_user` defaultValue with the acting user id on insert', async () => {
vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
if (name === 'ticket') return {
name: 'ticket',
fields: {
title: { type: 'text' },
owner: { type: 'user', reference: 'sys_user', defaultValue: 'current_user' },
},
} as any;
if (name === 'sys_user') return { name: 'sys_user', fields: { name: { type: 'text' } } } as any;
return undefined;
});

await engine.insert('ticket', { title: 'T1' }, { context: { userId: 'u-42' } as any });

expect(mockDriver.create).toHaveBeenCalledWith(
'ticket',
expect.objectContaining({ title: 'T1', owner: 'u-42' }),
expect.anything(),
);
});

it('leaves a `current_user` default unset when there is no authenticated user', async () => {
vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
if (name === 'ticket') return {
name: 'ticket',
fields: {
title: { type: 'text' },
owner: { type: 'user', reference: 'sys_user', defaultValue: 'current_user' },
},
} as any;
if (name === 'sys_user') return { name: 'sys_user', fields: { name: { type: 'text' } } } as any;
return undefined;
});

await engine.insert('ticket', { title: 'T2' });

const arg = (mockDriver.create as any).mock.calls.at(-1)[1];
expect(arg.owner).toBeUndefined();
});

it('should execute find operation', async () => {
const result = await engine.find('task', {});
expect(mockDriver.find).toHaveBeenCalled();
Expand Down Expand Up @@ -456,6 +497,42 @@ describe('ObjectQL Engine', () => {
);
});

it('should expand a `user` field (lookup specialized to sys_user) through the same path', async () => {
// Regression: $expand was gated on type lookup/master_detail only; the
// `user` type carries the same `reference` + id storage and must resolve.
vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
if (name === 'ticket') return {
name: 'ticket',
fields: {
owner: { type: 'user', reference: 'sys_user' },
title: { type: 'text' },
},
} as any;
if (name === 'sys_user') return {
name: 'sys_user',
fields: { name: { type: 'text' } },
} as any;
return undefined;
});

vi.mocked(mockDriver.find)
.mockResolvedValueOnce([
{ id: 'k1', title: 'Ticket 1', owner: 'u1' },
])
.mockResolvedValueOnce([
{ id: 'u1', name: 'Alice' },
]);

const result = await engine.find('ticket', { expand: { owner: { object: 'owner' } } });

expect(result[0].owner).toEqual({ id: 'u1', name: 'Alice' });
expect(mockDriver.find).toHaveBeenLastCalledWith(
'sys_user',
expect.objectContaining({ where: { id: { $in: ['u1'] } } }),
undefined,
);
});

it('should apply a nested expand where-filter to the related $in query', async () => {
// Regression: query-syntax.mdx documents `expand: { rel: { where: {...} } }`
// and the QueryAST schema accepts it, but the engine used to drop
Expand Down
12 changes: 11 additions & 1 deletion packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,14 @@ export class ObjectQL implements IDataEngine {
object, field: f.name, error: result.error,
});
}
} else if (dv === 'current_user') {
// `current_user` token → the acting user's id at insert time. Declarative
// counterpart to writing a beforeInsert hook; mirrors the 'NOW()' string
// convention and is resolved app-side per request (driver-agnostic), so
// `Field.user({ defaultValue: 'current_user' })` auto-fills the actor.
// When there is no authenticated user (system/anonymous), leave it unset
// and let required-validation decide — never stamp a bogus owner.
if (execCtx?.userId != null) out[f.name] = String(execCtx.userId);
} else {
out[f.name] = dv;
}
Expand Down Expand Up @@ -1766,7 +1774,9 @@ export class ObjectQL implements IDataEngine {

// Skip if field not found or not a relationship type
if (!fieldDef || !fieldDef.reference) continue;
if (fieldDef.type !== 'lookup' && fieldDef.type !== 'master_detail') continue;
// `user` is a lookup specialized to sys_user — it carries the same `reference`
// and id storage, so it expands through this exact path (single or multiple).
if (fieldDef.type !== 'lookup' && fieldDef.type !== 'master_detail' && fieldDef.type !== 'user') continue;

const referenceObject = fieldDef.reference;

Expand Down
2 changes: 1 addition & 1 deletion packages/objectql/src/metadata-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export function computeViewReferenceDiagnostics(
if (kanban?.groupByField) {
requireField(kanban.groupByField, 'kanban.groupByField');
const def = fields.get(kanban.groupByField);
if (def && def.type && !['select', 'multi-select', 'boolean', 'lookup', 'master_detail'].includes(def.type)) {
if (def && def.type && !['select', 'multi-select', 'boolean', 'lookup', 'master_detail', 'user'].includes(def.type)) {
errors.push({
path: 'kanban.groupByField',
message: `Field "${kanban.groupByField}" (type "${def.type}") cannot group a kanban — use a select-like field`,
Expand Down
4 changes: 2 additions & 2 deletions packages/objectql/src/seed-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export class SeedLoaderService implements ISeedLoaderService {
const fields = objDef.fields as Record<string, any>;
for (const [fieldName, fieldDef] of Object.entries(fields)) {
if (
(fieldDef.type === 'lookup' || fieldDef.type === 'master_detail') &&
(fieldDef.type === 'lookup' || fieldDef.type === 'master_detail' || fieldDef.type === 'user') &&
fieldDef.reference
) {
const targetObject = fieldDef.reference as string;
Expand All @@ -138,7 +138,7 @@ export class SeedLoaderService implements ISeedLoaderService {
field: fieldName,
targetObject,
targetField: DEFAULT_EXTERNAL_ID_FIELD,
fieldType: fieldDef.type as 'lookup' | 'master_detail',
fieldType: fieldDef.type as 'lookup' | 'master_detail' | 'user',
});
}
}
Expand Down
9 changes: 7 additions & 2 deletions packages/plugins/driver-mongodb/src/mongodb-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,13 @@ export async function syncCollectionSchema(
});
}

// Lookup fields get an index for join performance
if (field.type === 'lookup' && field.reference_to) {
// Lookup + user (a lookup specialized to sys_user) fields get an index for
// join performance. A `user` field always references sys_user, so it is
// indexed even when reference_to is not explicitly set.
if (
(field.type === 'lookup' && field.reference_to) ||
field.type === 'user'
) {
indexOps.push({
spec: { [fieldName]: 1 },
options: { name: `idx_${fieldName}_lookup` },
Expand Down
30 changes: 30 additions & 0 deletions packages/plugins/driver-sql/src/sql-driver-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,36 @@ describe('SqlDriver Schema Sync (SQLite)', () => {
expect(res[0].completion).toBe(0.85);
});

it('should store a `user` field like a lookup (string id; multiple ⇒ JSON)', async () => {
// `user` is a lookup specialized to sys_user — same physical storage as any
// lookup: a string id column, or a JSON array when multiple.
const objects = [
{
name: 'ticket_user_test',
fields: {
title: { type: 'text' } as any,
owner: { type: 'user', reference: 'sys_user' } as any,
watchers: { type: 'user', reference: 'sys_user', multiple: true } as any,
},
},
];

await driver.initObjects(objects);

const columns = await knexInstance('ticket_user_test').columnInfo();
expect(columns).toHaveProperty('owner');
expect(columns).toHaveProperty('watchers');

await driver.create('ticket_user_test', {
title: 'T',
owner: 'u-1',
watchers: ['u-2', 'u-3'],
});
const res = await driver.find('ticket_user_test', {});
expect(res[0].owner).toBe('u-1');
expect(res[0].watchers).toEqual(['u-2', 'u-3']);
});

it('should handle special fields (formula, summary, auto_number)', async () => {
const objects = [
{
Expand Down
5 changes: 5 additions & 0 deletions packages/plugins/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2828,7 +2828,12 @@ export class SqlDriver implements IDataDriver {
case 'time':
col = table.time(name);
break;
// `user` is a lookup specialized to sys_user (ADR: lookup → sys_user). Same
// physical storage as any lookup: a string column holding the related row id
// (multiple ⇒ JSON, handled at the top of createColumn). No bespoke storage
// primitive — it shares this exact DDL path so reads/$expand/FK stay uniform.
case 'lookup':
case 'user':
col = table.string(name);
if (field.reference_to) {
table.foreign(name).references('id').inTable(field.reference_to);
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-approvals/src/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1478,7 +1478,7 @@ export class ApprovalService implements IApprovalService {
const fields = schema?.fields ?? {};
const out: Array<{ key: string; reference: string }> = [];
for (const [key, f] of Object.entries<any>(fields)) {
if ((f?.type === 'lookup' || f?.type === 'master_detail') && f?.reference) {
if ((f?.type === 'lookup' || f?.type === 'master_detail' || f?.type === 'user') && f?.reference) {
out.push({ key, reference: String(f.reference) });
}
}
Expand Down
6 changes: 5 additions & 1 deletion packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3649,7 +3649,11 @@ export class RestServer {
const allow = (name: string, cfg: any): boolean => {
const def = objectSchema?.fields?.[name];
const t = def?.type;
if (t !== 'lookup' && t !== 'master_detail') return true;
// `user` is a lookup specialized to sys_user — same risk as a
// raw lookup: surfacing it on an anonymous public form would
// expose unrestricted user search to the internet. Gate it
// behind the same `publicPicker` opt-in.
if (t !== 'lookup' && t !== 'master_detail' && t !== 'user') return true;
return !!cfg?.publicPicker;
};
const sections = match.form.sections.map((sec: any) => {
Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/api/graphql.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1090,6 +1090,7 @@ export const mapFieldTypeToGraphQL = (fieldType: z.infer<typeof FieldType>): str
// Relational
'lookup': 'ID',
'master_detail': 'ID',
'user': 'ID', // lookup specialized to sys_user — id-valued, same as lookup
'tree': 'ID',

// Media
Expand Down
29 changes: 29 additions & 0 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ export const FieldType = z.enum([
// Relational
'lookup', 'master_detail', // Dynamic reference
'tree', // Hierarchical reference
// User reference — a lookup specialized to the `sys_user` system object (person
// picker; single, or multiple for collaborators/watchers). Stored IDENTICALLY to
// 'lookup' (FK string column → sys_user.id; `multiple` ⇒ JSON) and resolved via the
// same $expand machinery. The distinct type exists for modelling discoverability
// (Studio/AI field palette), the user-search picker, and `current_user` defaults —
// NOT a separate storage primitive. Ownership stays the existing `owner_id`
// convention (plugin-security); a declarative `owner` is a possible future flag.
'user',
// Media
'image', 'file', 'avatar', 'video', 'audio',
// Calculated / System
Expand Down Expand Up @@ -722,6 +730,27 @@ export const Field = {
...config
} as const),

/**
* User field — a person picker. Semantic specialization of `lookup` with the
* target fixed to the `sys_user` system object: stored identically (FK string
* column → sys_user.id; `multiple: true` ⇒ JSON array) and resolved via the same
* $expand machinery. The distinct `user` type drives the Studio/AI field palette,
* the user-search picker, and `current_user` defaults — without re-implementing
* lookup storage.
*
* @example Single assignee
* Field.user({ label: 'Assignee' })
* @example Collaborators / watchers (multi)
* Field.user({ label: 'Watchers', multiple: true })
* @example Auto-fill the acting user on create
* Field.user({ label: 'Reporter', defaultValue: 'current_user' })
*/
user: (config: FieldInput = {}) => ({
type: 'user',
reference: 'sys_user',
...config,
} as const),

// Enhanced Field Type Helpers
location: (config: FieldInput = {}) => ({
type: 'location',
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/src/data/seed-loader.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ export const ReferenceResolutionSchema = lazySchema(() => z.object({
*/
targetField: z.string().default('name').describe('Field on target object used for matching'),

/** The field type that triggered this resolution (lookup or master_detail) */
fieldType: z.enum(['lookup', 'master_detail']).describe('Relationship field type'),
/** The field type that triggered this resolution (lookup, master_detail, or user) */
fieldType: z.enum(['lookup', 'master_detail', 'user']).describe('Relationship field type'),
}).describe('Describes how a field reference is resolved during seed loading'));

export type ReferenceResolution = z.infer<typeof ReferenceResolutionSchema>;
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/data/type-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ const CANONICAL_TO_FIELD: Record<
time: { suggested: 'time', exact: ['time'], lossy: [] },
datetime: { suggested: 'datetime', exact: ['datetime'], lossy: ['date'] },
json: { suggested: 'json', exact: ['json', 'composite', 'repeater', 'record', 'location', 'address', 'tags', 'multiselect'], lossy: ['text'] },
uuid: { suggested: 'text', exact: ['text', 'lookup', 'master_detail'], lossy: [] },
uuid: { suggested: 'text', exact: ['text', 'lookup', 'master_detail', 'user'], lossy: [] },
binary: { suggested: 'file', exact: ['file', 'image', 'signature'], lossy: ['text'] },
enum: { suggested: 'select', exact: ['select', 'radio', 'text'], lossy: [] },
array: { suggested: 'multiselect', exact: ['multiselect', 'checkboxes', 'tags', 'json'], lossy: ['text'] },
Expand Down