From 6cb4161b15cfd1f67ba8e110c0119399c3fd7679 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:27:17 +0000 Subject: [PATCH 1/2] 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 --- .../src/objects/sys-record-share.object.ts | 17 ++ .../sys-record-share.recipient-face.test.ts | 164 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 packages/plugins/plugin-sharing/src/objects/sys-record-share.recipient-face.test.ts diff --git a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts index c3acf5f57a7..ecfd40c1d86 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts @@ -215,6 +215,23 @@ export const SysRecordShare = ObjectSchema.create({ maxLength: 100, description: 'ID of the user/group/position that receives access', group: 'Recipient', + // [#24701] Drawn as the recipient it names, not as the stored id: the + // console's `recipient-picker` face (objectui#12613) reads the kind from + // the row's `recipient_type` and names the id by that kind's directory + // (a `user` id by an exact-id `sys_user` read). It is the declaration + // `sys_sharing_rule.recipient_id` carries, less `object_name`: the face + // reads that sibling only for the `field` recipient kind, which this + // object's `recipient_type` does not offer, and `grant()` writes `user` + // alone (ADR-0111 D7). + // + // A display hint only. The column stays this text column, and no form + // ever draws the picker as an editor: the object is engine-owned and its + // data API is read-only (`enable.apiMethods` below), so `grant()` stays + // the one writer of a recipient. A list that does not fetch + // `recipient_type` gives the face no kind, and the cell draws the stored + // id as before (`sys-record-share.recipient-face.test.ts`). + widget: 'recipient-picker', + dependsOn: ['recipient_type'], }), access_level: Field.select( diff --git a/packages/plugins/plugin-sharing/src/objects/sys-record-share.recipient-face.test.ts b/packages/plugins/plugin-sharing/src/objects/sys-record-share.recipient-face.test.ts new file mode 100644 index 00000000000..66a00d19cdc --- /dev/null +++ b/packages/plugins/plugin-sharing/src/objects/sys-record-share.recipient-face.test.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { SysRecordShare } from './sys-record-share.object.js'; +import { SysSharingRule } from './sys-sharing-rule.object.js'; + +/** + * [#24701] A record share's Recipient column names the recipient through the + * console's `recipient-picker` face, by a display declaration alone. + * + * The face (objectui#12613, at this repository's `.objectui-sha`) reads the + * row's `recipient_type` from the field's declared siblings, names a `user` id + * by an exact-id `sys_user` read, and draws the stored text when the row + * carries no kind. It reads `object_name` only for the `field` recipient kind. + * + * Pinned here: + * 1. the declaration, read off the object, is the sibling object's widget; + * 2. `dependsOn` names exactly the siblings the face reads for the kinds this + * object's select can store; + * 3. the declaration opens no write path: the column keeps its stored shape, + * the data API stays read-only, so no form draws the picker as an editor; + * 4. `grant()` stays the one insert of a share row in this package, so every + * stored recipient is the `user` id it writes (ADR-0111 D7). + */ + +type FieldDef = Record; + +const shareFields = SysRecordShare.fields as Record; +const ruleFields = SysSharingRule.fields as Record; + +/** The stored values a select field offers. */ +function selectValues(field: FieldDef): string[] { + const options = (field.options as unknown[] | undefined) ?? []; + return options.map((o) => String(typeof o === 'object' && o !== null ? (o as { value: unknown }).value : o)); +} + +/** + * The siblings the `recipient-picker` face reads for an object whose + * `recipient_type` offers `kinds`: the kind itself always, and the shared + * object's name only when the `field` kind can be stored. + */ +function siblingsTheFaceReads(kinds: readonly string[]): string[] { + return ['recipient_type', ...(kinds.includes('field') ? ['object_name'] : [])]; +} + +describe('sys_record_share.recipient_id — drawn through the recipient-picker face (#24701)', () => { + it('declares the recipient-picker widget, the one sys_sharing_rule.recipient_id declares', () => { + expect(shareFields.recipient_id.widget).toBe('recipient-picker'); + expect(shareFields.recipient_id.widget).toBe(ruleFields.recipient_id.widget); + }); + + it('dependsOn names exactly the siblings the face reads for the kinds this object stores', () => { + const kinds = selectValues(shareFields.recipient_type); + expect(kinds).not.toContain('field'); + expect(shareFields.recipient_id.dependsOn).toEqual(siblingsTheFaceReads(kinds)); + for (const sibling of shareFields.recipient_id.dependsOn as string[]) { + expect(shareFields[sibling], `dependsOn names '${sibling}', a field this object does not declare`).toBeDefined(); + } + }); + + it('control: the same rule gives sys_sharing_rule its object_name sibling, because it stores the field kind', () => { + const kinds = selectValues(ruleFields.recipient_type); + expect(kinds).toContain('field'); + expect(ruleFields.recipient_id.dependsOn).toEqual(siblingsTheFaceReads(kinds)); + }); + + it('the declaration opens no write path: the stored column is unchanged and the data API stays read-only', () => { + const recipient = shareFields.recipient_id; + expect({ + type: recipient.type, + required: recipient.required, + maxLength: recipient.maxLength, + reference: recipient.reference, + referenceVia: recipient.referenceVia, + }).toEqual({ type: 'text', required: true, maxLength: 100, reference: undefined, referenceVia: undefined }); + const object = SysRecordShare as unknown as { + managedBy?: unknown; + enable?: { apiMethods?: unknown }; + userActions?: unknown; + }; + expect(object.managedBy).toBe('engine-owned'); + expect(object.enable?.apiMethods).toEqual(['get', 'list']); + expect(object.userActions).toBeUndefined(); + }); +}); + +/** + * Every `.insert(, …)` call in this package's non-test + * sources, with the table it names and the function it sits in. A table given + * as an identifier is resolved through a same-file string constant; anything + * else is reported as unresolved, so the census cannot be passed by spelling a + * table some other way. Scoped to this package's `src/` on purpose: the share + * rows are this plugin's, and outside it the only measured writer (the CLI's + * organization-ownership apply) updates `source_id` and inserts nothing. + */ +describe('sys_record_share — grant() stays the one insert of a share row (#24701)', () => { + const HERE = dirname(fileURLToPath(import.meta.url)); + const SRC = resolve(HERE, '..'); + + type InsertSite = { file: string; fn: string; table: string }; + + function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [path] : []; + }); + } + + function enclosingFunction(node: ts.Node): string { + for (let at: ts.Node | undefined = node.parent; at; at = at.parent) { + if ((ts.isMethodDeclaration(at) || ts.isFunctionDeclaration(at)) && at.name) return at.name.getText(); + } + return '(module)'; + } + + function insertSites(): InsertSite[] { + const sites: InsertSite[] = []; + for (const path of sourceFiles(SRC)) { + const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true); + const constants = new Map(); + const calls: ts.CallExpression[] = []; + const visit = (node: ts.Node): void => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer + && ts.isStringLiteralLike(node.initializer)) { + constants.set(node.name.text, node.initializer.text); + } + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) + && node.expression.name.text === 'insert' && node.arguments.length > 0) { + calls.push(node); + } + ts.forEachChild(node, visit); + }; + visit(source); + for (const call of calls) { + const arg = call.arguments[0]!; + const table = ts.isStringLiteralLike(arg) + ? arg.text + : (ts.isIdentifier(arg) && constants.get(arg.text)) || `(unresolved: ${arg.getText(source)})`; + sites.push({ file: relative(SRC, path), fn: enclosingFunction(call), table }); + } + } + return sites; + } + + const sites = insertSites(); + + it('control: the census reads this package\'s other inserts, so the case below cannot pass on an empty set', () => { + expect(sites.map((s) => s.table)).toEqual(expect.arrayContaining(['sys_share_link', 'sys_sharing_rule'])); + }); + + it('every insert names its table in a form the census reads', () => { + expect(sites.filter((s) => s.table.startsWith('(unresolved')).map((s) => `${s.file}#${s.fn}: ${s.table}`)).toEqual([]); + }); + + it('the only insert of a sys_record_share row is grant() in sharing-service.ts', () => { + expect(sites.filter((s) => s.table === 'sys_record_share').map((s) => `${s.file}#${s.fn}`)) + .toEqual(['sharing-service.ts#grant']); + }); +}); From e12bfec503cadf7c3af7546321a016b8df4236fd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:39:21 +0000 Subject: [PATCH 2/2] chore(changeset): plugin-sharing patch for the record share recipient face Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .changeset/24701-record-share-recipient-face.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/24701-record-share-recipient-face.md diff --git a/.changeset/24701-record-share-recipient-face.md b/.changeset/24701-record-share-recipient-face.md new file mode 100644 index 00000000000..def9ced011f --- /dev/null +++ b/.changeset/24701-record-share-recipient-face.md @@ -0,0 +1,13 @@ +--- +'@objectstack/plugin-sharing': patch +--- + +fix(plugin-sharing): a record share's Recipient column names the recipient instead of drawing its raw id + +Clause-②: no + +- **`sys_record_share.recipient_id` declares `widget: 'recipient-picker'` and `dependsOn: ['recipient_type']`**, the display declaration `sys_sharing_rule.recipient_id` already carries. The console draws a field with this widget by naming the stored id through the row's `recipient_type`, so a user grant reads as the user's name in a list that shows the Recipient Type column beside it (Setup → Record Shares → All). A list that does not fetch `recipient_type` still draws the stored id. +- **`object_name` is not a dependency here**: the face reads it only for the `field` recipient kind, which a record share cannot store. +- **Unchanged:** `recipient_id` stays the same required text column, the object stays engine-owned and read-only through the data API, and `grant()` stays the only writer of a share row. + +A patch: one display hint on one field of a shipped platform object. No export, type, accepted value, stored column or authorable key changes.