diff --git a/.changeset/adr-0057-lifecycle-followups.md b/.changeset/adr-0057-lifecycle-followups.md new file mode 100644 index 0000000000..58ebc77fd4 --- /dev/null +++ b/.changeset/adr-0057-lifecycle-followups.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': minor +'@objectstack/service-job': minor +'@objectstack/service-messaging': minor +'@objectstack/service-automation': patch +'@objectstack/platform-objects': patch +--- + +ADR-0057 data-lifecycle follow-ups (#2834): the per-plugin retention sweepers are retired, telemetry separation goes live in dev, and the lifecycle contract reaches the Studio. + +- **BREAKING (ships as minor per the launch-window convention)**: `JobRunRetention` / `NotificationRetention` and the `retentionDays` / `retentionSweepMs` options on `JobServicePlugin` / `MessagingServicePlugin` are removed. The platform LifecycleService enforces the same windows from the `lifecycle` declarations (`sys_job_run` 30d, notification pipeline 90d); tune them at runtime via the `lifecycle` settings namespace (`retention_overrides`, tenant-scoped). +- **Fix**: `sys_automation_run` no longer declares a blanket 30d lifecycle retention — that table interleaves live SUSPENDED runs (an approval may stay paused for months) with terminal history, and a blanket age reap could strand in-flight approvals. Bounding stays with the automation store's terminal-only sweep. +- **CLI**: `objectstack dev` now provisions a dedicated `telemetry` datasource (`.telemetry.db`) for file-backed SQLite primaries, so lifecycle-classed system data stops sharing the business dev DB (`OS_TELEMETRY_DB=0` opts out; `OS_TELEMETRY_DB=` opts in anywhere). New `os db clean` runs the one-time `VACUUM` that lets legacy files adopt `auto_vacuum=INCREMENTAL` and reports reclaimed bytes. +- **Studio**: the object metadata form exposes the `lifecycle` block (class + retention/TTL/rotation/archive/reclaim); metadata-forms i18n bundles regenerated with curated zh-CN translations. diff --git a/content/docs/getting-started/cli.mdx b/content/docs/getting-started/cli.mdx index 7aeac64c4d..cdbae50955 100644 --- a/content/docs/getting-started/cli.mdx +++ b/content/docs/getting-started/cli.mdx @@ -74,6 +74,7 @@ predicate/schema/binding mistakes that fail silently at runtime), and `os dev -- | `os init [name]` | | Initialize a new ObjectStack project in the current directory | | `os dev [package]` | | Start development mode with hot reload | | `os serve [config]` | | Start the ObjectStack server with plugin auto-detection | +| `os db clean` | | Reclaim SQLite free space with a one-time `VACUUM` (ADR-0057) | #### `os init` @@ -134,6 +135,13 @@ os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32) | `--no-compile` | — | Skip the auto-compile step (errors if no artifact present) | | `-v, --verbose` | — | Verbose output | +With a file-backed SQLite database, dev also provisions a sibling +`.telemetry.` file registered as the `telemetry` datasource — +lifecycle-classed system data (activity streams, job runs, notifications, +audit) lands there instead of the business DB (ADR-0057). Opt out with +`OS_TELEMETRY_DB=0`, or point it elsewhere (any mode, including `serve`) +with `OS_TELEMETRY_DB=`. + #### `os serve` Starts the ObjectStack server with automatic plugin discovery: @@ -195,6 +203,23 @@ export default defineStack({ }); ``` +#### `os db clean` + +Reclaims SQLite free space with a one-time `VACUUM` (ADR-0057 §3.4). The +platform reclaims space incrementally (`auto_vacuum=INCREMENTAL`), but that +setting only takes effect on a fresh database — files created before it +stay pinned at their high-water mark until one full `VACUUM` rebuilds them. +Non-destructive: every row survives; free pages return to the OS. Cleans +the telemetry sibling too when one exists. + +```bash +os db clean # default: the per-project dev DB +os db clean --database file:./data/app.db # explicit target +``` + +**Options:** +- `-d, --database ` — SQLite database URL/path (defaults to `$OS_DATABASE_URL`, then the per-project dev DB) + #### Console UI Launch the development server with the Console UI: diff --git a/docs/launch-readiness.md b/docs/launch-readiness.md index 2f574f46c5..cb09afea0f 100644 --- a/docs/launch-readiness.md +++ b/docs/launch-readiness.md @@ -160,6 +160,15 @@ fix or acceptance.** `AutomationServicePluginOptions.maxLogSize` (default unchanged at 1000, `DEFAULT_MAX_EXECUTION_LOG_SIZE`); +2 tests. Durable `sys_automation_run`-style persistence is deferred to the HA fast-follow (roadmap), not a GA blocker. +- **Superseded (ADR-0057, #2786/#2834):** the plugin-local sweepers above were + the stop-gap. Retention is now a *declarative platform primitive*: objects + carry a `lifecycle` block (`sys_job_run` 30d, notification pipeline 90d) and + the ObjectQL-registered **LifecycleService** is the ONE sweeper — + `JobRunRetention` / `NotificationRetention` and their `retentionDays` + options were removed. Windows are tuned via the `lifecycle` settings + namespace (`retention_overrides`, tenant-scoped). `sys_automation_run` + deliberately keeps its OWN terminal-only sweep (suspended runs are live + resumable state; the declarative contract has no status predicate). - **Owner:** _______ · Verify ✅ (confirmed real @ `main`; scope corrected) · Sign-off ☐ · Notes: `sys_job_run` retention is the one true fix; messaging default-flipped; automation already bounded (now tunable). Awaiting human sign-off. ### P1-3 — Graceful shutdown (mostly a false positive; one real drain bug fixed) diff --git a/packages/cli/src/commands/db/clean.ts b/packages/cli/src/commands/db/clean.ts new file mode 100644 index 0000000000..d51b321ea4 --- /dev/null +++ b/packages/cli/src/commands/db/clean.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { Command, Flags } from '@oclif/core'; +import { statSync, existsSync } from 'node:fs'; +import chalk from 'chalk'; +import { printError } from '../../utils/format.js'; +import { resolveDefaultDevDbUrl } from '../dev.js'; +import { resolveTelemetryDbPath } from '../../utils/telemetry-datasource.js'; + +/** + * `os db clean` — one-time SQLite space reclamation (ADR-0057 §3.4). + * + * The platform LifecycleService reclaims space incrementally + * (`auto_vacuum=INCREMENTAL` + `PRAGMA incremental_vacuum` after sweeps) — + * but `auto_vacuum` only changes the page layout of a FRESH database, so a + * file created before the ADR-0057 default stays pinned at its high-water + * mark until one full `VACUUM` rebuilds it. This command runs exactly that: + * set the pragma, `VACUUM`, report the reclaimed bytes. Non-destructive — + * every row survives; only free pages are returned to the OS. + */ +export default class DbClean extends Command { + static override description = + 'Reclaim SQLite free space (one-time VACUUM) so legacy files adopt auto_vacuum=INCREMENTAL — ADR-0057 §3.4'; + + static override examples = [ + '$ os db clean', + '$ os db clean --database file:./.objectstack/data/dev.db', + ]; + + static override flags = { + database: Flags.string({ + char: 'd', + description: 'SQLite database URL/path (defaults to $OS_DATABASE_URL, then the per-project dev DB)', + env: 'OS_DATABASE_URL', + }), + }; + + async run(): Promise { + const { flags } = await this.parse(DbClean); + + const raw = + flags.database?.trim() || + resolveDefaultDevDbUrl({ env: process.env, cwd: process.cwd() }) || + ''; + const primary = raw.replace(/^file:/i, '').replace(/^sqlite:/i, ''); + + if (!primary || primary === ':memory:' || primary.startsWith(':')) { + printError('No file-backed SQLite database to clean (in-memory databases reclaim nothing).'); + this.exit(1); + return; + } + if (!/\.(db|sqlite3|sqlite)$/i.test(primary) && !existsSync(primary)) { + printError(`Not a SQLite database path: ${primary}`); + this.exit(1); + return; + } + + // Clean the telemetry sibling too when one exists (ADR-0057 §3.6). + const telemetrySibling = resolveTelemetryDbPath({ primaryPath: primary, env: process.env, dev: true }); + const targets = [primary, telemetrySibling].filter( + (p): p is string => !!p && existsSync(p), + ); + if (targets.length === 0) { + printError(`Database file not found: ${primary}`); + this.exit(1); + return; + } + + const { resolveSqliteDriver } = await import('@objectstack/service-datasource'); + + let failed = false; + for (const file of targets) { + const before = statSync(file).size; + try { + const resolved = await resolveSqliteDriver({ + filename: file, + dev: true, // allow the wasm step-down; memory fallback is rejected below + warn: (m) => console.warn(chalk.yellow(m)), + }); + if (resolved.engine === 'memory') { + throw new Error('no SQLite engine available (native and wasm both failed to load)'); + } + // Order matters: the pragma must be set BEFORE the VACUUM so the + // rebuilt file carries auto_vacuum=INCREMENTAL from here on. + await resolved.driver.execute('PRAGMA auto_vacuum = INCREMENTAL'); + await resolved.driver.execute('VACUUM'); + await resolved.driver.disconnect(); + + const after = statSync(file).size; + const saved = before - after; + const fmt = (n: number) => `${(n / 1024 / 1024).toFixed(2)} MB`; + console.log( + `${chalk.green('✓')} ${file}: ${fmt(before)} → ${fmt(after)}` + + (saved > 0 ? chalk.dim(` (reclaimed ${fmt(saved)})`) : chalk.dim(' (already compact)')), + ); + } catch (error: any) { + failed = true; + printError(`VACUUM failed for ${file}: ${error?.message ?? error}`); + } + } + if (failed) this.exit(1); + } +} diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index c2dba06861..d877be687c 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -664,6 +664,38 @@ export default class Serve extends Command { trackPlugin(resolved.engine === 'memory' ? 'MemoryDriver' : resolved.engine === 'sqlite-wasm' ? 'SqliteWasmDriver' : 'SqlDriver'); resolvedDriverLabel = resolved.label; resolvedDatabaseUrl = resolved.engine === 'memory' ? '(in-memory)' : (databaseUrl ?? ':memory:'); + + // ADR-0057 §3.6 (#2834 ②): provision the dedicated `telemetry` + // datasource — a sibling SQLite file the engine routes every + // telemetry/event/audit-classed object to, so platform-generated + // growth can never again bloat the business DB. Dev default-on + // for file-backed primaries; `OS_TELEMETRY_DB=0` opts out, + // `OS_TELEMETRY_DB=` opts in anywhere (incl. serve). + if (resolved.engine !== 'memory') { + const { resolveTelemetryDbPath } = await import('../utils/telemetry-datasource.js'); + const telemetryPath = resolveTelemetryDbPath({ primaryPath: filePath, env: process.env, dev: isDev }); + if (telemetryPath) { + try { + const telemetry = await resolveSqliteDriver({ + filename: telemetryPath, + dev: isDev, + autoMigrate: isDev ? 'safe' : undefined, + warn: (m) => console.warn(chalk.yellow(m)), + }); + if (telemetry.engine !== 'memory') { + // The engine keys datasources by driver name — the + // lifecycle router looks this exact name up. + Object.defineProperty(telemetry.driver, 'name', { value: 'telemetry' }); + await kernel.use(new DriverPlugin(telemetry.driver, { datasourceName: 'telemetry', registerAsDefault: false })); + trackPlugin('TelemetryDatasource'); + console.log(chalk.dim(` telemetry datasource: ${telemetryPath} (lifecycle-classed system data; OS_TELEMETRY_DB=0 to disable)`)); + } + } catch { + // Best-effort: a failed telemetry provision must never block + // boot — objects simply stay on the primary datasource. + } + } + } } else if (driverType === 'sqlite-wasm' || driverType === 'wasm-sqlite' || driverType === 'wasm') { const { SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm'); const filePath = (databaseUrl ?? ':memory:').replace(/^file:/, '').replace(/^wasm-sqlite:\/\//, '').replace(/^sqlite:/, ''); diff --git a/packages/cli/src/utils/telemetry-datasource.test.ts b/packages/cli/src/utils/telemetry-datasource.test.ts new file mode 100644 index 0000000000..759ac51574 --- /dev/null +++ b/packages/cli/src/utils/telemetry-datasource.test.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { resolveTelemetryDbPath } from './telemetry-datasource.js'; + +describe('resolveTelemetryDbPath (ADR-0057 §3.6)', () => { + it('derives a sibling file next to a file-backed dev primary', () => { + expect(resolveTelemetryDbPath({ primaryPath: '/p/.objectstack/data/dev.db', env: {}, dev: true })) + .toBe('/p/.objectstack/data/dev.telemetry.db'); + expect(resolveTelemetryDbPath({ primaryPath: './app.sqlite', env: {}, dev: true })) + .toBe('./app.telemetry.sqlite'); + expect(resolveTelemetryDbPath({ primaryPath: 'data/store', env: {}, dev: true })) + .toBe('data/store.telemetry.db'); + }); + + it('never derives one for in-memory primaries', () => { + expect(resolveTelemetryDbPath({ primaryPath: ':memory:', env: {}, dev: true })).toBeUndefined(); + expect(resolveTelemetryDbPath({ primaryPath: '', env: {}, dev: true })).toBeUndefined(); + }); + + it('is opt-in outside dev', () => { + expect(resolveTelemetryDbPath({ primaryPath: '/srv/prod.db', env: {}, dev: false })).toBeUndefined(); + expect( + resolveTelemetryDbPath({ primaryPath: '/srv/prod.db', env: { OS_TELEMETRY_DB: '/srv/prod.telemetry.db' }, dev: false }), + ).toBe('/srv/prod.telemetry.db'); + }); + + it('OS_TELEMETRY_DB=0|false|off opts out entirely, even in dev', () => { + for (const off of ['0', 'false', 'off', 'OFF']) { + expect(resolveTelemetryDbPath({ primaryPath: '/p/dev.db', env: { OS_TELEMETRY_DB: off }, dev: true })).toBeUndefined(); + } + }); + + it('an explicit OS_TELEMETRY_DB path wins over derivation and strips url prefixes', () => { + expect( + resolveTelemetryDbPath({ primaryPath: '/p/dev.db', env: { OS_TELEMETRY_DB: 'file:/tmp/t.db' }, dev: true }), + ).toBe('/tmp/t.db'); + }); +}); diff --git a/packages/cli/src/utils/telemetry-datasource.ts b/packages/cli/src/utils/telemetry-datasource.ts new file mode 100644 index 0000000000..bb31591806 --- /dev/null +++ b/packages/cli/src/utils/telemetry-datasource.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0057 §3.6 (#2834 ②) — where the dedicated telemetry datasource lives. + * + * When a datasource named `telemetry` is registered, the engine routes every + * `telemetry`/`event`/`audit`-classed object to it, so platform-generated + * growth can never again bloat the business DB. This helper decides whether + * (and where) the CLI should provision that second SQLite file: + * + * - `OS_TELEMETRY_DB=0|false|off` → never (explicit opt-out) + * - `OS_TELEMETRY_DB=` → always, at that path (dev AND serve) + * - dev mode + file-backed primary → default ON, `.telemetry.` + * (`dev.db` → `dev.telemetry.db`, same directory) + * - everything else (prod serve, `:memory:`, non-sqlite) → off + * + * Production stays opt-in: a second file appearing next to a prod database + * is a deployment-topology change an operator should choose, not inherit. + */ +export function resolveTelemetryDbPath(opts: { + /** Primary sqlite file path (already stripped of `file:`/`sqlite:`). */ + primaryPath: string; + env: Record; + dev: boolean; +}): string | undefined { + const raw = opts.env.OS_TELEMETRY_DB?.trim(); + if (raw) { + const lowered = raw.toLowerCase(); + if (lowered === '0' || lowered === 'false' || lowered === 'off') return undefined; + return raw.replace(/^file:/i, '').replace(/^sqlite:/i, ''); + } + + if (!opts.dev) return undefined; + + const primary = opts.primaryPath.trim(); + // Only a real on-disk primary gets a sibling: separating one `:memory:` + // store into another has no reclamation value. + if (!primary || primary === ':memory:' || primary.startsWith(':')) return undefined; + + if (/\.(db|sqlite3|sqlite)$/i.test(primary)) { + return primary.replace(/\.(db|sqlite3|sqlite)$/i, '.telemetry.$1'); + } + return `${primary}.telemetry.db`; +} diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 6971c2ecae..787798368e 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -272,6 +272,70 @@ export const enMetadataForms: NonNullable = { datasource: { label: "Datasource", helpText: "Target datasource ID (default: \"default\")" + }, + lifecycle: { + label: "Lifecycle", + helpText: "Data lifecycle contract (ADR-0057): how long rows live and how space is reclaimed. Leave empty for permanent record semantics. Non-record classes require at least one bounding policy (retention, TTL, or rotation)." + }, + "lifecycle.class": { + label: "Class", + helpText: "Persistence contract for the rows of this object" + }, + "lifecycle.retention": { + label: "Retention", + helpText: "Age-based retention window" + }, + "lifecycle.retention.maxAge": { + label: "Max Age", + helpText: "Rows older than this (by created_at) are reaped. Duration literal: h/d/w/y, e.g. \"30d\"" + }, + "lifecycle.ttl": { + label: "Ttl", + helpText: "Per-row TTL expiry" + }, + "lifecycle.ttl.field": { + label: "Field", + helpText: "Timestamp field the TTL is measured from (e.g. expires_at)" + }, + "lifecycle.ttl.expireAfter": { + label: "Expire After", + helpText: "Rows expire this long after the field, e.g. \"1d\"" + }, + "lifecycle.storage": { + label: "Storage", + helpText: "Physical rotation for high-frequency telemetry (SQLite: O(1) shard DROP)" + }, + "lifecycle.storage.strategy": { + label: "Strategy", + helpText: "Storage strategy" + }, + "lifecycle.storage.shards": { + label: "Shards", + helpText: "Shards retained; total window = shards × unit" + }, + "lifecycle.storage.unit": { + label: "Unit", + helpText: "Time width of one shard" + }, + "lifecycle.archive": { + label: "Archive", + helpText: "Cold-store hand-off (audit class). Rows are never hot-deleted before the archive copy succeeded." + }, + "lifecycle.archive.after": { + label: "After", + helpText: "Archive rows older than this — must equal retention.maxAge" + }, + "lifecycle.archive.to": { + label: "To", + helpText: "Target datasource name for cold storage" + }, + "lifecycle.archive.keep": { + label: "Keep", + helpText: "How long the archive keeps rows (empty = forever), e.g. \"7y\"" + }, + "lifecycle.reclaim": { + label: "Reclaim", + helpText: "Reclaim driver space after sweeps (default on for non-record classes)" } } }, @@ -380,10 +444,6 @@ export const enMetadataForms: NonNullable = { label: "Summary Operations", helpText: "Roll-up summary configuration (for parent-child relationships)" }, - cached: { - label: "Cached", - helpText: "Caching configuration for computed fields" - }, columnName: { label: "Column Name", helpText: "Physical column name in database (defaults to field name)" @@ -411,28 +471,9 @@ export const enMetadataForms: NonNullable = { sortable: { label: "Sortable", helpText: "Allow sorting lists by this field" - }, - auditTrail: { - label: "Audit Trail", - helpText: "Track detailed changes with user and timestamp" - }, - trackFeedHistory: { - label: "Track Feed History", - helpText: "Show changes in activity feed" - }, - encryptionConfig: { - label: "Encryption Config", - helpText: "Field-level encryption (GDPR/HIPAA/PCI-DSS)" - }, - maskingRule: { - label: "Masking Rule", - helpText: "Data masking rules for PII protection" } } }, - trigger: { - label: "Trigger" - }, validation: { label: "Validation Rule" }, @@ -509,12 +550,36 @@ export const enMetadataForms: NonNullable = { onError: { label: "On Error" }, + timeout: { + label: "Timeout", + helpText: "Abort the hook after N milliseconds" + }, condition: { label: "Condition", helpText: "Optional formula — skip the hook when this evaluates to false" + }, + retryPolicy: { + label: "Retry Policy", + helpText: "Retry on failure — most useful for async hooks" + }, + "retryPolicy.maxRetries": { + label: "Max Retries", + helpText: "Maximum retry attempts" + }, + "retryPolicy.backoffMs": { + label: "Backoff Ms", + helpText: "Delay between retries (ms)" } } }, + seed: { + label: "Seed Data", + description: "Fixture / initialization data applied on publish" + }, + mapping: { + label: "Import Mapping", + description: "Reusable import/export field mapping (rename + transforms), referenced by name at import" + }, view: { label: "View", sections: { @@ -686,6 +751,10 @@ export const enMetadataForms: NonNullable = { label: "Layout", description: "Page regions and components placed within them." }, + interface: { + label: "Interface (list pages)", + description: "Interface mode (Airtable parity): the page defines its own data surface directly — columns, filters, visualizations and toolbar — no inheriting from a separate view." + }, advanced: { label: "Advanced", description: "Activation, audience, and accessibility." @@ -728,6 +797,58 @@ export const enMetadataForms: NonNullable = { label: "Regions", helpText: "Layout regions (header, main, sidebar, footer) with components" }, + interfaceConfig: { + label: "Interface Config", + helpText: "The page IS the view: source picks the object, columns/filterBy are defined directly here; appearance.allowedVisualizations whitelists renderers (one entry = locked); userActions toggles the toolbar." + }, + "interfaceConfig.source": { + label: "Source", + helpText: "Object this page reads from" + }, + "interfaceConfig.columns": { + label: "Columns", + helpText: "Columns to show — defined directly on the page (blank = all object fields)" + }, + "interfaceConfig.filterBy": { + label: "Filter By", + helpText: "Always-on base filter for the page — same visual builder as the list toolbar." + }, + "interfaceConfig.levels": { + label: "Levels", + helpText: "Hierarchy levels to display (tree-like sources)" + }, + "interfaceConfig.appearance": { + label: "Appearance", + helpText: "Allowed visualizations (Grid / Kanban / Calendar / …) and description visibility" + }, + "interfaceConfig.userFilters": { + label: "User Filters", + helpText: "End-user filter bar: None (no bar) / Tabs (named presets) / Dropdown (per-field). None removes the config." + }, + "interfaceConfig.userActions": { + label: "User Actions", + helpText: "Toolbar toggles (search, sort, filter, row height)" + }, + "interfaceConfig.addRecord": { + label: "Add Record", + helpText: "Add-record entry point" + }, + "interfaceConfig.buttons": { + label: "Buttons", + helpText: "Toolbar buttons — pick from this object's actions" + }, + "interfaceConfig.recordAction": { + label: "Record Action", + helpText: "How clicking a record opens its detail" + }, + "interfaceConfig.showRecordCount": { + label: "Show Record Count", + helpText: "Show the record count bar" + }, + "interfaceConfig.allowPrinting": { + label: "Allow Printing", + helpText: "Allow users to print this page" + }, isDefault: { label: "Is Default", helpText: "Set as default page for this page type" @@ -976,6 +1097,22 @@ export const enMetadataForms: NonNullable = { label: "Body", helpText: "JavaScript code to execute" }, + "body.language": { + label: "Language", + helpText: "expression = pure formula; js = sandboxed JavaScript" + }, + "body.source": { + label: "Source", + helpText: "Function body source — no top-level imports" + }, + "body.capabilities": { + label: "Capabilities", + helpText: "Allowed ctx APIs (api.read, api.write, crypto.uuid, log, …)" + }, + "body.timeoutMs": { + label: "Timeout Ms", + helpText: "Per-invocation timeout (ms)" + }, params: { label: "Params", helpText: "User input parameters (show form before executing)" @@ -1016,9 +1153,9 @@ export const enMetadataForms: NonNullable = { label: "Bulk Enabled", helpText: "Allow applying to multiple selected records" }, - aiExposed: { - label: "Ai Exposed", - helpText: "Allow AI agents to call this action" + ai: { + label: "Ai", + helpText: "AI exposure (opt-in): set ai.exposed=true and write ai.description (≥40 chars) to make this callable by agents." }, recordIdParam: { label: "Record Id Param", @@ -1041,13 +1178,9 @@ export const enMetadataForms: NonNullable = { label: "Basics", description: "Identity and data source." }, - columns: { - label: "Columns", - description: "Columns shown in the report output." - }, - groupings: { - label: "Groupings", - description: "How rows (and columns, for matrix reports) are grouped." + dataset_binding: { + label: "Dataset binding", + description: "The semantic-layer dataset this report renders. Values are the dataset’s measures; rows are its dimensions." }, joined_blocks: { label: "Joined blocks", @@ -1073,33 +1206,37 @@ export const enMetadataForms: NonNullable = { description: { label: "Description" }, - objectName: { - label: "Object Name", - helpText: "Data source object" - }, type: { label: "Type", helpText: "Report type: tabular/summary/matrix/joined" }, + dataset: { + label: "Dataset", + helpText: "Dataset to bind (measures/dimensions come from its semantic layer)" + }, + values: { + label: "Values", + helpText: "Measure names (from the dataset) to display" + }, + rows: { + label: "Rows", + helpText: "Dimension names (from the dataset) to group rows by" + }, columns: { label: "Columns", helpText: "Columns to display in the report" }, - groupingsDown: { - label: "Groupings Down", - helpText: "Row grouping levels" - }, - groupingsAcross: { - label: "Groupings Across", - helpText: "Column grouping levels (matrix only)" + drilldown: { + label: "Drilldown", + helpText: "Click an aggregated row/cell to open the underlying records" }, blocks: { label: "Blocks", helpText: "Join multiple objects (joined report only)" }, - filter: { - label: "Filter", - helpText: "Report-level filters" + runtimeFilter: { + label: "Runtime Filter", + helpText: "Render-time scope filter, ANDed at query time" }, chart: { label: "Chart", @@ -1115,6 +1252,62 @@ export const enMetadataForms: NonNullable = { } } }, + dataset: { + label: "Dataset", + description: "Analytics semantic layer — dimensions & measures", + sections: { + basics: { + label: "Basics", + description: "Dataset identity." + }, + source: { + label: "Source", + description: "The base object, the relationships to join, and the dataset’s intrinsic scope. Joins are derived from the object graph — pick relationship (lookup / master_detail) names, never write an ON clause." + }, + dimensions: { + label: "Dimensions", + description: "Groupable axes. Use a base field, or `relationship.field` (e.g. account.region) for a relationship included above." + }, + measures: { + label: "Measures", + description: "Aggregatable values defined once and referenced by name. A measure is sum/avg/count/… of a field; a derived measure combines other measures (ratio/sum/difference/product). Measure-scoped filters and derived ops are edited per-row in the dataset designer." + } + }, + fields: { + name: { + label: "Name", + helpText: "snake_case unique identifier" + }, + label: { + label: "Label", + helpText: "Display name" + }, + description: { + label: "Description", + helpText: "What this dataset measures" + }, + object: { + label: "Object", + helpText: "Base object — the FROM" + }, + include: { + label: "Include", + helpText: "Relationship (lookup / master_detail) field names to join — enables `relationship.field` dimensions/measures (e.g. include \"account\" → group by account.region)" + }, + filter: { + label: "Filter", + helpText: "Intrinsic scope filter (e.g. exclude soft-deleted records), ANDed into every query" + }, + dimensions: { + label: "Dimensions", + helpText: "Each: name (referenced by presentations), field, type, and — for dates — a default bucketing granularity" + }, + measures: { + label: "Measures", + helpText: "Each: name, aggregate, field (optional for count), display format/currency, and a “certified” governance flag" + } + } + }, flow: { label: "Flow", sections: { @@ -1194,15 +1387,6 @@ export const enMetadataForms: NonNullable = { translation: { label: "Translation" }, - router: { - label: "Router" - }, - function: { - label: "Function" - }, - service: { - label: "Service" - }, email_template: { label: "Email Template", sections: { @@ -1283,6 +1467,14 @@ export const enMetadataForms: NonNullable = { } } }, + doc: { + label: "Documentation", + description: "Package documentation — flat Markdown items (ADR-0046)" + }, + book: { + label: "Documentation Book", + description: "Documentation navigation spine — ordered groups with derived membership (ADR-0046 §6)" + }, permission: { label: "Permission Set", sections: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 05f3d1bf2a..5fba5af574 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -272,6 +272,70 @@ export const esESMetadataForms: NonNullable = datasource: { label: "Fuente de datos", helpText: "ID de fuente de datos de destino (valor predeterminado: \"default\")" + }, + lifecycle: { + label: "Lifecycle", + helpText: "Data lifecycle contract (ADR-0057): how long rows live and how space is reclaimed. Leave empty for permanent record semantics. Non-record classes require at least one bounding policy (retention, TTL, or rotation)." + }, + "lifecycle.class": { + label: "Class", + helpText: "Persistence contract for the rows of this object" + }, + "lifecycle.retention": { + label: "Retention", + helpText: "Age-based retention window" + }, + "lifecycle.retention.maxAge": { + label: "Max Age", + helpText: "Rows older than this (by created_at) are reaped. Duration literal: h/d/w/y, e.g. \"30d\"" + }, + "lifecycle.ttl": { + label: "Ttl", + helpText: "Per-row TTL expiry" + }, + "lifecycle.ttl.field": { + label: "Field", + helpText: "Timestamp field the TTL is measured from (e.g. expires_at)" + }, + "lifecycle.ttl.expireAfter": { + label: "Expire After", + helpText: "Rows expire this long after the field, e.g. \"1d\"" + }, + "lifecycle.storage": { + label: "Storage", + helpText: "Physical rotation for high-frequency telemetry (SQLite: O(1) shard DROP)" + }, + "lifecycle.storage.strategy": { + label: "Strategy", + helpText: "Storage strategy" + }, + "lifecycle.storage.shards": { + label: "Shards", + helpText: "Shards retained; total window = shards × unit" + }, + "lifecycle.storage.unit": { + label: "Unit", + helpText: "Time width of one shard" + }, + "lifecycle.archive": { + label: "Archive", + helpText: "Cold-store hand-off (audit class). Rows are never hot-deleted before the archive copy succeeded." + }, + "lifecycle.archive.after": { + label: "After", + helpText: "Archive rows older than this — must equal retention.maxAge" + }, + "lifecycle.archive.to": { + label: "To", + helpText: "Target datasource name for cold storage" + }, + "lifecycle.archive.keep": { + label: "Keep", + helpText: "How long the archive keeps rows (empty = forever), e.g. \"7y\"" + }, + "lifecycle.reclaim": { + label: "Reclaim", + helpText: "Reclaim driver space after sweeps (default on for non-record classes)" } } }, @@ -380,10 +444,6 @@ export const esESMetadataForms: NonNullable = label: "Operaciones de resumen", helpText: "Configuración de resumen roll-up (para relaciones padre-hijo)" }, - cached: { - label: "En caché", - helpText: "Configuración de caché para campos calculados" - }, columnName: { label: "Nombre de columna", helpText: "Nombre de columna física en la base de datos (por defecto, el nombre del campo)" @@ -411,28 +471,9 @@ export const esESMetadataForms: NonNullable = sortable: { label: "Ordenable", helpText: "Permite ordenar listas por este campo" - }, - auditTrail: { - label: "Rastro de auditoría", - helpText: "Registra cambios detallados con usuario y marca temporal" - }, - trackFeedHistory: { - label: "Historial de feed", - helpText: "Muestra cambios en el feed de actividad" - }, - encryptionConfig: { - label: "Configuración de cifrado", - helpText: "Cifrado a nivel de campo (GDPR/HIPAA/PCI-DSS)" - }, - maskingRule: { - label: "Regla de enmascaramiento", - helpText: "Reglas de enmascaramiento de datos para protección de PII" } } }, - trigger: { - label: "Disparador" - }, validation: { label: "Regla de validación" }, @@ -509,12 +550,36 @@ export const esESMetadataForms: NonNullable = onError: { label: "Al error" }, + timeout: { + label: "Timeout", + helpText: "Abort the hook after N milliseconds" + }, condition: { label: "Condición", helpText: "Fórmula opcional — omite el hook cuando evalúa a false" + }, + retryPolicy: { + label: "Retry Policy", + helpText: "Retry on failure — most useful for async hooks" + }, + "retryPolicy.maxRetries": { + label: "Max Retries", + helpText: "Maximum retry attempts" + }, + "retryPolicy.backoffMs": { + label: "Backoff Ms", + helpText: "Delay between retries (ms)" } } }, + seed: { + label: "Seed Data", + description: "Fixture / initialization data applied on publish" + }, + mapping: { + label: "Import Mapping", + description: "Reusable import/export field mapping (rename + transforms), referenced by name at import" + }, view: { label: "Vista", sections: { @@ -686,6 +751,10 @@ export const esESMetadataForms: NonNullable = label: "Diseño", description: "Regiones de página y componentes colocados en ellas." }, + interface: { + label: "Interface (list pages)", + description: "Interface mode (Airtable parity): the page defines its own data surface directly — columns, filters, visualizations and toolbar — no inheriting from a separate view." + }, advanced: { label: "Avanzado", description: "Activación, audiencia y accesibilidad." @@ -728,6 +797,58 @@ export const esESMetadataForms: NonNullable = label: "Regiones", helpText: "Regiones de diseño (header, main, sidebar, footer) con componentes" }, + interfaceConfig: { + label: "Interface Config", + helpText: "The page IS the view: source picks the object, columns/filterBy are defined directly here; appearance.allowedVisualizations whitelists renderers (one entry = locked); userActions toggles the toolbar." + }, + "interfaceConfig.source": { + label: "Source", + helpText: "Object this page reads from" + }, + "interfaceConfig.columns": { + label: "Columns", + helpText: "Columns to show — defined directly on the page (blank = all object fields)" + }, + "interfaceConfig.filterBy": { + label: "Filter By", + helpText: "Always-on base filter for the page — same visual builder as the list toolbar." + }, + "interfaceConfig.levels": { + label: "Levels", + helpText: "Hierarchy levels to display (tree-like sources)" + }, + "interfaceConfig.appearance": { + label: "Appearance", + helpText: "Allowed visualizations (Grid / Kanban / Calendar / …) and description visibility" + }, + "interfaceConfig.userFilters": { + label: "User Filters", + helpText: "End-user filter bar: None (no bar) / Tabs (named presets) / Dropdown (per-field). None removes the config." + }, + "interfaceConfig.userActions": { + label: "User Actions", + helpText: "Toolbar toggles (search, sort, filter, row height)" + }, + "interfaceConfig.addRecord": { + label: "Add Record", + helpText: "Add-record entry point" + }, + "interfaceConfig.buttons": { + label: "Buttons", + helpText: "Toolbar buttons — pick from this object's actions" + }, + "interfaceConfig.recordAction": { + label: "Record Action", + helpText: "How clicking a record opens its detail" + }, + "interfaceConfig.showRecordCount": { + label: "Show Record Count", + helpText: "Show the record count bar" + }, + "interfaceConfig.allowPrinting": { + label: "Allow Printing", + helpText: "Allow users to print this page" + }, isDefault: { label: "Predeterminado", helpText: "Establece como página predeterminada para este tipo de página" @@ -976,6 +1097,22 @@ export const esESMetadataForms: NonNullable = label: "Cuerpo", helpText: "Código JavaScript que ejecutar" }, + "body.language": { + label: "Language", + helpText: "expression = pure formula; js = sandboxed JavaScript" + }, + "body.source": { + label: "Source", + helpText: "Function body source — no top-level imports" + }, + "body.capabilities": { + label: "Capabilities", + helpText: "Allowed ctx APIs (api.read, api.write, crypto.uuid, log, …)" + }, + "body.timeoutMs": { + label: "Timeout Ms", + helpText: "Per-invocation timeout (ms)" + }, params: { label: "Parámetros", helpText: "Parámetros de entrada de usuario (muestra el formulario antes de ejecutar)" @@ -1016,9 +1153,9 @@ export const esESMetadataForms: NonNullable = label: "Acción masiva", helpText: "Permite aplicar a varios registros seleccionados" }, - aiExposed: { - label: "Expuesto a IA", - helpText: "Permite que agentes de IA llamen a esta acción" + ai: { + label: "Ai", + helpText: "AI exposure (opt-in): set ai.exposed=true and write ai.description (≥40 chars) to make this callable by agents." }, recordIdParam: { label: "Parámetro de ID de registro", @@ -1041,13 +1178,9 @@ export const esESMetadataForms: NonNullable = label: "Aspectos básicos", description: "Identidad y fuente de datos." }, - columns: { - label: "Columnas", - description: "Columnas mostradas en la salida del informe." - }, - groupings: { - label: "Agrupaciones", - description: "Cómo se agrupan las filas (y columnas, para informes matrix)." + dataset_binding: { + label: "Dataset binding", + description: "The semantic-layer dataset this report renders. Values are the dataset’s measures; rows are its dimensions." }, joined_blocks: { label: "Bloques unidos", @@ -1073,33 +1206,37 @@ export const esESMetadataForms: NonNullable = description: { label: "Descripción" }, - objectName: { - label: "Nombre de objeto", - helpText: "Objeto de fuente de datos" - }, type: { label: "Tipo", helpText: "Tipo de informe: tabular/summary/matrix/joined" }, + dataset: { + label: "Dataset", + helpText: "Dataset to bind (measures/dimensions come from its semantic layer)" + }, + values: { + label: "Values", + helpText: "Measure names (from the dataset) to display" + }, + rows: { + label: "Rows", + helpText: "Dimension names (from the dataset) to group rows by" + }, columns: { label: "Columnas", helpText: "Columnas que mostrar en el informe" }, - groupingsDown: { - label: "Agrupaciones verticales", - helpText: "Niveles de agrupación de filas" - }, - groupingsAcross: { - label: "Agrupaciones horizontales", - helpText: "Niveles de agrupación de columnas (solo matrix)" + drilldown: { + label: "Drilldown", + helpText: "Click an aggregated row/cell to open the underlying records" }, blocks: { label: "Bloques", helpText: "Une varios objetos (solo informe joined)" }, - filter: { - label: "Filtro", - helpText: "Filtros a nivel de informe" + runtimeFilter: { + label: "Runtime Filter", + helpText: "Render-time scope filter, ANDed at query time" }, chart: { label: "Gráfico", @@ -1115,6 +1252,62 @@ export const esESMetadataForms: NonNullable = } } }, + dataset: { + label: "Dataset", + description: "Analytics semantic layer — dimensions & measures", + sections: { + basics: { + label: "Basics", + description: "Dataset identity." + }, + source: { + label: "Source", + description: "The base object, the relationships to join, and the dataset’s intrinsic scope. Joins are derived from the object graph — pick relationship (lookup / master_detail) names, never write an ON clause." + }, + dimensions: { + label: "Dimensions", + description: "Groupable axes. Use a base field, or `relationship.field` (e.g. account.region) for a relationship included above." + }, + measures: { + label: "Measures", + description: "Aggregatable values defined once and referenced by name. A measure is sum/avg/count/… of a field; a derived measure combines other measures (ratio/sum/difference/product). Measure-scoped filters and derived ops are edited per-row in the dataset designer." + } + }, + fields: { + name: { + label: "Name", + helpText: "snake_case unique identifier" + }, + label: { + label: "Label", + helpText: "Display name" + }, + description: { + label: "Description", + helpText: "What this dataset measures" + }, + object: { + label: "Object", + helpText: "Base object — the FROM" + }, + include: { + label: "Include", + helpText: "Relationship (lookup / master_detail) field names to join — enables `relationship.field` dimensions/measures (e.g. include \"account\" → group by account.region)" + }, + filter: { + label: "Filter", + helpText: "Intrinsic scope filter (e.g. exclude soft-deleted records), ANDed into every query" + }, + dimensions: { + label: "Dimensions", + helpText: "Each: name (referenced by presentations), field, type, and — for dates — a default bucketing granularity" + }, + measures: { + label: "Measures", + helpText: "Each: name, aggregate, field (optional for count), display format/currency, and a “certified” governance flag" + } + } + }, flow: { label: "Flujo", sections: { @@ -1194,15 +1387,6 @@ export const esESMetadataForms: NonNullable = translation: { label: "Traducción" }, - router: { - label: "Enrutador" - }, - function: { - label: "Función" - }, - service: { - label: "Servicio" - }, email_template: { label: "Plantilla de email", sections: { @@ -1283,6 +1467,14 @@ export const esESMetadataForms: NonNullable = } } }, + doc: { + label: "Documentation", + description: "Package documentation — flat Markdown items (ADR-0046)" + }, + book: { + label: "Documentation Book", + description: "Documentation navigation spine — ordered groups with derived membership (ADR-0046 §6)" + }, permission: { label: "Conjunto de permisos", sections: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index baa0a40f53..15e7c5a399 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -272,6 +272,70 @@ export const jaJPMetadataForms: NonNullable = datasource: { label: "データソース", helpText: "対象データソース ID(既定: \"default\")" + }, + lifecycle: { + label: "Lifecycle", + helpText: "Data lifecycle contract (ADR-0057): how long rows live and how space is reclaimed. Leave empty for permanent record semantics. Non-record classes require at least one bounding policy (retention, TTL, or rotation)." + }, + "lifecycle.class": { + label: "Class", + helpText: "Persistence contract for the rows of this object" + }, + "lifecycle.retention": { + label: "Retention", + helpText: "Age-based retention window" + }, + "lifecycle.retention.maxAge": { + label: "Max Age", + helpText: "Rows older than this (by created_at) are reaped. Duration literal: h/d/w/y, e.g. \"30d\"" + }, + "lifecycle.ttl": { + label: "Ttl", + helpText: "Per-row TTL expiry" + }, + "lifecycle.ttl.field": { + label: "Field", + helpText: "Timestamp field the TTL is measured from (e.g. expires_at)" + }, + "lifecycle.ttl.expireAfter": { + label: "Expire After", + helpText: "Rows expire this long after the field, e.g. \"1d\"" + }, + "lifecycle.storage": { + label: "Storage", + helpText: "Physical rotation for high-frequency telemetry (SQLite: O(1) shard DROP)" + }, + "lifecycle.storage.strategy": { + label: "Strategy", + helpText: "Storage strategy" + }, + "lifecycle.storage.shards": { + label: "Shards", + helpText: "Shards retained; total window = shards × unit" + }, + "lifecycle.storage.unit": { + label: "Unit", + helpText: "Time width of one shard" + }, + "lifecycle.archive": { + label: "Archive", + helpText: "Cold-store hand-off (audit class). Rows are never hot-deleted before the archive copy succeeded." + }, + "lifecycle.archive.after": { + label: "After", + helpText: "Archive rows older than this — must equal retention.maxAge" + }, + "lifecycle.archive.to": { + label: "To", + helpText: "Target datasource name for cold storage" + }, + "lifecycle.archive.keep": { + label: "Keep", + helpText: "How long the archive keeps rows (empty = forever), e.g. \"7y\"" + }, + "lifecycle.reclaim": { + label: "Reclaim", + helpText: "Reclaim driver space after sweeps (default on for non-record classes)" } } }, @@ -380,10 +444,6 @@ export const jaJPMetadataForms: NonNullable = label: "集計操作", helpText: "ロールアップ集計設定(親子関係用)" }, - cached: { - label: "キャッシュ", - helpText: "計算フィールドのキャッシュ設定" - }, columnName: { label: "列名", helpText: "データベース上の物理列名(既定はフィールド名)" @@ -411,28 +471,9 @@ export const jaJPMetadataForms: NonNullable = sortable: { label: "並び替え可能", helpText: "このフィールドでリストの並べ替えを許可" - }, - auditTrail: { - label: "監査証跡", - helpText: "ユーザーとタイムスタンプ付きで詳細変更を追跡" - }, - trackFeedHistory: { - label: "フィード履歴追跡", - helpText: "アクティビティフィードに変更を表示" - }, - encryptionConfig: { - label: "暗号化設定", - helpText: "フィールドレベル暗号化(GDPR/HIPAA/PCI-DSS)" - }, - maskingRule: { - label: "マスキングルール", - helpText: "PII 保護用データマスキングルール" } } }, - trigger: { - label: "トリガー" - }, validation: { label: "検証ルール" }, @@ -509,12 +550,36 @@ export const jaJPMetadataForms: NonNullable = onError: { label: "エラー時" }, + timeout: { + label: "Timeout", + helpText: "Abort the hook after N milliseconds" + }, condition: { label: "条件", helpText: "任意の数式 — false 評価時はフックをスキップ" + }, + retryPolicy: { + label: "Retry Policy", + helpText: "Retry on failure — most useful for async hooks" + }, + "retryPolicy.maxRetries": { + label: "Max Retries", + helpText: "Maximum retry attempts" + }, + "retryPolicy.backoffMs": { + label: "Backoff Ms", + helpText: "Delay between retries (ms)" } } }, + seed: { + label: "Seed Data", + description: "Fixture / initialization data applied on publish" + }, + mapping: { + label: "Import Mapping", + description: "Reusable import/export field mapping (rename + transforms), referenced by name at import" + }, view: { label: "ビュー", sections: { @@ -686,6 +751,10 @@ export const jaJPMetadataForms: NonNullable = label: "レイアウト", description: "ページ領域とそこに配置するコンポーネント。" }, + interface: { + label: "Interface (list pages)", + description: "Interface mode (Airtable parity): the page defines its own data surface directly — columns, filters, visualizations and toolbar — no inheriting from a separate view." + }, advanced: { label: "詳細", description: "有効化、対象ユーザー、アクセシビリティ。" @@ -728,6 +797,58 @@ export const jaJPMetadataForms: NonNullable = label: "リージョン", helpText: "コンポーネントを含むレイアウト領域(header, main, sidebar, footer)" }, + interfaceConfig: { + label: "Interface Config", + helpText: "The page IS the view: source picks the object, columns/filterBy are defined directly here; appearance.allowedVisualizations whitelists renderers (one entry = locked); userActions toggles the toolbar." + }, + "interfaceConfig.source": { + label: "Source", + helpText: "Object this page reads from" + }, + "interfaceConfig.columns": { + label: "Columns", + helpText: "Columns to show — defined directly on the page (blank = all object fields)" + }, + "interfaceConfig.filterBy": { + label: "Filter By", + helpText: "Always-on base filter for the page — same visual builder as the list toolbar." + }, + "interfaceConfig.levels": { + label: "Levels", + helpText: "Hierarchy levels to display (tree-like sources)" + }, + "interfaceConfig.appearance": { + label: "Appearance", + helpText: "Allowed visualizations (Grid / Kanban / Calendar / …) and description visibility" + }, + "interfaceConfig.userFilters": { + label: "User Filters", + helpText: "End-user filter bar: None (no bar) / Tabs (named presets) / Dropdown (per-field). None removes the config." + }, + "interfaceConfig.userActions": { + label: "User Actions", + helpText: "Toolbar toggles (search, sort, filter, row height)" + }, + "interfaceConfig.addRecord": { + label: "Add Record", + helpText: "Add-record entry point" + }, + "interfaceConfig.buttons": { + label: "Buttons", + helpText: "Toolbar buttons — pick from this object's actions" + }, + "interfaceConfig.recordAction": { + label: "Record Action", + helpText: "How clicking a record opens its detail" + }, + "interfaceConfig.showRecordCount": { + label: "Show Record Count", + helpText: "Show the record count bar" + }, + "interfaceConfig.allowPrinting": { + label: "Allow Printing", + helpText: "Allow users to print this page" + }, isDefault: { label: "既定", helpText: "このページ種別の既定ページに設定" @@ -976,6 +1097,22 @@ export const jaJPMetadataForms: NonNullable = label: "本文", helpText: "実行する JavaScript コード" }, + "body.language": { + label: "Language", + helpText: "expression = pure formula; js = sandboxed JavaScript" + }, + "body.source": { + label: "Source", + helpText: "Function body source — no top-level imports" + }, + "body.capabilities": { + label: "Capabilities", + helpText: "Allowed ctx APIs (api.read, api.write, crypto.uuid, log, …)" + }, + "body.timeoutMs": { + label: "Timeout Ms", + helpText: "Per-invocation timeout (ms)" + }, params: { label: "パラメーター", helpText: "ユーザー入力パラメーター(実行前にフォームを表示)" @@ -1016,9 +1153,9 @@ export const jaJPMetadataForms: NonNullable = label: "一括操作有効", helpText: "選択した複数レコードへの適用を許可" }, - aiExposed: { - label: "AI に公開", - helpText: "AI エージェントによるこのアクションの呼び出しを許可" + ai: { + label: "Ai", + helpText: "AI exposure (opt-in): set ai.exposed=true and write ai.description (≥40 chars) to make this callable by agents." }, recordIdParam: { label: "レコード ID パラメーター", @@ -1041,13 +1178,9 @@ export const jaJPMetadataForms: NonNullable = label: "基本", description: "ID とデータソース。" }, - columns: { - label: "列", - description: "レポート出力に表示する列。" - }, - groupings: { - label: "グループ化", - description: "行(matrix レポートでは列も)のグループ化方法。" + dataset_binding: { + label: "Dataset binding", + description: "The semantic-layer dataset this report renders. Values are the dataset’s measures; rows are its dimensions." }, joined_blocks: { label: "結合ブロック", @@ -1073,33 +1206,37 @@ export const jaJPMetadataForms: NonNullable = description: { label: "説明" }, - objectName: { - label: "オブジェクト名", - helpText: "データソースオブジェクト" - }, type: { label: "型", helpText: "レポート種別: tabular/summary/matrix/joined" }, + dataset: { + label: "Dataset", + helpText: "Dataset to bind (measures/dimensions come from its semantic layer)" + }, + values: { + label: "Values", + helpText: "Measure names (from the dataset) to display" + }, + rows: { + label: "Rows", + helpText: "Dimension names (from the dataset) to group rows by" + }, columns: { label: "列", helpText: "レポートに表示する列" }, - groupingsDown: { - label: "縦グループ", - helpText: "行グループ化レベル" - }, - groupingsAcross: { - label: "横グループ", - helpText: "列グループ化レベル(matrix のみ)" + drilldown: { + label: "Drilldown", + helpText: "Click an aggregated row/cell to open the underlying records" }, blocks: { label: "ブロック", helpText: "複数オブジェクトを結合(joined レポートのみ)" }, - filter: { - label: "フィルター", - helpText: "レポートレベルのフィルター" + runtimeFilter: { + label: "Runtime Filter", + helpText: "Render-time scope filter, ANDed at query time" }, chart: { label: "チャート", @@ -1115,6 +1252,62 @@ export const jaJPMetadataForms: NonNullable = } } }, + dataset: { + label: "Dataset", + description: "Analytics semantic layer — dimensions & measures", + sections: { + basics: { + label: "Basics", + description: "Dataset identity." + }, + source: { + label: "Source", + description: "The base object, the relationships to join, and the dataset’s intrinsic scope. Joins are derived from the object graph — pick relationship (lookup / master_detail) names, never write an ON clause." + }, + dimensions: { + label: "Dimensions", + description: "Groupable axes. Use a base field, or `relationship.field` (e.g. account.region) for a relationship included above." + }, + measures: { + label: "Measures", + description: "Aggregatable values defined once and referenced by name. A measure is sum/avg/count/… of a field; a derived measure combines other measures (ratio/sum/difference/product). Measure-scoped filters and derived ops are edited per-row in the dataset designer." + } + }, + fields: { + name: { + label: "Name", + helpText: "snake_case unique identifier" + }, + label: { + label: "Label", + helpText: "Display name" + }, + description: { + label: "Description", + helpText: "What this dataset measures" + }, + object: { + label: "Object", + helpText: "Base object — the FROM" + }, + include: { + label: "Include", + helpText: "Relationship (lookup / master_detail) field names to join — enables `relationship.field` dimensions/measures (e.g. include \"account\" → group by account.region)" + }, + filter: { + label: "Filter", + helpText: "Intrinsic scope filter (e.g. exclude soft-deleted records), ANDed into every query" + }, + dimensions: { + label: "Dimensions", + helpText: "Each: name (referenced by presentations), field, type, and — for dates — a default bucketing granularity" + }, + measures: { + label: "Measures", + helpText: "Each: name, aggregate, field (optional for count), display format/currency, and a “certified” governance flag" + } + } + }, flow: { label: "フロー", sections: { @@ -1194,15 +1387,6 @@ export const jaJPMetadataForms: NonNullable = translation: { label: "翻訳" }, - router: { - label: "ルーター" - }, - function: { - label: "関数" - }, - service: { - label: "サービス" - }, email_template: { label: "メールテンプレート", sections: { @@ -1283,6 +1467,14 @@ export const jaJPMetadataForms: NonNullable = } } }, + doc: { + label: "Documentation", + description: "Package documentation — flat Markdown items (ADR-0046)" + }, + book: { + label: "Documentation Book", + description: "Documentation navigation spine — ordered groups with derived membership (ADR-0046 §6)" + }, permission: { label: "権限セット", sections: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index f815af5e65..260a791ae0 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -272,6 +272,70 @@ export const zhCNMetadataForms: NonNullable = datasource: { label: "数据源", helpText: "目标数据源 ID(默认:\"default\")" + }, + lifecycle: { + label: "数据生命周期", + helpText: "数据生命周期契约(ADR-0057):数据保留多久、空间如何回收。留空即永久保留(record 语义)。非 record 类必须声明至少一个界定策略(保留期、TTL 或轮转)。" + }, + "lifecycle.class": { + label: "生命周期类别", + helpText: "本对象数据的持久化契约" + }, + "lifecycle.retention": { + label: "保留期", + helpText: "按数据年龄界定的保留窗口" + }, + "lifecycle.retention.maxAge": { + label: "最长保留", + helpText: "早于该窗口(按 created_at)的行将被清理。时长字面量:h/d/w/y,如 \"30d\"" + }, + "lifecycle.ttl": { + label: "TTL 过期", + helpText: "按行的 TTL 自动过期" + }, + "lifecycle.ttl.field": { + label: "时间字段", + helpText: "TTL 计时起点的时间戳字段(如 expires_at)" + }, + "lifecycle.ttl.expireAfter": { + label: "过期时长", + helpText: "行在该字段之后这么久过期,如 \"1d\"" + }, + "lifecycle.storage": { + label: "存储策略", + helpText: "高频遥测的物理轮转(SQLite:O(1) 分片 DROP)" + }, + "lifecycle.storage.strategy": { + label: "策略", + helpText: "存储策略" + }, + "lifecycle.storage.shards": { + label: "分片数", + helpText: "保留的分片数量;总窗口 = 分片数 × 单位" + }, + "lifecycle.storage.unit": { + label: "分片单位", + helpText: "单个分片的时间宽度" + }, + "lifecycle.archive": { + label: "归档", + helpText: "冷存交接(audit 类)。归档拷贝成功前,行绝不会被热删除。" + }, + "lifecycle.archive.after": { + label: "归档时点", + helpText: "早于该窗口的行进入归档 — 必须等于 retention.maxAge" + }, + "lifecycle.archive.to": { + label: "归档数据源", + helpText: "冷存储的目标数据源名称" + }, + "lifecycle.archive.keep": { + label: "归档保留", + helpText: "归档中保留多久(留空 = 永久),如 \"7y\"" + }, + "lifecycle.reclaim": { + label: "空间回收", + helpText: "清理后回收驱动层空间(非 record 类默认开启)" } } }, @@ -380,10 +444,6 @@ export const zhCNMetadataForms: NonNullable = label: "汇总操作", helpText: "父子关系下的汇总聚合配置" }, - cached: { - label: "缓存", - helpText: "计算字段的缓存配置" - }, columnName: { label: "列名", helpText: "数据库中的物理列名(默认与字段名相同)" @@ -411,28 +471,9 @@ export const zhCNMetadataForms: NonNullable = sortable: { label: "可排序", helpText: "允许按此字段排序" - }, - auditTrail: { - label: "审计跟踪", - helpText: "记录详细变更与操作人、时间戳" - }, - trackFeedHistory: { - label: "动态历史跟踪", - helpText: "在活动动态中展示变更" - }, - encryptionConfig: { - label: "加密配置", - helpText: "字段级加密(GDPR / HIPAA / PCI-DSS)" - }, - maskingRule: { - label: "掩码规则", - helpText: "PII 数据脱敏规则" } } }, - trigger: { - label: "触发器" - }, validation: { label: "验证规则" }, @@ -509,12 +550,36 @@ export const zhCNMetadataForms: NonNullable = onError: { label: "错误处理" }, + timeout: { + label: "Timeout", + helpText: "Abort the hook after N milliseconds" + }, condition: { label: "条件", helpText: "可选公式——求值为 false 时跳过该钩子" + }, + retryPolicy: { + label: "Retry Policy", + helpText: "Retry on failure — most useful for async hooks" + }, + "retryPolicy.maxRetries": { + label: "Max Retries", + helpText: "Maximum retry attempts" + }, + "retryPolicy.backoffMs": { + label: "Backoff Ms", + helpText: "Delay between retries (ms)" } } }, + seed: { + label: "Seed Data", + description: "Fixture / initialization data applied on publish" + }, + mapping: { + label: "Import Mapping", + description: "Reusable import/export field mapping (rename + transforms), referenced by name at import" + }, view: { label: "视图", sections: { @@ -686,6 +751,10 @@ export const zhCNMetadataForms: NonNullable = label: "布局", description: "页面区块与组件" }, + interface: { + label: "Interface (list pages)", + description: "Interface mode (Airtable parity): the page defines its own data surface directly — columns, filters, visualizations and toolbar — no inheriting from a separate view." + }, advanced: { label: "高级设置", description: "默认页、类型与分配" @@ -728,6 +797,58 @@ export const zhCNMetadataForms: NonNullable = label: "区域", helpText: "布局区域(header、main、sidebar、footer)及其组件" }, + interfaceConfig: { + label: "Interface Config", + helpText: "The page IS the view: source picks the object, columns/filterBy are defined directly here; appearance.allowedVisualizations whitelists renderers (one entry = locked); userActions toggles the toolbar." + }, + "interfaceConfig.source": { + label: "Source", + helpText: "Object this page reads from" + }, + "interfaceConfig.columns": { + label: "Columns", + helpText: "Columns to show — defined directly on the page (blank = all object fields)" + }, + "interfaceConfig.filterBy": { + label: "Filter By", + helpText: "Always-on base filter for the page — same visual builder as the list toolbar." + }, + "interfaceConfig.levels": { + label: "Levels", + helpText: "Hierarchy levels to display (tree-like sources)" + }, + "interfaceConfig.appearance": { + label: "Appearance", + helpText: "Allowed visualizations (Grid / Kanban / Calendar / …) and description visibility" + }, + "interfaceConfig.userFilters": { + label: "User Filters", + helpText: "End-user filter bar: None (no bar) / Tabs (named presets) / Dropdown (per-field). None removes the config." + }, + "interfaceConfig.userActions": { + label: "User Actions", + helpText: "Toolbar toggles (search, sort, filter, row height)" + }, + "interfaceConfig.addRecord": { + label: "Add Record", + helpText: "Add-record entry point" + }, + "interfaceConfig.buttons": { + label: "Buttons", + helpText: "Toolbar buttons — pick from this object's actions" + }, + "interfaceConfig.recordAction": { + label: "Record Action", + helpText: "How clicking a record opens its detail" + }, + "interfaceConfig.showRecordCount": { + label: "Show Record Count", + helpText: "Show the record count bar" + }, + "interfaceConfig.allowPrinting": { + label: "Allow Printing", + helpText: "Allow users to print this page" + }, isDefault: { label: "默认", helpText: "设为该页面类型的默认页" @@ -976,6 +1097,22 @@ export const zhCNMetadataForms: NonNullable = label: "正文", helpText: "要执行的 JavaScript 代码" }, + "body.language": { + label: "Language", + helpText: "expression = pure formula; js = sandboxed JavaScript" + }, + "body.source": { + label: "Source", + helpText: "Function body source — no top-level imports" + }, + "body.capabilities": { + label: "Capabilities", + helpText: "Allowed ctx APIs (api.read, api.write, crypto.uuid, log, …)" + }, + "body.timeoutMs": { + label: "Timeout Ms", + helpText: "Per-invocation timeout (ms)" + }, params: { label: "参数", helpText: "执行前向用户收集的输入参数" @@ -1016,9 +1153,9 @@ export const zhCNMetadataForms: NonNullable = label: "批量启用", helpText: "允许对多条选中记录执行" }, - aiExposed: { - label: "暴露给 AI", - helpText: "允许 AI 智能体调用此操作" + ai: { + label: "Ai", + helpText: "AI exposure (opt-in): set ai.exposed=true and write ai.description (≥40 chars) to make this callable by agents." }, recordIdParam: { label: "记录 ID 参数", @@ -1041,13 +1178,9 @@ export const zhCNMetadataForms: NonNullable = label: "基础信息", description: "名称与数据源" }, - columns: { - label: "列", - description: "选择要展示的列" - }, - groupings: { - label: "分组与汇总", - description: "行列分组维度" + dataset_binding: { + label: "Dataset binding", + description: "The semantic-layer dataset this report renders. Values are the dataset’s measures; rows are its dimensions." }, joined_blocks: { label: "关联对象", @@ -1073,33 +1206,37 @@ export const zhCNMetadataForms: NonNullable = description: { label: "描述" }, - objectName: { - label: "对象名称", - helpText: "报表数据源对象" - }, type: { label: "类型", helpText: "报表类型:tabular / summary / matrix / joined" }, + dataset: { + label: "Dataset", + helpText: "Dataset to bind (measures/dimensions come from its semantic layer)" + }, + values: { + label: "Values", + helpText: "Measure names (from the dataset) to display" + }, + rows: { + label: "Rows", + helpText: "Dimension names (from the dataset) to group rows by" + }, columns: { label: "列", helpText: "报表中显示的列" }, - groupingsDown: { - label: "行分组", - helpText: "行方向分组层级" - }, - groupingsAcross: { - label: "列分组", - helpText: "列方向分组层级(matrix 报表)" + drilldown: { + label: "Drilldown", + helpText: "Click an aggregated row/cell to open the underlying records" }, blocks: { label: "分块", helpText: "joined 报表的联合查询块" }, - filter: { - label: "筛选", - helpText: "报表级别的筛选规则" + runtimeFilter: { + label: "Runtime Filter", + helpText: "Render-time scope filter, ANDed at query time" }, chart: { label: "图表", @@ -1115,6 +1252,62 @@ export const zhCNMetadataForms: NonNullable = } } }, + dataset: { + label: "Dataset", + description: "Analytics semantic layer — dimensions & measures", + sections: { + basics: { + label: "Basics", + description: "Dataset identity." + }, + source: { + label: "Source", + description: "The base object, the relationships to join, and the dataset’s intrinsic scope. Joins are derived from the object graph — pick relationship (lookup / master_detail) names, never write an ON clause." + }, + dimensions: { + label: "Dimensions", + description: "Groupable axes. Use a base field, or `relationship.field` (e.g. account.region) for a relationship included above." + }, + measures: { + label: "Measures", + description: "Aggregatable values defined once and referenced by name. A measure is sum/avg/count/… of a field; a derived measure combines other measures (ratio/sum/difference/product). Measure-scoped filters and derived ops are edited per-row in the dataset designer." + } + }, + fields: { + name: { + label: "Name", + helpText: "snake_case unique identifier" + }, + label: { + label: "Label", + helpText: "Display name" + }, + description: { + label: "Description", + helpText: "What this dataset measures" + }, + object: { + label: "Object", + helpText: "Base object — the FROM" + }, + include: { + label: "Include", + helpText: "Relationship (lookup / master_detail) field names to join — enables `relationship.field` dimensions/measures (e.g. include \"account\" → group by account.region)" + }, + filter: { + label: "Filter", + helpText: "Intrinsic scope filter (e.g. exclude soft-deleted records), ANDed into every query" + }, + dimensions: { + label: "Dimensions", + helpText: "Each: name (referenced by presentations), field, type, and — for dates — a default bucketing granularity" + }, + measures: { + label: "Measures", + helpText: "Each: name, aggregate, field (optional for count), display format/currency, and a “certified” governance flag" + } + } + }, flow: { label: "流程", sections: { @@ -1194,15 +1387,6 @@ export const zhCNMetadataForms: NonNullable = translation: { label: "翻译" }, - router: { - label: "路由器" - }, - function: { - label: "函数" - }, - service: { - label: "服务" - }, email_template: { label: "邮件模板", sections: { @@ -1283,6 +1467,14 @@ export const zhCNMetadataForms: NonNullable = } } }, + doc: { + label: "Documentation", + description: "Package documentation — flat Markdown items (ADR-0046)" + }, + book: { + label: "Documentation Book", + description: "Documentation navigation spine — ordered groups with derived membership (ADR-0046 §6)" + }, permission: { label: "权限集 / 配置文件", sections: { diff --git a/packages/platform-objects/src/audit/sys-job-run.object.ts b/packages/platform-objects/src/audit/sys-job-run.object.ts index 2239ff3845..17f1e72621 100644 --- a/packages/platform-objects/src/audit/sys-job-run.object.ts +++ b/packages/platform-objects/src/audit/sys-job-run.object.ts @@ -22,8 +22,9 @@ export const SysJobRun = ObjectSchema.create({ icon: 'play', isSystem: true, managedBy: 'append-only', - // ADR-0057: mirrors the JobRunRetention default (30d) so the platform - // LifecycleService and the plugin sweeper enforce one window. + // ADR-0057: run history is append-only telemetry. The platform + // LifecycleService is the ONE sweeper for this window (the plugin-local + // JobRunRetention it replaced kept the same 30d default). lifecycle: { class: 'telemetry', retention: { maxAge: '30d' }, diff --git a/packages/platform-objects/src/audit/sys-notification.object.ts b/packages/platform-objects/src/audit/sys-notification.object.ts index 8ae8a7aab0..667e7934ac 100644 --- a/packages/platform-objects/src/audit/sys-notification.object.ts +++ b/packages/platform-objects/src/audit/sys-notification.object.ts @@ -33,7 +33,8 @@ export const SysNotification = ObjectSchema.create({ isSystem: true, managedBy: 'system', // ADR-0057: one 90d window across the whole notification pipeline - // (event → delivery → receipt/inbox), matching NotificationRetention. + // (event → delivery → receipt/inbox), enforced by the LifecycleService + // (the retired NotificationRetention sweeper kept the same default). lifecycle: { class: 'telemetry', retention: { maxAge: '90d' }, diff --git a/packages/plugins/driver-sql/src/sql-driver-retention-prune.test.ts b/packages/plugins/driver-sql/src/sql-driver-retention-prune.test.ts index 264bc28114..0c43eae8d0 100644 --- a/packages/plugins/driver-sql/src/sql-driver-retention-prune.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-retention-prune.test.ts @@ -1,8 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Regression for the messaging / job retention sweeps on Postgres - * (service-messaging `NotificationRetention`, service-job `JobRunRetention`): + * Regression for age-based retention sweeps on Postgres (today the ADR-0057 + * LifecycleService Reaper; originally the retired NotificationRetention / + * JobRunRetention plugin sweepers, which hit this exact bug): * * The builtin `created_at` audit column is provisioned as a native `TIMESTAMP` * (`table.timestamp`), so a retention prune MUST filter it with an ISO-8601 diff --git a/packages/services/service-automation/src/suspended-run-store.ts b/packages/services/service-automation/src/suspended-run-store.ts index 52ccaa6993..498e1dd359 100644 --- a/packages/services/service-automation/src/suspended-run-store.ts +++ b/packages/services/service-automation/src/suspended-run-store.ts @@ -322,8 +322,8 @@ export class ObjectStoreSuspendedRunStore implements SuspendedRunStore { let total: number | undefined = 0; for (const status of TERMINAL_STATUSES) { // ISO-8601 comparand: `created_at` is a native timestamp column, which - // rejects a bare epoch-ms number on Postgres (see service-messaging's - // NotificationRetention for the prior art this mirrors). + // rejects a bare epoch-ms number on Postgres (same convention as the + // ADR-0057 LifecycleService Reaper). const res = await this.engine.delete(TABLE, { where: { status, created_at: { $lt: cutoffIso } }, multi: true, diff --git a/packages/services/service-automation/src/sys-automation-run.object.ts b/packages/services/service-automation/src/sys-automation-run.object.ts index ec02c0a77b..36a1a93673 100644 --- a/packages/services/service-automation/src/sys-automation-run.object.ts +++ b/packages/services/service-automation/src/sys-automation-run.object.ts @@ -40,13 +40,15 @@ export const SysAutomationRun = ObjectSchema.create({ icon: 'pause-circle', isSystem: true, managedBy: 'system', - // ADR-0057 (#2786 "why now"): terminal run history is append-only - // telemetry — bounded like sys_job_run. Suspended runs are recent by - // definition (SLA-bounded), so a 30d age reap cannot strand a live run. - lifecycle: { - class: 'telemetry', - retention: { maxAge: '30d' }, - }, + // ADR-0057: deliberately NO `lifecycle` block. This is a MIXED table — live + // suspended runs (resumable workflow state, record semantics: an approval + // may legitimately stay paused for months) interleaved with terminal run + // history (telemetry semantics). A blanket age-based retention would reap + // suspended runs and strand in-flight approvals; the declarative contract + // has no status predicate yet. Bounding is owned by the automation store's + // specialized default-on sweep instead (ObjectStoreSuspendedRunStore + // .pruneHistory, #2585): TERMINAL statuses only, by age + per-flow cap — + // suspended rows are never age-pruned. description: 'Durable automation run state: live suspended runs (resumable, ADR-0019) and terminal run history (completed / failed, for observability).', displayNameField: 'id', nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) diff --git a/packages/services/service-job/src/index.ts b/packages/services/service-job/src/index.ts index 0d639ea361..ff5827c894 100644 --- a/packages/services/service-job/src/index.ts +++ b/packages/services/service-job/src/index.ts @@ -8,12 +8,5 @@ export { CronJobAdapter } from './cron-job-adapter.js'; export type { CronJobAdapterOptions } from './cron-job-adapter.js'; export { DbJobAdapter } from './db-job-adapter.js'; export type { DbJobAdapterOptions, JobEngineLike, JobLoggerLike } from './db-job-adapter.js'; -export { - JobRunRetention, - DEFAULT_JOB_RUN_RETENTION_DAYS, - DEFAULT_JOB_RUN_SWEEP_MS, -} from './job-run-retention.js'; -export type { - JobRunRetentionOptions, - JobRunPruneOutcome, -} from './job-run-retention.js'; +// JobRunRetention was retired (ADR-0057): sys_job_run declares a `lifecycle` +// window and the platform LifecycleService is the one sweeper. diff --git a/packages/services/service-job/src/job-run-retention.test.ts b/packages/services/service-job/src/job-run-retention.test.ts deleted file mode 100644 index b0e66cf505..0000000000 --- a/packages/services/service-job/src/job-run-retention.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { JobRunRetention, DEFAULT_JOB_RUN_RETENTION_DAYS } from './job-run-retention'; - -const DAY = 86_400_000; - -/** Minimal engine supporting the `delete … where created_at < cutoff` shape. */ -function makeFakeEngine() { - const rows: Array<{ id: string; created_at: string }> = []; - let deleteCalls = 0; - return { - rows, - get deleteCalls() { - return deleteCalls; - }, - seed(...createdAts: string[]) { - createdAts.forEach((created_at, i) => rows.push({ id: `run_${i}`, created_at })); - }, - async find() { - return [...rows]; - }, - async insert() { - return {}; - }, - async update() { - return {}; - }, - async delete(_table: string, opts: any) { - deleteCalls++; - const cutoff = opts?.where?.created_at?.$lt as string | undefined; - if (cutoff === undefined) return 0; - let deleted = 0; - for (let i = rows.length - 1; i >= 0; i--) { - if (rows[i].created_at < cutoff) { - rows.splice(i, 1); - deleted++; - } - } - return deleted; - }, - }; -} - -const silentLogger = { info() {}, warn() {} }; - -describe('JobRunRetention', () => { - it('prunes only rows older than the retention window', async () => { - const engine = makeFakeEngine(); - const nowMs = Date.parse('2026-06-14T00:00:00.000Z'); - // 3 old (>30d), 2 recent (<30d) - engine.seed( - new Date(nowMs - 100 * DAY).toISOString(), - new Date(nowMs - 60 * DAY).toISOString(), - new Date(nowMs - 31 * DAY).toISOString(), - new Date(nowMs - 10 * DAY).toISOString(), - new Date(nowMs - 1 * DAY).toISOString(), - ); - - const retention = new JobRunRetention({ - getEngine: () => engine as any, - logger: silentLogger, - now: () => nowMs, - }); - - const outcome = await retention.prune(30); - expect(outcome.deleted).toBe(3); - expect(outcome.error).toBeUndefined(); - expect(engine.rows).toHaveLength(2); - }); - - it('is a no-op when retentionDays is not positive', async () => { - const engine = makeFakeEngine(); - engine.seed(new Date(0).toISOString()); - const retention = new JobRunRetention({ getEngine: () => engine as any, logger: silentLogger }); - - const outcome = await retention.prune(0); - expect(outcome.deleted).toBe(0); - expect(engine.deleteCalls).toBe(0); - expect(engine.rows).toHaveLength(1); - }); - - it('is a no-op when no data engine is available', async () => { - const retention = new JobRunRetention({ getEngine: () => undefined, logger: silentLogger }); - const outcome = await retention.prune(30); - expect(outcome.deleted).toBe(0); - }); - - it('reports an error (without throwing) when the engine delete fails', async () => { - const retention = new JobRunRetention({ - getEngine: () => - ({ - delete() { - throw new Error('db down'); - }, - }) as any, - logger: silentLogger, - }); - const outcome = await retention.prune(30); - expect(outcome.error).toMatch(/db down/); - expect(outcome.deleted).toBeUndefined(); - }); - - it('exposes a sane positive default retention window', () => { - expect(DEFAULT_JOB_RUN_RETENTION_DAYS).toBeGreaterThan(0); - expect(Number.isFinite(DEFAULT_JOB_RUN_RETENTION_DAYS)).toBe(true); - }); -}); diff --git a/packages/services/service-job/src/job-run-retention.ts b/packages/services/service-job/src/job-run-retention.ts deleted file mode 100644 index 5b7cef091d..0000000000 --- a/packages/services/service-job/src/job-run-retention.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { JobEngineLike, JobLoggerLike } from './db-job-adapter.js'; - -const RUN_TABLE = 'sys_job_run'; -const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; - -/** - * Default retention window for `sys_job_run` rows, in days. Every job execution - * appends a run row (see {@link DbJobAdapter}); without pruning the table grows - * unbounded on a long-running deployment (launch-readiness.md P1-2). 30 days - * keeps recent history for operational triage while bounding growth. Operators - * raise/lower it via `JobServicePlugin` options; `0` disables retention. - */ -export const DEFAULT_JOB_RUN_RETENTION_DAYS = 30; - -/** - * Default interval between retention sweeps. Job-run volume is far lower than the - * notification pipeline's, so a 6-hour cadence is ample — the sweep is a single - * bulk `delete … where created_at < cutoff`. - */ -export const DEFAULT_JOB_RUN_SWEEP_MS = 6 * 3_600_000; - -export interface JobRunRetentionOptions { - /** Resolve the data engine; `undefined` ⇒ prune is a no-op. */ - getEngine(): JobEngineLike | undefined; - logger: JobLoggerLike; - /** Override the swept object (tests). Defaults to `sys_job_run`. */ - object?: string; - /** Timestamp field used for the cutoff (ISO-8601). Defaults to `created_at`. */ - tsField?: string; - /** Clock injection for deterministic tests. Defaults to `Date.now()`. */ - now?(): number; -} - -export interface JobRunPruneOutcome { - object: string; - /** `undefined` when the driver doesn't report a count. */ - deleted?: number; - error?: string; -} - -/** - * Retention sweeper for `sys_job_run` (launch-readiness.md P1-2). - * - * Mirrors the proven `NotificationRetention` shape in `service-messaging`: - * a single bulk delete of rows older than a cutoff, under a system context - * (retention is a cross-tenant operator policy). Isolated from job execution — - * a sweep failure is logged and never throws into the scheduler. - * - * Unlike the messaging sweeper, this one is **default-on** in the plugin: an - * append-only run log with no ceiling is a guaranteed slow leak, so GA ships - * with a sensible window rather than requiring opt-in. - */ -export class JobRunRetention { - private readonly now: () => number; - private readonly object: string; - private readonly tsField: string; - - constructor(private readonly opts: JobRunRetentionOptions) { - this.now = opts.now ?? (() => Date.now()); - this.object = opts.object ?? RUN_TABLE; - this.tsField = opts.tsField ?? 'created_at'; - } - - /** - * Delete `sys_job_run` rows older than `retentionDays`. No-op when no data - * engine is available, the engine can't delete, or `retentionDays` is not a - * positive number. - */ - async prune(retentionDays: number): Promise { - const engine = this.opts.getEngine(); - if (!engine || typeof engine.delete !== 'function') { - this.opts.logger.warn('[job] retention: no deletable data engine; prune skipped'); - return { object: this.object, deleted: 0 }; - } - if (!(retentionDays > 0)) { - this.opts.logger.warn(`[job] retention: invalid retentionDays=${retentionDays}; prune skipped`); - return { object: this.object, deleted: 0 }; - } - - const cutoffIso = new Date(this.now() - retentionDays * 86_400_000).toISOString(); - try { - const res = await engine.delete(this.object, { - where: { [this.tsField]: { $lt: cutoffIso } }, - multi: true, - context: SYSTEM_CTX, - }); - const deleted = countDeleted(res); - if (deleted === undefined || deleted > 0) { - this.opts.logger.info( - `[job] retention: pruned ${deleted ?? '?'} ${this.object} rows older than ${cutoffIso}`, - ); - } - return { object: this.object, deleted }; - } catch (err) { - const msg = (err as Error)?.message ?? String(err); - this.opts.logger.warn(`[job] retention: prune of ${this.object} failed (${msg})`); - return { object: this.object, error: msg }; - } - } -} - -/** Best-effort row-count extraction from a driver's delete result. */ -function countDeleted(res: unknown): number | undefined { - if (typeof res === 'number') return res; - if (Array.isArray(res)) return res.length; - if (res && typeof res === 'object') { - const r = res as Record; - for (const k of ['deletedCount', 'deleted', 'count', 'affected', 'affectedRows']) { - if (typeof r[k] === 'number') return r[k] as number; - } - } - return undefined; -} diff --git a/packages/services/service-job/src/job-service-plugin.ts b/packages/services/service-job/src/job-service-plugin.ts index 28a40d2ced..705b175e27 100644 --- a/packages/services/service-job/src/job-service-plugin.ts +++ b/packages/services/service-job/src/job-service-plugin.ts @@ -6,12 +6,7 @@ import { IntervalJobAdapter } from './interval-job-adapter.js'; import type { IntervalJobAdapterOptions } from './interval-job-adapter.js'; import { CronJobAdapter } from './cron-job-adapter.js'; import { DbJobAdapter } from './db-job-adapter.js'; -import type { DbJobAdapterOptions, JobEngineLike } from './db-job-adapter.js'; -import { - JobRunRetention, - DEFAULT_JOB_RUN_RETENTION_DAYS, - DEFAULT_JOB_RUN_SWEEP_MS, -} from './job-run-retention.js'; +import type { DbJobAdapterOptions } from './db-job-adapter.js'; /** * Configuration options for the JobServicePlugin. @@ -36,17 +31,6 @@ export interface JobServicePluginOptions { db?: DbJobAdapterOptions; /** Whether to also wire CronJobAdapter for cron schedules (default: true when available) */ enableCron?: boolean; - /** - * Retention window in days for `sys_job_run` execution-history rows - * (launch-readiness.md P1-2). Every run appends a row, so without pruning the - * table grows unbounded. **Default-on** at {@link DEFAULT_JOB_RUN_RETENTION_DAYS} - * — a periodic sweep deletes rows older than this. Set to `0` to disable - * retention (rows kept forever; operator owns cleanup). Only applies on the - * DB-backed adapter (no `sys_job_run` table exists for interval/cron). - */ - retentionDays?: number; - /** Retention sweep interval in ms (default {@link DEFAULT_JOB_RUN_SWEEP_MS}). Only used when `retentionDays > 0`. */ - retentionSweepMs?: number; } /** @@ -65,14 +49,11 @@ export class JobServicePlugin implements Plugin { private readonly options: JobServicePluginOptions; private dbAdapter?: DbJobAdapter; private intervalAdapter?: IntervalJobAdapter; - private retentionTimer?: ReturnType; constructor(options: JobServicePluginOptions = {}) { this.options = { adapter: 'auto', enableCron: true, - retentionDays: DEFAULT_JOB_RUN_RETENTION_DAYS, - retentionSweepMs: DEFAULT_JOB_RUN_SWEEP_MS, ...options, }; } @@ -154,38 +135,16 @@ export class JobServicePlugin implements Plugin { ctx.logger.warn('JobServicePlugin: replaceService failed; staying on IntervalJobAdapter', err as any); } - // Retention sweep (launch-readiness.md P1-2): bound the append-only - // sys_job_run log. Default-on — an unbounded run history is a guaranteed - // slow leak. Runs once now then on a low-frequency interval; the timer is - // unref'd so it never keeps the process alive. Only wired on the DB path - // (the table exists only there). - const retentionDays = this.options.retentionDays ?? DEFAULT_JOB_RUN_RETENTION_DAYS; - if (retentionDays > 0) { - const retention = new JobRunRetention({ - getEngine: () => engine as JobEngineLike, - logger: ctx.logger, - }); - const sweepMs = this.options.retentionSweepMs ?? DEFAULT_JOB_RUN_SWEEP_MS; - const sweep = () => { - void retention.prune(retentionDays).catch((err) => - ctx.logger.warn(`JobServicePlugin: retention sweep failed: ${(err as Error)?.message ?? err}`), - ); - }; - sweep(); - this.retentionTimer = setInterval(sweep, sweepMs); - this.retentionTimer.unref?.(); - ctx.logger.info( - `JobServicePlugin: sys_job_run retention on (prune > ${retentionDays}d every ${Math.round(sweepMs / 1000)}s)`, - ); - } + // Retention is owned by the platform LifecycleService (ADR-0057): + // sys_job_run declares a 30d `lifecycle` window and the Reaper enforces + // it — the plugin-local JobRunRetention sweeper this used to wire is + // retired (ADR-0057 §6: lifecycle is a platform primitive, owned once). + // Override windows per environment/tenant via the `lifecycle` settings + // namespace (`retention_overrides`). }); } async destroy(): Promise { - if (this.retentionTimer) { - clearInterval(this.retentionTimer); - this.retentionTimer = undefined; - } await this.dbAdapter?.destroy(); await this.intervalAdapter?.destroy(); } diff --git a/packages/services/service-messaging/src/index.ts b/packages/services/service-messaging/src/index.ts index 903b32fa7e..4a1368d589 100644 --- a/packages/services/service-messaging/src/index.ts +++ b/packages/services/service-messaging/src/index.ts @@ -104,8 +104,8 @@ export type { DispatchCluster, DispatchLockHandle, } from './dispatcher.js'; -export { NotificationRetention, DEFAULT_RETENTION_TARGETS } from './retention.js'; -export type { NotificationRetentionOptions, RetentionTarget, PruneOutcome } from './retention.js'; +// NotificationRetention was retired (ADR-0057): the pipeline objects declare +// `lifecycle` windows and the platform LifecycleService is the one sweeper. // Generic outbound-HTTP delivery outbox (ADR-0018 M3) — the raw-callout // counterpart to the notification outbox, shared by the Flow `http` node and diff --git a/packages/services/service-messaging/src/messaging-service-plugin.test.ts b/packages/services/service-messaging/src/messaging-service-plugin.test.ts index d22d540514..86f015a49f 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.test.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.test.ts @@ -134,7 +134,7 @@ describe('MessagingServicePlugin — email/sms channel registration (kernel:read it('registers the sms channel when an sms service is present', async () => { const { ctx, fireReady } = provisionCtx(); ctx.registerService('sms', { async send() { return { status: 'sent' }; } }); - await new MessagingServicePlugin({ reliableDelivery: false, retentionDays: 0 }).init(ctx); + await new MessagingServicePlugin({ reliableDelivery: false }).init(ctx); const messaging: any = ctx.getService('messaging'); expect(messaging.getRegisteredChannels()).not.toContain('sms'); @@ -144,7 +144,7 @@ describe('MessagingServicePlugin — email/sms channel registration (kernel:read it('does NOT register the sms channel when no sms service exists', async () => { const { ctx, fireReady } = provisionCtx(); - await new MessagingServicePlugin({ reliableDelivery: false, retentionDays: 0 }).init(ctx); + await new MessagingServicePlugin({ reliableDelivery: false }).init(ctx); await fireReady(); const messaging: any = ctx.getService('messaging'); expect(messaging.getRegisteredChannels()).not.toContain('sms'); @@ -155,7 +155,7 @@ describe('MessagingServicePlugin — system table provisioning', () => { it('creates the inbox + receipt tables on kernel:ready', async () => { const { ctx, engine, synced, fireReady } = provisionCtx(); // reliableDelivery/retention off so only the provisioning hook runs. - await new MessagingServicePlugin({ reliableDelivery: false, retentionDays: 0 }).init(ctx); + await new MessagingServicePlugin({ reliableDelivery: false }).init(ctx); // Before kernel:ready the inbox read throws the "no such table" the // engine logs as `Find operation failed`. diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index 7526c3a2c6..f38e3a21e2 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -9,7 +9,6 @@ import { SqlNotificationOutbox } from './sql-outbox.js'; import { SqlHttpOutbox } from './sql-http-outbox.js'; import { NotificationDispatcher, type DispatchCluster } from './dispatcher.js'; import { HttpDispatcher } from './http-dispatcher.js'; -import { NotificationRetention, DEFAULT_NOTIFICATION_RETENTION_DAYS } from './retention.js'; import { createEmailChannel } from './email-channel.js'; import { createSmsChannel } from './sms-channel.js'; import { NotificationTemplateStore } from './template-renderer.js'; @@ -45,21 +44,6 @@ export interface MessagingServicePluginOptions { * `prefix.` entry for a prefix match (default none). */ mandatoryTopics?: readonly string[]; - /** - * Retention window in days for the notification pipeline (ADR-0030 - * hardening). When > 0, a periodic sweep prunes events, deliveries, inbox - * materializations and receipts older than this — bounding the event-log - * growth from high-frequency periodic flows. - * - * **Default-on** at {@link DEFAULT_NOTIFICATION_RETENTION_DAYS} as of GA - * (launch-readiness.md P1-2): an unbounded notification log is a slow leak, - * so retention ships enabled rather than opt-in. Set to `0` to disable - * retention entirely (notification history kept forever; operator owns - * cleanup). Raise/lower the number to widen/narrow the window. - */ - retentionDays?: number; - /** Retention sweep interval in ms (default 1 hour). Only used when `retentionDays` is set. */ - retentionSweepMs?: number; } /** @@ -94,7 +78,6 @@ export class MessagingServicePlugin implements Plugin { private readonly options: Required; private dispatcher?: NotificationDispatcher; private httpDispatcher?: HttpDispatcher; - private retentionTimer?: ReturnType; constructor(options: MessagingServicePluginOptions = {}) { this.options = { @@ -103,8 +86,6 @@ export class MessagingServicePlugin implements Plugin { partitionCount: 8, dispatchIntervalMs: 500, mandatoryTopics: [], - retentionDays: DEFAULT_NOTIFICATION_RETENTION_DAYS, - retentionSweepMs: 3_600_000, ...options, }; } @@ -286,27 +267,13 @@ export class MessagingServicePlugin implements Plugin { }); } - // Retention sweep (ADR-0030 hardening): opt-in pruning of the - // notification pipeline so the event log can't grow unbounded. Runs once - // at ready then on a low-frequency interval; the timer is unref'd so it - // never keeps the process alive. - if (this.options.retentionDays > 0 && typeof ctx.hook === 'function') { - ctx.hook('kernel:ready', async () => { - const retention = new NotificationRetention({ getData, logger: ctx.logger }); - const days = this.options.retentionDays; - const sweep = () => { - void retention.prune(days).catch((err) => - ctx.logger.warn(`[messaging] retention sweep failed: ${(err as Error)?.message ?? err}`), - ); - }; - sweep(); - this.retentionTimer = setInterval(sweep, this.options.retentionSweepMs); - this.retentionTimer.unref?.(); - ctx.logger.info( - `[messaging] retention on (prune > ${days}d every ${Math.round(this.options.retentionSweepMs / 1000)}s)`, - ); - }); - } + // Retention is owned by the platform LifecycleService (ADR-0057): the + // pipeline objects (sys_notification / delivery / receipt / inbox) + // declare one 90d `lifecycle` window and the Reaper enforces it — the + // plugin-local NotificationRetention sweeper this used to wire is + // retired (ADR-0057 §6: lifecycle is a platform primitive, owned once). + // Override windows per environment/tenant via the `lifecycle` + // settings namespace (`retention_overrides`). ctx.logger.info( `[messaging] service registered with channels: ${service.getRegisteredChannels().join(', ') || '(none)'}`, @@ -354,9 +321,5 @@ export class MessagingServicePlugin implements Plugin { this.dispatcher = undefined; await this.httpDispatcher?.stop(); this.httpDispatcher = undefined; - if (this.retentionTimer) { - clearInterval(this.retentionTimer); - this.retentionTimer = undefined; - } } } diff --git a/packages/services/service-messaging/src/objects/inbox-message.object.ts b/packages/services/service-messaging/src/objects/inbox-message.object.ts index 68d5577a48..a52232344e 100644 --- a/packages/services/service-messaging/src/objects/inbox-message.object.ts +++ b/packages/services/service-messaging/src/objects/inbox-message.object.ts @@ -23,7 +23,7 @@ export const InboxMessage = ObjectSchema.create({ pluralLabel: 'Inbox Messages', icon: 'inbox', // ADR-0057: user-facing but ephemeral — expires with the pipeline's 90d - // window (the same bound NotificationRetention applies when enabled). + // window, enforced by the platform LifecycleService. lifecycle: { class: 'transient', ttl: { field: 'created_at', expireAfter: '90d' }, diff --git a/packages/services/service-messaging/src/retention.test.ts b/packages/services/service-messaging/src/retention.test.ts deleted file mode 100644 index 985856622d..0000000000 --- a/packages/services/service-messaging/src/retention.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { NotificationRetention, DEFAULT_RETENTION_TARGETS } from './retention.js'; - -function silentLogger() { - return { info: () => {}, warn: () => {} }; -} - -/** A fake engine capturing every `delete(object, options)` call. */ -function captureEngine(deleteImpl?: (object: string, opts: any) => any) { - const deletes: Array<{ object: string; where: any; multi: any; context: any }> = []; - return { - deletes, - engine: { - async find() { return []; }, - async findOne() { return null; }, - async insert() { return {}; }, - async update() { return {}; }, - async delete(object: string, opts: any) { - deletes.push({ object, where: opts?.where, multi: opts?.multi, context: opts?.context }); - return deleteImpl ? deleteImpl(object, opts) : { deletedCount: 1 }; - }, - async count() { return 0; }, - async aggregate() { return []; }, - } as any, - }; -} - -const FIXED_NOW = 1_700_000_000_000; // fixed clock for deterministic cutoffs - -describe('NotificationRetention', () => { - it('prunes every target older than an ISO-8601 cutoff (never a bare epoch-ms number)', async () => { - const { engine, deletes } = captureEngine(); - const retention = new NotificationRetention({ - getData: () => engine, - logger: silentLogger(), - now: () => FIXED_NOW, - }); - - const outcomes = await retention.prune(30); - - // One bulk delete per default target (receipt, inbox, delivery, event). - expect(deletes.map((d) => d.object)).toEqual(DEFAULT_RETENTION_TARGETS.map((t) => t.object)); - - const cutoffMs = FIXED_NOW - 30 * 86_400_000; - const cutoffIso = new Date(cutoffMs).toISOString(); - for (const d of deletes) { - expect(d.multi).toBe(true); - // Cross-tenant system context — retention is an operator policy. - expect(d.context).toEqual({ isSystem: true }); - } - // Every target's `created_at` is a native timestamp column, so the cutoff - // is an ISO-8601 string — NEVER a bare epoch-ms number (a bigint compared - // to a timestamp column makes Postgres throw "date/time field value out of - // range"; SQLite's lenient affinity used to hide it for the delivery outbox). - const byObject = Object.fromEntries(deletes.map((d) => [d.object, d.where])); - for (const obj of DEFAULT_RETENTION_TARGETS.map((t) => t.object)) { - expect(byObject[obj]).toEqual({ created_at: { $lt: cutoffIso } }); - const lt = (byObject[obj].created_at as { $lt: unknown }).$lt; - expect(typeof lt).toBe('string'); - expect(lt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); - } - - expect(outcomes.every((o) => o.deleted === 1 && !o.error)).toBe(true); - }); - - it('no-ops when there is no data engine', async () => { - const retention = new NotificationRetention({ getData: () => undefined, logger: silentLogger() }); - expect(await retention.prune(30)).toEqual([]); - }); - - it('no-ops for a non-positive retention window', async () => { - const { engine, deletes } = captureEngine(); - const retention = new NotificationRetention({ getData: () => engine, logger: silentLogger() }); - expect(await retention.prune(0)).toEqual([]); - expect(await retention.prune(-5)).toEqual([]); - expect(deletes).toHaveLength(0); - }); - - it('isolates a failing target — the rest of the sweep still runs', async () => { - const { engine, deletes } = captureEngine((object) => { - if (object === 'sys_inbox_message') throw new Error('boom'); - return { deletedCount: 2 }; - }); - const retention = new NotificationRetention({ - getData: () => engine, - logger: silentLogger(), - now: () => FIXED_NOW, - }); - - const outcomes = await retention.prune(7); - - // All four were attempted despite the inbox failure. - expect(deletes).toHaveLength(4); - const failed = outcomes.find((o) => o.object === 'sys_inbox_message'); - expect(failed?.error).toContain('boom'); - const ok = outcomes.filter((o) => o.object !== 'sys_inbox_message'); - expect(ok.every((o) => o.deleted === 2)).toBe(true); - }); - - it('reports an undefined count when the driver returns no count', async () => { - const { engine } = captureEngine(() => ({})); - const retention = new NotificationRetention({ - getData: () => engine, - logger: silentLogger(), - now: () => FIXED_NOW, - targets: [{ object: 'sys_notification', tsField: 'created_at' }], - }); - const outcomes = await retention.prune(1); - expect(outcomes).toEqual([{ object: 'sys_notification', deleted: undefined }]); - }); -}); diff --git a/packages/services/service-messaging/src/retention.ts b/packages/services/service-messaging/src/retention.ts deleted file mode 100644 index 220975a27b..0000000000 --- a/packages/services/service-messaging/src/retention.ts +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { IDataEngine } from '@objectstack/spec/contracts'; -import { NOTIFICATION_EVENT_OBJECT } from './messaging-service.js'; -import { DELIVERY_OBJECT } from './sql-outbox.js'; -import { INBOX_OBJECT, RECEIPT_OBJECT } from './inbox-channel.js'; - -/** - * Default retention window for the notification pipeline, in days. Default-on as - * of GA (launch-readiness.md P1-2): 90 days keeps a quarter of in-app history - * for the bell / audit while bounding `sys_notification` (+ delivery / inbox / - * receipt) growth. Operators override via `MessagingServicePlugin` options; - * `0` disables pruning entirely. - */ -export const DEFAULT_NOTIFICATION_RETENTION_DAYS = 90; - -/** Minimal logger surface (matches the channel/service context). */ -interface RetentionLogger { - info(msg: string): void; - warn(msg: string): void; -} - -/** - * One object the sweeper prunes, plus the field that carries its age. Every - * target's `created_at` is a builtin audit column — a native `TIMESTAMP` on - * Postgres/MySQL — so the cutoff is always an ISO-8601 string. (An earlier - * version passed an epoch-ms number for the delivery outbox; that compared a - * bigint to a timestamp column and Postgres rejected it with "date/time field - * value out of range". SQLite's lenient column affinity hid the bug.) The - * driver coerces the ISO comparand to the column's storage form per dialect. - */ -export interface RetentionTarget { - readonly object: string; - readonly tsField: string; -} - -/** - * Default sweep set, ordered leaf-first (materializations/receipts/deliveries - * before the event) so the log reads top-down even though there are no enforced - * FKs. A notification ages out **wholesale** — event + delivery + materialization - * + receipt past the cutoff are all removed — keeping the model consistent (no - * dangling `notification_id`) and the bell (which only shows recent rows) - * unaffected. - */ -export const DEFAULT_RETENTION_TARGETS: readonly RetentionTarget[] = [ - { object: RECEIPT_OBJECT, tsField: 'created_at' }, - { object: INBOX_OBJECT, tsField: 'created_at' }, - { object: DELIVERY_OBJECT, tsField: 'created_at' }, - { object: NOTIFICATION_EVENT_OBJECT, tsField: 'created_at' }, -]; - -export interface NotificationRetentionOptions { - /** Resolve the data engine; `undefined` ⇒ prune is a no-op. */ - getData(): IDataEngine | undefined; - logger: RetentionLogger; - /** Override the swept objects (tests / custom deployments). */ - targets?: readonly RetentionTarget[]; - /** Clock injection for deterministic tests. Defaults to `Date.now()`. */ - now?(): number; -} - -/** Per-object prune outcome. `deleted` is `undefined` when the driver doesn't report a count. */ -export interface PruneOutcome { - object: string; - deleted?: number; - error?: string; -} - -/** - * Retention sweeper for the notification pipeline (ADR-0030 hardening). - * - * Every `emit()` writes a `sys_notification` event row (plus delivery / - * materialization / receipt rows), so a high-frequency periodic flow grows the - * tables unbounded. {@link prune} deletes everything older than a cutoff across - * all {@link RetentionTarget}s in one bulk `delete` per object, under a system - * context (cross-tenant: retention is an operator policy). Each target is - * isolated — one object's failure is logged and the sweep continues. - * - * Retention is **opt-in**: the plugin runs this only when `retentionDays` is - * configured. The mechanism lives here so it's testable in isolation. - */ -export class NotificationRetention { - private readonly now: () => number; - private readonly targets: readonly RetentionTarget[]; - - constructor(private readonly opts: NotificationRetentionOptions) { - this.now = opts.now ?? (() => Date.now()); - this.targets = opts.targets ?? DEFAULT_RETENTION_TARGETS; - } - - /** - * Delete pipeline rows older than `retentionDays`. Returns one outcome per - * swept object. No-op (empty result) when no data engine is available or - * `retentionDays` is not a positive number. - */ - async prune(retentionDays: number): Promise { - const data = this.opts.getData(); - if (!data) { - this.opts.logger.warn('[messaging] retention: no data engine; prune skipped'); - return []; - } - if (!(retentionDays > 0)) { - this.opts.logger.warn(`[messaging] retention: invalid retentionDays=${retentionDays}; prune skipped`); - return []; - } - - const cutoffIso = new Date(this.now() - retentionDays * 86_400_000).toISOString(); - const outcomes: PruneOutcome[] = []; - - for (const t of this.targets) { - try { - const res = await data.delete(t.object, { - // ISO-8601 cutoff for every target: `created_at` is a native - // timestamp column, which rejects a bare epoch-ms number on - // Postgres. The driver coerces this per dialect on the way down. - where: { [t.tsField]: { $lt: cutoffIso } }, - multi: true, - // System context: retention is an operator policy that spans - // tenants, so it must not be scoped by the caller's RLS. - context: { isSystem: true }, - } as any); - const deleted = countDeleted(res); - outcomes.push({ object: t.object, deleted }); - if (deleted === undefined || deleted > 0) { - this.opts.logger.info( - `[messaging] retention: pruned ${deleted ?? '?'} ${t.object} rows older than ${cutoffIso}`, - ); - } - } catch (err) { - const msg = (err as Error)?.message ?? String(err); - this.opts.logger.warn(`[messaging] retention: prune of ${t.object} failed (${msg}); continuing`); - outcomes.push({ object: t.object, error: msg }); - } - } - return outcomes; - } -} - -/** Best-effort row-count extraction from a driver's delete result. */ -function countDeleted(res: unknown): number | undefined { - if (typeof res === 'number') return res; - if (Array.isArray(res)) return res.length; - if (res && typeof res === 'object') { - const r = res as Record; - for (const k of ['deletedCount', 'deleted', 'count', 'affected', 'affectedRows']) { - if (typeof r[k] === 'number') return r[k] as number; - } - } - return undefined; -} diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index cc9a52ec9c..966c0108b5 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -208,6 +208,78 @@ export const objectForm = defineForm({ collapsed: true, fields: [ { field: 'datasource', type: 'text', helpText: 'Target datasource ID (default: "default")' }, + { + field: 'lifecycle', + type: 'composite', + helpText: + 'Data lifecycle contract (ADR-0057): how long rows live and how space is reclaimed. Leave empty for permanent record semantics. Non-record classes require at least one bounding policy (retention, TTL, or rotation).', + fields: [ + { + field: 'class', + type: 'select', + helpText: 'Persistence contract for the rows of this object', + options: [ + { label: 'Record (business truth — permanent)', value: 'record' }, + { label: 'Audit (compliance ledger — retain → archive → delete)', value: 'audit' }, + { label: 'Telemetry (high-frequency log — short retention)', value: 'telemetry' }, + { label: 'Transient (ephemeral state — TTL expiry)', value: 'transient' }, + { label: 'Event (bus messages — very short TTL)', value: 'event' }, + ], + }, + { + field: 'retention', + type: 'composite', + helpText: 'Age-based retention window', + fields: [ + { field: 'maxAge', type: 'text', helpText: 'Rows older than this (by created_at) are reaped. Duration literal: h/d/w/y, e.g. "30d"' }, + ], + }, + { + field: 'ttl', + type: 'composite', + helpText: 'Per-row TTL expiry', + fields: [ + { field: 'field', type: 'text', helpText: 'Timestamp field the TTL is measured from (e.g. expires_at)' }, + { field: 'expireAfter', type: 'text', helpText: 'Rows expire this long after the field, e.g. "1d"' }, + ], + }, + { + field: 'storage', + type: 'composite', + helpText: 'Physical rotation for high-frequency telemetry (SQLite: O(1) shard DROP)', + fields: [ + { + field: 'strategy', + type: 'select', + helpText: 'Storage strategy', + options: [{ label: 'Rotation (time-shard + drop oldest)', value: 'rotation' }], + }, + { field: 'shards', type: 'number', min: 2, helpText: 'Shards retained; total window = shards × unit' }, + { + field: 'unit', + type: 'select', + helpText: 'Time width of one shard', + options: [ + { label: 'Day', value: 'day' }, + { label: 'Week', value: 'week' }, + { label: 'Month', value: 'month' }, + ], + }, + ], + }, + { + field: 'archive', + type: 'composite', + helpText: 'Cold-store hand-off (audit class). Rows are never hot-deleted before the archive copy succeeded.', + fields: [ + { field: 'after', type: 'text', helpText: 'Archive rows older than this — must equal retention.maxAge' }, + { field: 'to', type: 'text', helpText: 'Target datasource name for cold storage' }, + { field: 'keep', type: 'text', helpText: 'How long the archive keeps rows (empty = forever), e.g. "7y"' }, + ], + }, + { field: 'reclaim', type: 'boolean', helpText: 'Reclaim driver space after sweeps (default on for non-record classes)' }, + ], + }, ], }, ],