Skip to content

Commit 6cb4161

Browse files
committed
fix(plugin-sharing): draw a record share's recipient through the recipient-picker face
sys_record_share.recipient_id now declares widget 'recipient-picker' and dependsOn ['recipient_type'], the display declaration the sibling sys_sharing_rule.recipient_id carries, less object_name, which the face reads only for the field recipient kind this object does not offer. No stored column, spec key or accepted value moves; the object stays engine-owned and read-only through the data API. Pins read the declaration off the object, derive dependsOn from the kinds the select can store, and hold that the declaration opens no write path and that grant() stays the package's one insert of a share row. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
1 parent a43405d commit 6cb4161

2 files changed

Lines changed: 181 additions & 0 deletions

File tree

‎packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,23 @@ export const SysRecordShare = ObjectSchema.create({
215215
maxLength: 100,
216216
description: 'ID of the user/group/position that receives access',
217217
group: 'Recipient',
218+
// [#24701] Drawn as the recipient it names, not as the stored id: the
219+
// console's `recipient-picker` face (objectui#12613) reads the kind from
220+
// the row's `recipient_type` and names the id by that kind's directory
221+
// (a `user` id by an exact-id `sys_user` read). It is the declaration
222+
// `sys_sharing_rule.recipient_id` carries, less `object_name`: the face
223+
// reads that sibling only for the `field` recipient kind, which this
224+
// object's `recipient_type` does not offer, and `grant()` writes `user`
225+
// alone (ADR-0111 D7).
226+
//
227+
// A display hint only. The column stays this text column, and no form
228+
// ever draws the picker as an editor: the object is engine-owned and its
229+
// data API is read-only (`enable.apiMethods` below), so `grant()` stays
230+
// the one writer of a recipient. A list that does not fetch
231+
// `recipient_type` gives the face no kind, and the cell draws the stored
232+
// id as before (`sys-record-share.recipient-face.test.ts`).
233+
widget: 'recipient-picker',
234+
dependsOn: ['recipient_type'],
218235
}),
219236

220237
access_level: Field.select(
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { readFileSync, readdirSync } from 'node:fs';
5+
import { dirname, join, relative, resolve } from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
import ts from 'typescript';
8+
import { SysRecordShare } from './sys-record-share.object.js';
9+
import { SysSharingRule } from './sys-sharing-rule.object.js';
10+
11+
/**
12+
* [#24701] A record share's Recipient column names the recipient through the
13+
* console's `recipient-picker` face, by a display declaration alone.
14+
*
15+
* The face (objectui#12613, at this repository's `.objectui-sha`) reads the
16+
* row's `recipient_type` from the field's declared siblings, names a `user` id
17+
* by an exact-id `sys_user` read, and draws the stored text when the row
18+
* carries no kind. It reads `object_name` only for the `field` recipient kind.
19+
*
20+
* Pinned here:
21+
* 1. the declaration, read off the object, is the sibling object's widget;
22+
* 2. `dependsOn` names exactly the siblings the face reads for the kinds this
23+
* object's select can store;
24+
* 3. the declaration opens no write path: the column keeps its stored shape,
25+
* the data API stays read-only, so no form draws the picker as an editor;
26+
* 4. `grant()` stays the one insert of a share row in this package, so every
27+
* stored recipient is the `user` id it writes (ADR-0111 D7).
28+
*/
29+
30+
type FieldDef = Record<string, unknown>;
31+
32+
const shareFields = SysRecordShare.fields as Record<string, FieldDef>;
33+
const ruleFields = SysSharingRule.fields as Record<string, FieldDef>;
34+
35+
/** The stored values a select field offers. */
36+
function selectValues(field: FieldDef): string[] {
37+
const options = (field.options as unknown[] | undefined) ?? [];
38+
return options.map((o) => String(typeof o === 'object' && o !== null ? (o as { value: unknown }).value : o));
39+
}
40+
41+
/**
42+
* The siblings the `recipient-picker` face reads for an object whose
43+
* `recipient_type` offers `kinds`: the kind itself always, and the shared
44+
* object's name only when the `field` kind can be stored.
45+
*/
46+
function siblingsTheFaceReads(kinds: readonly string[]): string[] {
47+
return ['recipient_type', ...(kinds.includes('field') ? ['object_name'] : [])];
48+
}
49+
50+
describe('sys_record_share.recipient_id — drawn through the recipient-picker face (#24701)', () => {
51+
it('declares the recipient-picker widget, the one sys_sharing_rule.recipient_id declares', () => {
52+
expect(shareFields.recipient_id.widget).toBe('recipient-picker');
53+
expect(shareFields.recipient_id.widget).toBe(ruleFields.recipient_id.widget);
54+
});
55+
56+
it('dependsOn names exactly the siblings the face reads for the kinds this object stores', () => {
57+
const kinds = selectValues(shareFields.recipient_type);
58+
expect(kinds).not.toContain('field');
59+
expect(shareFields.recipient_id.dependsOn).toEqual(siblingsTheFaceReads(kinds));
60+
for (const sibling of shareFields.recipient_id.dependsOn as string[]) {
61+
expect(shareFields[sibling], `dependsOn names '${sibling}', a field this object does not declare`).toBeDefined();
62+
}
63+
});
64+
65+
it('control: the same rule gives sys_sharing_rule its object_name sibling, because it stores the field kind', () => {
66+
const kinds = selectValues(ruleFields.recipient_type);
67+
expect(kinds).toContain('field');
68+
expect(ruleFields.recipient_id.dependsOn).toEqual(siblingsTheFaceReads(kinds));
69+
});
70+
71+
it('the declaration opens no write path: the stored column is unchanged and the data API stays read-only', () => {
72+
const recipient = shareFields.recipient_id;
73+
expect({
74+
type: recipient.type,
75+
required: recipient.required,
76+
maxLength: recipient.maxLength,
77+
reference: recipient.reference,
78+
referenceVia: recipient.referenceVia,
79+
}).toEqual({ type: 'text', required: true, maxLength: 100, reference: undefined, referenceVia: undefined });
80+
const object = SysRecordShare as unknown as {
81+
managedBy?: unknown;
82+
enable?: { apiMethods?: unknown };
83+
userActions?: unknown;
84+
};
85+
expect(object.managedBy).toBe('engine-owned');
86+
expect(object.enable?.apiMethods).toEqual(['get', 'list']);
87+
expect(object.userActions).toBeUndefined();
88+
});
89+
});
90+
91+
/**
92+
* Every `<receiver>.insert(<table>, …)` call in this package's non-test
93+
* sources, with the table it names and the function it sits in. A table given
94+
* as an identifier is resolved through a same-file string constant; anything
95+
* else is reported as unresolved, so the census cannot be passed by spelling a
96+
* table some other way. Scoped to this package's `src/` on purpose: the share
97+
* rows are this plugin's, and outside it the only measured writer (the CLI's
98+
* organization-ownership apply) updates `source_id` and inserts nothing.
99+
*/
100+
describe('sys_record_share — grant() stays the one insert of a share row (#24701)', () => {
101+
const HERE = dirname(fileURLToPath(import.meta.url));
102+
const SRC = resolve(HERE, '..');
103+
104+
type InsertSite = { file: string; fn: string; table: string };
105+
106+
function sourceFiles(dir: string): string[] {
107+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
108+
const path = join(dir, entry.name);
109+
if (entry.isDirectory()) return sourceFiles(path);
110+
return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [path] : [];
111+
});
112+
}
113+
114+
function enclosingFunction(node: ts.Node): string {
115+
for (let at: ts.Node | undefined = node.parent; at; at = at.parent) {
116+
if ((ts.isMethodDeclaration(at) || ts.isFunctionDeclaration(at)) && at.name) return at.name.getText();
117+
}
118+
return '(module)';
119+
}
120+
121+
function insertSites(): InsertSite[] {
122+
const sites: InsertSite[] = [];
123+
for (const path of sourceFiles(SRC)) {
124+
const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true);
125+
const constants = new Map<string, string>();
126+
const calls: ts.CallExpression[] = [];
127+
const visit = (node: ts.Node): void => {
128+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer
129+
&& ts.isStringLiteralLike(node.initializer)) {
130+
constants.set(node.name.text, node.initializer.text);
131+
}
132+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
133+
&& node.expression.name.text === 'insert' && node.arguments.length > 0) {
134+
calls.push(node);
135+
}
136+
ts.forEachChild(node, visit);
137+
};
138+
visit(source);
139+
for (const call of calls) {
140+
const arg = call.arguments[0]!;
141+
const table = ts.isStringLiteralLike(arg)
142+
? arg.text
143+
: (ts.isIdentifier(arg) && constants.get(arg.text)) || `(unresolved: ${arg.getText(source)})`;
144+
sites.push({ file: relative(SRC, path), fn: enclosingFunction(call), table });
145+
}
146+
}
147+
return sites;
148+
}
149+
150+
const sites = insertSites();
151+
152+
it('control: the census reads this package\'s other inserts, so the case below cannot pass on an empty set', () => {
153+
expect(sites.map((s) => s.table)).toEqual(expect.arrayContaining(['sys_share_link', 'sys_sharing_rule']));
154+
});
155+
156+
it('every insert names its table in a form the census reads', () => {
157+
expect(sites.filter((s) => s.table.startsWith('(unresolved')).map((s) => `${s.file}#${s.fn}: ${s.table}`)).toEqual([]);
158+
});
159+
160+
it('the only insert of a sys_record_share row is grant() in sharing-service.ts', () => {
161+
expect(sites.filter((s) => s.table === 'sys_record_share').map((s) => `${s.file}#${s.fn}`))
162+
.toEqual(['sharing-service.ts#grant']);
163+
});
164+
});

0 commit comments

Comments
 (0)