Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/24235-audit-write-locale-seam.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>[]>;
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<string> {
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>('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<unknown> }).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<string, unknown>[];
};
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<void>((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);
});
});
64 changes: 64 additions & 0 deletions packages/plugins/plugin-audit/src/audit-writers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => {
entered = resolve;
});
let release!: () => void;
const inFlight = new Promise<void>((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.
Expand Down
73 changes: 65 additions & 8 deletions packages/plugins/plugin-audit/src/audit-writers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>;
}
Expand Down Expand Up @@ -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<string, { value: string | undefined; expires: number }>();
/**
* 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<string, { value: string | undefined; expires: number; generation: number }>();
const resolveWriteLocale = async (tenantId?: string, userId?: string): Promise<string | undefined> => {
if (!opts.getLocale) return undefined;
const cacheKey = `${tenantId ?? ''}|${userId ?? ''}`;
// 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);
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 });
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<string> = 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;
};

// Translate function bound to a locale. Returns undefined on any miss
// (no locale / no i18n / key miss) so callers keep their English fallback
Expand All @@ -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.
Expand Down
Loading