Skip to content

Commit d980f0d

Browse files
os-zhuangclaude
andauthored
feat: add a first-class user field type (person picker) (#2351)
* feat: add a first-class `user` field type (person picker) A `user` field type — the equivalent of Airtable's Collaborator / Notion's Person / Salesforce's `Lookup(User)`. Authored as `Field.user({ ... })`; `{ multiple: true }` for collaborators/watchers and `{ defaultValue: 'current_user' }` to auto-fill the acting user on create. Designed as a SEMANTIC SPECIALIZATION OF `lookup` with the target fixed to the `sys_user` system object — NOT a new storage primitive. 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, an existing `Field.lookup('sys_user')` is equivalent at the storage layer (zero data migration), and nothing is re-implemented. The distinct type exists for 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` object — plus a user-search picker and `current_user` defaults. Ownership semantics are unchanged: the existing `owner_id` convention + plugin-security auto-stamp/RLS still apply. A declarative `owner` field type was intentionally NOT added — ownership is a system role (one per object, auto-stamped), already handled, and a second type would only grow the platform-wide `FieldType` surface for marginal benefit. Changes: - spec: `FieldType` gains `'user'` + `Field.user()` builder; seed-loader / GraphQL / SQL type-compat reference maps accept `user`. - drivers (sql, mongodb): treat `user` exactly like `lookup`. - engine: resolve `$expand` for `user` fields; honour a new `defaultValue: 'current_user'` token (resolved app-side from the execution context, mirroring the `NOW()` convention). - kanban group-by + symbolic seed references + approvals enrichment accept `user`. Tests: engine $expand-for-user + current_user stamp (present / absent actor); driver-sql string-id + multiple⇒JSON round-trip. Public API surface unchanged (additive enum member). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(showcase): cover the new `user` field type in field-zoo `field-zoo` is the exhaustive FieldType coverage object, and `coverage.test.ts` asserts every member of `FieldType` appears across the showcase objects. Adding `'user'` to the enum without a representative field made "covers every FieldType" fail (uncovered → user). Add single, multiple, and `current_user`-default `user` fields so the guard is satisfied and the new type gets real data-layer coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(rest): gate `user` fields on anonymous public forms (privacy) The public-form schema endpoint strips `lookup`/`master_detail` fields from anonymous forms unless the designer opts in via `publicPicker` — so a stray spec mistake can't expose unrestricted record search to the internet. A `user` field is a lookup specialized to `sys_user`, so the same risk applies: it would surface a user search/list to unauthenticated visitors. Gate it behind the same `publicPicker` opt-in. (The companion `/forms/:slug/lookup/:field` endpoint already 403s without `publicPicker`, independent of type — this closes the form-schema side.) Found while dogfooding the new `user` field type end-to-end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 795b6d1 commit d980f0d

16 files changed

Lines changed: 225 additions & 11 deletions

File tree

.changeset/user-field-type.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/driver-sql': minor
5+
'@objectstack/driver-mongodb': minor
6+
'@objectstack/cli': patch
7+
'@objectstack/plugin-approvals': patch
8+
---
9+
10+
feat: add a first-class `user` field type (person picker)
11+
12+
A new `user` field type — the equivalent of Airtable's Collaborator / Notion's
13+
Person / Salesforce's `Lookup(User)`. Authored as `Field.user({ ... })`; use
14+
`{ multiple: true }` for collaborators/watchers and `{ defaultValue: 'current_user' }`
15+
to auto-fill the acting user on create.
16+
17+
**Why a distinct type rather than telling authors to `Field.lookup('sys_user')`:**
18+
selecting a person is table-stakes, but the value is in *modelling
19+
discoverability* — a "User" entry in the Studio/AI field palette instead of
20+
requiring authors (and AI) to know to reference the internal `sys_user` system
21+
object — plus `current_user` defaults and a user-search picker. Storage and
22+
runtime are unchanged.
23+
24+
**Deliberately NOT a new storage primitive.** `user` is a *semantic
25+
specialization of `lookup`* with the target fixed to `sys_user`: it shares the
26+
exact lookup code path — same FK string column (`multiple` ⇒ JSON), same
27+
`$expand` resolution, same indexing — so referential integrity and fresh display
28+
names come for free, and nothing is re-implemented. An existing
29+
`Field.lookup('sys_user')` is therefore equivalent at the storage layer (zero
30+
data migration to adopt `Field.user`).
31+
32+
Ownership semantics are **unchanged**: the existing `owner_id` convention +
33+
`plugin-security` auto-stamp/RLS still apply. A declarative `owner` flag is a
34+
possible future follow-up; intentionally not added here to avoid a second
35+
field type for what is a system role (rationale: keep the `FieldType` surface
36+
lean — see related ADR-0059 freeze discipline).
37+
38+
Changes: `FieldType` gains `'user'` + `Field.user()` builder; the SQL/Mongo
39+
drivers treat `user` exactly like `lookup`; the engine resolves `$expand` for
40+
`user` fields and honours a new `defaultValue: 'current_user'` token (resolved
41+
app-side from the execution context, mirroring the `NOW()` convention); kanban
42+
group-by and symbolic seed references accept `user`; approvals enrich `user`
43+
references. The public API surface is unchanged (additive enum member).

examples/app-showcase/src/objects/field-zoo.object.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ export const FieldZoo = ObjectSchema.create({
9595
f_master_detail: Field.masterDetail('showcase_project', { label: 'Master-Detail → Project' }),
9696
f_tree: { type: 'tree', label: 'Tree (self/category)', reference: 'showcase_category' },
9797

98+
// ── User (lookup specialized to sys_user) ────────────────────────────
99+
f_user: Field.user({ label: 'User → sys_user (single)' }),
100+
f_users: Field.user({ label: 'Users (multiple)', multiple: true }),
101+
f_owner: Field.user({ label: 'Owner (current_user default)', defaultValue: 'current_user' }),
102+
98103
// ── Media ────────────────────────────────────────────────────────────
99104
f_image: Field.image({ label: 'Image' }),
100105
f_file: Field.file({ label: 'File' }),

packages/cli/src/commands/generate.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,11 @@ function generateMigrationTs(config: Record<string, unknown>): string {
734734
case 'uuid': case 'lookup': case 'master_detail':
735735
colMethod = `table.uuid('${fieldName}')`;
736736
break;
737+
// `user` references sys_user, whose id is a text identifier (not a uuid),
738+
// so store it as a string column — consistent with the runtime sql-driver.
739+
case 'user':
740+
colMethod = `table.string('${fieldName}')`;
741+
break;
737742
default:
738743
colMethod = `table.text('${fieldName}')`;
739744
}

packages/objectql/src/engine.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,47 @@ describe('ObjectQL Engine', () => {
278278
expect(result).toEqual({ id: '1', success: true });
279279
});
280280

281+
it('stamps a `current_user` defaultValue with the acting user id on insert', async () => {
282+
vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
283+
if (name === 'ticket') return {
284+
name: 'ticket',
285+
fields: {
286+
title: { type: 'text' },
287+
owner: { type: 'user', reference: 'sys_user', defaultValue: 'current_user' },
288+
},
289+
} as any;
290+
if (name === 'sys_user') return { name: 'sys_user', fields: { name: { type: 'text' } } } as any;
291+
return undefined;
292+
});
293+
294+
await engine.insert('ticket', { title: 'T1' }, { context: { userId: 'u-42' } as any });
295+
296+
expect(mockDriver.create).toHaveBeenCalledWith(
297+
'ticket',
298+
expect.objectContaining({ title: 'T1', owner: 'u-42' }),
299+
expect.anything(),
300+
);
301+
});
302+
303+
it('leaves a `current_user` default unset when there is no authenticated user', async () => {
304+
vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
305+
if (name === 'ticket') return {
306+
name: 'ticket',
307+
fields: {
308+
title: { type: 'text' },
309+
owner: { type: 'user', reference: 'sys_user', defaultValue: 'current_user' },
310+
},
311+
} as any;
312+
if (name === 'sys_user') return { name: 'sys_user', fields: { name: { type: 'text' } } } as any;
313+
return undefined;
314+
});
315+
316+
await engine.insert('ticket', { title: 'T2' });
317+
318+
const arg = (mockDriver.create as any).mock.calls.at(-1)[1];
319+
expect(arg.owner).toBeUndefined();
320+
});
321+
281322
it('should execute find operation', async () => {
282323
const result = await engine.find('task', {});
283324
expect(mockDriver.find).toHaveBeenCalled();
@@ -456,6 +497,42 @@ describe('ObjectQL Engine', () => {
456497
);
457498
});
458499

500+
it('should expand a `user` field (lookup specialized to sys_user) through the same path', async () => {
501+
// Regression: $expand was gated on type lookup/master_detail only; the
502+
// `user` type carries the same `reference` + id storage and must resolve.
503+
vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
504+
if (name === 'ticket') return {
505+
name: 'ticket',
506+
fields: {
507+
owner: { type: 'user', reference: 'sys_user' },
508+
title: { type: 'text' },
509+
},
510+
} as any;
511+
if (name === 'sys_user') return {
512+
name: 'sys_user',
513+
fields: { name: { type: 'text' } },
514+
} as any;
515+
return undefined;
516+
});
517+
518+
vi.mocked(mockDriver.find)
519+
.mockResolvedValueOnce([
520+
{ id: 'k1', title: 'Ticket 1', owner: 'u1' },
521+
])
522+
.mockResolvedValueOnce([
523+
{ id: 'u1', name: 'Alice' },
524+
]);
525+
526+
const result = await engine.find('ticket', { expand: { owner: { object: 'owner' } } });
527+
528+
expect(result[0].owner).toEqual({ id: 'u1', name: 'Alice' });
529+
expect(mockDriver.find).toHaveBeenLastCalledWith(
530+
'sys_user',
531+
expect.objectContaining({ where: { id: { $in: ['u1'] } } }),
532+
undefined,
533+
);
534+
});
535+
459536
it('should apply a nested expand where-filter to the related $in query', async () => {
460537
// Regression: query-syntax.mdx documents `expand: { rel: { where: {...} } }`
461538
// and the QueryAST schema accepts it, but the engine used to drop

packages/objectql/src/engine.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,14 @@ export class ObjectQL implements IDataEngine {
783783
object, field: f.name, error: result.error,
784784
});
785785
}
786+
} else if (dv === 'current_user') {
787+
// `current_user` token → the acting user's id at insert time. Declarative
788+
// counterpart to writing a beforeInsert hook; mirrors the 'NOW()' string
789+
// convention and is resolved app-side per request (driver-agnostic), so
790+
// `Field.user({ defaultValue: 'current_user' })` auto-fills the actor.
791+
// When there is no authenticated user (system/anonymous), leave it unset
792+
// and let required-validation decide — never stamp a bogus owner.
793+
if (execCtx?.userId != null) out[f.name] = String(execCtx.userId);
786794
} else {
787795
out[f.name] = dv;
788796
}
@@ -1766,7 +1774,9 @@ export class ObjectQL implements IDataEngine {
17661774

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

17711781
const referenceObject = fieldDef.reference;
17721782

packages/objectql/src/metadata-diagnostics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ export function computeViewReferenceDiagnostics(
183183
if (kanban?.groupByField) {
184184
requireField(kanban.groupByField, 'kanban.groupByField');
185185
const def = fields.get(kanban.groupByField);
186-
if (def && def.type && !['select', 'multi-select', 'boolean', 'lookup', 'master_detail'].includes(def.type)) {
186+
if (def && def.type && !['select', 'multi-select', 'boolean', 'lookup', 'master_detail', 'user'].includes(def.type)) {
187187
errors.push({
188188
path: 'kanban.groupByField',
189189
message: `Field "${kanban.groupByField}" (type "${def.type}") cannot group a kanban — use a select-like field`,

packages/objectql/src/seed-loader.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export class SeedLoaderService implements ISeedLoaderService {
123123
const fields = objDef.fields as Record<string, any>;
124124
for (const [fieldName, fieldDef] of Object.entries(fields)) {
125125
if (
126-
(fieldDef.type === 'lookup' || fieldDef.type === 'master_detail') &&
126+
(fieldDef.type === 'lookup' || fieldDef.type === 'master_detail' || fieldDef.type === 'user') &&
127127
fieldDef.reference
128128
) {
129129
const targetObject = fieldDef.reference as string;
@@ -138,7 +138,7 @@ export class SeedLoaderService implements ISeedLoaderService {
138138
field: fieldName,
139139
targetObject,
140140
targetField: DEFAULT_EXTERNAL_ID_FIELD,
141-
fieldType: fieldDef.type as 'lookup' | 'master_detail',
141+
fieldType: fieldDef.type as 'lookup' | 'master_detail' | 'user',
142142
});
143143
}
144144
}

packages/plugins/driver-mongodb/src/mongodb-schema.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,13 @@ export async function syncCollectionSchema(
7373
});
7474
}
7575

76-
// Lookup fields get an index for join performance
77-
if (field.type === 'lookup' && field.reference_to) {
76+
// Lookup + user (a lookup specialized to sys_user) fields get an index for
77+
// join performance. A `user` field always references sys_user, so it is
78+
// indexed even when reference_to is not explicitly set.
79+
if (
80+
(field.type === 'lookup' && field.reference_to) ||
81+
field.type === 'user'
82+
) {
7883
indexOps.push({
7984
spec: { [fieldName]: 1 },
8085
options: { name: `idx_${fieldName}_lookup` },

packages/plugins/driver-sql/src/sql-driver-schema.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,36 @@ describe('SqlDriver Schema Sync (SQLite)', () => {
158158
expect(res[0].completion).toBe(0.85);
159159
});
160160

161+
it('should store a `user` field like a lookup (string id; multiple ⇒ JSON)', async () => {
162+
// `user` is a lookup specialized to sys_user — same physical storage as any
163+
// lookup: a string id column, or a JSON array when multiple.
164+
const objects = [
165+
{
166+
name: 'ticket_user_test',
167+
fields: {
168+
title: { type: 'text' } as any,
169+
owner: { type: 'user', reference: 'sys_user' } as any,
170+
watchers: { type: 'user', reference: 'sys_user', multiple: true } as any,
171+
},
172+
},
173+
];
174+
175+
await driver.initObjects(objects);
176+
177+
const columns = await knexInstance('ticket_user_test').columnInfo();
178+
expect(columns).toHaveProperty('owner');
179+
expect(columns).toHaveProperty('watchers');
180+
181+
await driver.create('ticket_user_test', {
182+
title: 'T',
183+
owner: 'u-1',
184+
watchers: ['u-2', 'u-3'],
185+
});
186+
const res = await driver.find('ticket_user_test', {});
187+
expect(res[0].owner).toBe('u-1');
188+
expect(res[0].watchers).toEqual(['u-2', 'u-3']);
189+
});
190+
161191
it('should handle special fields (formula, summary, auto_number)', async () => {
162192
const objects = [
163193
{

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2828,7 +2828,12 @@ export class SqlDriver implements IDataDriver {
28282828
case 'time':
28292829
col = table.time(name);
28302830
break;
2831+
// `user` is a lookup specialized to sys_user (ADR: lookup → sys_user). Same
2832+
// physical storage as any lookup: a string column holding the related row id
2833+
// (multiple ⇒ JSON, handled at the top of createColumn). No bespoke storage
2834+
// primitive — it shares this exact DDL path so reads/$expand/FK stay uniform.
28312835
case 'lookup':
2836+
case 'user':
28322837
col = table.string(name);
28332838
if (field.reference_to) {
28342839
table.foreign(name).references('id').inTable(field.reference_to);

0 commit comments

Comments
 (0)