Skip to content

Commit b7cd1af

Browse files
fix(core)!: an import reads which fields are references through the spec's arbiter, so a user field without reference resolves against sys_user (#22785) (#22818)
Fixes #22785 Clause-②: yes (narrowing) ## What changes `buildFieldMetaMap` (`packages/core/src/utils/import-field-meta.ts`) now asks the spec's arbiter `referenceTargetOf` both WHETHER a field is a reference and WHAT its target is. Before, only a field carrying a `reference` string reached the arbiter. So a `user` field written without `reference` was not a reference at the import, export and import-template doors, although the arbiter (and the data door's `$expand`) reads it as `sys_user`. - No second list of implicit targets: the arbiter's own `IMPLICIT_REFERENCE_TARGETS` is the only one. - A `reference` key that is present but not a string still stays out of the arbiter, which throws on it. Such a field names no target, as before. - The #22739 narrowing stands: a non-reference type declaring `reference` still names no target. - Every newly resolved target is asked its exposure first (`servesReferenceTarget`, #22739). No code path bypasses it. - Source: +9 / -6 in one file. No source change in `packages/rest`, in `import-runner.ts` (`toFailedResult` untouched) or in `packages/types`. ## Door readings, before and after Real boot through the dogfood harness (SecurityPlugin, ObjectQL, SQL driver, REST). Base `9f5eca52b3` against the fix `ce23f40ae8`, with `@objectstack/core` rebuilt and its `dist` checked. The fixture object has `owner` written as `{ type: 'user' }` (no `reference`) and `assignee` written as `Field.user()`. The administrator and the member answered identically, except on the hidden-user row. | Door | Input | Before | After | |---|---|---|---| | `POST /data/:object/import` and `/import/jobs` | `owner` = a user's email | `reference_not_found` ("no sys_user record has id ..."), nothing stored | stores the user's id | | same | CONTROL `assignee` (Field.user) = the same email | stores the id | stores the id | | same | `owner` = a visible user's id | stores | stores | | same | `owner` = an unknown email | `reference_not_found` (the write's sentence) | `reference_not_found` ("no record matches ...") | | same, member | `owner` = the id of a user outside the member's organizations (the member's `GET /data/sys_user/ID` answers 404) | stores | `reference_not_found`; CONTROL `assignee` answers `reference_not_found` in both | | `GET /data/:object/export` (JSON and CSV) | `owner` holding the admin's id | the raw id | the user's name ("Dev Admin"), same as `assignee` | | `GET /data/:object/export?template=true` | `owner` instructions row | "The name of a record, or its id." | "The name of a User record, or its id." | | connector pull (`pullConnectorSource`, stub protocol, built core) | `owner_email` mapped to `owner` | the raw email written as the id (ok) | the user's id written; an unknown email is `reference_not_found` | | plugin-auth identity import | `sys_user`'s served map (26 fields) | `manager_id` to `sys_user`, `primary_business_unit_id` to `sys_business_unit`; 0 reference-typed fields without `reference` | identical | | data door `POST /data/:object` (not this change) | `owner` = the hidden user's id | 201 | 201 | - The connector-pull before reading and the hidden-user before reading were taken under ablation A1 below: main's guard restored and core rebuilt. - Two rows are a narrowing or a change to published output, and the changeset names both under its BREAKING banner: the hidden-user import row (stored before, refused now) and the export column (the stored id before, the user's name now). Each moves to the answer a `Field.user` field already gives. ## Pins - **core** `import-runner-reference-exposure.test.ts`: the #22739 control block (`owner` with no target) is replaced by four cases. - The map names `sys_user` for both user fields, and no target for a non-reference type or for a non-string carrier. - An email in the field without `reference` stores the user's id. - CONTROL: `Field.user` does the same. - CONTROL: an unserved `sys_user` (`apiEnabled: false`) refuses both cells, and `findData` is never called. - **rest** `export-format.test.ts`: `referenceFieldNames` includes the field without `reference`. `import-template.test.ts`: its instructions name `User`. - **dogfood** `import-user-field-target.dogfood.test.ts`: the sync and jobs doors for `owner` and for the `Field.user` control, plus the export door. ## Ablations Each ablation ran on a committed tree through `scripts/ablation-replace.mjs` (WRAP mode). Each restore was proven: blob equals HEAD and `git diff HEAD` is empty. - **A1, the fix reverted** (delete `f.reference == null || `). Core rebuilt; `ablation-dist-preflight.mjs --absent` green over 14 built files. Before the mutation, the fixed build carried the marker in `dist/index.js` (count 1). - core: 3 red / 14 green. Both `Field.user` cases and the 13 #22739 pins stay green. - rest: 2 red / 78 green. - dogfood: 3 red / 2 green. Both `Field.user` legs stay green. - Restore leg: core rebuilt, preflight presence green (2 built files), tree clean, everything green. - **A2, the exposure ask removed** (`if (!(await served)) return {};` in `import-runner.ts`): 10 red / 7 green. The red cases are the 9 #22739 withheld legs and the new unserved-`sys_user` control, so that control is #22739's refusal. - **A3, the non-string guard removed** (the ternary replaced by `(referenceTargetOf(f))`): 4 red / 13 green. Each red is the arbiter's TypeError on the non-string carrier. - The first A1 attempt sent nothing: the tool refused it because the replacement was a substring of the anchor, so its count could not rise. It was re-run as a delete. ## Verification The package suites ran at `4b78f72c5f`. The next merge, to `272f15bd62` (`origin/main` `098481744f`), brought only `plugin-webhooks`, the lockfile and `scripts/engine-double-contract.pinned.json`. The dogfood pins were re-run at `272f15bd62`. - core: `test` 93 files / 2352 passed; `test:repo` 5 / 55, the enumeration pin `second-object-read-exposure.pin.test.ts` included; `typecheck` exit 0 (`check:test-typecheck` OK). - rest: the 38 test files reaching the import, export and template doors: 1225 passed, 22 skipped. `typecheck` exit 0. - plugin-auth: `admin-import-users` 2 files / 50. service-automation: `connector-pull` 3 files / 22. - dogfood: `typecheck` exit 0. The two pins (this one and #22739's `import-reference-exposure`) passed 16 / 16 at `272f15bd62`. - Each new or edited test file is in its package's tsc program (`--listFiles`). - **Gates at `272f15bd62`.** - `dispatch-gates --commands` derived 68 gates; the dispatch's 50 are a subset. All 68 ran, and each exited 0. - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (8 packages had no `dist`). After those packages were built it exited 0. - `--ran` reconcile: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN. - **Lint, narrowed, at `272f15bd62`.** `eslint --no-inline-config` over the 5 changed `.ts` files. - Population from `--print-config`: 5 to 6 active rules each, none ignored. - `--format json`: 5 files, 0 errors, 0 warnings. - `eslint.config.mjs` enables no type-aware linting, so no untouched file's verdict can move. Repo-wide lint belongs to CI. - **Round 2 (changeset text only, head `7fffd20e84`, no code change).** `check-adr-0087-registration --base origin/main` 0 (`[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)`); `check-changeset-no-major --base origin/main` 0; `check-empty-changeset --base origin/main` 0; `check:changeset-gate-self-tests` 0; `check:nul-bytes`, `check:issue-citations` (both spellings), `check:doc-authoring`, `check:pm-changeset-deadline-census`, `check:objectui-changeset` 0. Re-derived `dispatch-gates --commands`: the same 68 gates as above. - The changeset's per-door section is now labelled "Door by door, before and after". The gate read the old label, "FROM → TO", as a migration prescription, which contradicted `no-migration-prescription`. The bullets are unchanged. - `origin/main` has since moved to `23419bafb7`. Its one file overlapping this branch's reads, the enumeration pin, changes a different row (the relation-filter one). `git merge-tree` is clean, so the merge is left to CI and the queue. ## Acceptance notes - **Clause-② arm.** The seat ruled the arm `yes (narrowing)`: the hidden-user id moves from stored to refused, and the export column moves from the stored id to the user's name. The changeset therefore carries `fix(core)!`, the `**BREAKING**` banner naming both populations, and the ADR-0087 marker `not-required (no-migration-prescription)`. It also adds a one-line fix for a reader that needs the stored id: the record read (`GET /api/v1/data/:object`, without `expand`). - `buildFieldMetaMap` serves the export and template doors from `packages/rest`, so their answers moved with no `packages/rest` source change. They are pinned by test only. --- _Generated by [Claude Code](https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5fdc1a8 commit b7cd1af

6 files changed

Lines changed: 223 additions & 29 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/core': minor
3+
---
4+
5+
fix(core)!: an import reads WHICH fields are references through the spec's arbiter, so a `user` field written without `reference` resolves against `sys_user` (#22785)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, exported declaration or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the runtime answer of one field map, `buildFieldMetaMap`, for a `user` field written without `reference`: the import resolves its cell against `sys_user`, which refuses the id of a user the caller cannot see, and the export expands its column into the user's name. Both answers are the ones a `Field.user` field already gets; the author's metadata stays as written. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); no exported declaration is removed or narrowed, `ExportFieldMeta` and `buildFieldMetaMap` keep their signatures (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing and a change to published output), shipped as `minor` under the launch-window convention for breaking changes. Two populations are affected, both only for a `user` field written without `reference`:
12+
13+
- an import row whose cell holds the id of a user the caller cannot see (a user outside the caller's organizations): stored before, `reference_not_found` now, as on a `Field.user` field;
14+
- a downstream reader of that column in `GET /api/v1/data/:object/export`: the cell held the stored user id and now holds the user's name, as a `Field.user` column already does.
15+
16+
A `user` field's target is fixed by its type: the spec's arbiter `referenceTargetOf` gives `{ type: 'user' }` the target `sys_user` whether or not `reference` is written (`Field.user()` writes it; hand-written JSON, a form or an AI author may not), and the data door's `$expand` already reads it that way. `buildFieldMetaMap`, the field map behind the import, export and import-template doors, counted a field as a reference only when it carried a `reference` string, so those doors disagreed with the arbiter for such a field. It now asks the arbiter both whether a field is a reference and what its target is.
17+
18+
**Door by door, before and after.** Measured on a real boot, as an administrator and as a member, on a fixture object holding a `user` field written without `reference` beside a `Field.user` one; the connector pull through its executor over a stub protocol.
19+
20+
- **Import: `POST /api/v1/data/:object/import`, `POST /api/v1/data/:object/import/jobs`, and a mapping's `connectorSource` pull (same runner).**
21+
- FROM: a cell holding a user's email or name was written as the field's id, so the row failed `reference_not_found` ("no sys_user record has id …") and nothing was stored.
22+
- TO: the cell is matched against `sys_user` exactly as on a `Field.user` field, so an email or a name stores the user's id. A pasted id of a user the caller can see still stores. A value matching no user still fails `reference_not_found`, now in the import's own words ("no record matches …"), and a name matching several users fails `reference_ambiguous`.
23+
- `sys_user` is asked its declared exposure first, as every lookup target is: where its `enable` declaration refuses `list`, the cell is refused.
24+
- **The one population that narrows, to `Field.user`'s answer.** A pasted id of a user the caller's row scope hides (a user outside the caller's organizations) used to store, since the write's own reference check runs elevated. It now fails `reference_not_found`, as it already did on a `Field.user` field. The data door's `POST /api/v1/data/:object` is unchanged and still accepts that id.
25+
- **Export: `GET /api/v1/data/:object/export`.** FROM: the column carried the stored user id (JSON and CSV). TO: it is expanded like a `Field.user` column and renders the user's name, so an exported file imports back through the name.
26+
- **Import template: `GET /api/v1/data/:object/export?template=true`.** FROM: the column's instructions named no target ("The name of a record, or its id."). TO: they name `sys_user` by its label ("The name of a User record, or its id.").
27+
- **Unchanged:**
28+
- `Field.user` and every field declaring `reference`.
29+
- A `lookup`, `master_detail` or `tree` field without `reference`: the arbiter names no target for it.
30+
- A non-reference type declaring `reference`: still no target.
31+
- A present `reference` that is not a string: still no target, and no throw.
32+
- plugin-auth's identity import, which reads `sys_user`'s own map. That map has no reference-typed field without `reference`, so it is identical.
33+
34+
**Fix, if a pipeline relied on the old answer:** none is needed for one that wrote visible user ids. A pipeline that imported ids of users outside the caller's organizations runs as a caller who can see them, as a `Field.user` field already required. A reader that needs the stored user id takes it from the record read (`GET /api/v1/data/:object`, without `expand`), not from the export.

‎packages/core/src/utils/import-field-meta.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ export interface ExportFieldMeta {
1818
label?: string;
1919
options?: Array<{ label?: string; value?: unknown; color?: string }>;
2020
/**
21-
* Target object of a field that declares a `reference` string, read through
22-
* the spec's one arbiter `referenceTargetOf` (so a non-reference type names
23-
* none). The import's reference resolution matches cells against it and asks
24-
* it its exposure first (#22739). Only a declared `reference` makes a field
25-
* one: a `user` field written without it keeps no target here, as before.
21+
* Target object of a reference field: WHICH fields are references, and their
22+
* target, are both the spec's one arbiter `referenceTargetOf`'s answer — so a
23+
* non-reference type names none, and a `user` field written without
24+
* `reference` names `sys_user`, as `$expand` reads it (#22785). The import's
25+
* reference resolution matches cells against it and asks it its exposure
26+
* first (#22739).
2627
*/
2728
reference?: string;
2829
/** Field on the referenced record to show as its label. */
@@ -87,7 +88,9 @@ export function buildFieldMetaMap(schema: unknown): Map<string, ExportFieldMeta>
8788
type: typeof f.type === 'string' ? f.type : undefined,
8889
label: typeof f.label === 'string' ? f.label : undefined,
8990
options: Array.isArray(f.options) ? f.options : undefined,
90-
reference: typeof f.reference === 'string' ? referenceTargetOf(f) : undefined,
91+
// A present non-string carrier stays out of the arbiter, which throws on
92+
// it: such a field names no target here, as before.
93+
reference: f.reference == null || typeof f.reference === 'string' ? referenceTargetOf(f) : undefined,
9194
displayField: typeof f.displayField === 'string' ? f.displayField : undefined,
9295
multiple: f.multiple === true,
9396
});

‎packages/core/src/utils/import-runner-reference-exposure.test.ts‎

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -91,32 +91,51 @@ describe('[#22739] import reference resolution asks the target its declared expo
9191
});
9292

9393
/**
94-
* CONTROL — which fields the import treats as references is unchanged: a field
95-
* declaring a `reference` string. A `user` field written without one is NOT
96-
* resolved (its raw cell reaches the write, as on `main`), although the spec's
97-
* arbiter gives it `sys_user`; adopting that target widens the accept set and
98-
* is not this change. A non-reference type declaring `reference` names no target.
94+
* [#22785] WHICH fields are references is the spec arbiter's answer too: a
95+
* `user` field written without `reference` targets `sys_user`, as `$expand`
96+
* reads it, so its cell is resolved like a `Field.user` one. The widened
97+
* target is still asked its exposure first. A non-reference type declaring
98+
* `reference`, and a non-string carrier, name no target (and do not throw).
99+
* The real import and export doors: `import-user-field-target.dogfood.test.ts`.
99100
*/
100-
describe('[#22739] the reference field set stays the declared-reference set', () => {
101-
it('a user field without reference passes its cell through unresolved; one declaring it is resolved', async () => {
102-
const meta = buildFieldMetaMap({ fields: {
103-
owner: { type: 'user', label: 'Owner' },
104-
assignee: { type: 'user', label: 'Assignee', reference: 'sys_user' },
105-
amount: { type: 'number', label: 'Amount', reference: 'rx_target' },
106-
} });
107-
expect([meta.get('owner')!.reference, meta.get('assignee')!.reference, meta.get('amount')!.reference]).toEqual([undefined, 'sys_user', undefined]);
108-
109-
const findData = vi.fn(async (args: FindArgs) => (args.object === 'sys_user' && Object.values(args.query!.where!)[0] === 'Ann' ? [{ id: 'u_ann' }] : []));
110-
const getMetaItem = vi.fn(async ({ name }: { type: string; name: string }) => ({ type: 'object', name, item: { name } }));
101+
describe('[#22785] a user field without reference resolves through the arbiter target', () => {
102+
const EMAIL = 'ann@iuf.test';
103+
const fields = {
104+
owner: { type: 'user', label: 'Owner' },
105+
assignee: { type: 'user', label: 'Assignee', reference: 'sys_user' },
106+
amount: { type: 'number', label: 'Amount', reference: 'rx_target' },
107+
broken: { type: 'user', label: 'Broken', reference: { object: 'sys_user' } },
108+
};
109+
const userProtocol = (rows: Array<Record<string, unknown>>, enable?: unknown) => {
110+
const findData = vi.fn(async (args: FindArgs) => (args.object === 'sys_user' && (args.query!.where as Record<string, unknown>).email === EMAIL ? [{ id: 'u_ann' }] : []));
111+
const getMetaItem = vi.fn(async ({ name }: { type: string; name: string }) => ({ type: 'object', name, item: { name, ...(enable ? { enable } : {}) } }));
111112
const createData = vi.fn(async (a: { data: Record<string, unknown> }) => ({ id: 'new', ...a.data }));
112113
const p: ImportProtocolLike = { findData, getMetaItem, createData, updateData: vi.fn() };
113-
const summary = await runImport({
114-
p, objectName: 'rx_source', metaMap: meta, writeMode: 'insert', matchFields: [], dryRun: false,
115-
runAutomations: false, trimWhitespace: true, createMissingOptions: false, skipBlankMatchKey: false,
116-
rows: [{ owner: 'Ann', assignee: 'Ann' }],
114+
const summary = runImport({
115+
p, objectName: 'rx_source', metaMap: buildFieldMetaMap({ fields }), writeMode: 'insert', matchFields: [], dryRun: false,
116+
runAutomations: false, trimWhitespace: true, createMissingOptions: false, skipBlankMatchKey: false, rows,
117+
});
118+
return { summary, findData, getMetaItem, createData };
119+
};
120+
const OK = { ok: true, code: undefined, field: undefined };
121+
122+
it('names sys_user for both user fields, and no target for a non-reference type or a non-string carrier', () => {
123+
const meta = buildFieldMetaMap({ fields });
124+
expect(['owner', 'assignee', 'amount', 'broken'].map((f) => meta.get(f)!.reference)).toEqual(['sys_user', 'sys_user', undefined, undefined]);
125+
});
126+
127+
for (const field of ['owner', 'assignee'] as const) {
128+
it(`${field === 'owner' ? 'the field without reference' : 'CONTROL Field.user'}: an email stores the user id, sys_user asked once`, async () => {
129+
const { summary, getMetaItem, createData } = userProtocol([{ [field]: EMAIL }]);
130+
expect((await summary).results.map(verdict)).toEqual([OK]);
131+
expect(createData.mock.calls.map(([a]) => a.data)).toEqual([{ [field]: 'u_ann' }]);
132+
expect(getMetaItem.mock.calls.map(([r]) => r.name)).toEqual(['sys_user']);
117133
});
118-
expect(summary.results.map(verdict)).toEqual([{ ok: true, code: undefined, field: undefined }]);
119-
expect(createData.mock.calls[0]![0].data).toMatchObject({ owner: 'Ann', assignee: 'u_ann' });
120-
expect(getMetaItem.mock.calls.map(([r]) => r.name)).toEqual(['sys_user']);
134+
}
135+
136+
it('CONTROL — a sys_user the decision does not serve refuses both cells and is never read', async () => {
137+
const { summary, findData } = userProtocol([{ owner: EMAIL }, { assignee: EMAIL }], { apiEnabled: false });
138+
expect((await summary).results.map(verdict)).toEqual([{ ...NOT_FOUND, field: 'owner' }, { ...NOT_FOUND, field: 'assignee' }]);
139+
expect(findData).not.toHaveBeenCalled();
121140
});
122141
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#22785] A `user` field written without `reference` is a reference to
4+
// `sys_user` at the import and export doors, as the spec's arbiter
5+
// (`referenceTargetOf`) and the data door's `$expand` read it — on a real boot.
6+
// The `Field.user` field (which writes `reference: 'sys_user'`) is the control:
7+
// its answers do not move. The refusal of an unserved target is pinned in
8+
// core's `import-runner-reference-exposure.test.ts`.
9+
//
10+
// `@objectstack/core` (the field map and the runner) resolves through its
11+
// BUILT output here, so a verdict on a change to it is a verdict on its last
12+
// build.
13+
14+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
15+
import { bootStack, type VerifyStack } from '@objectstack/verify';
16+
import { defineStack } from '@objectstack/spec';
17+
import { ObjectSchema, Field } from '@objectstack/spec/data';
18+
import { PermissionSetSchema } from '@objectstack/spec/security';
19+
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
20+
21+
const SYS = { context: { isSystem: true } } as const;
22+
const ADMIN = 'admin@objectos.ai';
23+
24+
const Task = ObjectSchema.create({
25+
name: 'iuf_task',
26+
sharingModel: 'public_read_write',
27+
label: 'Task',
28+
pluralLabel: 'Tasks',
29+
fields: {
30+
name: Field.text({ label: 'Name', required: true }),
31+
owner: { type: 'user' as const, label: 'Owner' },
32+
assignee: Field.user({ label: 'Assignee' }),
33+
},
34+
});
35+
36+
const stackDef = defineStack({
37+
manifest: {
38+
id: 'com.dogfood.import-user-field-target',
39+
namespace: 'iuf',
40+
version: '0.0.0',
41+
type: 'app',
42+
name: 'Import User Field Target Fixture',
43+
description: 'A user field written without reference beside a Field.user one, at the import and export doors.',
44+
},
45+
objects: [Task],
46+
});
47+
48+
/** Read, create and export on the fixture object, so the export door answers. */
49+
const exporter = PermissionSetSchema.parse({
50+
name: 'iuf_fixture_exporter',
51+
label: 'Import user field fixture exporter',
52+
objects: { iuf_task: { allowRead: true, allowCreate: true, allowExport: true } },
53+
});
54+
55+
type Row = { ok: boolean; code?: string; field?: string; id?: string };
56+
57+
describe('[#22785] a user field without reference resolves to sys_user at the import and export doors', () => {
58+
let stack: VerifyStack;
59+
let token = '';
60+
let adminId = '';
61+
let ql: any;
62+
63+
beforeAll(async () => {
64+
stack = await bootStack(stackDef as never, {
65+
security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, exporter], fallbackPermissionSet: exporter.name }),
66+
});
67+
token = await stack.signIn();
68+
ql = await stack.kernel.getServiceAsync('objectql');
69+
adminId = String((await ql.find('sys_user', { where: { email: ADMIN }, ...SYS }))[0].id);
70+
}, 180_000);
71+
72+
afterAll(async () => {
73+
await stack?.stop?.();
74+
});
75+
76+
const doors = {
77+
sync: async (rows: object[]): Promise<Row[]> => {
78+
const res = await stack.apiAs(token, 'POST', '/data/iuf_task/import', { format: 'json', rows, runAutomations: false });
79+
expect(res.status, await res.clone().text()).toBe(200);
80+
return ((await res.json()) as { results: Row[] }).results;
81+
},
82+
jobs: async (rows: object[]): Promise<Row[]> => {
83+
const res = await stack.apiAs(token, 'POST', '/data/iuf_task/import/jobs', { format: 'json', rows, runAutomations: false });
84+
expect(res.status, await res.clone().text()).toBe(201);
85+
const { jobId } = (await res.json()) as { jobId: string };
86+
for (let i = 0; i < 200; i++) {
87+
const body: any = await (await stack.apiAs(token, 'GET', `/data/import/jobs/${jobId}/results`)).json();
88+
if (body.status === 'succeeded') return body.results;
89+
expect(['pending', 'running'], JSON.stringify(body)).toContain(body.status);
90+
await new Promise((r) => setTimeout(r, 25));
91+
}
92+
throw new Error(`import job ${jobId} did not finish`);
93+
},
94+
};
95+
96+
for (const door of ['sync', 'jobs'] as const) {
97+
for (const field of ['owner', 'assignee'] as const) {
98+
it(`${door}: an email in ${field === 'owner' ? 'the field without reference' : 'CONTROL Field.user'} stores the user id`, async () => {
99+
const [row] = await doors[door]([{ name: `${door}-${field}`, [field]: ADMIN }]);
100+
expect(row!.ok, JSON.stringify(row)).toBe(true);
101+
expect((await ql.find('iuf_task', { where: { id: row!.id }, ...SYS }))[0]?.[field]).toBe(adminId);
102+
});
103+
}
104+
}
105+
106+
it('export: the field without reference renders the user as Field.user does, not the stored id', async () => {
107+
await ql.insert('iuf_task', { name: 'export-row', owner: adminId, assignee: adminId }, SYS);
108+
const filter = encodeURIComponent(JSON.stringify({ name: 'export-row' }));
109+
const res = await stack.apiAs(token, 'GET', `/data/iuf_task/export?format=json&filter=${filter}`);
110+
expect(res.status, await res.clone().text()).toBe(200);
111+
const [row] = (await res.json()) as Array<Record<string, unknown>>;
112+
expect(row!.assignee, 'CONTROL Field.user renders a name').not.toBe(adminId);
113+
expect(row!.owner).toBe(row!.assignee);
114+
});
115+
});

‎packages/rest/src/export-format.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
exportContentDisposition,
1919
formatCellValue,
2020
buildFieldMetaMap,
21+
referenceFieldNames,
2122
type ExportFieldMeta,
2223
} from './export-format';
2324

@@ -296,3 +297,19 @@ describe('buildFieldMetaMap — presentation keys only (#6536)', () => {
296297
});
297298
});
298299
});
300+
301+
/**
302+
* [#22785] The export expands a `user` field written without `reference` like a
303+
* `Field.user` one: the map names its target through the spec's arbiter, so its
304+
* cell renders the user's name instead of the stored id.
305+
*/
306+
describe('referenceFieldNames — the fields the export expands (#22785)', () => {
307+
it('a user field without reference is expanded, as Field.user is; a non-reference type is not', () => {
308+
const map = buildFieldMetaMap({ fields: {
309+
owner: { type: 'user', label: 'Owner' },
310+
assignee: { type: 'user', label: 'Assignee', reference: 'sys_user' },
311+
amount: { type: 'number', label: 'Amount', reference: 'contracts' },
312+
} });
313+
expect(referenceFieldNames(map)).toEqual(['owner', 'assignee']);
314+
});
315+
});

‎packages/rest/src/import-template.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,12 @@ describe('describeTemplateColumns', () => {
440440
}
441441
});
442442

443+
it('[#22785] a user field without reference names sys_user by its label, as Field.user does', () => {
444+
const schema = { fields: { owner: { name: 'owner', type: 'user', label: 'Owner' } } };
445+
const [owner] = describeTemplateColumns(schema, ['owner'], { locale: 'en', referenceLabels: new Map([['sys_user', 'User']]) });
446+
expect(owner.howToFill).toContain('The name of a User record');
447+
});
448+
443449
it('a name in ?fields= that is no field of the object is described as such', () => {
444450
const [unknown] = describeTemplateColumns(KITCHEN_SINK, ['nope'], { locale: 'en' });
445451
expect(unknown).toMatchObject({ header: 'nope', type: '', howToFill: 'Not a field of this object.' });

0 commit comments

Comments
 (0)