diff --git a/.changeset/sharing-rule-unknown-sort-and-stale-help.md b/.changeset/sharing-rule-unknown-sort-and-stale-help.md new file mode 100644 index 0000000000..bfcc9c545f --- /dev/null +++ b/.changeset/sharing-rule-unknown-sort-and-stale-help.md @@ -0,0 +1,64 @@ +--- +"@objectstack/driver-sql": patch +"@objectstack/plugin-sharing": patch +--- + +fix(driver-sql,sharing): an unsortable query loses its ORDER BY, not its rows (#3821) + +`SqlDriver.find()` already recovered from a SELECT projection naming a column +the table lacks (retry with `select('*')`, the unknown field is simply absent +from each row). The identical failure one clause over — an **ORDER BY** column +the table lacks — fell through to `return []`. Because `count()` is a separate +statement, the list endpoint answered `HTTP 200` with `records: []` and +`total: 3`: the rows are there, none are shown, nothing is logged. Same family +as the `$`-param footgun closed by #2926. + +It surfaced through the Console's sharing-rule **recipient picker**, which +never listed a single candidate. The client mangled `'name asc'` into +`0 n,1 a,2 m,…` (fixed separately in objectui) and the driver turned that into +"no users exist", so no sharing rule could be authored from the UI at all. + +Rows now outrank their order: the retry ladder drops the projection first (the +likelier culprit and the cheaper thing to lose), then the sort, then gives up. +A query that cannot be sorted comes back **unordered instead of empty**. Errors +that are not about an unknown column still propagate untouched. + +**A rule authored in Setup now actually applies — and switching it off actually +withdraws access.** Writing a `sys_sharing_rule` rebound the per-record hooks, +which only makes the rule reach records written FROM THEN ON. So an admin who +created a rule and enabled it saw nothing happen: the recipient's list stayed +empty until somebody happened to touch each record. The reverse was worse — +switching a rule OFF, or deleting it, left every grant it had already issued in +place, and boot backfill only reconciles ACTIVE rules, so those grants outlived +restarts while the UI displayed the rule as disabled. The reconcile was reachable +only through `POST /sharing/rules/:id/evaluate`, which the Console never calls. + +Each non-system write to `sys_sharing_rule` now also reconciles that rule's +grants, chained behind the existing rebind: insert/update run the same +diff-based `evaluateRule` the REST endpoint runs (it purges when the rule is +inactive), and delete purges directly via the new +`SharingRuleService.revokeRuleGrants` — `evaluateRule` can't help there because +the row is already gone (`RULE_NOT_FOUND`), which is also why a rule deleted +through the plain data API used to orphan its grants. Seeding and package +bootstrap write with `isSystem` and are skipped; `kernel:bootstrapped` already +backfills those. Reconciliation is best-effort and never fails the write. + +**The dialog's help text was engineering notes, shown to tenant admins.** The +field descriptions on `sys_sharing_rule` render under each input in Setup, and +they cited ADR numbers, table and column names (`parent_business_unit_id`, +`sys_business_unit`), enum machine values the dropdown never shows +(`business_unit`, `team`), a third-party library (better-auth), and engine +vocabulary ("evaluation", "lifecycle"). Several were also stale: they still told +admins to type an id or hand-write a `FilterCondition` after those inputs became +a record picker and a visual builder. Rewritten for the reader who actually sees +them — the implementation detail was already in the object's doc comment, which +is where it stays. `criteria_json`'s LABEL loses its "(FilterCondition JSON)" +suffix for the same reason, and `active` can finally say what it now does: +turning it off withdraws the access. + +Also refreshes the `sys_sharing_rule` help text in the zh-CN / ja-JP / es-ES +translation bundles, which still described `recipient_type` in terms of +`department` (the enum value is `business_unit`) and told admins to enter a +queue name for `recipient_id` (`queue` was removed in ADR-0078). The es-ES +option labels for `position` / `unit_and_subordinates` were translated as +"rol" — corrected to "Puesto" / "Unidad de negocio y subordinados". diff --git a/packages/plugins/driver-sql/src/sql-driver-unknown-column-recovery.test.ts b/packages/plugins/driver-sql/src/sql-driver-unknown-column-recovery.test.ts new file mode 100644 index 0000000000..0c7fe247ab --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-unknown-column-recovery.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#3821 — `find()` must not turn an unknown column into "no rows". + * + * A SELECT projection naming a column the table lacks already had a retry + * (select `*` and let the missing field simply be absent). An unknown ORDER BY + * column did not: it fell through to `return []`, so the page came back empty + * while `count()` — a separate statement — still reported the true total. The + * UI reads that as "the records vanished". It surfaced through the sharing-rule + * recipient picker, whose client mangled `'name asc'` into a list of + * one-character column names. + * + * Rows matter more than their order, so an unsortable query returns the rows + * unordered. These tests pin that, and that a genuine SQL error still throws. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; + +describe('SqlDriver find() recovers from unknown columns (objectstack#3821)', () => { + let driver: SqlDriver; + + beforeEach(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + + await driver.initObjects([ + { + name: 'member', + fields: { + name: { type: 'string' }, + rank: { type: 'integer' }, + }, + }, + ]); + + await driver.create('member', { id: 'm1', name: 'Carol', rank: 3 }, { bypassTenantAudit: true }); + await driver.create('member', { id: 'm2', name: 'Alice', rank: 1 }, { bypassTenantAudit: true }); + await driver.create('member', { id: 'm3', name: 'Bob', rank: 2 }, { bypassTenantAudit: true }); + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + it('returns the rows unordered when ORDER BY names a column the table lacks', async () => { + const rows = await driver.find('member', { + orderBy: [{ field: 'nosuchfield', order: 'asc' }], + } as any); + + expect(rows.map((r: any) => r.id).sort()).toEqual(['m1', 'm2', 'm3']); + }); + + it('still sorts when every ORDER BY column exists', async () => { + const rows = await driver.find('member', { + orderBy: [{ field: 'rank', order: 'asc' }], + } as any); + + expect(rows.map((r: any) => r.name)).toEqual(['Alice', 'Bob', 'Carol']); + }); + + it('drops only the unknown clause when a known and an unknown sort are mixed', async () => { + // knex emits both columns in one ORDER BY, so the whole clause has to go; + // what must NOT happen is losing the rows. + const rows = await driver.find('member', { + orderBy: [{ field: 'rank', order: 'asc' }, { field: 'nosuchfield', order: 'desc' }], + } as any); + + expect(rows).toHaveLength(3); + }); + + it('recovers from an unknown SELECT projection and an unknown sort together', async () => { + const rows = await driver.find('member', { + fields: ['name', 'nosuchfield'], + orderBy: [{ field: 'alsomissing', order: 'asc' }], + } as any); + + expect(rows).toHaveLength(3); + expect(rows.map((r: any) => r.name).sort()).toEqual(['Alice', 'Bob', 'Carol']); + }); + + it('keeps honouring the WHERE clause while recovering', async () => { + const rows = await driver.find('member', { + where: { rank: { $gte: 2 } }, + orderBy: [{ field: 'nosuchfield', order: 'asc' }], + } as any); + + expect(rows.map((r: any) => r.id).sort()).toEqual(['m1', 'm3']); + }); + + it('propagates errors that are not about an unknown column', async () => { + await expect(driver.find('no_such_table', {} as any)).rejects.toThrow(); + }); +}); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index c564f1c237..16c452b084 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -803,7 +803,8 @@ export class SqlDriver implements IDataDriver { async find(object: string, query: QueryAST, options?: DriverOptions): Promise { // Build everything EXCEPT the SELECT list, so the unknown-column retry // below can rebuild without re-deriving where/order/pagination. - const buildBase = () => { + const buildBase = (opts?: { withOrderBy?: boolean }) => { + const withOrderBy = opts?.withOrderBy !== false; const b = this.getBuilder(object, options); this.applyTenantScope(b, object, options); @@ -813,7 +814,7 @@ export class SqlDriver implements IDataDriver { } // ORDER BY - if (query.orderBy && Array.isArray(query.orderBy)) { + if (withOrderBy && query.orderBy && Array.isArray(query.orderBy)) { for (const item of query.orderBy) { if (item.field) { b.orderBy(this.remoteColumn(object, item.field, this.mapSortField(item.field)), item.order || 'asc'); @@ -858,15 +859,31 @@ export class SqlDriver implements IDataDriver { // only fires when the object's schema is populated in the registry — // this driver backstop holds even when it isn't (notably the cloud // multi-tenant runtime, where the projection otherwise zeroes the list). - if (query.fields) { + // + // An unknown ORDER BY column is the same footgun one clause over + // (objectstack#3821): a client that sorts by a field the table lacks + // used to get an empty page with a non-zero `total` — "the rows are + // there but none are shown". Rows matter more than their order, so + // drop the sort and return them unordered rather than nothing. Ladder: + // projection first (it is the likelier culprit and the cheaper thing + // to lose), then the sort, then give up. + const retries: Array<() => any> = []; + if (query.fields) retries.push(() => buildBase().select('*')); + if (query.orderBy && Array.isArray(query.orderBy) && query.orderBy.length > 0) { + retries.push(() => buildBase({ withOrderBy: false }).select('*')); + } + results = []; + let recovered = false; + for (const retry of retries) { try { - results = await buildBase().select('*'); + results = await retry(); + recovered = true; + break; } catch { - return []; + // Try the next, broader fallback. } - } else { - return []; } + if (!recovered) return []; } else { throw error; } diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index 0bd3a29bfc..099b1dfb28 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -37,7 +37,7 @@ export const SysSharingRule = ObjectSchema.create({ // We still recommend `defineSharingRule({...})` for repo-controlled // baselines, but admins can safely create/edit/delete from the UI. userActions: { create: true, edit: true, delete: true, import: false }, - description: 'Declarative sharing rule that auto-materialises sys_record_share grants. Authored via defineSharingRule() in code or the Studio criteria builder.', + description: 'Grants a group of people access to the records that match a condition.', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{label}', @@ -99,7 +99,13 @@ export const SysSharingRule = ObjectSchema.create({ label: 'Name', required: true, maxLength: 100, - description: 'Unique snake_case rule name', + // Field `description`s on this object are ADMIN-FACING help text rendered + // under each input in Setup — not notes for the next engineer. They must + // not name tables, columns, enum values, ADRs or third-party libraries: + // the reader is a tenant admin who sees only the labels in the dropdown + // (objectstack#3821). Implementation detail belongs in the doc comments + // above, where it already is. + description: 'Identifies the rule. Lowercase letters, digits and underscores only.', group: 'Identity', }), @@ -124,12 +130,12 @@ export const SysSharingRule = ObjectSchema.create({ // instead of a free-text machine-name input. Falls back to a text input // when the `field:object-ref` widget is unavailable. widget: 'object-ref', - description: 'Short object name (e.g. opportunity, account)', + description: 'The object whose records this rule shares.', group: 'Target', }), criteria_json: Field.textarea({ - label: 'Criteria (FilterCondition JSON)', + label: 'Criteria', required: false, // Rendered as a visual criteria builder scoped to the selected object's // fields (dependsOn: object_name), storing the same JSON FilterCondition. @@ -137,7 +143,7 @@ export const SysSharingRule = ObjectSchema.create({ // editable. Falls back to a textarea when the widget is unavailable. widget: 'filter-condition', dependsOn: ['object_name'], - description: 'JSON FilterCondition matched against records of object_name. Empty = match all.', + description: 'Which records to share. Leave empty to share every record of the object.', group: 'Target', }), @@ -150,7 +156,9 @@ export const SysSharingRule = ObjectSchema.create({ label: 'Recipient Type', required: true, defaultValue: 'business_unit', - description: 'Kind of principal that receives access — expanded to user grants at evaluation time. `business_unit` walks the parent_business_unit_id tree; `team` is flat (better-auth); `position` expands the position\'s holders (positions are flat, ADR-0090 D3); `unit_and_subordinates` expands the named business unit PLUS every descendant unit\'s members via the sys_business_unit tree (ADR-0057 D5).', + // The engine detail this used to spell out (which tree is walked, which + // expansion is flat, the ADRs behind each) lives in the class doc above. + description: 'Who receives access. Picking a team, business unit or position gives access to everyone in it. "Business unit and subordinates" also covers every unit below the one you pick.', group: 'Recipient', }, ), @@ -166,7 +174,7 @@ export const SysSharingRule = ObjectSchema.create({ // back to a text input when the widget is unavailable. widget: 'recipient-picker', dependsOn: ['recipient_type'], - description: 'business-unit id / team id / position name / user id depending on recipient_type', + description: 'The specific user, team, business unit or position that receives access.', group: 'Recipient', }), @@ -184,7 +192,7 @@ export const SysSharingRule = ObjectSchema.create({ label: 'Active', required: false, defaultValue: true, - description: 'Only active rules participate in lifecycle evaluation', + description: 'Turn off to withdraw the access this rule granted, without deleting the rule.', group: 'Lifecycle', }), @@ -203,8 +211,7 @@ export const SysSharingRule = ObjectSchema.create({ readonly: true, defaultValue: 'admin', description: - 'Record provenance (unified tri-state, A4 #2920): platform = framework built-in / ' + - 'package = app/package-declared (boot-seeded) / admin = tenant-created in Setup.', + 'Where this rule came from: built into the platform, installed with an app, or created here in Setup.', options: [ { value: 'platform', label: 'Platform' }, { value: 'package', label: 'Package' }, @@ -219,8 +226,7 @@ export const SysSharingRule = ObjectSchema.create({ readonly: true, defaultValue: false, description: - 'Set when an admin edits a package-declared rule; boot seeding will no longer ' + - 'overwrite the row (deactivations survive redeploys). Meaningless on admin rows.', + 'Set once you edit a rule that came with an app, so your changes are kept when the app is updated.', group: 'System', }), diff --git a/packages/plugins/plugin-sharing/src/rule-rebind.test.ts b/packages/plugins/plugin-sharing/src/rule-rebind.test.ts index a0d2d2d1e6..90bfd0af2e 100644 --- a/packages/plugins/plugin-sharing/src/rule-rebind.test.ts +++ b/packages/plugins/plugin-sharing/src/rule-rebind.test.ts @@ -68,6 +68,8 @@ describe('SharingServicePlugin sys_sharing_rule data-change rebind (#2592)', () rules = []; ruleService = { listRules: vi.fn(async () => rules), + evaluateRule: vi.fn(async () => ({ ruleId: 'r1', matchedRecords: 0, expandedUsers: 0, grantsCreated: 0, grantsUpdated: 0, grantsRevoked: 0 })), + revokeRuleGrants: vi.fn(async () => 0), }; (plugin as any).ruleService = ruleService; (plugin as any).bindRuleRebindTriggers(engine, makeCtx()); @@ -148,3 +150,81 @@ describe('SharingServicePlugin sys_sharing_rule data-change rebind (#2592)', () expect(new Set(bound.map((h) => h.options.object))).toEqual(new Set(['beta'])); }); }); + +/** + * objectstack#3821 — rebinding alone only makes a rule apply to records written + * from now on. A rule authored in Setup did nothing to the records already + * there, and one switched OFF (or deleted) kept every grant it had issued — + * boot backfill only reconciles ACTIVE rules, so those grants outlived + * restarts while the UI showed the rule as disabled. + */ +describe('SharingServicePlugin reconciles grants on rule writes (#3821)', () => { + let engine: ReturnType; + let plugin: SharingServicePlugin; + let ruleService: AnyRecord; + let logger: AnyRecord; + + beforeEach(() => { + engine = makeEngine(); + plugin = new SharingServicePlugin(); + ruleService = { + listRules: vi.fn(async () => []), + evaluateRule: vi.fn(async () => ({ ruleId: 'r1', matchedRecords: 2, expandedUsers: 1, grantsCreated: 2, grantsUpdated: 0, grantsRevoked: 0 })), + revokeRuleGrants: vi.fn(async () => 2), + }; + (plugin as any).ruleService = ruleService; + const ctx = makeCtx(); + logger = ctx.logger; + (plugin as any).bindRuleRebindTriggers(engine, ctx); + }); + + it('backfills existing records when a rule is created', async () => { + await engine.fire('afterInsert', 'sys_sharing_rule', { result: { id: 'r1' } }); + expect(ruleService.evaluateRule).toHaveBeenCalledWith('r1', expect.objectContaining({ isSystem: true })); + }); + + it('reconciles on update — this is what withdraws access when a rule is switched off', async () => { + // `evaluateRule` purges the grants itself when the rule is inactive, so the + // plugin does not need to branch on `active` here. + await engine.fire('afterUpdate', 'sys_sharing_rule', { result: { id: 'r1', active: false } }); + expect(ruleService.evaluateRule).toHaveBeenCalledWith('r1', expect.objectContaining({ isSystem: true })); + }); + + it('purges grants on delete rather than evaluating a row that no longer exists', async () => { + await engine.fire('afterDelete', 'sys_sharing_rule', { input: { id: 'r1' } }); + expect(ruleService.revokeRuleGrants).toHaveBeenCalledWith('r1'); + expect(ruleService.evaluateRule).not.toHaveBeenCalled(); + }); + + it('skips system-context writes — boot backfill owns those', async () => { + await engine.fire('afterInsert', 'sys_sharing_rule', { + result: { id: 'r1' }, + session: { isSystem: true }, + }); + expect(ruleService.evaluateRule).not.toHaveBeenCalled(); + }); + + it('never fails the authoring write when reconciliation throws', async () => { + ruleService.evaluateRule = vi.fn(async () => { throw new Error('db gone'); }); + await expect( + engine.fire('afterUpdate', 'sys_sharing_rule', { result: { id: 'r1' } }), + ).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('does nothing when the write carries no rule id', async () => { + await engine.fire('afterUpdate', 'sys_sharing_rule', {}); + expect(ruleService.evaluateRule).not.toHaveBeenCalled(); + expect(ruleService.revokeRuleGrants).not.toHaveBeenCalled(); + }); + + it('serializes reconciles behind the rebind chain', async () => { + const order: string[] = []; + ruleService.listRules = vi.fn(async () => { order.push('rebind'); return []; }); + ruleService.evaluateRule = vi.fn(async () => { order.push('reconcile'); return {} as any; }); + + await engine.fire('afterInsert', 'sys_sharing_rule', { result: { id: 'r1' } }); + + expect(order).toEqual(['rebind', 'reconcile']); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index 7adc22ba42..073d55491a 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -144,6 +144,22 @@ export class SharingServicePlugin implements Plugin { * same lifecycle pipeline (awaited, so the rule is enforceable the moment * the authoring call returns), but never fails the write — a rebind * failure logs and leaves the previous bindings in place. + * + * [#3821] The rebind alone only makes the rule apply to records written + * FROM NOW ON. Authoring in the Setup UI therefore looked inert: an admin + * created a rule, switched it on, and the recipient still saw nothing — + * the rule only reached a record once somebody happened to touch it. Worse + * in the other direction, switching a rule OFF (or deleting it) left every + * grant it had already issued in place, and boot backfill only reconciles + * ACTIVE rules, so those grants survived restarts forever: the UI said + * "disabled" while the access was still live. + * + * So each write now also reconciles THAT rule's grants — the same + * `evaluateRule` the REST `/sharing/rules/:id/evaluate` endpoint runs, which + * is diff-based and purges when the rule is inactive. Deletes can't go + * through it (the row is gone, `RULE_NOT_FOUND`), so they purge directly. + * System-context writes are skipped: seeding and package bootstrap write + * with `isSystem`, and `kernel:bootstrapped` already backfills those. */ private bindRuleRebindTriggers(engine: any, ctx: PluginContext): void { const scheduleRebind = (): Promise => { @@ -159,7 +175,27 @@ export class SharingServicePlugin implements Plugin { this.ruleRebindChain = run.catch(() => undefined); return run; }; - const handler = async () => { + /** + * Reconcile the written rule's grants, chained behind the rebind so two + * rapid writes can't interleave their reconciles. Best-effort: a failure + * logs and leaves the previous grants alone — it must never fail the + * authoring write. + */ + const scheduleReconcile = (ruleId: string, deleted: boolean): Promise => { + const run = this.ruleRebindChain.then(async () => { + const ruleService = this.ruleService; + if (!ruleService) return; + if (deleted) { + await ruleService.revokeRuleGrants(ruleId); + return; + } + await ruleService.evaluateRule(ruleId, { isSystem: true } as any); + }); + this.ruleRebindChain = run.catch(() => undefined); + return run; + }; + + const makeHandler = (deleted: boolean) => async (hookCtx: any) => { try { await scheduleRebind(); } catch (err: any) { @@ -167,9 +203,24 @@ export class SharingServicePlugin implements Plugin { error: err?.message, }); } + // Seeding / package bootstrap write with `isSystem`; `kernel:bootstrapped` + // backfills those, so reconciling here would only duplicate that work. + if (hookCtx?.session?.isSystem) return; + const data = hookCtx?.result ?? hookCtx?.input?.data ?? {}; + const ruleId = String(data?.id ?? hookCtx?.input?.id ?? ''); + if (!ruleId) return; + try { + await scheduleReconcile(ruleId, deleted); + } catch (err: any) { + ctx.logger.warn('SharingServicePlugin: sharing-rule grant reconcile failed — grants left unchanged', { + rule: ruleId, + error: err?.message, + }); + } }; + for (const event of ['afterInsert', 'afterUpdate', 'afterDelete']) { - engine.registerHook(event, handler, { + engine.registerHook(event, makeHandler(event === 'afterDelete'), { object: 'sys_sharing_rule', packageId: RULE_REBIND_TRIGGER_PACKAGE, priority: 200, diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index 5121aa550a..c93b03df64 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -231,6 +231,17 @@ export class SharingRuleService implements ISharingRuleService { return this.reconcile(rule, matches, users); } + /** + * Revoke every grant this rule materialised, without needing the rule row to + * still exist. `evaluateRule` throws `RULE_NOT_FOUND` once the row is gone, + * so a rule DELETED through the plain data API (which is what the Setup UI's + * delete action issues — it never reaches {@link deleteRule}) would otherwise + * leave its grants behind forever (objectstack#3821). + */ + async revokeRuleGrants(ruleId: string): Promise { + return this.purgeRuleGrants(ruleId); + } + async evaluateAllForRecord( object: string, recordId: string, diff --git a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts index 845553397a..cfd76042a0 100644 --- a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts @@ -102,7 +102,7 @@ export const enObjects: NonNullable = { sys_sharing_rule: { label: "Sharing Rule", pluralLabel: "Sharing Rules", - description: "Declarative sharing rule that auto-materialises sys_record_share grants. Authored via defineSharingRule() in code or the Studio criteria builder.", + description: "Grants a group of people access to the records that match a condition.", fields: { id: { label: "Rule ID" @@ -113,7 +113,7 @@ export const enObjects: NonNullable = { }, name: { label: "Name", - help: "Unique snake_case rule name" + help: "Identifies the rule. Lowercase letters, digits and underscores only." }, label: { label: "Display Label" @@ -123,15 +123,15 @@ export const enObjects: NonNullable = { }, object_name: { label: "Object", - help: "Short object name (e.g. opportunity, account)" + help: "The object whose records this rule shares." }, criteria_json: { - label: "Criteria (FilterCondition JSON)", - help: "JSON FilterCondition matched against records of object_name. Empty = match all." + label: "Criteria", + help: "Which records to share. Leave empty to share every record of the object." }, recipient_type: { label: "Recipient Type", - help: "Kind of principal that receives access — expanded to user grants at evaluation time. `business_unit` walks the parent_business_unit_id tree; `team` is flat (better-auth); `position` expands the position's holders (positions are flat, ADR-0090 D3); `unit_and_subordinates` expands the named business unit PLUS every descendant unit's members via the sys_business_unit tree (ADR-0057 D5).", + help: "Who receives access. Picking a team, business unit or position gives access to everyone in it. \"Business unit and subordinates\" also covers every unit below the one you pick.", options: { user: "user", team: "team", @@ -142,7 +142,7 @@ export const enObjects: NonNullable = { }, recipient_id: { label: "Recipient", - help: "business unit id / team id / position name / user id depending on recipient_type" + help: "The specific user, team, business unit or position that receives access." }, access_level: { label: "Access Level", @@ -154,11 +154,11 @@ export const enObjects: NonNullable = { }, active: { label: "Active", - help: "Only active rules participate in lifecycle evaluation" + help: "Turn off to withdraw the access this rule granted, without deleting the rule." }, managed_by: { label: "Managed By", - help: "Record provenance (unified tri-state, A4 #2920): platform = framework built-in / package = app/package-declared (boot-seeded) / admin = tenant-created in Setup.", + help: "Where this rule came from: built into the platform, installed with an app, or created here in Setup.", options: { platform: "Platform", package: "Package", @@ -167,7 +167,7 @@ export const enObjects: NonNullable = { }, customized: { label: "Customized", - help: "Set when an admin edits a package-declared rule; boot seeding will no longer overwrite the row (deactivations survive redeploys). Meaningless on admin rows." + help: "Set once you edit a rule that came with an app, so your changes are kept when the app is updated." }, created_at: { label: "Created At" diff --git a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts index de1566ca62..17dd86dee6 100644 --- a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts @@ -102,7 +102,7 @@ export const esESObjects: NonNullable = { sys_sharing_rule: { label: "Regla de compartición", pluralLabel: "Reglas de compartición", - description: "Regla de compartición declarativa que materializa automáticamente concesiones de sys_record_share. Se define mediante defineSharingRule() en código o con el generador de criterios de Studio.", + description: "Da acceso a un grupo de personas a los registros que cumplen una condición.", fields: { id: { label: "ID de regla" @@ -113,7 +113,7 @@ export const esESObjects: NonNullable = { }, name: { label: "Nombre", - help: "Nombre de regla snake_case único." + help: "Identifica la regla. Solo minúsculas, dígitos y guiones bajos." }, label: { label: "Nombre visible" @@ -123,26 +123,26 @@ export const esESObjects: NonNullable = { }, object_name: { label: "Objeto", - help: "Nombre corto del objeto (p. ej. opportunity, account)." + help: "El objeto cuyos registros comparte esta regla." }, criteria_json: { - label: "Criterios (JSON de FilterCondition)", - help: "FilterCondition JSON comparado con los registros de object_name. Vacío = coincide con todos." + label: "Criterios", + help: "Qué registros se comparten. Déjalo vacío para compartir todos los registros del objeto." }, recipient_type: { label: "Tipo de destinatario", - help: "Tipo de principal que recibe acceso; se expande a concesiones de usuario durante la evaluación. `department` recorre el árbol parent_business_unit_id; `team` es plano (better-auth).", + help: "Quién recibe el acceso. Al elegir un equipo, una unidad de negocio o un puesto, lo reciben todas las personas que lo integran. «Unidad de negocio y subordinadas» abarca además todas las unidades por debajo de la elegida.", options: { user: "Usuario", team: "Equipo", business_unit: "Unidad de negocio", - position: "posición", - unit_and_subordinates: "Rol y subordinados" + position: "Puesto", + unit_and_subordinates: "Unidad de negocio y subordinados" } }, recipient_id: { label: "Destinatario", - help: "ID de departamento / ID de equipo / nombre del rol / nombre de la cola / ID del usuario según recipient_type." + help: "El usuario, equipo, unidad de negocio o puesto concreto que recibe el acceso." }, access_level: { label: "Nivel de acceso", @@ -154,11 +154,11 @@ export const esESObjects: NonNullable = { }, active: { label: "Activo", - help: "Solo las reglas activas participan en la evaluación del ciclo de vida." + help: "Desactívala para retirar el acceso que concedió, sin eliminar la regla." }, managed_by: { label: "Gestionado por", - help: "Procedencia del registro (tri-estado unificado, A4 #2920): platform = integrado en el framework / package = declarado por app o paquete (sembrado al arrancar) / admin = creado por el inquilino en Setup.", + help: "De dónde viene esta regla: integrada en la plataforma, instalada con una app o creada aquí.", options: { platform: "Plataforma", package: "Paquete", @@ -167,7 +167,7 @@ export const esESObjects: NonNullable = { }, customized: { label: "Personalizado", - help: "Se establece cuando un administrador edita una regla declarada por un paquete; a partir de entonces el sembrado de arranque deja de sobrescribir la fila (las desactivaciones sobreviven a los redespliegues). Sin sentido en filas admin." + help: "Se marca al editar una regla que vino con una app, para conservar tus cambios cuando la app se actualice." }, created_at: { label: "Creado el" diff --git a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts index e35b851c0f..1d4cb41e9c 100644 --- a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts @@ -102,7 +102,7 @@ export const jaJPObjects: NonNullable = { sys_sharing_rule: { label: "共有ルール", pluralLabel: "共有ルール", - description: "sys_record_share 付与を自動マテリアライズする宣言的共有ルール。コードの defineSharingRule() または Studio の条件ビルダーで作成します。", + description: "条件に一致するレコードへのアクセスを、指定した人たちに与えます。", fields: { id: { label: "ルール ID" @@ -113,7 +113,7 @@ export const jaJPObjects: NonNullable = { }, name: { label: "名前", - help: "一意の snake_case ルール名" + help: "ルールの識別名。半角小文字・数字・アンダースコアのみ使用できます。" }, label: { label: "表示名" @@ -123,15 +123,15 @@ export const jaJPObjects: NonNullable = { }, object_name: { label: "オブジェクト", - help: "短いオブジェクト名(例: opportunity、account)" + help: "どのオブジェクトのレコードを共有するか。" }, criteria_json: { - label: "条件(FilterCondition JSON)", - help: "object_name のレコードに対してマッチする JSON FilterCondition。空 = すべてにマッチ。" + label: "条件", + help: "どのレコードを共有するか。空欄にすると、そのオブジェクトのすべてのレコードを共有します。" }, recipient_type: { label: "受信者タイプ", - help: "アクセスを受け取るプリンシパルの種別 — 評価時にユーザー付与に展開されます。`department` は parent_business_unit_id ツリーをたどります。`team` はフラット(better-auth)。", + help: "誰にアクセスを与えるか。チーム・事業単位・役職を選ぶと、そこに属する全員がアクセスできます。「事業単位と下位組織」を選ぶと、その配下のすべての単位も対象になります。", options: { user: "ユーザー", team: "チーム", @@ -142,7 +142,7 @@ export const jaJPObjects: NonNullable = { }, recipient_id: { label: "受信者", - help: "recipient_type に応じた部門 ID / チーム ID / ロール名 / キュー名 / ユーザー ID" + help: "アクセスを与える具体的なユーザー・チーム・事業単位・役職。" }, access_level: { label: "アクセスレベル", @@ -154,11 +154,11 @@ export const jaJPObjects: NonNullable = { }, active: { label: "有効", - help: "有効なルールのみがライフサイクル評価に参加します" + help: "オフにすると、このルールが与えたアクセスをただちに取り消します(ルール自体は残ります)。" }, managed_by: { label: "管理元", - help: "レコードの出所(統一三状態、A4 #2920): platform = フレームワーク組み込み / package = アプリ・パッケージ宣言(起動時シード)/ admin = テナントが Setup で作成。", + help: "このルールの出所:プラットフォーム内蔵、アプリと一緒に導入、またはここで作成。", options: { platform: "プラットフォーム", package: "パッケージ", @@ -167,7 +167,7 @@ export const jaJPObjects: NonNullable = { }, customized: { label: "カスタマイズ済み", - help: "管理者がパッケージ宣言のルールを編集すると設定されます。以後、起動時シードはこの行を上書きしません(無効化は再デプロイ後も保持)。admin 行では意味を持ちません。" + help: "アプリ付属のルールを編集すると設定され、アプリ更新時も変更内容が保持されます。" }, created_at: { label: "作成日時" diff --git a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts index 5ca5ac63e5..ba201682c4 100644 --- a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts @@ -102,7 +102,7 @@ export const zhCNObjects: NonNullable = { sys_sharing_rule: { label: "共享规则", pluralLabel: "共享规则", - description: "声明式共享规则,会自动生成 sys_record_share 授权。可在代码中通过 defineSharingRule() 编写,或在 Studio 条件构建器中维护。", + description: "把符合条件的记录,授权给一组人访问。", fields: { id: { label: "规则 ID" @@ -113,7 +113,7 @@ export const zhCNObjects: NonNullable = { }, name: { label: "名称", - help: "唯一的 snake_case 规则名称" + help: "规则的标识。只能使用小写字母、数字和下划线。" }, label: { label: "显示标签" @@ -123,15 +123,15 @@ export const zhCNObjects: NonNullable = { }, object_name: { label: "对象", - help: "短对象名(例如 opportunity、account)" + help: "要共享哪个对象的记录。" }, criteria_json: { - label: "条件(FilterCondition JSON)", - help: "针对 object_name 记录匹配的 JSON FilterCondition。为空表示匹配全部。" + label: "条件", + help: "共享哪些记录。留空表示共享该对象的全部记录。" }, recipient_type: { label: "接收方类型", - help: "接收访问权限的主体类型——求值时会展开为用户授权。`department` 会沿 parent_business_unit_id 树展开;`team` 为扁平结构(better-auth)。", + help: "把访问权限给谁。选择团队、业务单元或岗位时,其中的每个人都会获得访问权限;选择「业务单元及下级」还会包含该单元下属的所有单元。", options: { user: "用户", team: "团队", @@ -142,7 +142,7 @@ export const zhCNObjects: NonNullable = { }, recipient_id: { label: "接收方", - help: "根据 recipient_type 填写 业务单元 id / 团队 id / 岗位名 / 队列名 / 用户 id" + help: "具体把访问权限给哪一个用户、团队、业务单元或岗位。" }, access_level: { label: "访问级别", @@ -154,11 +154,11 @@ export const zhCNObjects: NonNullable = { }, active: { label: "启用", - help: "只有启用的规则才会参与生命周期求值" + help: "关闭即收回本规则已授予的访问权限,规则本身保留。" }, managed_by: { label: "管理来源", - help: "记录来源(统一三态,A4 #2920):platform = 框架内置 / package = 应用包声明(启动时种入)/ admin = 租户在 Setup 中创建。", + help: "这条规则的来源:平台内置、随应用安装,或在此处创建。", options: { platform: "平台", package: "应用包", @@ -167,7 +167,7 @@ export const zhCNObjects: NonNullable = { }, customized: { label: "已定制", - help: "当管理员编辑应用包声明的规则时置位;此后启动种入不再覆盖该行(停用状态可跨重新部署保留)。对 admin 来源的记录无意义。" + help: "当你修改了随应用安装的规则后置位,应用更新时你的改动会被保留。" }, created_at: { label: "创建时间"