Skip to content

Commit 72593bb

Browse files
committed
Add settings ADR, sys_setting, and schemas
Introduce ADR-0007 (Settings Manifest + K/V store + resolver) and groundwork for the Settings feature. Adds the ADR document, implements a new sys_setting ObjectSchema and system index export in @objectstack/platform-objects (package.json, tsup, and tests updated), and adds SettingsManifest zod schemas and tests in @objectstack/spec. Prepares the resolver/service and UI integration work by registering the manifest types and a single K/V backing table.
1 parent 2d50aff commit 72593bb

10 files changed

Lines changed: 1507 additions & 0 deletions

File tree

docs/adr/0007-settings-manifest-and-kv-store.md

Lines changed: 453 additions & 0 deletions
Large diffs are not rendered by default.

packages/platform-objects/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@
3636
"import": "./dist/metadata/index.mjs",
3737
"require": "./dist/metadata/index.js"
3838
},
39+
"./system": {
40+
"types": "./dist/system/index.d.ts",
41+
"import": "./dist/system/index.mjs",
42+
"require": "./dist/system/index.js"
43+
},
3944
"./apps": {
4045
"types": "./dist/apps/index.d.ts",
4146
"import": "./dist/apps/index.mjs",

packages/platform-objects/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ export * from './security/index.js';
2323
export * from './audit/index.js';
2424
export * from './integration/index.js';
2525
export * from './metadata/index.js';
26+
export * from './system/index.js';
2627
export * from './apps/index.js';

packages/platform-objects/src/platform-objects.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
SysMetadata,
2929
SysMetadataHistoryObject,
3030
} from './metadata/index.js';
31+
import { SysSetting } from './system/index.js';
3132

3233
const systemObjects = [
3334
['SysUser', SysUser, 'sys_user'],
@@ -51,6 +52,7 @@ const systemObjects = [
5152
['SysWebhook', SysWebhook, 'sys_webhook'],
5253
['SysMetadata', SysMetadata, 'sys_metadata'],
5354
['SysMetadataHistoryObject', SysMetadataHistoryObject, 'sys_metadata_history'],
55+
['SysSetting', SysSetting, 'sys_setting'],
5456
] as const;
5557

5658
describe('@objectstack/platform-objects', () => {
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* platform-objects/system — Platform System Objects
5+
*
6+
* Cross-cutting system-level objects that don't belong to identity,
7+
* security, audit, or integration. Currently hosts the generic
8+
* settings K/V store backing ADR-0007 (Settings Manifest + K/V Store +
9+
* Resolver).
10+
*/
11+
12+
export { SysSetting } from './sys-setting.object.js';
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_setting — Generic K/V store backing the SettingsManifest contract
7+
*
8+
* Single physical table that holds *every* value for *every* settings
9+
* namespace declared by a `SettingsManifest`. Plugins MUST NOT define
10+
* per-namespace tables (e.g. `sys_mail_config`); they declare a manifest
11+
* and the value persists here.
12+
*
13+
* Row identity: (namespace, key, scope, user_id?).
14+
*
15+
* Resolution order (handled by `SettingsService.get`):
16+
* 1. process.env override (source='env', locked=true)
17+
* 2. sys_setting WHERE scope='tenant' (source='tenant')
18+
* 3. sys_setting WHERE scope='user' (source='user')
19+
* 4. manifest specifier.default (source='default')
20+
*
21+
* Encryption: rows with `encrypted=true` store ciphertext in `value_enc`
22+
* and leave `value` null. The plain value is never written to audit log
23+
* or history snapshots — only an `'<encrypted>'` placeholder + a digest.
24+
*
25+
* managedBy: 'system' — the admin grid in Setup is a diagnostic surface
26+
* only; all writes flow through `SettingsService.set()` so the resolver
27+
* stays the single source of truth.
28+
*
29+
* See ADR-0007 (Settings Manifest + K/V Store + Resolver).
30+
*
31+
* @namespace sys
32+
*/
33+
export const SysSetting = ObjectSchema.create({
34+
name: 'sys_setting',
35+
label: 'Setting',
36+
pluralLabel: 'Settings',
37+
icon: 'sliders',
38+
isSystem: true,
39+
managedBy: 'system',
40+
description: 'Generic K/V store backing the SettingsManifest contract.',
41+
displayNameField: 'key',
42+
titleFormat: '{namespace}.{key}',
43+
compactLayout: ['namespace', 'key', 'scope', 'updated_at'],
44+
45+
listViews: {
46+
by_namespace: {
47+
type: 'grid',
48+
name: 'by_namespace',
49+
label: 'By Namespace',
50+
data: { provider: 'object', object: 'sys_setting' },
51+
columns: ['namespace', 'key', 'scope', 'encrypted', 'updated_by', 'updated_at'],
52+
sort: [{ field: 'namespace', order: 'asc' }, { field: 'key', order: 'asc' }],
53+
grouping: { fields: [{ field: 'namespace', order: 'asc', collapsed: false }] },
54+
pagination: { pageSize: 200 },
55+
},
56+
tenant_only: {
57+
type: 'grid',
58+
name: 'tenant_only',
59+
label: 'Tenant',
60+
data: { provider: 'object', object: 'sys_setting' },
61+
columns: ['namespace', 'key', 'encrypted', 'updated_by', 'updated_at'],
62+
filter: [{ field: 'scope', operator: 'equals', value: 'tenant' }],
63+
sort: [{ field: 'namespace', order: 'asc' }, { field: 'key', order: 'asc' }],
64+
pagination: { pageSize: 200 },
65+
},
66+
user_only: {
67+
type: 'grid',
68+
name: 'user_only',
69+
label: 'User',
70+
data: { provider: 'object', object: 'sys_setting' },
71+
columns: ['user_id', 'namespace', 'key', 'updated_at'],
72+
filter: [{ field: 'scope', operator: 'equals', value: 'user' }],
73+
sort: [{ field: 'user_id', order: 'asc' }, { field: 'namespace', order: 'asc' }],
74+
pagination: { pageSize: 200 },
75+
},
76+
all_settings: {
77+
type: 'grid',
78+
name: 'all_settings',
79+
label: 'All',
80+
data: { provider: 'object', object: 'sys_setting' },
81+
columns: ['namespace', 'key', 'scope', 'user_id', 'encrypted', 'updated_at'],
82+
sort: [{ field: 'updated_at', order: 'desc' }],
83+
pagination: { pageSize: 100 },
84+
},
85+
},
86+
87+
fields: {
88+
id: Field.text({
89+
label: 'Setting ID',
90+
required: true,
91+
readonly: true,
92+
}),
93+
94+
created_at: Field.datetime({
95+
label: 'Created At',
96+
defaultValue: 'NOW()',
97+
readonly: true,
98+
}),
99+
100+
updated_at: Field.datetime({
101+
label: 'Updated At',
102+
defaultValue: 'NOW()',
103+
readonly: true,
104+
}),
105+
106+
namespace: Field.text({
107+
label: 'Namespace',
108+
required: true,
109+
maxLength: 64,
110+
description: 'Manifest namespace (e.g. mail, branding, feature_flags).',
111+
}),
112+
113+
key: Field.text({
114+
label: 'Key',
115+
required: true,
116+
maxLength: 128,
117+
description: 'Specifier key inside the namespace (snake_case).',
118+
}),
119+
120+
scope: Field.select(
121+
[
122+
{ label: 'Tenant', value: 'tenant' },
123+
{ label: 'User', value: 'user' },
124+
{ label: 'Runtime',value: 'runtime' },
125+
],
126+
{
127+
label: 'Scope',
128+
required: true,
129+
defaultValue: 'tenant',
130+
description: 'Which layer of the config-resolution hierarchy this row belongs to.',
131+
},
132+
),
133+
134+
user_id: Field.lookup('sys_user', {
135+
label: 'User',
136+
description: 'Owning user when scope=user; null otherwise.',
137+
}),
138+
139+
value: Field.json({
140+
label: 'Value',
141+
description: 'JSON-encoded value. Null when encrypted=true (see value_enc).',
142+
}),
143+
144+
encrypted: Field.boolean({
145+
label: 'Encrypted',
146+
defaultValue: false,
147+
description: 'When true, the value is stored encrypted-at-rest in value_enc; value column is null.',
148+
}),
149+
150+
value_enc: Field.text({
151+
label: 'Encrypted Value',
152+
readonly: true,
153+
description: 'Ciphertext payload (KMS-wrapped). Set only when encrypted=true.',
154+
}),
155+
156+
updated_by: Field.lookup('sys_user', {
157+
label: 'Updated By',
158+
readonly: true,
159+
description: 'Last actor who wrote this row via SettingsService.set().',
160+
}),
161+
},
162+
163+
indexes: [
164+
// Primary lookup path: (namespace, key, scope, user_id?) is what
165+
// SettingsService.get hits on every resolve. The composite UNIQUE
166+
// covers both the row-identity constraint and the read path.
167+
{ fields: ['namespace', 'key', 'scope', 'user_id'], unique: true },
168+
// Common range read: full namespace dump for SettingsService.getNamespace.
169+
{ fields: ['namespace', 'scope'], unique: false },
170+
// Per-user listing on the user-prefs scope.
171+
{ fields: ['user_id', 'namespace'], unique: false },
172+
],
173+
174+
enable: {
175+
// History on settings is opt-in per namespace (handled at service
176+
// layer when needed) to avoid bloating sys_history with churn from
177+
// feature flags and similar high-frequency toggles.
178+
trackHistory: false,
179+
searchable: false,
180+
apiEnabled: true,
181+
// Direct data API exposed for the admin grid view, but writes from
182+
// the UI MUST go through /api/settings/:namespace so the resolver
183+
// and audit hooks fire. The grid is diagnostic-only.
184+
apiMethods: ['get', 'list'],
185+
trash: false,
186+
mru: false,
187+
},
188+
});

packages/platform-objects/tsup.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export default defineConfig({
1010
'audit/index': 'src/audit/index.ts',
1111
'integration/index': 'src/integration/index.ts',
1212
'metadata/index': 'src/metadata/index.ts',
13+
'system/index': 'src/system/index.ts',
1314
'apps/index': 'src/apps/index.ts',
1415
},
1516
format: ['cjs', 'esm'],

packages/spec/src/system/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ export * from './incident-response.zod';
3838
export * from './supplier-security.zod';
3939
export * from './training.zod';
4040

41+
// Settings (ADR-0007: Manifest + K/V Store + Resolver)
42+
export * from './settings-manifest.zod';
43+
4144
// Runtime Services
4245
export * from './job.zod';
4346
export * from './worker.zod';

0 commit comments

Comments
 (0)