From 3b36d7be42ade25cfa94678f73e5518fcb356f48 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:17:53 +0000 Subject: [PATCH 1/3] fix(plugin-audit): retire the held write locale on a settings-store write The activity summary writer held the workspace locale per tenant/user scope for a flat 30 s, with nothing retiring it when the localization setting changed. A summary line is stored once, so writes in that window kept the old language permanently. The held answer is now retired by any write to sys_setting or sys_platform_setting through the engine (the writer's own object-scoped after-hooks), with a generation counter taken before the lookup so an in-flight read cannot repopulate a stale answer. The TTL stays as the bound for what that seam cannot see: a host resolver's own storage and a peer node's write. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../plugins/plugin-audit/src/audit-writers.ts | 73 +++++++++++++++++-- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index 1305c5c8e5f..1f79f1af75f 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -143,8 +143,10 @@ export interface AuditWriterOptions { /** * Resolve the workspace default locale (ADR-0053 `localization.locale`) for * the write's tenant/user scope. Called per audited write but memoized here - * with a short TTL, so implementations may hit the settings service - * directly. Absent → summaries stay English (status quo). + * per scope, so implementations may hit the settings service directly. A + * held answer lasts until the next write to `sys_setting` or + * `sys_platform_setting` through this engine, or a short TTL, whichever + * comes first. Absent → summaries stay English (status quo). */ getLocale?(tenantId?: string, userId?: string): Promise; } @@ -1273,26 +1275,72 @@ export function installAuditWriters( } }; - // Workspace locale changes rarely, but writeAudit runs on every CRUD write — - // memoize the settings lookup per principal scope with a short TTL so audit - // logging doesn't add a settings query to every mutation's hot path. + // [#24235] The write locale, held per principal scope. Asking for it costs + // the settings cascade (one `sys_platform_setting` and one `sys_setting` + // read on a stock boot) and writeAudit runs on every CRUD write, so the + // answer is held — but a summary line is stored ONCE, at write time, so a + // held answer that outlives a language change stores the old language for + // good. It is therefore held only until the first of: + // + // 1. A write to either settings store, seen by this writer's own + // after-hooks (`retireHeldLocales`, registered below). `SettingsService` + // persists through this engine, so a `localization` change made through + // the settings API is one; a direct row write (a seeder) is the other, + // and it emits no settings event at all. Those are the writes that + // retire the core resolver's success cache beneath this one + // (`resolveLocalizationContext`: the settings generation and the engine + // write epoch), so this memo no longer outlives the layer it sits on. + // 2. `LOCALE_TTL_MS` — the residual bound, for what (1) cannot see: a host + // resolver's own storage (`AuditPluginOptions.getLocale` offers no + // seam), and a peer node's settings write. + // + // ⛔ Not the engine write epoch itself, and not a read through the core + // resolver with no memo here. Every audited write IS an engine write, so the + // epoch has always moved by the time this writer asks: either shape costs + // the full settings read on every audited write (measured on a real engine: + // two settings reads per audited write, against none for a held answer). const LOCALE_TTL_MS = 30_000; - const localeCache = new Map(); + /** Advanced by every settings-store write; an entry held at an older value is dead. */ + let localeGeneration = 0; + const localeCache = new Map(); const resolveWriteLocale = async (tenantId?: string, userId?: string): Promise => { if (!opts.getLocale) return undefined; const cacheKey = `${tenantId ?? ''}|${userId ?? ''}`; + // Read BEFORE the lookup and stored with its answer: a settings write that + // lands while the lookup is in flight moves the generation past it, so a + // pre-write answer is never held (the clear-then-repopulate-from-a-stale- + // read shape the core resolver guards against the same way). + const generation = localeGeneration; const now = Date.now(); const hit = localeCache.get(cacheKey); - if (hit && hit.expires > now) return hit.value; + if (hit && hit.generation === generation && hit.expires > now) return hit.value; let value: string | undefined; try { value = await opts.getLocale(tenantId, userId); } catch { value = undefined; } - localeCache.set(cacheKey, { value, expires: now + LOCALE_TTL_MS }); + if (generation === localeGeneration) { + localeCache.set(cacheKey, { value, expires: now + LOCALE_TTL_MS, generation }); + } return value; }; + /** + * The two objects the settings cascade answers from (ADR-0131 D7: the global + * rung has its own store). Coarse on purpose — any write to either retires + * every held locale, whatever its namespace: a predicate update names no + * namespace the hook can read without fetching the row, and settings writes + * are rare, so the over-retirement costs one locale read per scope. + */ + const LOCALE_SOURCE_OBJECTS: ReadonlySet = new Set(['sys_setting', 'sys_platform_setting']); + const retireHeldLocales = (ctx: HookContext): void => { + // Defence in depth, as `writeAudit`'s early return is for its exclusions: + // the registration is object-scoped, but a dispatcher that ignored the + // scope would otherwise retire on every write — a memo that never hits. + if (!LOCALE_SOURCE_OBJECTS.has(ctx.object)) return; + localeGeneration += 1; + localeCache.clear(); + }; // Translate function bound to a locale. Returns undefined on any miss // (no locale / no i18n / key miss) so callers keep their English fallback @@ -1317,6 +1365,15 @@ export function installAuditWriters( engine.unregisterHooksByPackage(packageId); } + // [#24235] The held write locale's retirement seam — see `resolveWriteLocale`. + // One registration per object, in the string form every registration in this + // file uses; `retireHeldLocales` re-checks the object either way. + for (const object of LOCALE_SOURCE_OBJECTS) { + for (const event of ['afterInsert', 'afterUpdate', 'afterDelete']) { + engine.registerHook(event, retireHeldLocales, { object, packageId }); + } + } + // Whether a given object's *registered* schema declares a field — see // `createFieldPresenceProbe` for why the conditional stamp exists. Shared // with the auth-event writer so both stamp on the same answer. From 537da42bd6877b2b760b9acb1eea8b5febb58c4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:20:52 +0000 Subject: [PATCH 2/3] test(plugin-audit): pin the write locale seam on a real engine, plus a changeset A settings-store write between two audited writes: the second one stores the new language. The control is a write with no settings change, answered from the held locale with no settings read. Two writer cases cover the global rung's own store and a write landing while a lookup is in flight. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .changeset/24235-audit-write-locale-seam.md | 11 ++ ...e-locale-settings-seam.integration.test.ts | 138 ++++++++++++++++++ .../plugin-audit/src/audit-writers.test.ts | 64 ++++++++ 3 files changed, 213 insertions(+) create mode 100644 .changeset/24235-audit-write-locale-seam.md create mode 100644 packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts diff --git a/.changeset/24235-audit-write-locale-seam.md b/.changeset/24235-audit-write-locale-seam.md new file mode 100644 index 00000000000..88745963718 --- /dev/null +++ b/.changeset/24235-audit-write-locale-seam.md @@ -0,0 +1,11 @@ +--- +'@objectstack/plugin-audit': patch +--- + +fix(plugin-audit): an activity summary written right after a workspace language change is stored in the new language, not the old one + +Clause-②: no (no export, option key, signature or schema changes; the held write locale is retired sooner, and the option docs say when) + +- **What was wrong.** The audit writer held the write locale for each tenant/user scope for 30 seconds, and nothing retired it when the `localization.locale` setting changed. A `sys_activity.summary` line is stored once, at write time, so every audited write in that window (a user's create, a seed publish) kept the old language for good. +- **What changes.** A write to `sys_setting` or `sys_platform_setting` through the engine now retires every held write locale. That covers a change made through the settings API, which persists through the engine, and a direct row write such as a seeder's, which emits no settings event. These are the same writes that retire `resolveLocalizationContext`'s own cache. A lookup still in flight when such a write lands is not held. The 30-second bound stays for what the writer cannot see: the storage behind a host `getLocale` resolver, and a settings write on another node. +- **Unchanged:** the host `getLocale` resolver is still asked first, and a write with no settings change between it and the last one is still answered from the held locale, without a settings read. diff --git a/packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts b/packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts new file mode 100644 index 00000000000..631a632f989 --- /dev/null +++ b/packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#24235] The held write locale against a REAL engine, in its own file: it +// boots a kernel, an engine, a SQLite driver and the audit plugin, and that +// load is kept out of `audit-writers.test.ts`. Static imports, so the load is +// paid during collection, not by one test's timeout. +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { AuditPlugin } from './audit-plugin.js'; + +/** + * [#24235] A summary line is stored ONCE, at write time, so the writer's held + * locale must not outlive a language change. Pinned on `ObjectKernel` + + * `ObjectQLPlugin` + `AuditPlugin` over a real SQLite driver: the plugin's own + * locale composition, the core resolver beneath it (which caches here, since + * this engine carries the write epoch) and the engine's real hook dispatch. + * + * No settings service is registered, so the core resolver answers from the + * tenant-scoped `sys_setting` rows directly, and the change is a write to + * that store through the engine — the write `SettingsService` makes when it + * persists, and the one a seeder makes without emitting any settings event. + * + * The clock is frozen from the first audited write on, so no case can pass by + * the TTL running out: what retires the held locale here is the write, or + * nothing does. + */ +describe('audit writers — a localization change reaches the very next audited write (#24235)', () => { + const HARNESS_PACKAGE = 'com.objectstack.audit.test.write-locale-seam'; + /** A stand-in under the real name: service-settings owns the definition, and the name is what the seam reads. */ + const sysSetting = { + name: 'sys_setting', + label: 'Setting', + isSystem: true, + fields: { + namespace: { name: 'namespace', label: 'Namespace', type: 'text' as const }, + key: { name: 'key', label: 'Key', type: 'text' as const }, + scope: { name: 'scope', label: 'Scope', type: 'text' as const }, + value: { name: 'value', label: 'Value', type: 'text' as const }, + }, + }; + const task = { + name: 'biz_task', + label: 'Task', + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, + }; + + let kernel: ObjectKernel; + let engine: ObjectQL; + let sql: (statement: string, bindings: unknown[]) => Promise[]>; + let settingsReads: () => number; + + const SYSTEM = { context: { isSystem: true } }; + const asAdmin = { context: { userId: 'u_admin' } }; + + /** Write one task as a user and answer the summary its activity row stored. */ + async function auditedWrite(name: string): Promise { + const row = await engine.insert('biz_task', { name }, asAdmin as any); + const rows = await sql('SELECT summary FROM "sys_activity" WHERE object_name = ? AND record_id = ?', [ + 'biz_task', + String((row as { id: unknown }).id), + ]); + expect(rows).toHaveLength(1); + return String(rows[0].summary); + } + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'silent' } } as any); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AuditPlugin()); + await kernel.bootstrap(); + + engine = kernel.getService('objectql'); + const driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + engine.registerDriver(driver, true); + engine.registry.registerObject(sysSetting as any, HARNESS_PACKAGE); + engine.registry.registerObject(task as any, HARNESS_PACKAGE); + await engine.syncSchemas(); + + sql = async (statement, bindings) => { + const result = await (driver as unknown as { execute(s: string, b: unknown[]): Promise }).execute( + statement, + bindings, + ); + // knex wraps some results as `[rows]`; normalize both shapes. + const list = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result; + expect(Array.isArray(list)).toBe(true); + return list as Record[]; + }; + const find = vi.spyOn(engine, 'find'); + settingsReads = () => find.mock.calls.filter(([object]) => object === 'sys_setting').length; + }, 120_000); + + afterAll(async () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + if (kernel) { + await Promise.race([kernel.shutdown(), new Promise((resolve) => setTimeout(resolve, 10_000))]); + } + }, 30_000); + + it('pin: a settings-store write between two audited writes; the second stores the new language', async () => { + // The precondition the whole case leans on: this engine carries the write + // epoch, so the core resolver beneath the writer caches as it does in a + // stock boot rather than declining to. + expect(typeof (engine as unknown as { writeEpoch?: { current?: unknown } }).writeEpoch?.current).toBe('number'); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-18T00:00:00Z')); + + // No row yet: the built-in default (`en-US`), read once and held. + expect(await auditedWrite('T1')).toBe('Created Task "T1"'); + + // CONTROL: a write with no settings change between is answered from the + // held locale — not one settings read. + const beforeControl = settingsReads(); + expect(await auditedWrite('T2')).toBe('Created Task "T2"'); + expect(settingsReads() - beforeControl).toBe(0); + + // The change: an insert into the settings store… + await engine.insert( + 'sys_setting', + { namespace: 'localization', key: 'locale', scope: 'tenant', value: 'zh-CN' }, + SYSTEM as any, + ); + expect(await auditedWrite('T3')).toBe('创建了 Task "T3"'); + + // …and an update of it, the shape a settings write takes once the row exists. + await engine.update('sys_setting', { value: 'ja-JP' }, { where: { key: 'locale' }, multi: true, ...SYSTEM } as any); + expect(await auditedWrite('T4')).toBe('Task「T4」を作成しました'); + + // The new answer is held in turn: the next unchanged write reads nothing. + const beforeHeld = settingsReads(); + expect(await auditedWrite('T5')).toBe('Task「T5」を作成しました'); + expect(settingsReads() - beforeHeld).toBe(0); + }); +}); diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index bbbe7781a69..577849155e8 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -1087,6 +1087,70 @@ describe('audit writers — localized activity summaries (framework#3039)', () = expect(localeCalls()).toBe(1); }); + // [#24235] The held locale is retired by a write to either settings store; + // the real-engine pin is `audit-write-locale-settings-seam.integration.test.ts`. + // These two hold what it cannot reach: the global rung's own store, and a + // write landing while a lookup is still in flight. + const settingsWrite = (object: string) => ({ + object, + input: {}, + result: { id: 's-1', namespace: 'localization', key: 'locale' }, + previous: { id: 's-1', namespace: 'localization', key: 'locale' }, + session: {}, + }); + const summaryOf = (created: CapturedRow[], name: string) => + created.find((c) => c.object === 'sys_activity' && c.row.record_label === name)?.row.summary; + + it('[#24235] a write to either settings store retires the held locale; without one it is held', async () => { + for (const store of [undefined, 'sys_setting', 'sys_platform_setting']) { + const { engine, fire, created } = makeEngine(WITH_QUALIFICATION); + let locale: string | undefined; + installAuditWriters(engine as any, 'test.audit', { getI18n: () => makeI18n() as any, getLocale: async () => locale }); + await fire('afterInsert', insertCtx()); + locale = 'zh-CN'; + if (store) await fire('afterUpdate', settingsWrite(store)); + await fire('afterInsert', { ...insertCtx(), result: { id: 'q-2', name: 'OC-00002' } }); + expect(summaryOf(created, 'OC-00001'), String(store)).toBe('Created person_qualification "OC-00001"'); + // The control (`undefined`: no settings write) still answers from the held locale. + expect(summaryOf(created, 'OC-00002'), String(store)).toBe( + store ? '创建了 人员资质 "OC-00002"' : 'Created person_qualification "OC-00002"', + ); + } + }); + + it('[#24235] a settings write landing while the lookup is in flight leaves the pre-write answer unheld', async () => { + const { engine, fire, created } = makeEngine(WITH_QUALIFICATION); + let entered!: () => void; + const lookupStarted = new Promise((resolve) => { + entered = resolve; + }); + let release!: () => void; + const inFlight = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + installAuditWriters(engine as any, 'test.audit', { + getI18n: () => makeI18n() as any, + getLocale: async () => { + calls += 1; + if (calls === 1) { + entered(); + await inFlight; // read before the write below, answered after it + return undefined; + } + return 'zh-CN'; + }, + }); + const first = fire('afterInsert', insertCtx()); + await lookupStarted; + await fire('afterUpdate', settingsWrite('sys_setting')); + release(); + await first; + await fire('afterInsert', { ...insertCtx(), result: { id: 'q-2', name: 'OC-00002' } }); + expect(summaryOf(created, 'OC-00001')).toBe('Created person_qualification "OC-00001"'); + expect(summaryOf(created, 'OC-00002')).toBe('创建了 人员资质 "OC-00002"'); + }); + // Collaboration notification titles (assignment / @mention) are localized to // the RECIPIENT's locale with the same key shapes, and fall back to the // authored object label (not the API name) in English. From 98e0d9fec8777be9969d64daf93029512c3b8dac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:23:02 +0000 Subject: [PATCH 3/3] refactor(plugin-audit): one generation-stamp rule retires a held write locale The memo carried three overlapping guards (a map clear, a store guard and a generation check on read); an ablation of the store guard stayed green, since the read check alone already decides. Keep that one rule, the core resolver's: an entry stamped with an older generation is dead. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../plugins/plugin-audit/src/audit-writers.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index 1f79f1af75f..f8bbd5f1b14 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -1300,16 +1300,19 @@ export function installAuditWriters( // the full settings read on every audited write (measured on a real engine: // two settings reads per audited write, against none for a held answer). const LOCALE_TTL_MS = 30_000; - /** Advanced by every settings-store write; an entry held at an older value is dead. */ + /** + * Advanced by every settings-store write. ONE rule retires a held answer: + * an entry stamped with an older generation is dead. + */ let localeGeneration = 0; const localeCache = new Map(); const resolveWriteLocale = async (tenantId?: string, userId?: string): Promise => { if (!opts.getLocale) return undefined; const cacheKey = `${tenantId ?? ''}|${userId ?? ''}`; - // Read BEFORE the lookup and stored with its answer: a settings write that - // lands while the lookup is in flight moves the generation past it, so a - // pre-write answer is never held (the clear-then-repopulate-from-a-stale- - // read shape the core resolver guards against the same way). + // Read BEFORE the lookup and stamped on its answer, as the core resolver + // stamps its epoch: a settings write that lands while the lookup is in + // flight moves the generation past the stamp, so the pre-write answer is + // dead on arrival instead of held for the TTL. const generation = localeGeneration; const now = Date.now(); const hit = localeCache.get(cacheKey); @@ -1320,9 +1323,7 @@ export function installAuditWriters( } catch { value = undefined; } - if (generation === localeGeneration) { - localeCache.set(cacheKey, { value, expires: now + LOCALE_TTL_MS, generation }); - } + localeCache.set(cacheKey, { value, expires: now + LOCALE_TTL_MS, generation }); return value; }; /** @@ -1339,7 +1340,6 @@ export function installAuditWriters( // scope would otherwise retire on every write — a memo that never hits. if (!LOCALE_SOURCE_OBJECTS.has(ctx.object)) return; localeGeneration += 1; - localeCache.clear(); }; // Translate function bound to a locale. Returns undefined on any miss