Skip to content
Merged
22 changes: 11 additions & 11 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,13 @@ These are the backbone of ObjectStack's enterprise capabilities.
| Packages (total) | 27 |
| Apps | 2 (Studio, Docs) |
| Examples | 4 (Todo, CRM, Host, BI Plugin) |
| Zod Schema Files | 176 |
| Zod Schema Files | 177 |
| Exported Schemas | 1,100+ |
| `.describe()` Annotations | 7,111+ |
| Service Contracts | 25 |
| Contracts Implemented | 11 (44%) |
| Test Files | 197 |
| Tests Passing | 5,363 / 5,363 |
| Test Files | 199 |
| Tests Passing | 5,468 / 5,468 |
| `@deprecated` Items | 3 |
| Protocol Domains | 15 (Data, UI, AI, API, Automation, Cloud, Contracts, Identity, Integration, Kernel, QA, Security, Shared, Studio, System) |

Expand Down Expand Up @@ -129,7 +129,7 @@ The following renames are planned for packages that implement core service contr

- [x] **Data Protocol** — Object, Field (35+ types), Query, Filter, Validation, Hook, Datasource, Dataset, Analytics, Document
- [x] **Driver Specifications** — Memory, PostgreSQL, MongoDB driver schemas + SQL/NoSQL abstractions
- [x] **UI Protocol** — View (List/Form/Kanban/Calendar/Gantt), App, Dashboard, Report, Action, Page, Chart, Widget, Theme, Animation, DnD, Touch, Keyboard, Responsive, Offline, Notification, i18n
- [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, Interface, Content Elements
- [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
- [x] **Automation Protocol** — Flow (autolaunched/screen/schedule), Workflow, State Machine, Trigger Registry, Approval, ETL, Sync, Webhook
- [x] **AI Protocol** — Agent, Agent Action, Conversation, Cost, MCP, Model Registry, NLQ, Orchestration, Predictive, RAG Pipeline, Runtime Ops, Feedback Loop, DevOps Agent, Plugin Development
Expand All @@ -142,7 +142,7 @@ The following renames are planned for packages that implement core service contr
- [x] **QA Protocol** — Testing framework schemas
- [x] **Studio Protocol** — Plugin extension schemas
- [x] **Contracts** — 25 service interfaces with full method signatures
- [x] **Stack Definition** — `defineStack()`, `defineView()`, `defineApp()`, `defineFlow()`, `defineAgent()` helpers
- [x] **Stack Definition** — `defineStack()`, `defineView()`, `defineApp()`, `defineInterface()`, `defineFlow()`, `defineAgent()` helpers
- [x] **Error Map** — Custom Zod error messages with `objectStackErrorMap`
- [x] **DX Utilities** — `safeParsePretty()`, `formatZodError()`, `suggestFieldType()`

Expand Down Expand Up @@ -356,13 +356,13 @@ The following renames are planned for packages that implement core service contr

> See [Airtable Interface Gap Analysis](docs/design/airtable-interface-gap-analysis.md) for the full evaluation.

#### Phase A: Interface Foundation (v3.2)
#### Phase A: Interface Foundation (v3.2)

- [ ] `InterfaceSchema` — Self-contained, shareable, multi-page application surface (`src/ui/interface.zod.ts`)
- [ ] `RecordReviewConfigSchema` — Sequential record review/approval page type with navigation and actions
- [ ] Content elements — `element:text`, `element:number`, `element:image`, `element:divider` as `PageComponentType` extensions
- [ ] Per-element data binding — `dataSource` property on `PageComponentInstanceSchema` for multi-object pages
- [ ] Element props — `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema`
- [x] `InterfaceSchema` — Self-contained, shareable, multi-page application surface (`src/ui/interface.zod.ts`)
- [x] `RecordReviewConfigSchema` — Sequential record review/approval page type with navigation and actions
- [x] Content elements — `element:text`, `element:number`, `element:image`, `element:divider` as `PageComponentType` extensions
- [x] Per-element data binding — `dataSource` property on `PageComponentSchema` for multi-object pages
- [x] Element props — `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema`

#### Phase B: Element Library & Builder (v3.3)

Expand Down
41 changes: 26 additions & 15 deletions docs/design/airtable-interface-gap-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **Author:** ObjectStack Core Team
> **Created:** 2026-02-16
> **Status:** Proposal
> **Status:** Phase A Implemented
> **Target Version:** v3.2 – v4.0

---
Expand Down Expand Up @@ -58,13 +58,13 @@ ties them together — specifically:

| Area | Airtable | ObjectStack |
|:---|:---|:---|
| **Interface as a first-class entity** | ✅ Multi-page app per base | 🟡 App + Page exist separately |
| **Interface as a first-class entity** | ✅ Multi-page app per base | ✅ `InterfaceSchema` + `InterfaceNavItemSchema` in App navigation |
| **Drag-and-drop element canvas** | ✅ Free-form element placement | 🟡 Region-based composition |
| **Record Review workflow** | ✅ Built-in record-by-record review | ❌ Not modeled |
| **Element-level data binding** | ✅ Each element binds to any table/view | 🟡 Page-level object binding |
| **Shareable interface URLs** | ✅ Public/private share links | ❌ Not modeled |
| **Interface-level permissions** | ✅ Per-interface user access | 🟡 App-level permissions only |
| **Embeddable interfaces** | ✅ iframe embed codes | ❌ Not modeled |
| **Record Review workflow** | ✅ Built-in record-by-record review | ✅ `RecordReviewConfigSchema` in `PageSchema` |
| **Element-level data binding** | ✅ Each element binds to any table/view | ✅ `ElementDataSourceSchema` per component |
| **Shareable interface URLs** | ✅ Public/private share links | ❌ Not modeled (Phase C) |
| **Interface-level permissions** | ✅ Per-interface user access | ✅ `assignedRoles` on `InterfaceSchema` |
| **Embeddable interfaces** | ✅ iframe embed codes | ❌ Not modeled (Phase C) |

This document proposes specific schema additions and a phased roadmap to close these gaps while
preserving ObjectStack's superior extensibility and enterprise capabilities.
Expand Down Expand Up @@ -562,17 +562,22 @@ export const EmbedConfigSchema = z.object({

## 7. Implementation Road Map

### 7.1 Phase A: Interface Foundation (v3.2 — Q3 2026)
### 7.1 Phase A: Interface Foundation (v3.2 — Q3 2026)

> **Goal:** Establish the "Interface" abstraction as a first-class protocol entity.

- [ ] Define `InterfaceSchema` in `src/ui/interface.zod.ts`
- [ ] Add `RecordReviewConfigSchema` to `PageSchema` types
- [ ] Add content elements to `PageComponentType` (`element:text`, `element:number`, `element:image`, `element:divider`)
- [ ] Add `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema` to component props
- [ ] Add `dataSource` property to `PageComponentInstanceSchema` for per-element data binding
- [ ] Write comprehensive tests for all new schemas
- [ ] Update `src/ui/index.ts` exports
- [x] Define `InterfaceSchema` in `src/ui/interface.zod.ts`
- [x] Add `RecordReviewConfigSchema` to `PageSchema` types
- [x] Add content elements to `PageComponentType` (`element:text`, `element:number`, `element:image`, `element:divider`)
- [x] Add `ElementTextPropsSchema`, `ElementNumberPropsSchema`, `ElementImagePropsSchema` to component props
- [x] Add `dataSource` property to `PageComponentSchema` for per-element data binding
- [x] Write comprehensive tests for all new schemas
- [x] Update `src/ui/index.ts` exports
- [x] Merge `InterfacePageSchema` into `PageSchema` — unified `PageTypeSchema` with 16 types
- [x] Extract shared `SortItemSchema` to `shared/enums.zod.ts`
- [x] Export `defineInterface()` from root index.ts
- [x] Add `InterfaceNavItemSchema` to `AppSchema` navigation for App↔Interface bridging
- [x] Disambiguate overlapping page types (`record`/`record_detail`, `home`/`overview`) in `PageTypeSchema` docs
- [ ] Generate JSON Schema for new types

**Estimated effort:** 2–3 weeks
Expand Down Expand Up @@ -644,6 +649,12 @@ export const EmbedConfigSchema = z.object({
| 3 | Phase sharing/embedding to v4.0 | Requires security infrastructure (RLS, share tokens, origin validation) that depends on service implementations in v3.x | 2026-02-16 |
| 4 | Keep `RecordReviewConfig` as part of `PageSchema` rather than a new view type | Record Review is a page layout pattern, not a data visualization (view). It combines record display with workflow actions. | 2026-02-16 |
| 5 | Support per-element `dataSource` instead of page-level-only binding | Critical for dashboards and overview pages that aggregate data from multiple objects | 2026-02-16 |
| 6 | Merge `InterfacePageSchema` into `PageSchema` | 7 of 9 properties were identical. Unified `PageTypeSchema` with 16 types (4 platform + 12 interface) eliminates duplication while preserving both use cases. `InterfaceSchema.pages` now references `PageSchema` directly. | 2026-02-16 |
| 7 | Extract shared `SortItemSchema` to `shared/enums.zod.ts` | Sort item pattern `{ field, order }` was defined inline in 4+ schemas (ElementDataSource, RecordReview, ListView, RecordRelatedList). Shared schema ensures consistency and reduces duplication. | 2026-02-16 |
| 8 | `InterfaceBrandingSchema` extends `AppBrandingSchema` | 2 of 3 fields (`primaryColor`, `logo`) were identical. Using `.extend()` adds only `coverImage`, avoiding property divergence. | 2026-02-16 |
| 9 | Keep `InterfaceSchema` and `AppSchema` separate — do NOT merge | **App** = navigation container (menu tree, routing, mobile nav). **Interface** = content surface (ordered pages, data binding, role-specific views). Merging would conflate navigation topology with page composition. An App can embed multiple Interfaces via `InterfaceNavItemSchema`. This mirrors Salesforce App/FlexiPage and Airtable Base/Interface separation. | 2026-02-16 |
| 10 | Add `InterfaceNavItemSchema` to bridge App↔Interface | `AppSchema.navigation` lacked a way to reference Interfaces. Added `type: 'interface'` nav item with `interfaceName` and optional `pageName` to enable App→Interface navigation without merging the schemas. | 2026-02-16 |
| 11 | Keep all 16 page types — no merge, disambiguate in docs | Reviewed overlapping pairs: `record` vs `record_detail` (component-based layout vs auto-generated field display), `home` vs `overview` (platform landing vs interface navigation hub), `app`/`utility`/`blank` (distinct layout contexts). Each serves a different use case at a different abstraction level. Added disambiguation comments to `PageTypeSchema`. | 2026-02-16 |

---

Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export * from './stack.zod';
// DX Helper Functions (re-exported for convenience)
export { defineView } from './ui/view.zod';
export { defineApp } from './ui/app.zod';
export { defineInterface } from './ui/interface.zod';
export { defineFlow } from './automation/flow.zod';
export { defineAgent } from './ai/agent.zod';

Expand Down
21 changes: 21 additions & 0 deletions packages/spec/src/shared/enums.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
AggregationFunctionEnum,
SortDirectionEnum,
SortItemSchema,
MutationEventEnum,
IsolationLevelEnum,
CacheStrategyEnum,
Expand Down Expand Up @@ -46,6 +47,26 @@ describe('SortDirectionEnum', () => {
});
});

describe('SortItemSchema', () => {
it('should accept valid sort item', () => {
const result = SortItemSchema.parse({ field: 'created_at', order: 'desc' });
expect(result.field).toBe('created_at');
expect(result.order).toBe('desc');
});

it('should reject without field', () => {
expect(() => SortItemSchema.parse({ order: 'asc' })).toThrow();
});

it('should reject without order', () => {
expect(() => SortItemSchema.parse({ field: 'name' })).toThrow();
});

it('should reject invalid order direction', () => {
expect(() => SortItemSchema.parse({ field: 'name', order: 'up' })).toThrow();
});
});

describe('MutationEventEnum', () => {
it('should accept all mutation events', () => {
const valid = ['insert', 'update', 'delete', 'upsert'];
Expand Down
7 changes: 7 additions & 0 deletions packages/spec/src/shared/enums.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ export const SortDirectionEnum = z.enum(['asc', 'desc'])
.describe('Sort order direction');
export type SortDirection = z.infer<typeof SortDirectionEnum>;

/** Reusable sort item — field + direction pair used across views, data sources, filters */
export const SortItemSchema = z.object({
field: z.string().describe('Field name to sort by'),
order: SortDirectionEnum.describe('Sort direction'),
}).describe('Sort field and direction pair');
export type SortItem = z.infer<typeof SortItemSchema>;

/** CRUD mutation events used across hook, validation, object CDC */
export const MutationEventEnum = z.enum([
'insert', 'update', 'delete', 'upsert',
Expand Down
81 changes: 81 additions & 0 deletions packages/spec/src/ui/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DashboardNavItemSchema,
PageNavItemSchema,
UrlNavItemSchema,
InterfaceNavItemSchema,
GroupNavItemSchema,
defineApp,
type App,
Expand Down Expand Up @@ -127,6 +128,53 @@ describe('UrlNavItemSchema', () => {
});
});

describe('InterfaceNavItemSchema', () => {
it('should accept interface nav item with just interfaceName', () => {
const navItem = {
id: 'nav_order_review',
label: 'Order Review',
type: 'interface' as const,
interfaceName: 'order_review',
};

const result = InterfaceNavItemSchema.parse(navItem);
expect(result.interfaceName).toBe('order_review');
expect(result.pageName).toBeUndefined();
});

it('should accept interface nav item with pageName', () => {
const navItem = {
id: 'nav_sales_dashboard',
label: 'Sales Dashboard',
icon: 'layout-dashboard',
type: 'interface' as const,
interfaceName: 'sales_portal',
pageName: 'page_dashboard',
};

const result = InterfaceNavItemSchema.parse(navItem);
expect(result.interfaceName).toBe('sales_portal');
expect(result.pageName).toBe('page_dashboard');
});

it('should work in NavigationItemSchema union', () => {
expect(() => NavigationItemSchema.parse({
id: 'nav_interface',
label: 'Interface',
type: 'interface',
interfaceName: 'my_interface',
})).not.toThrow();
});

it('should reject without interfaceName', () => {
expect(() => InterfaceNavItemSchema.parse({
id: 'nav_missing',
label: 'Missing',
type: 'interface',
})).toThrow();
});
});

describe('GroupNavItemSchema', () => {
it('should accept group nav item', () => {
const navItem = {
Expand Down Expand Up @@ -459,6 +507,39 @@ describe('AppSchema', () => {

expect(() => AppSchema.parse(hrApp)).not.toThrow();
});

it('should accept app with interface navigation items', () => {
const app: App = {
name: 'data_platform',
label: 'Data Platform',
navigation: [
{
id: 'nav_home',
label: 'Home',
icon: 'home',
type: 'dashboard',
dashboardName: 'main_dashboard',
},
{
id: 'nav_order_review',
label: 'Order Review',
icon: 'clipboard-check',
type: 'interface',
interfaceName: 'order_review',
},
{
id: 'nav_sales_portal',
label: 'Sales Portal',
icon: 'layout-dashboard',
type: 'interface',
interfaceName: 'sales_portal',
pageName: 'page_dashboard',
},
],
};

expect(() => AppSchema.parse(app)).not.toThrow();
});
});
});

Expand Down
15 changes: 14 additions & 1 deletion packages/spec/src/ui/app.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,18 @@ export const UrlNavItemSchema = BaseNavItemSchema.extend({
});

/**
* 5. Group Navigation Item
* 5. Interface Navigation Item
* Navigates to a specific Interface (self-contained multi-page surface).
* Bridges AppSchema (navigation container) with InterfaceSchema (content surface).
*/
export const InterfaceNavItemSchema = BaseNavItemSchema.extend({
type: z.literal('interface'),
interfaceName: z.string().describe('Target interface name (snake_case)'),
pageName: z.string().optional().describe('Specific page within the interface to open'),
});

/**
* 6. Group Navigation Item
* A container for child navigation items (Sub-menu).
* Does not perform navigation itself.
*/
Expand All @@ -101,6 +112,7 @@ export const NavigationItemSchema: z.ZodType<any> = z.lazy(() =>
DashboardNavItemSchema,
PageNavItemSchema,
UrlNavItemSchema,
InterfaceNavItemSchema,
GroupNavItemSchema.extend({
children: z.array(NavigationItemSchema).describe('Child navigation items'),
})
Expand Down Expand Up @@ -256,4 +268,5 @@ export type ObjectNavItem = z.infer<typeof ObjectNavItemSchema>;
export type DashboardNavItem = z.infer<typeof DashboardNavItemSchema>;
export type PageNavItem = z.infer<typeof PageNavItemSchema>;
export type UrlNavItem = z.infer<typeof UrlNavItemSchema>;
export type InterfaceNavItem = z.infer<typeof InterfaceNavItemSchema>;
export type GroupNavItem = z.infer<typeof GroupNavItemSchema> & { children: NavigationItem[] };
47 changes: 46 additions & 1 deletion packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,45 @@ export const AIChatWindowProps = z.object({
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
});

/**
* ----------------------------------------------------------------------
* 3. Content Element Components (Airtable Interface Parity)
* ----------------------------------------------------------------------
*/

export const ElementTextPropsSchema = z.object({
content: z.string().describe('Text or Markdown content'),
variant: z.enum(['heading', 'subheading', 'body', 'caption'])
.optional().default('body').describe('Text style variant'),
align: z.enum(['left', 'center', 'right'])
.optional().default('left').describe('Text alignment'),
/** ARIA accessibility */
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
});

export const ElementNumberPropsSchema = z.object({
object: z.string().describe('Source object'),
field: z.string().optional().describe('Field to aggregate'),
aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max'])
.describe('Aggregation function'),
filter: z.any().optional().describe('Filter criteria'),
format: z.enum(['number', 'currency', 'percent']).optional().describe('Number display format'),
prefix: z.string().optional().describe('Prefix text (e.g. "$")'),
suffix: z.string().optional().describe('Suffix text (e.g. "%")'),
/** ARIA accessibility */
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
});

export const ElementImagePropsSchema = z.object({
src: z.string().describe('Image URL or attachment field'),
alt: z.string().optional().describe('Alt text for accessibility'),
fit: z.enum(['cover', 'contain', 'fill'])
.optional().default('cover').describe('Image object-fit mode'),
height: z.number().optional().describe('Fixed height in pixels'),
/** ARIA accessibility */
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
});

/**
* ----------------------------------------------------------------------
* Component Props Map
Expand Down Expand Up @@ -164,7 +203,13 @@ export const ComponentPropsMap = {

// AI
'ai:chat_window': AIChatWindowProps,
'ai:suggestion': z.object({ context: z.string().optional() })
'ai:suggestion': z.object({ context: z.string().optional() }),

// Content Elements
'element:text': ElementTextPropsSchema,
'element:number': ElementNumberPropsSchema,
'element:image': ElementImagePropsSchema,
'element:divider': EmptyProps,
} as const;

/**
Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ export * from './keyboard.zod';
export * from './animation.zod';
export * from './notification.zod';
export * from './dnd.zod';
export * from './interface.zod';
Loading
Loading