diff --git a/.changeset/user-field-type.md b/.changeset/user-field-type.md new file mode 100644 index 0000000000..e680ed7f13 --- /dev/null +++ b/.changeset/user-field-type.md @@ -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). diff --git a/examples/app-showcase/src/objects/field-zoo.object.ts b/examples/app-showcase/src/objects/field-zoo.object.ts index df1bb58d20..c0c5471ded 100644 --- a/examples/app-showcase/src/objects/field-zoo.object.ts +++ b/examples/app-showcase/src/objects/field-zoo.object.ts @@ -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' }), diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 78b2ed31d7..92155e4969 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -734,6 +734,11 @@ function generateMigrationTs(config: Record): 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}')`; } diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index b405cd4c38..09c02a786b 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -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(); @@ -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 diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 6f037f8bfe..e584da41ab 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -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; } @@ -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; diff --git a/packages/objectql/src/metadata-diagnostics.ts b/packages/objectql/src/metadata-diagnostics.ts index 77b09d71d1..8022959bfc 100644 --- a/packages/objectql/src/metadata-diagnostics.ts +++ b/packages/objectql/src/metadata-diagnostics.ts @@ -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`, diff --git a/packages/objectql/src/seed-loader.ts b/packages/objectql/src/seed-loader.ts index e6e5bf3198..f4312d42f4 100644 --- a/packages/objectql/src/seed-loader.ts +++ b/packages/objectql/src/seed-loader.ts @@ -123,7 +123,7 @@ export class SeedLoaderService implements ISeedLoaderService { const fields = objDef.fields as Record; 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; @@ -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', }); } } diff --git a/packages/plugins/driver-mongodb/src/mongodb-schema.ts b/packages/plugins/driver-mongodb/src/mongodb-schema.ts index 26f71c093e..2a07880c66 100644 --- a/packages/plugins/driver-mongodb/src/mongodb-schema.ts +++ b/packages/plugins/driver-mongodb/src/mongodb-schema.ts @@ -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` }, diff --git a/packages/plugins/driver-sql/src/sql-driver-schema.test.ts b/packages/plugins/driver-sql/src/sql-driver-schema.test.ts index 37e2c8a19e..f94e714f2f 100644 --- a/packages/plugins/driver-sql/src/sql-driver-schema.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-schema.test.ts @@ -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 = [ { diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index a1e97989a4..d3029a113c 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -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); diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 063072ecf9..33fdbb05ad 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -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(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) }); } } diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 19753f78ad..d34c43b657 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -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) => { diff --git a/packages/spec/src/api/graphql.zod.ts b/packages/spec/src/api/graphql.zod.ts index 156d899253..4f1ab583b2 100644 --- a/packages/spec/src/api/graphql.zod.ts +++ b/packages/spec/src/api/graphql.zod.ts @@ -1090,6 +1090,7 @@ export const mapFieldTypeToGraphQL = (fieldType: z.infer): str // Relational 'lookup': 'ID', 'master_detail': 'ID', + 'user': 'ID', // lookup specialized to sys_user — id-valued, same as lookup 'tree': 'ID', // Media diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 3228a8ed75..08f4b8dd3c 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -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 @@ -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', diff --git a/packages/spec/src/data/seed-loader.zod.ts b/packages/spec/src/data/seed-loader.zod.ts index 360e03fbb2..e135582991 100644 --- a/packages/spec/src/data/seed-loader.zod.ts +++ b/packages/spec/src/data/seed-loader.zod.ts @@ -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; diff --git a/packages/spec/src/data/type-compat.ts b/packages/spec/src/data/type-compat.ts index be6a2c645c..d98e4e3e7a 100644 --- a/packages/spec/src/data/type-compat.ts +++ b/packages/spec/src/data/type-compat.ts @@ -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'] },