Skip to content

Commit 6fd11b9

Browse files
authored
Merge pull request #730 from objectstack-ai/copilot/support-object-name-decoupling
2 parents dfa3a4f + 3a4b39a commit 6fd11b9

8 files changed

Lines changed: 340 additions & 2 deletions

File tree

ROADMAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,10 @@ The following renames are planned for packages that implement core service contr
146146
147147
### Deliverables — All Completed
148148

149-
- [x] **Data Protocol** — Object, Field (35+ types), Query, Filter, Validation, Hook, Datasource, Dataset, Analytics, Document
149+
- [x] **Data Protocol** — Object, Field (35+ types), Query, Filter, Validation, Hook, Datasource, Dataset, Analytics, Document, Storage Name Mapping (`tableName`/`columnName`)
150150
- [x] **Driver Specifications** — Memory, PostgreSQL, MongoDB driver schemas + SQL/NoSQL abstractions
151151
- [x] **UI Protocol** — View (List/Form/Kanban/Calendar/Gantt), App, Dashboard, Report, Action, Page (16 types), Chart, Widget, Theme, Animation, DnD, Touch, Keyboard, Responsive, Offline, Notification, i18n, Content Elements
152-
- [x] **System Protocol** — Manifest, Auth Config, Cache, Logging, Metrics, Tracing, Audit, Encryption, Masking, Migration, Tenant, Translation, Search Engine, HTTP Server, Worker, Job, Object Storage, Notification, Message Queue, Registry Config, Collaboration, Compliance, Change Management, Disaster Recovery, License, Security Context, Core Services
152+
- [x] **System Protocol** — Manifest, Auth Config, Cache, Logging, Metrics, Tracing, Audit, Encryption, Masking, Migration, Tenant, Translation, Search Engine, HTTP Server, Worker, Job, Object Storage, Notification, Message Queue, Registry Config, Collaboration, Compliance, Change Management, Disaster Recovery, License, Security Context, Core Services, SystemObjectName/SystemFieldName Constants, StorageNameMapping Utilities
153153
- [x] **Automation Protocol** — Flow (autolaunched/screen/schedule), Workflow, State Machine, Trigger Registry, Approval, ETL, Sync, Webhook
154154
- [x] **AI Protocol** — Agent, Agent Action, Conversation, Cost, MCP, Model Registry, NLQ, Orchestration, Predictive, RAG Pipeline, Runtime Ops, Feedback Loop, DevOps Agent, Plugin Development
155155
- [x] **API Protocol** — Protocol (104 schemas), Endpoint, Contract, Router, Dispatcher, REST Server, GraphQL, OData, WebSocket, Realtime, Batch, Versioning, HTTP Cache, Documentation, Discovery, Registry, Errors, Auth, Auth Endpoints, Metadata, Analytics, Query Adapter, Storage, Plugin REST API

packages/spec/src/data/field.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1433,3 +1433,32 @@ describe('FieldSchema - conditionalRequired property', () => {
14331433
expect(result.conditionalRequired).toBe('amount > 1000');
14341434
});
14351435
});
1436+
1437+
// ============================================================================
1438+
// columnName — Storage Layer Mapping
1439+
// ============================================================================
1440+
1441+
describe('FieldSchema - columnName', () => {
1442+
it('should accept columnName for storage layer mapping', () => {
1443+
const result = FieldSchema.parse({
1444+
type: 'text',
1445+
columnName: 'user_email',
1446+
});
1447+
expect(result.columnName).toBe('user_email');
1448+
});
1449+
1450+
it('should accept field without columnName (optional, defaults to key)', () => {
1451+
const result = FieldSchema.parse({
1452+
type: 'text',
1453+
});
1454+
expect(result.columnName).toBeUndefined();
1455+
});
1456+
1457+
it('should accept camelCase columnName for legacy DB compatibility', () => {
1458+
const result = FieldSchema.parse({
1459+
type: 'datetime',
1460+
columnName: 'expiresAt',
1461+
});
1462+
expect(result.columnName).toBe('expiresAt');
1463+
});
1464+
});

packages/spec/src/data/field.zod.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,9 @@ export const FieldSchema = z.object({
349349
description: z.string().optional().describe('Tooltip/Help text'),
350350
format: z.string().optional().describe('Format string (e.g. email, phone)'),
351351

352+
/** Storage Layer Mapping */
353+
columnName: z.string().optional().describe('Physical column name in the target datasource. Defaults to the field key when not set.'),
354+
352355
/** Database Constraints */
353356
required: z.boolean().default(false).describe('Is required'),
354357
searchable: z.boolean().default(false).describe('Is searchable'),

packages/spec/src/data/object.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,28 @@ describe('ObjectSchema', () => {
283283

284284
expect(() => ObjectSchema.parse(fullObject)).not.toThrow();
285285
});
286+
287+
it('should accept object with tableName and field-level columnName for storage decoupling', () => {
288+
const object = ObjectSchema.parse({
289+
name: 'user',
290+
tableName: 'ba_users',
291+
fields: {
292+
email: {
293+
type: 'email',
294+
columnName: 'email_address',
295+
},
296+
created_at: {
297+
type: 'datetime',
298+
columnName: 'createdAt',
299+
},
300+
},
301+
});
302+
303+
expect(object.name).toBe('user');
304+
expect(object.tableName).toBe('ba_users');
305+
expect(object.fields.email.columnName).toBe('email_address');
306+
expect(object.fields.created_at.columnName).toBe('createdAt');
307+
});
286308
});
287309

288310
describe('Object with Indexes', () => {

packages/spec/src/system/constants/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@
1111
*/
1212

1313
export * from './paths';
14+
export * from './system-names';
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
SystemObjectName,
4+
SystemFieldName,
5+
StorageNameMapping,
6+
} from './system-names';
7+
8+
// ============================================================================
9+
// SystemObjectName
10+
// ============================================================================
11+
12+
describe('SystemObjectName', () => {
13+
it('should expose all expected object names', () => {
14+
expect(SystemObjectName.USER).toBe('user');
15+
expect(SystemObjectName.SESSION).toBe('session');
16+
expect(SystemObjectName.ACCOUNT).toBe('account');
17+
expect(SystemObjectName.VERIFICATION).toBe('verification');
18+
expect(SystemObjectName.METADATA).toBe('sys_metadata');
19+
});
20+
21+
it('should be readonly (const assertion)', () => {
22+
const names: readonly string[] = Object.values(SystemObjectName);
23+
expect(names).toContain('user');
24+
expect(names).toContain('session');
25+
});
26+
});
27+
28+
// ============================================================================
29+
// SystemFieldName
30+
// ============================================================================
31+
32+
describe('SystemFieldName', () => {
33+
it('should expose all expected field names', () => {
34+
expect(SystemFieldName.ID).toBe('id');
35+
expect(SystemFieldName.CREATED_AT).toBe('created_at');
36+
expect(SystemFieldName.UPDATED_AT).toBe('updated_at');
37+
expect(SystemFieldName.OWNER_ID).toBe('owner_id');
38+
expect(SystemFieldName.TENANT_ID).toBe('tenant_id');
39+
expect(SystemFieldName.USER_ID).toBe('user_id');
40+
expect(SystemFieldName.DELETED_AT).toBe('deleted_at');
41+
});
42+
43+
it('should be readonly (const assertion)', () => {
44+
const names: readonly string[] = Object.values(SystemFieldName);
45+
expect(names).toContain('id');
46+
expect(names).toContain('owner_id');
47+
});
48+
});
49+
50+
// ============================================================================
51+
// StorageNameMapping
52+
// ============================================================================
53+
54+
describe('StorageNameMapping', () => {
55+
describe('resolveTableName', () => {
56+
it('should return tableName when specified', () => {
57+
expect(StorageNameMapping.resolveTableName({ name: 'user', tableName: 'ba_users' })).toBe('ba_users');
58+
});
59+
60+
it('should fall back to name when tableName is not set', () => {
61+
expect(StorageNameMapping.resolveTableName({ name: 'user' })).toBe('user');
62+
});
63+
64+
it('should fall back to name when tableName is undefined', () => {
65+
expect(StorageNameMapping.resolveTableName({ name: 'session', tableName: undefined })).toBe('session');
66+
});
67+
});
68+
69+
describe('resolveColumnName', () => {
70+
it('should return columnName when specified', () => {
71+
expect(StorageNameMapping.resolveColumnName('user_id', { columnName: 'userId' })).toBe('userId');
72+
});
73+
74+
it('should fall back to fieldKey when columnName is not set', () => {
75+
expect(StorageNameMapping.resolveColumnName('user_id', {})).toBe('user_id');
76+
});
77+
78+
it('should fall back to fieldKey when columnName is undefined', () => {
79+
expect(StorageNameMapping.resolveColumnName('email', { columnName: undefined })).toBe('email');
80+
});
81+
});
82+
83+
describe('buildColumnMap', () => {
84+
it('should build a complete field-key → column-name map', () => {
85+
const fields = {
86+
user_id: { columnName: 'userId' },
87+
email: {},
88+
expires_at: { columnName: 'expiresAt' },
89+
};
90+
91+
const map = StorageNameMapping.buildColumnMap(fields);
92+
93+
expect(map).toEqual({
94+
user_id: 'userId',
95+
email: 'email',
96+
expires_at: 'expiresAt',
97+
});
98+
});
99+
100+
it('should return empty map for empty fields', () => {
101+
expect(StorageNameMapping.buildColumnMap({})).toEqual({});
102+
});
103+
});
104+
105+
describe('buildReverseColumnMap', () => {
106+
it('should build a reverse column-name → field-key map', () => {
107+
const fields = {
108+
user_id: { columnName: 'userId' },
109+
email: {},
110+
expires_at: { columnName: 'expiresAt' },
111+
};
112+
113+
const reverseMap = StorageNameMapping.buildReverseColumnMap(fields);
114+
115+
expect(reverseMap).toEqual({
116+
userId: 'user_id',
117+
email: 'email',
118+
expiresAt: 'expires_at',
119+
});
120+
});
121+
122+
it('should return empty map for empty fields', () => {
123+
expect(StorageNameMapping.buildReverseColumnMap({})).toEqual({});
124+
});
125+
126+
it('should handle all fields without columnName (identity mapping)', () => {
127+
const fields = {
128+
name: {},
129+
status: {},
130+
};
131+
132+
const reverseMap = StorageNameMapping.buildReverseColumnMap(fields);
133+
134+
expect(reverseMap).toEqual({
135+
name: 'name',
136+
status: 'status',
137+
});
138+
});
139+
});
140+
});
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* System Object Names — Protocol Layer Constants
5+
*
6+
* These constants define the canonical, protocol-level names for system objects.
7+
* All API calls, SDK references, permissions checks, and metadata lookups MUST use
8+
* these names instead of hardcoded strings or physical table names.
9+
*
10+
* The actual storage table name may differ via `ObjectSchema.tableName`.
11+
* The mapping between protocol name and storage name is handled by the
12+
* ObjectQL Engine / Driver layer.
13+
*
14+
* @example
15+
* ```ts
16+
* import { SystemObjectName } from '@objectstack/spec/system';
17+
*
18+
* // Always use the constant for API / SDK / permission references
19+
* const users = await engine.find(SystemObjectName.USER, { ... });
20+
* ```
21+
*/
22+
export const SystemObjectName = {
23+
/** Authentication: user identity */
24+
USER: 'user',
25+
/** Authentication: active session */
26+
SESSION: 'session',
27+
/** Authentication: OAuth / credential account */
28+
ACCOUNT: 'account',
29+
/** Authentication: email / phone verification */
30+
VERIFICATION: 'verification',
31+
/** System metadata storage */
32+
METADATA: 'sys_metadata',
33+
} as const;
34+
35+
/** Union type of all system object names */
36+
export type SystemObjectName = typeof SystemObjectName[keyof typeof SystemObjectName];
37+
38+
/**
39+
* System Field Names — Protocol Layer Constants
40+
*
41+
* These constants define the canonical, protocol-level names for common system fields.
42+
* All API calls, SDK references, and permission checks MUST use these constants
43+
* instead of hardcoded strings or physical column names.
44+
*
45+
* The actual storage column name may differ via `FieldSchema.columnName`.
46+
*
47+
* @example
48+
* ```ts
49+
* import { SystemFieldName } from '@objectstack/spec/system';
50+
*
51+
* // Use the constant to reference the owner field in queries
52+
* const myRecords = await engine.find('project', {
53+
* filters: [SystemFieldName.OWNER_ID, '=', currentUserId],
54+
* });
55+
* ```
56+
*/
57+
export const SystemFieldName = {
58+
/** Primary key */
59+
ID: 'id',
60+
/** Record creation timestamp */
61+
CREATED_AT: 'created_at',
62+
/** Record last-updated timestamp */
63+
UPDATED_AT: 'updated_at',
64+
/** Record owner (lookup to user) */
65+
OWNER_ID: 'owner_id',
66+
/** Tenant isolation key */
67+
TENANT_ID: 'tenant_id',
68+
/** Foreign key to user on session / account objects */
69+
USER_ID: 'user_id',
70+
/** Soft-delete timestamp */
71+
DELETED_AT: 'deleted_at',
72+
} as const;
73+
74+
/** Union type of all system field names */
75+
export type SystemFieldName = typeof SystemFieldName[keyof typeof SystemFieldName];
76+
77+
/**
78+
* Storage Name Mapping — Protocol ↔ Physical Name Resolution
79+
*
80+
* Provides pure utility functions for resolving protocol-level names to
81+
* physical storage names and vice-versa.
82+
*
83+
* These helpers are intended for use inside the ObjectQL Engine and Driver layers.
84+
* They are intentionally stateless — they receive the object definition and return
85+
* the resolved name.
86+
*/
87+
export const StorageNameMapping = {
88+
/**
89+
* Resolve the physical table name for an object.
90+
* Falls back to `object.name` when `tableName` is not set.
91+
*
92+
* @param object - Object definition (at minimum `{ name: string; tableName?: string }`)
93+
* @returns The physical table / collection name to use in storage operations.
94+
*/
95+
resolveTableName(object: { name: string; tableName?: string }): string {
96+
return object.tableName ?? object.name;
97+
},
98+
99+
/**
100+
* Resolve the physical column name for a field.
101+
* Falls back to `fieldKey` when `columnName` is not set on the field.
102+
*
103+
* @param fieldKey - The protocol-level field key (snake_case identifier).
104+
* @param field - Field definition (at minimum `{ columnName?: string }`).
105+
* @returns The physical column name to use in storage operations.
106+
*/
107+
resolveColumnName(fieldKey: string, field: { columnName?: string }): string {
108+
return field.columnName ?? fieldKey;
109+
},
110+
111+
/**
112+
* Build a complete field-key → column-name map for an entire object.
113+
*
114+
* @param fields - The fields record from an ObjectSchema.
115+
* @returns A record mapping every protocol field key to its physical column name.
116+
*/
117+
buildColumnMap(fields: Record<string, { columnName?: string }>): Record<string, string> {
118+
const map: Record<string, string> = {};
119+
for (const key of Object.keys(fields)) {
120+
map[key] = fields[key].columnName ?? key;
121+
}
122+
return map;
123+
},
124+
125+
/**
126+
* Build a reverse column-name → field-key map for an entire object.
127+
* Useful for translating storage-layer results back to protocol-level field keys.
128+
*
129+
* @param fields - The fields record from an ObjectSchema.
130+
* @returns A record mapping every physical column name back to its protocol field key.
131+
*/
132+
buildReverseColumnMap(fields: Record<string, { columnName?: string }>): Record<string, string> {
133+
const map: Record<string, string> = {};
134+
for (const key of Object.keys(fields)) {
135+
const col = fields[key].columnName ?? key;
136+
map[col] = key;
137+
}
138+
return map;
139+
},
140+
} as const;

packages/spec/src/system/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,8 @@ export * from './tenant.zod';
4747
export * from './license.zod';
4848
export * from './registry-config.zod';
4949

50+
// Constants
51+
export * from './constants';
52+
5053
// Types
5154
export * from './types';

0 commit comments

Comments
 (0)