diff --git a/content/docs/concepts/architecture.mdx b/content/docs/concepts/architecture.mdx index c845706bed..5b930d9cd4 100644 --- a/content/docs/concepts/architecture.mdx +++ b/content/docs/concepts/architecture.mdx @@ -143,65 +143,65 @@ That's the job of the other layers. ### Example: Permission Rules ```typescript -// packages/crm/src/permissions/customer.permission.ts -import { Permission } from '@objectstack/spec'; - -export const CustomerPermission = Permission({ - object: 'customer', - rules: [ - { - profile: 'sales_rep', - crud: { - create: true, - read: true, - update: true, - delete: false, // Only managers can delete - }, - fieldPermissions: { - annual_revenue: { read: true, edit: false }, // Read-only - }, +// packages/crm/src/permissions/sales_rep.permission.ts +import type { PermissionSet } from '@objectstack/spec/security'; + +// A permission set is keyed by object name. Each object grants the four +// CRUD verbs (allowCreate / allowRead / allowEdit / allowDelete), and +// optional field-level security keyed by "object.field". +export const SalesRep: PermissionSet = { + name: 'sales_rep', + isProfile: true, + objects: { + customer: { + allowCreate: true, + allowRead: true, + allowEdit: true, + allowDelete: false, // Only managers can delete }, - { - profile: 'sales_manager', - crud: { - create: true, - read: true, - update: true, - delete: true, - }, - }, - ], -}); + }, + fields: { + 'customer.annual_revenue': { readable: true, editable: false }, // Read-only + }, +}; ``` ### Example: Workflow Automation -```typescript -// packages/crm/src/workflows/customer.workflow.ts -import { Workflow } from '@objectstack/spec'; +Automations are authored with `defineFlow`. A flow is a **node graph** — +a typed `start`/`end` plus decision and action nodes wired together by +`edges` — driven by a trigger `type` (here `record_change`): -export const CustomerWorkflow = Workflow({ - object: 'customer', - trigger: 'after_create', - conditions: [ - { field: 'annual_revenue', operator: 'greaterThan', value: 1000000 }, +```typescript +// packages/crm/src/flows/high_value_customer.flow.ts +import { defineFlow } from '@objectstack/spec'; + +export const HighValueCustomer = defineFlow({ + name: 'high_value_customer', + label: 'High-Value Customer Routing', + type: 'record_change', // runs when a customer record changes + nodes: [ + { id: 'start', type: 'start', label: 'Customer created' }, + { id: 'assign', type: 'action', label: 'Assign to enterprise team' }, + { id: 'notify', type: 'action', label: 'Alert sales leadership' }, + { id: 'end', type: 'end', label: 'End' }, ], - actions: [ - { - type: 'assign_owner', - params: { owner: 'enterprise_sales_team' }, - }, - { - type: 'send_email', - params: { - template: 'high_value_customer_alert', - to: 'sales-leadership@company.com', - }, - }, + edges: [ + // Only branch when annual_revenue > 1,000,000 (CEL condition) + { id: 'e1', source: 'start', target: 'assign', condition: 'record.annual_revenue > 1000000' }, + { id: 'e2', source: 'assign', target: 'notify' }, + { id: 'e3', source: 'notify', target: 'end' }, ], }); ``` + +The node/edge wiring above is illustrative. For the full set of node types, +trigger semantics, and condition syntax, see the +[automation skill](/docs/protocol/objectos) and the `defineFlow` schema in +`@objectstack/spec/automation`. + + ObjectOS **orchestrates** these rules at runtime, independent of the data structure or UI. ## Layer 3: ObjectUI (View Protocol) @@ -220,63 +220,47 @@ ObjectOS **orchestrates** these rules at runtime, independent of the data struct ### Example: List View ```typescript -// packages/crm/src/views/customer_list.view.ts -import { ListView } from '@objectstack/spec'; +// packages/crm/src/views/customer.view.ts +import { defineView } from '@objectstack/spec'; -export const CustomerListView = ListView({ +// A view is a container bound to an object; its `list` slot holds the +// list-view config. Columns are field names; sort is { field, order }. +export const CustomerView = defineView({ object: 'customer', - label: 'All Customers', - type: 'grid', - columns: [ - { field: 'name', width: 200 }, - { field: 'industry', width: 150 }, - { field: 'annual_revenue', width: 150 }, - { field: 'primary_contact', width: 180 }, - ], - filters: [ - { field: 'industry', operator: 'equals' }, - { field: 'annual_revenue', operator: 'greaterThan' }, - ], - defaultSort: { field: 'name', direction: 'asc' }, + list: { + label: 'All Customers', + type: 'grid', + columns: ['name', 'industry', 'annual_revenue', 'primary_contact'], + sort: [{ field: 'name', order: 'asc' }], + }, }); ``` ### Example: Form View ```typescript -// packages/crm/src/views/customer_form.view.ts -import { FormView } from '@objectstack/spec'; +// packages/crm/src/views/customer.view.ts +import { defineView } from '@objectstack/spec'; -export const CustomerFormView = FormView({ +// The same view container can also carry a `form` slot. A form is laid out +// with `sections`, each section listing the fields it renders. +export const CustomerView = defineView({ object: 'customer', - label: 'Customer Details', - type: 'tabbed', - tabs: [ - { - label: 'Overview', - sections: [ - { - label: 'Company Information', - fields: ['name', 'industry', 'annual_revenue'], - }, - { - label: 'Contact', - fields: ['primary_contact'], - }, - ], - }, - { - label: 'Related Records', - sections: [ - { - label: 'Opportunities', - component: 'related_list', - object: 'opportunity', - filter: { customer: '$recordId' }, - }, - ], - }, - ], + form: { + label: 'Customer Details', + type: 'tabbed', + sections: [ + { + label: 'Company Information', + columns: 2, + fields: ['name', 'industry', 'annual_revenue'], + }, + { + label: 'Contact', + fields: ['primary_contact'], + }, + ], + }, }); ``` @@ -290,6 +274,13 @@ The UI doesn't "know" the field types. It asks ObjectQL for the schema and rende Let's trace a **real-world scenario**: A sales rep creates a new high-value customer. + +The snippets in this walkthrough (`Auth.getCurrentUser`, `Permission.check`, +`ObjectQL.getSchema`, `driver.insert`, `Workflow.getTriggersFor`, …) are +**conceptual pseudo-code** that illustrate the control flow between layers. +They are not literal exported APIs — don't search for these exact symbols. + + ### Step 1: User Action (ObjectUI) ``` @@ -431,15 +422,22 @@ export const Opportunity = ObjectSchema.create({ ### 2. ObjectOS: Define Business Rules ```typescript -export const OpportunityWorkflow = Workflow({ - object: 'opportunity', - trigger: 'field_update', - conditions: [ - { field: 'stage', operator: 'equals', value: 'closed_won' }, +import { defineFlow } from '@objectstack/spec'; + +export const OpportunityWon = defineFlow({ + name: 'opportunity_won', + label: 'On Opportunity Won', + type: 'record_change', + nodes: [ + { id: 'start', type: 'start', label: 'Stage changed' }, + { id: 'invoice', type: 'action', label: 'Create invoice' }, + { id: 'notify', type: 'action', label: 'Notify sales team' }, + { id: 'end', type: 'end', label: 'End' }, ], - actions: [ - { type: 'create_invoice', params: { object: 'invoice' } }, - { type: 'send_notification', params: { to: 'sales_team' } }, + edges: [ + { id: 'e1', source: 'start', target: 'invoice', condition: "record.stage == 'closed_won'" }, + { id: 'e2', source: 'invoice', target: 'notify' }, + { id: 'e3', source: 'notify', target: 'end' }, ], }); ``` @@ -447,16 +445,22 @@ export const OpportunityWorkflow = Workflow({ ### 3. ObjectUI: Define the Kanban View ```typescript -export const OpportunityKanban = ListView({ +import { defineView } from '@objectstack/spec'; + +// For a kanban list, set type: 'kanban' and put the board config under +// `kanban` (groupByField selects the column axis). `columns` is the list +// of card fields. Drag-and-drop between columns is built in. +export const OpportunityBoard = defineView({ object: 'opportunity', - type: 'kanban', - groupBy: 'stage', - columns: [ - { field: 'title' }, - { field: 'amount' }, - { field: 'customer' }, - ], - enableDragDrop: true, + list: { + type: 'kanban', + columns: ['title', 'amount', 'customer'], + kanban: { + groupByField: 'stage', + summarizeField: 'amount', + columns: ['title', 'amount'], + }, + }, }); ``` @@ -546,7 +550,7 @@ The three protocols are **loosely coupled** but **tightly integrated**: ## Next Steps -- [ObjectQL: Data Protocol]((/docs/protocol/objectql)) - Full data protocol specification +- [ObjectQL: Data Protocol](/docs/protocol/objectql) - Full data protocol specification - [ObjectUI: UI Protocol](/docs/protocol/objectui) - Full view protocol specification - [ObjectOS: System Protocol](/docs/protocol/objectos) - Full control protocol specification - [Developer Guide](/docs/getting-started/quick-start) - Build your first ObjectStack application diff --git a/content/docs/concepts/cloud-artifact-api.mdx b/content/docs/concepts/cloud-artifact-api.mdx index e878c66e07..9632a50830 100644 --- a/content/docs/concepts/cloud-artifact-api.mdx +++ b/content/docs/concepts/cloud-artifact-api.mdx @@ -97,6 +97,11 @@ data-plane request: 5. Configured default environment 6. Single unambiguous environment +This resolution order is implemented by the cloud host distribution's +kernel-resolver (`@objectstack/objectos-runtime`). The open-source dispatcher +(`packages/runtime/src/http-dispatcher.ts`) only provides the seam: when no +resolver is injected it serves every request from a single default kernel. + Control-plane routes under `/api/v1/cloud/environments/...` are management calls, not data-plane calls. @@ -108,8 +113,8 @@ calls, not data-plane calls. |:---|:---| | `packages/cli/src/commands/publish.ts` | CLI publish command and endpoint construction. | | `packages/cli/src/commands/rollback.ts` | CLI revision activation command. | -| `packages/runtime/src/cloud/environment-registry.ts` | Runtime seam for id/hostname environment resolution. | -| `packages/runtime/src/cloud/runtime-config-plugin.ts` | Console runtime-config for active/default environment state. | +| `packages/runtime/src/http-dispatcher.ts` | Kernel-resolution seam for per-request environment resolution. The concrete id/hostname registry ships in the host distribution `@objectstack/objectos-runtime` (not part of this open-source repo). | +| `packages/cloud-connection/src/runtime-config-plugin.ts` | Console runtime-config for active/default environment state. | | `packages/spec/src/system/environment-artifact.zod.ts` | Normative artifact envelope schema. | --- diff --git a/content/docs/concepts/cluster-semantics.mdx b/content/docs/concepts/cluster-semantics.mdx index ae81353b90..9424e39992 100644 --- a/content/docs/concepts/cluster-semantics.mdx +++ b/content/docs/concepts/cluster-semantics.mdx @@ -134,8 +134,10 @@ deliverySemantics: 'best-effort' | 'at-least-once' | 'exactly-once' duplicates are possible during retry. Use for: webhook outbox, audit shipping, async job enqueue. - **`exactly-once`** — Reserved keyword; **not implemented in v1**. The - protocol accepts it so future runtimes can add it without a breaking - change. Today the runtime rejects it at startup with a clear error. + protocol accepts the enum value so future runtimes can add it without a + breaking change. Startup rejection of this value is *intended* behaviour + but is **not yet implemented** — the bus does not currently inspect or + reject it. ### 4.3 Partition key — what ordering does the bus preserve? @@ -155,9 +157,11 @@ out of emit order. ```ts eventBus.emit('account.updated', payload, { - scope: 'cluster', - deliverySemantics: 'at-least-once', - partitionKey: payload.id, + cluster: { + scope: 'cluster', + deliverySemantics: 'at-least-once', + partitionKey: payload.id, + }, }) ``` @@ -197,21 +201,44 @@ leaderStrategy: 'leader-elected' | 'partitioned' | 'idempotent-broadcast' and the work itself is idempotent (writes use UPSERT, side effects are keyed). Use for: cache invalidation handlers, projection rebuilders. +These annotations are **nested** under a `cluster` key on the service +registration (`ServiceMetadata.cluster` / `ServiceFactoryRegistration.cluster` += `ServiceClusterAnnotations`), not declared as flat top-level fields: + +```ts +{ + name: 'cron-scheduler', + cluster: { + clusterScope: 'cluster', + leaderStrategy: 'leader-elected', + // clusterId: 'scheduler', // optional — share one leadership lock + // across physically different services (e.g. safe rolling upgrades); + // defaults to the service name. + }, +} +``` + > **Default rationale.** `node` is the only safe default. A plugin author > who forgets to declare scope gets the single-machine behaviour, which is > always correct; opting up to `cluster` is an explicit decision. -### 5.1 What the runtime gives you for free +### 5.1 What the runtime will give you for free -When a service declares `clusterScope: 'cluster'`, the runtime automatically: +> **Status: not yet implemented (Phase 4).** The schema accepts +> `clusterScope` / `leaderStrategy` annotations today, but no runtime code +> yet consumes them or performs leader election. The behaviour below is the +> intended contract once Phase 4 (see §10) lands. -1. Wraps `onEnable` so the work only starts after lock acquisition (for +When a service declares `clusterScope: 'cluster'`, the runtime will +automatically: + +1. Wrap `onEnable` so the work only starts after lock acquisition (for `leader-elected`). -2. Refreshes the lock TTL via heartbeat while the node is healthy. -3. Releases the lock on `onDisable` or graceful shutdown. -4. Emits `cluster:leader-changed` events so dependent services can react. +2. Refresh the lock TTL via heartbeat while the node is healthy. +3. Release the lock on `onDisable` or graceful shutdown. +4. Emit a leadership-change event so dependent services can react. -A plugin author writing a cron scheduler does **not** write any lock code — +A plugin author writing a cron scheduler will **not** write any lock code — they declare scope and strategy, and the runtime does the rest. ## 6. Metadata versioning and cache coherence @@ -224,14 +251,18 @@ current protocol lacks: a **monotonic version per item** and a ### 6.1 Monotonic version -Every persisted metadata record gains a `version: bigint` column that -increments on every write (already present in -`system/metadata-persistence.zod.ts` as `version: number` for optimistic -concurrency — this ADR formalises and widens it to `bigint` so caches can -compare freshness across long-running clusters without overflow). +A `version` column is already present on every persisted metadata record in +`system/metadata-persistence.zod.ts` (`version: z.number()`, used today for +optimistic concurrency). This ADR proposes formalising it as a monotonic +per-item version and **widening it to `bigint`** so caches can compare +freshness across long-running clusters without overflow. + +> **Status: planned.** The column is still `version: number` in the schema; +> the widening and the cache-comparison contract below describe the target +> design, not current runtime behaviour. -Caches store `{value, version}`. On any incoming change notification the -cache compares the incoming version with the stored one: +Once wired, caches store `{value, version}`. On any incoming change +notification the cache compares the incoming version with the stored one: - Incoming version `>` stored → invalidate (apply new value or evict) - Incoming version `≤` stored → ignore (out-of-order notification, already @@ -240,35 +271,57 @@ cache compares the incoming version with the stored one: This eliminates a whole class of bugs where a slow-arriving "old" invalidation evicts a "newer" value the node has already learned about. -### 6.2 The `metadata:changed` event +### 6.2 The metadata change event -Whenever a metadata record is written (create / update / delete / publish), -the persistence layer **must** emit: +**Current behaviour.** Cross-node metadata invalidation already works, but +it does **not** go through `eventBus.emit`. The metadata manager fans out +over the cluster PubSub channel `metadata.changed` (note: dot, not colon) +with the payload shape `ClusterMetadataChangedPayload`: ```ts -eventBus.emit('metadata:changed', { +// packages/metadata/src/metadata-manager.ts +ctx.cluster.pubsub.publish('metadata.changed', { + originNode, // used for loopback suppression + type, // 'object' | 'view' | 'dashboard' | … + event, // the local watch event, replayed verbatim on peers +}) +``` + +Peers suppress their own messages by `originNode` and replay the watch +event locally — there is currently **no** `version` / `name` / `tenantId` / +`operation` field and **no** version comparison. + +**Target spec (planned).** The richer, version-stamped payload below is +defined as `MetadataChangedEventPayloadSchema` in `kernel/cluster.zod.ts` +but is **not yet wired** into the runtime: + +```ts +// MetadataChangedEventPayloadSchema — target shape, not yet emitted +{ type: 'object' | 'view' | 'flow' | …, name: '', tenantId?: '', version: , operation: 'create' | 'update' | 'delete' | 'publish', -}, { - scope: 'cluster', - deliverySemantics: 'at-least-once', - partitionKey: `${type}:${name}`, -}) +} ``` -The partition key guarantees that two rapid updates to the same item are -applied to every node's cache in the order they occurred. The `at-least- -once` guarantee means a node briefly partitioned from the bus will catch -up on reconnect (the transport replays from the last acknowledged offset). +When wired with `scope: 'cluster'` + `deliverySemantics: 'at-least-once'` +and a `partitionKey` of `` `${type}:${name}` ``, the partition key would +guarantee that two rapid updates to the same item are applied to every +node's cache in order, and the at-least-once guarantee would let a briefly +partitioned node catch up on reconnect. ### 6.3 Reader contract -All metadata readers (registry, loader, query engine) must: +> **Status: planned.** Today readers are invalidated by the `metadata.changed` +> PubSub fan-out described in §6.2, which replays watch events verbatim +> without version comparison. The contract below is the target design that +> goes with the version-stamped payload. -1. Subscribe to `metadata:changed` on startup. +All metadata readers (registry, loader, query engine) should: + +1. Subscribe to the metadata change channel on startup. 2. Compare incoming `version` with cached `version` before evicting. 3. Treat missing `version` as `0` (legacy compatibility). @@ -305,18 +358,27 @@ Ask: **if I deploy 5 nodes, should there be 5 of these or 1?** ### 7.3 "I want to read metadata in a long-lived cache" -You don't write a cache. You ask the kernel for one: +There is **no** `ctx.cluster.cache(...)` factory. The cluster service +exposes only the four primitives (`ctx.cluster.{pubsub, lock, kv, counter}`, +see §3) — caches are *derived* from `KV + PubSub` by `service-cache`, they +are not a built-in cluster method. + +To keep an in-process metadata cache coherent today, subscribe to the +metadata change channel via PubSub and invalidate on each notification: ```ts -const cache = ctx.cluster.cache({ - key: m => `obj:${m.name}`, - version: m => m.version, - invalidateOn: { type: 'object' }, +ctx.cluster.pubsub.subscribe('metadata.changed', (payload) => { + // payload: { originNode?, type, event } + if (payload.type === 'object') { + myObjectCache.invalidate(payload.event) + } }) ``` -The runtime wires `metadata:changed` subscription, version comparison, and -the underlying KV automatically. No `if (cluster)` branches in your code. +A higher-level cache factory (with automatic version comparison) is part of +the Phase 4 / §6 target design but is **not yet implemented**. No +`if (cluster)` branches are needed either way — the `memory` driver makes +the subscribe call a no-op-equivalent local fan-out on a single node. ### 7.4 What you must never do @@ -349,15 +411,17 @@ defineStack({ lockTtlMs: 15000, // 3× heartbeat is the safe ratio tenantIsolation: 'channel-prefix', // 'channel-prefix' | 'none' + // driverOptions: { /* opaque, driver-specific options */ }, }, }) ``` Every cluster primitive selects its implementation from `cluster.driver`. -When the field is absent, the kernel uses the in-memory driver and a -single-line warning is logged in `production` mode: *"running in single- -node cluster mode; horizontal scale will be incorrect until cluster.driver -is configured."* +When the field is absent, the kernel silently auto-registers the in-memory, +single-node driver. Emitting a production warning in this case (so operators +notice that horizontal scale will be incorrect until `cluster.driver` is +configured) is *intended* but **not yet implemented** — no such warning is +logged today. ### 8.1 Driver matrix @@ -455,8 +519,9 @@ a new implementation that calls `registerClusterDriver()`. ## 11. Non-goals (v1) -- **Exactly-once delivery** — accepted as a keyword in the protocol, - rejected at runtime startup. Implementing it correctly requires +- **Exactly-once delivery** — accepted as a keyword in the protocol; + startup rejection of the value is intended but not yet implemented. + Implementing it correctly requires distributed transactions or idempotency tokens that are out of scope for v1. - **Cross-region replication** — single-region clusters only. Multi-region diff --git a/content/docs/concepts/core/architecture.mdx b/content/docs/concepts/core/architecture.mdx index 9302a0dd30..7061d299b6 100644 --- a/content/docs/concepts/core/architecture.mdx +++ b/content/docs/concepts/core/architecture.mdx @@ -22,61 +22,69 @@ graph TD ``` ### 1. Discovery Phase -The kernel scans for registered plugins. In a typical app, plugins are explicitly registered via code, but the kernel also supports scanning `package.json` for auto-discovery in certain environments. +The kernel collects the plugins that have been explicitly registered via `kernel.use(plugin)`. The core kernel has no automatic plugin discovery; every plugin must be registered in code before `bootstrap()` is called. ### 2. Validation Phase -Each plugin's manifest is validated against the **Plugin Protocol** specific in `@objectstack/spec`. -- **Name**: Must be unique. -- **Version**: SemVer compatible. +Each plugin's structure is validated locally by the plugin loader. There is no spec-driven schema check here — the kernel verifies a small set of required properties: +- **Name**: Must be present (and unique across registered plugins). +- **Init hook**: An `init` function is required. +- **Version**: Must be a valid SemVer string (defaults to `0.0.0` when omitted). ### 3. Resolution Phase The kernel resolves the dependency graph. If Plugin A depends on Plugin B, the kernel ensures Plugin B is loaded first. Circular dependencies are detected and will throw an error. -### 4. Init Phase (`onInit`) -The `onInit` hook is called for all plugins in dependency order. This is where plugins should: +### 4. Init Phase (`init`) +The required `init(ctx)` hook is called for all plugins in dependency order. This is where plugins should: - Register Services involved in Dependency Injection. - Register Event Listeners. - **NOT** perform async IO (like database connections) if possible. -### 5. Start Phase (`onStart`) -After all plugins have successfully initialized, the `onStart` hook is called in dependency order. This is where the application "comes alive": +### 5. Start Phase (`start`) +After all plugins have successfully initialized, the optional `start(ctx)` hook is called in dependency order. This is where the application "comes alive": - Database connections are established. - HTTP servers start listening. - Background jobs are scheduled. -## IKernel Interface Reference +Plugins may also implement an optional `destroy()` hook, which the kernel calls in reverse dependency order during `shutdown()` to release resources. -The `IKernel` is the central orchestrator exposed to the host application. +## ObjectKernel Public API + +The `ObjectKernel` is the central orchestrator exposed to the host application. The methods you call from host code are: ```typescript -export interface IKernel { +class ObjectKernel { /** - * Start the Kernel - * Initializes and starts all registered plugins and drivers. + * Register a plugin. Must be called before bootstrap(). + * Returns `this` so calls can be chained. */ - start(): Promise; + use(plugin: Plugin): Promise; /** - * Stop the Kernel - * Gracefully shuts down all plugins. + * Bootstrap the kernel. + * Runs the validation -> resolution -> init -> start lifecycle for all + * registered plugins, then moves the kernel into the ready state. */ - stop(): Promise; + bootstrap(): Promise; /** - * Get a Service - * Retrieves a registered service by ID. + * Gracefully shut down the kernel, calling each plugin's destroy() hook + * in reverse dependency order. */ - getService(id: string): T; + shutdown(): Promise; /** - * Register a Service - * Manually register a service instance (dynamic registration). + * Register a service instance directly. + * Returns `this` so calls can be chained. */ - registerService(id: string, instance: any): void; + registerService(name: string, service: T): this; /** - * Check Service Availability + * Retrieve a registered service by name. */ - hasService(id: string): boolean; + getService(name: string): T; } ``` + + +`bootstrap()` / `shutdown()` are the kernel lifecycle methods — there is no `start()` / `stop()` on `ObjectKernel`. (The `start`/`stop` naming applies to the per-plugin `start()` hook, not the kernel itself.) + diff --git a/content/docs/concepts/core/events.mdx b/content/docs/concepts/core/events.mdx index d176f5e8d2..ef2313ad78 100644 --- a/content/docs/concepts/core/events.mdx +++ b/content/docs/concepts/core/events.mdx @@ -5,111 +5,114 @@ description: System-wide event bus for loose coupling between plugins # Events & Hooks -The Kernel provides a high-performance, typed Event Bus for loose coupling between plugins and the core system. +ObjectStack has **two distinct hook systems**. They look similar but use different APIs, payloads, ordering, and error semantics — pick the right one for the job: -## Lifecycle Events +- **Kernel lifecycle hooks** — `ctx.hook(name, handler)` / `ctx.trigger(name, ...args)` on the `PluginContext`. Used for system bootstrap events and custom plugin-to-plugin events, matched by **exact name**. +- **Data lifecycle hooks** — object-level hooks (`beforeInsert`, `afterUpdate`, …) registered via object `Hook` metadata or `engine.registerHook()`. They receive a single `HookContext` and run on record mutations. -Emitted by the Kernel during the bootstrap process: +## Kernel Lifecycle Hooks + +### Lifecycle Events + +Triggered by the Kernel during bootstrap and shutdown: | Event | Description | | :--- | :--- | -| `kernel:init` | The kernel has started initialization. | | `kernel:ready` | All plugins have successfully started. System is live. | +| `kernel:listening` | Fired after every `kernel:ready` handler has completed (e.g. the HTTP server is accepting connections). | | `kernel:shutdown` | Shutdown signal received. Plugins should clean up resources. | -| `kernel:error` | A fatal error occurred during startup or runtime. | - -## Data Events - -The Data Engine emits events for record mutations. Every mutation triggers a `before` and `after` hook. - -| Event | Payload | Cancelable | -| :--- | :--- | :---: | -| `data:beforeCreate` | `{ object, record }` | ✅ | -| `data:afterCreate` | `{ object, record, id }` | ❌ | -| `data:beforeUpdate` | `{ object, id, changes, previous }` | ✅ | -| `data:afterUpdate` | `{ object, id, record }` | ❌ | -| `data:beforeDelete` | `{ object, id }` | ✅ | -| `data:afterDelete` | `{ object, id }` | ❌ | - - -**Cancelable** events can be aborted by throwing an error in the hook handler. This prevents the operation from proceeding. - - -## Usage -### Listening to Events +### Listening to Kernel Events ```typescript -// Lifecycle hook — runs once when system is ready ctx.hook('kernel:ready', async () => { ctx.logger.info('System is ready! Sending startup notification...'); }); - -// Data hook — runs on every record creation -ctx.hook('data:beforeCreate', async ({ object, record }) => { - if (object === 'order' && record.amount > 10000) { - ctx.logger.warn('Large order detected!'); - // Optionally enrich the record - record.requires_approval = true; - } -}); ``` +`ctx.hook(name, handler)` takes exactly two arguments — there is no options/priority parameter. Kernel hooks run in **registration order**, and `ctx.trigger()` awaits each handler sequentially. + ### Emitting Custom Events -Plugins can emit their own namespaced events for inter-plugin communication: +Plugins can trigger their own namespaced events for inter-plugin communication. Use `ctx.trigger()` (it is async and returns a `Promise`); handlers receive the positional arguments you pass to `trigger()`: ```typescript -// Emit a custom business event -ctx.emit('order:shipped', { orderId: '123', carrier: 'fedex' }); +// Trigger a custom business event (await it — trigger returns a Promise) +await ctx.trigger('order:shipped', { orderId: '123', carrier: 'fedex' }); -// Another plugin listens +// Another plugin listens — the handler receives the same positional args ctx.hook('order:shipped', async ({ orderId, carrier }) => { await sendTrackingEmail(orderId, carrier); }); ``` -### Wildcard Listeners + +Kernel hooks are matched by **exact name** — there is no wildcard or namespace-glob support. A handler registered under `'order:*'` will never fire for `'order:shipped'`. + + +## Data Lifecycle Hooks + +The Data Engine runs hooks around record reads and mutations. Write operations fire `before*` and `after*` events: -Use wildcards to listen to all events in a namespace: +| Event | When | +| :--- | :--- | +| `beforeInsert` / `afterInsert` | Around record creation. | +| `beforeUpdate` / `afterUpdate` | Around record update. | +| `beforeDelete` / `afterDelete` | Around record deletion. | + +Read and bulk variants (`beforeFind`/`afterFind`, `beforeCount`, `beforeAggregate`, `beforeUpdateMany`, `beforeDeleteMany`, …) also exist. + +### The HookContext + +Every data hook is a single-argument handler `(ctx: HookContext) => void | Promise`. The context exposes: + +| Field | Description | +| :--- | :--- | +| `ctx.object` | Target object name (immutable). | +| `ctx.event` | Current lifecycle event, e.g. `'beforeInsert'` (immutable). | +| `ctx.input` | **Mutable** input. Shapes: insert `{ doc }`, update `{ id, doc }`, delete `{ id }`. Modify this to change the operation. | +| `ctx.result` | Operation result, available in `after*` events (mutable). | +| `ctx.previous` | Record state before the operation (update/delete). | +| `ctx.session` | Auth/tenancy info (`userId`, `tenantId`, `roles`, …). | +| `ctx.api` | Scoped cross-object data access. | + +### Registering Data Hooks ```typescript -// Listen to all data events -ctx.hook('data:*', async (event) => { - ctx.logger.debug(`Data event: ${event.type}`, event.payload); -}); +// Enrich a record before it is created +engine.registerHook('beforeInsert', async (ctx) => { + if (ctx.object === 'order' && ctx.input.doc.amount > 10000) { + ctx.logger?.warn?.('Large order detected!'); + ctx.input.doc.requires_approval = true; + } +}, { object: 'order' }); + +// React after a record is created +engine.registerHook('afterInsert', async (ctx) => { + await notifyWebhook(ctx.object, ctx.input.doc.id); +}, { object: 'order' }); ``` -### Error Handling in Hooks +### Error Handling -Errors in `before` hooks cancel the operation. Errors in `after` hooks are logged but don't roll back: +What happens when a data hook throws is governed by the hook's **`onError`** policy (`'abort'` | `'log'`, default `'abort'`): + +- `onError: 'abort'` (default) — the error rolls back the transaction (when the hook is blocking), cancelling the operation. +- `onError: 'log'` — the error is logged and execution continues; the operation is **not** cancelled. ```typescript -ctx.hook('data:beforeCreate', async ({ object, record }) => { - if (object === 'invoice' && !record.customer_id) { - throw new Error('Invoice must have a customer'); // Cancels the create +engine.registerHook('beforeInsert', async (ctx) => { + if (ctx.object === 'invoice' && !ctx.input.doc.customer_id) { + throw new Error('Invoice must have a customer'); // Aborts the insert (default onError) } }); - -ctx.hook('data:afterCreate', async ({ object, id }) => { - // This runs after the record is saved - // Errors here are logged but don't affect the saved record - await notifyWebhook(object, id); -}); ``` -## Event Ordering +### Ordering -Hooks execute in plugin registration order. Use `priority` to control execution: +Data hooks accept a `priority` option (**default `100`**). They are sorted so that **lower priority values run first**: ```typescript -ctx.hook('data:beforeCreate', async (event) => { - // Validation runs first -}, { priority: 100 }); - -ctx.hook('data:beforeCreate', async (event) => { - // Enrichment runs after validation -}, { priority: 50 }); +engine.registerHook('beforeInsert', validate, { priority: 50 }); // runs first +engine.registerHook('beforeInsert', enrich, { priority: 100 }); // runs after ``` - -Higher priority values execute first (default: `0`). diff --git a/content/docs/concepts/core/index.mdx b/content/docs/concepts/core/index.mdx index 666d271c29..7588fdb0b6 100644 --- a/content/docs/concepts/core/index.mdx +++ b/content/docs/concepts/core/index.mdx @@ -20,7 +20,7 @@ ObjectStack is designed to be: 1. **Plugin Lifecycle Management**: Loading, validating, initializing, and starting plugins in the correct dependency order. 2. **Dependency Injection (DI)**: A central registry for services to communicate without tight coupling. -3. **Event Bus**: System-wide hook system for intercepting logic (e.g., `data:beforeCreate`, `kernel:ready`). +3. **Event Bus**: System-wide hook system for intercepting logic (e.g., `data:beforeInsert`, `kernel:ready`). 4. **Configuration Management**: Standardized configuration loading using Zod validation. ## Installation diff --git a/content/docs/concepts/core/plugins.mdx b/content/docs/concepts/core/plugins.mdx index b001b0c9f2..1ffe8e70f5 100644 --- a/content/docs/concepts/core/plugins.mdx +++ b/content/docs/concepts/core/plugins.mdx @@ -11,39 +11,49 @@ Plugins are the building blocks of ObjectStack. A plugin is a plain JavaScript/T ```typescript import type { Plugin, PluginContext } from '@objectstack/core'; +import { z } from 'zod'; export class MyPlugin implements Plugin { // Identity name = 'com.example.myplugin'; version = '1.0.0'; - description = 'An example plugin'; - + // Dependencies (Optional) - dependencies = { - '@objectstack/runtime': '^1.0.0' - }; + // An array of *plugin names* the kernel must initialize before this one. + // This controls init ordering — it is NOT an npm-style version map. + dependencies = ['com.objectstack.engine.objectql']; - // Configuration Schema (Optional Zod Schema) + // Configuration Schema (Optional) + // Read by the plugin loader to validate config; it lives on the plugin + // metadata rather than the base `Plugin` interface. configSchema = z.object({ apiKey: z.string() }); /** - * Initialization Phase + * Init Phase (REQUIRED) * Use this to register services, listeners, or other early setup. */ - async onInit(ctx: PluginContext) { + async init(ctx: PluginContext) { // Register a service - ctx.services.register('myService', new MyService()); + ctx.registerService('myService', new MyService()); } /** - * Start Phase - * The system is fully initialized. Use this to start servers or background jobs. + * Start Phase (Optional) + * All plugins are initialized. Use this to start servers or background jobs. */ - async onStart(ctx: PluginContext) { + async start(ctx: PluginContext) { console.log('MyPlugin started!'); } + + /** + * Destroy Phase (Optional) + * Called during kernel shutdown — clean up resources here. + */ + async destroy() { + console.log('MyPlugin stopped!'); + } } ``` @@ -93,12 +103,12 @@ ObjectStack uses `type` discrimination to optimize runtime behavior, allowing th * **Use Cases:** RAG Pipelines, Autonomous Agents. * **Behavior:** Extensions for the AI Gateway. -## RuntimePlugin Interface Reference +## Plugin Interface Reference -The `RuntimePlugin` interface defines the contract that all functionality modules must implement. +The `Plugin` interface (exported from `@objectstack/core`) defines the contract that all functionality modules must implement. ```typescript -export interface RuntimePlugin { +export interface Plugin { /** * Plugin Unique Identifier * Recommended format: com.organization.plugin-name @@ -106,39 +116,46 @@ export interface RuntimePlugin { name: string; /** - * Plugin Version - * Must follow Semantic Versioning (SemVer) + * Plugin Version (Optional) */ version?: string; /** - * Description - * Brief explanation of the plugin's purpose. + * Plugin Type (Optional) + * One of: standard, ui, driver, server, app, theme, agent. + * @default 'standard' + */ + type?: string; + + /** + * Dependencies (Optional) + * List of other plugin names that this plugin depends on. + * The kernel ensures these plugins are initialized before this one. */ - description?: string; + dependencies?: string[]; /** - * Initialization Hook - * Called during Kernel bootstrap. Use this to: + * Init Phase (REQUIRED) + * Called when the kernel is initializing. Use this to: * - Register Services * - Register Event Listeners * - Extend Metadata */ - onInit?: (context: PluginContext) => Promise; + init(ctx: PluginContext): Promise | void; /** - * Start Hook + * Start Phase (Optional) * Called after all plugins are initialized. Use this to: * - Start HTTP servers * - Connect to databases * - Start background workers */ - onStart?: (context: PluginContext) => Promise; + start?(ctx: PluginContext): Promise | void; /** - * Stop Hook - * Called during Kernel shutdown. cleanup resources here. + * Destroy Phase (Optional) + * Called during kernel shutdown. Clean up resources here. */ - onStop?: (context: PluginContext) => Promise; + destroy?(): Promise | void; } ``` diff --git a/content/docs/concepts/core/services.mdx b/content/docs/concepts/core/services.mdx index e1ee1b438f..737e80ee16 100644 --- a/content/docs/concepts/core/services.mdx +++ b/content/docs/concepts/core/services.mdx @@ -9,7 +9,7 @@ ObjectStack uses a lightweight **Service Locator pattern** for Dependency Inject ## Concepts -- **Service Name**: A unique string identifier (e.g., `http-server`, `database`, `auth-provider`). +- **Service Name**: A unique string identifier (e.g., `http-server`, `data`, `auth`). - **Service Implementation**: Any JavaScript object, class instance, or function. - **Service Contract**: A TypeScript interface that defines the expected API surface. @@ -25,8 +25,9 @@ export const myPlugin: Plugin = { async init(ctx) { // Register a service with a concrete implementation + const settings = ctx.getService('settings'); ctx.registerService('cache', new RedisCacheProvider({ - url: ctx.config.get('redis.url'), + url: settings.get('redis.url'), })); }, }; @@ -34,35 +35,45 @@ export const myPlugin: Plugin = { ### Factory Registration (Lazy) -Use a factory function when the service requires async initialization: +Use `registerServiceFactory` when the service requires async initialization. +`registerService` stores a concrete instance as-is, so passing it a function +would just register the function object. The factory receives the plugin +context (and an optional scope id) and is wrapped in lifecycle management: ```typescript -ctx.registerService('database', async () => { - const pool = await createPool(ctx.config.get('database')); +import { ServiceLifecycle } from '@objectstack/core'; + +ctx.registerServiceFactory('data', async (ctx) => { + const settings = ctx.getService('settings'); + const pool = await createPool(settings.get('database')); return new PostgresDriver(pool); -}); +}, ServiceLifecycle.SINGLETON); ``` -The factory is called once on first access and the result is cached. +The lifecycle defaults to `ServiceLifecycle.SINGLETON`, so the factory runs once +on first access and the instance is cached. Use `ServiceLifecycle.SCOPED` for a +per-scope (e.g. per-project) instance, or `ServiceLifecycle.TRANSIENT` to create +a fresh instance on every access. ## Consuming Services ```typescript -// Synchronous retrieval (if already initialized) +// Synchronous retrieval (if already registered) const http = ctx.getService('http-server'); -http.get('/hello', (c) => c.text('Hello')); +http.get('/hello', (req, res) => res.send('Hello')); -// Async retrieval (for factory-registered services) -const db = await ctx.getServiceAsync('database'); -const users = await db.query('user', { filters: [['active', '=', true]] }); +// Async retrieval (for factory- or scope-registered services) +const db = await ctx.getServiceScoped('data', scopeId); +const users = await db.find('user', { where: { active: true } }); ``` ### Optional Services -Check if a service exists before using it: +`ctx.getService` throws when a service is not registered. To probe optionally, +check the registry map with `ctx.getServices()`: ```typescript -if (ctx.hasService('analytics')) { +if (ctx.getServices().has('analytics')) { const analytics = ctx.getService('analytics'); analytics.track('page_view', { url: '/dashboard' }); } @@ -75,13 +86,14 @@ The core ecosystem defines several standard service contracts: | Service Name | Interface | Provider Example | | :--- | :--- | :--- | | `http-server` | `IHttpServer` | `plugin-hono-server`, `adapter-nextjs` | -| `database` | `IDatabaseDriver` | `driver-sql` (pg/mysql/sqlite), `driver-mongodb`, `driver-turso` | +| `data` | `IDataEngine` | `@objectstack/objectql` (drivers implement `IDataDriver`) | | `auth` | `IAuthService` | `plugin-auth` | -| `protocol` | `IProtocolEngine` | `@objectstack/objectql` | -| `api-registry` | `IApiRegistry` | `@objectstack/core` | -| `cache` | `ICacheProvider` | Redis, Memcached, or in-memory | -| `logger` | `ILogger` | `@objectstack/core` (built-in) | -| `event-bus` | `IEventBus` | `@objectstack/core` (built-in) | +| `api-registry` | `ApiRegistry` | `@objectstack/core` | +| `cache` | `ICacheService` | Redis, Memcached, or in-memory | + +The logger is not a registered service — it is exposed directly as `ctx.logger` +(the `Logger` contract). Inter-plugin events also do not go through a service: +use `ctx.hook(name, handler)` and `ctx.trigger(name, ...args)` instead. ## Replacing Core Services @@ -91,15 +103,17 @@ Swap any core component by providing an alternative plugin: // Replace the default HTTP server with a custom one export const customHttpPlugin: Plugin = { name: 'custom-http', - provides: ['http-server'], // Declares what this plugin provides - + async init(ctx) { - ctx.registerService('http-server', new FastifyServer()); + ctx.replaceService('http-server', new FastifyServer()); }, }; ``` -The Kernel ensures only one plugin provides each service name. If multiple plugins declare the same service, the last-registered one wins (or an error is thrown in strict mode). +`registerService` always throws if the name is already registered — there is no +strict-mode toggle and no last-registered-wins behavior. To swap an existing +core service, use `ctx.replaceService(name, implementation)`, which replaces the +current instance and throws if the service does not yet exist. ## Service Lifecycle @@ -107,20 +121,24 @@ Services follow the plugin lifecycle: 1. **`init`** — Register services 2. **`start`** — Services are now available to all plugins -3. **`stop`** — Clean up resources (close connections, flush buffers) +3. **`destroy`** — Clean up resources (close connections, flush buffers) ```typescript export const dbPlugin: Plugin = { name: 'database', - + async init(ctx) { - const pool = await createPool(ctx.config.get('database')); - ctx.registerService('database', new PostgresDriver(pool)); + const settings = ctx.getService('settings'); + const pool = await createPool(settings.get('database')); + ctx.registerService('data', new PostgresDriver(pool)); + this.db = pool; }, - - async stop(ctx) { - const db = ctx.getService('database'); - await db.close(); // Clean shutdown + + async destroy() { + await this.db.close(); // Clean shutdown }, }; ``` + +The `Plugin` interface defines `init`, `start?`, and `destroy?` — there is no +`stop` hook, and `destroy()` takes no arguments. diff --git a/content/docs/concepts/design-principles.mdx b/content/docs/concepts/design-principles.mdx index dbe4a6b5f7..86963195a3 100644 --- a/content/docs/concepts/design-principles.mdx +++ b/content/docs/concepts/design-principles.mdx @@ -77,8 +77,8 @@ We cleanly separate the **Definition** from the **Execution**. | Layer | Responsibility | Example | | :--- | :--- | :--- | -| **Protocol (Mechanism)** | Defines the capabilities. | `allowRead: string` (A slot for a formula) | -| **App (Policy)** | Defines the business logic. | `allowRead: "$user.role == 'admin'"` | +| **Protocol (Mechanism)** | Defines the capabilities. | `condition: Expression` (a CEL predicate slot for sharing rules) | +| **App (Policy)** | Defines the business logic. | `condition: P\`record.department == "Sales"\`` | | **Engine (Execution)** | Enforces the logic. | Compiles formula to SQL `WHERE` clause. | **Policies are metadata, not code.** Permission rules, validation predicates, sharing conditions, and flow guards are all expressed as analyzable CEL/Zod metadata — the same engine evaluates them for human users, REST callers, and AI agent tools, and every decision is traceable to a versioned artifact. @@ -160,4 +160,4 @@ By adhering to these values, we build software that is **resilient to change**, - [Architecture](/docs/concepts/architecture) - See how these principles shape the system - [Glossary](/docs/concepts/terminology) - Understand key terms -- [Core Concepts](/docs/core-concepts) - Learn about metadata-driven development +- [Core Concepts](/docs/getting-started/core-concepts) - Learn about metadata-driven development diff --git a/content/docs/concepts/implementation-status.mdx b/content/docs/concepts/implementation-status.mdx index 59f5c656bf..434bc88448 100644 --- a/content/docs/concepts/implementation-status.mdx +++ b/content/docs/concepts/implementation-status.mdx @@ -8,7 +8,7 @@ description: Detailed status of protocol implementations across ObjectStack pack This document provides a comprehensive overview of which protocols from `@objectstack/spec` have been implemented in the ObjectStack packages. -**Last Updated**: February 2026 +**Last Updated**: June 2026 This matrix is generated from actual codebase analysis and represents the current implementation status. @@ -79,17 +79,17 @@ This matrix is generated from actual codebase analysis and represents the curren |:---------|:----------------------|:------:|:------| | **Logging** | @objectstack/core | ✅ | Cross-platform logger (browser + server) | | **API Registry** | @objectstack/core | ✅ | Central endpoint registry | -| **Metrics** | ❌ | 📋 | Planned | -| **Tracing** | ❌ | 📋 | Planned | -| **Audit** | ❌ | 📋 | Planned | -| **Job** | ❌ | 📋 | Planned | +| **Metrics** | @objectstack/observability | ✅ | Metrics exporters shipped (`metrics-exporters.ts`) | +| **Tracing** | @objectstack/observability | ⚠️ | Error/exporter pipeline shipped (`error-exporters.ts`); full distributed tracing in progress | +| **Audit** | @objectstack/plugin-audit | ✅ | Audit writers + audit objects shipped | +| **Job** | @objectstack/service-job | ✅ | Job service with cron/db/interval adapters | | **Cache** | ⚠️ | ⚠️ | HTTP caching implemented, in-memory cache partial | -| **Translation** | ❌ | 📋 | Planned | +| **Translation** | @objectstack/service-i18n | ✅ | i18n/translation service with file adapter | | **Feature Flags** | ❌ | 📋 | Planned | | **Encryption** | ❌ | 📋 | Planned | | **Compliance** | ❌ | 📋 | Planned | | **Masking** | ❌ | 📋 | Planned | -| **Notification** | ✅ | 🟡 | Framework pipeline shipped; objectui bell cut-over remains | +| **Notification** | @objectstack/service-messaging, @objectstack/service-feed | 🟡 | Framework pipeline shipped; objectui bell cut-over remains | | **Change Management** | ❌ | 📋 | Planned | | **Collaboration** | ❌ | 📋 | Planned | @@ -151,18 +151,20 @@ This matrix is generated from actual codebase analysis and represents the curren ### HTTP & REST -| Protocol | @objectstack/runtime | @objectstack/plugin-hono-server | @objectstack/client | Status | -|:---------|:--------------------:|:-------------------------------:|:-------------------:|:------:| -| **REST Server** | ✅ | ✅ | ❌ | ✅ Full | -| **HTTP Server** | ✅ | ✅ | ❌ | ✅ Full | -| **Endpoint** | ✅ | ✅ | ❌ | ✅ Full | -| **Router** | ✅ | ✅ | ❌ | ✅ Full | -| **Discovery** | ✅ | ✅ | ✅ | ✅ Full | -| **Contract** | ✅ | ❌ | ❌ | ⚠️ Partial | -| **Protocol** | ✅ | ❌ | ✅ | ✅ Full | -| **Errors** | ✅ | ✅ | ✅ | ✅ Full | -| **HTTP Cache** | ✅ | ✅ | ✅ | ✅ Full | -| **Batch** | ✅ | ✅ | ✅ | ✅ Full | +| Protocol | @objectstack/rest | @objectstack/runtime | @objectstack/plugin-hono-server | @objectstack/client | Status | +|:---------|:-----------------:|:--------------------:|:-------------------------------:|:-------------------:|:------:| +| **REST Server** | ✅ | ✅ (re-export) | ✅ | ❌ | ✅ Full | +| **HTTP Server** | ❌ | ✅ | ✅ | ❌ | ✅ Full | +| **Endpoint** | ✅ | ❌ | ✅ | ❌ | ✅ Full | +| **Router** | ✅ | ✅ | ✅ | ❌ | ✅ Full | +| **Discovery** | ❌ | ✅ | ✅ | ✅ | ✅ Full | +| **Contract** | ✅ | ✅ | ❌ | ❌ | ⚠️ Partial | +| **Protocol** | ❌ | ✅ | ❌ | ✅ | ✅ Full | +| **Errors** | ✅ | ✅ | ✅ | ✅ | ✅ Full | +| **HTTP Cache** | ✅ | ✅ | ✅ | ✅ | ✅ Full | +| **Batch** | ✅ | ✅ | ✅ | ✅ | ✅ Full | + +The data (`/data`), metadata (`/meta`), and batch endpoints are implemented in `@objectstack/rest` (`rest-server.ts`); `@objectstack/runtime` re-exports `RestServer` from that package and provides the underlying HTTP server/dispatcher. **REST Endpoints Implemented:** @@ -189,8 +191,8 @@ This matrix is generated from actual codebase analysis and represents the curren | **Analytics** | ✅ | ✅ | ObjectQL aggregation plus `@objectstack/service-analytics` dataset execution; analytics read scope auto-bridges to `security.getReadFilter` for RLS-aware dashboards/reports | | **OData** | ❌ | 📋 | Protocol defined, not implemented | | **GraphQL** | ❌ | 📋 | Protocol defined, not implemented | -| **Realtime** | ❌ | 📋 | Protocol defined, not implemented | -| **WebSocket** | ❌ | 📋 | Protocol defined, not implemented | +| **Realtime** | @objectstack/service-realtime | ⚠️ | Realtime service with in-memory adapter shipped; production adapters in progress | +| **WebSocket** | @objectstack/service-realtime | ⚠️ | Transport provided via the realtime service in-memory adapter | --- @@ -235,23 +237,23 @@ This matrix is generated from actual codebase analysis and represents the curren ## Automation Layer -**Plugin-Provided Service** — The kernel does NOT include an automation engine. Flow, workflow, and approval services must be provided by plugins (e.g., `@objectstack/plugin-automation`). +**Plugin-Provided Service** — The kernel does NOT include an automation engine. Flow, workflow, and approval services are provided by plugins — the flow engine ships in `@objectstack/service-automation`, with approval nodes in `@objectstack/plugin-approvals`. | Protocol | @objectstack/spec | Kernel | Plugin Required | Status | |:---------|:-----------------:|:------:|:---------------:|:------:| -| **Flow** | ✅ | ❌ | ✅ | 📋 Plugin | -| **Workflow** | ✅ | ❌ | ✅ | 📋 Plugin | -| **Approval** | ✅ | ❌ | ✅ | 📋 Plugin | -| **Webhook** | ✅ | ❌ | ✅ | 📋 Plugin | +| **Flow** | ✅ | ❌ | ✅ | ✅ `@objectstack/service-automation` | +| **Workflow** | ✅ | ❌ | ✅ | ✅ `@objectstack/service-automation` | +| **Approval** | ✅ | ❌ | ✅ | ✅ `@objectstack/plugin-approvals` | +| **Webhook** | ✅ | ❌ | ✅ | ✅ `@objectstack/plugin-webhooks` | | **ETL** | ✅ | ❌ | ✅ | 📋 Plugin | | **Sync** | ✅ | ❌ | ✅ | 📋 Plugin | -| **Trigger Registry** | ✅ | ❌ | ✅ | 📋 Plugin | +| **Trigger Registry** | ✅ | ❌ | ✅ | ✅ `plugin-trigger-record-change` / `plugin-trigger-schedule` | **Notes:** - Hook system is implemented in ObjectQL (beforeFind, afterInsert, etc.) — this is data-layer eventing, not workflow automation -- Full workflow/automation engine will be provided by plugins -- Protocols are complete and ready for plugin implementation +- The flow/workflow engine ships in `@objectstack/service-automation` (`engine.ts`, builtin nodes, `plugin.ts`); approvals, webhooks, and triggers ship as `plugin-approvals`, `plugin-webhooks`, `plugin-trigger-record-change`, and `plugin-trigger-schedule` +- ETL and Sync protocols are defined but not yet implemented as plugins - Discovery API reports automation service as `unavailable` until a plugin is registered --- @@ -292,22 +294,23 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity | Protocol | @objectstack/spec | Implementation | Status | |:---------|:-----------------:|:-------------:|:------:| -| **Agent** | ✅ | ❌ | ❌ Spec only | -| **Model Registry** | ✅ | ❌ | ❌ Spec only | -| **RAG Pipeline** | ✅ | ❌ | ❌ Spec only | -| **NLQ** | ✅ | ❌ | ❌ Spec only | -| **Conversation** | ✅ | ❌ | ❌ Spec only | -| **Agent Action** | ✅ | ❌ | ❌ Spec only | +| **Agent** | ✅ | @objectstack/service-ai | ✅ Agent runtime (`agent-runtime.ts`, `agents/`) | +| **Model Registry** | ✅ | @objectstack/service-ai | ✅ `model-registry.ts` | +| **RAG Pipeline** | ✅ | @objectstack/service-knowledge | ⚠️ Knowledge service + `knowledge-memory` / `knowledge-ragflow` / `embedder-openai` plugins | +| **NLQ** | ✅ | @objectstack/service-ai | ⚠️ `tools/query-data`, `schema-retriever.ts` | +| **Conversation** | ✅ | @objectstack/service-ai | ✅ In-memory + ObjectQL conversation services (`conversation/`) | +| **Agent Action** | ✅ | @objectstack/service-ai | ⚠️ Action/data/knowledge tools (`tools/`) | | **Cost** | ✅ | ❌ | ❌ Spec only | | **Predictive** | ✅ | ❌ | ❌ Spec only | -| **Orchestration** | ✅ | ❌ | ❌ Spec only | -| **Feedback Loop** | ✅ | ❌ | ❌ Spec only | +| **Orchestration** | ✅ | @objectstack/service-ai | ⚠️ Agent tool orchestration | +| **Feedback Loop** | ✅ | @objectstack/service-ai | ⚠️ Eval harness (`eval/`) | | **DevOps Agent** | ✅ | ❌ | ❌ Spec only | **Notes:** - Complete AI protocol suite defined -- Ready for AI/ML integration -- No implementation yet +- `@objectstack/service-ai` ships agents, model registry, conversation, tools, skills, and an eval harness +- RAG/embedding is provided by `@objectstack/service-knowledge` plus the `knowledge-memory`, `knowledge-ragflow`, and `embedder-openai` plugins +- Cost tracking, predictive, and the DevOps agent protocols remain spec-only --- @@ -315,11 +318,11 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity | Protocol | @objectstack/spec | Implementation | Status | |:---------|:-----------------:|:-------------:|:------:| -| **Connector** | ✅ | ❌ | ❌ Spec only | -| **SaaS Connector** | ✅ | ❌ | ❌ Spec only | -| **Database Connector** | ✅ | ❌ | ❌ Spec only | -| **File Storage** | ✅ | ❌ | ❌ Spec only | -| **Message Queue** | ✅ | ❌ | ❌ Spec only | +| **Connector** | ✅ | connector-rest, connector-openapi, connector-mcp, connector-slack | ✅ REST/OpenAPI/MCP/Slack connectors shipped | +| **SaaS Connector** | ✅ | connector-slack | ⚠️ Slack connector shipped; broader SaaS catalogue in progress | +| **Database Connector** | ✅ | @objectstack/driver-sql, driver-mongodb | ✅ Delivered via the database drivers | +| **File Storage** | ✅ | @objectstack/service-storage | ✅ File storage service with local + S3 adapters and storage routes | +| **Message Queue** | ✅ | @objectstack/service-queue | ⚠️ Queue service shipped | | **GitHub Connector** | ✅ | ❌ | ❌ Spec only | | **Vercel Connector** | ✅ | ❌ | ❌ Spec only | @@ -378,7 +381,7 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity - [x] Metadata Management ### Phase 6: Advanced Features 🚧 **IN PROGRESS** -- [ ] Production Database Drivers (PostgreSQL, MySQL, MongoDB) +- [x] Production Database Drivers — `@objectstack/driver-sql` (PostgreSQL/MySQL dialects), `@objectstack/driver-mongodb`, and `@objectstack/driver-sqlite-wasm` ship; additional dialect coverage ongoing - [ ] GraphQL API - [ ] OData Support - [ ] Realtime Subscriptions @@ -430,24 +433,21 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity ### Overall Implementation Status -| Category | Total Protocols | Fully Implemented | Partially Implemented | Not Implemented | -|:---------|:---------------:|:-----------------:|:---------------------:|:---------------:| -| **Data** | 16 | 7 | 3 | 6 | -| **UI** | 10 | 0 | 0 | 10 | -| **API** | 14 | 11 | 1 | 2 | -| **System** | 39 | 8 | 1 | 30 | -| **Auth** (plugin) | 10 | 0 | 0 | 10 | -| **Automation** (plugin) | 7 | 1 | 0 | 6 | -| **AI** | 12 | 0 | 0 | 12 | -| **Integration** | 7 | 0 | 0 | 7 | -| **QA** | 1 | 1 | 0 | 0 | -| **TOTAL** | **112** | **28** | **5** | **79** | +| Category | Total Protocols | Status | +|:---------|:---------------:|:-------| +| **Data** | 16 | Core modeling, hooks, and query engine fully implemented; document/mapping/external-lookup still pending | +| **UI** | 10 | Studio/ObjectUI render most authored surfaces (🟡); full cross-surface renderer parity in progress | +| **API** | 14 | REST/HTTP/discovery/batch fully implemented; OData/GraphQL pending | +| **System** | 39 | Logging, audit, job, translation, metrics, notification implemented; several governance services pending | +| **Auth** (plugin) | 10 | Permission + RLS live (`plugin-security`); identity/sharing/territory still plugin-pending | +| **Automation** (plugin) | 7 | Flow, workflow, approval, webhook, and triggers implemented; ETL/Sync pending | +| **AI** | 12 | Agents, model registry, conversation, tools, RAG implemented (`service-ai` + knowledge plugins); cost/predictive/DevOps-agent pending | +| **Integration** | 7 | REST/OpenAPI/MCP/Slack connectors, file storage, and queue implemented; GitHub/Vercel connectors pending | +| **QA** | 1 | Fully implemented | ### Implementation Coverage -- **Fully Implemented**: 25.0% (28/112) -- **Partially Implemented**: 4.5% (5/112) -- **Not Implemented**: 70.5% (79/112) +Implementation spans every layer of the platform. Core infrastructure, data modeling, the REST API, client SDKs, security (Phase-1), automation, AI, and integration all have shipping implementations. Remaining gaps are concentrated in specific protocols (OData/GraphQL, cross-surface UI renderer parity, sharing/territory authorization, ETL/Sync, and a handful of governance and AI-cost services) rather than entire layers. Refer to the per-layer tables above for protocol-level status. ### Core Functionality Status @@ -462,9 +462,9 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity | **HTTP Caching** | ✅ | Yes | | **Testing Tools** | ✅ | Yes | | **UI Rendering** | ❌ | No | -| **Workflows** | ❌ | No (plugin required) | -| **Security** | ❌ | No (plugin required) | -| **AI Features** | ❌ | No | +| **Workflows** | ✅ | Yes (plugin: `service-automation`) | +| **Security** | ⚠️ | Partial (Phase-1 RBAC/RLS via `plugin-security`) | +| **AI Features** | ⚠️ | Partial (`service-ai` + knowledge plugins) | --- @@ -494,6 +494,8 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity | **Database Driver** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ⚠️ | ❌ | | **API Mocking** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | +> The REST server, endpoint generation, and data/meta/batch endpoints are implemented in the `@objectstack/rest` package (not shown as a column above); `@objectstack/runtime` re-exports `RestServer` from it. + --- ## Next Steps @@ -510,7 +512,7 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity description="How protocols relate to each other" /> diff --git a/content/docs/concepts/index.mdx b/content/docs/concepts/index.mdx index c845706bed..12ccc1c19b 100644 --- a/content/docs/concepts/index.mdx +++ b/content/docs/concepts/index.mdx @@ -120,6 +120,10 @@ export const Customer = ObjectSchema.create({ }); ``` + +The object names on this page (`customer`, `opportunity`, `contact`) are illustrative. In a real package, object names carry their domain/package prefix directly in the name (e.g. `crm_customer`, `sys_user`) — there is no separate `namespace` field. + + This definition is **pure metadata**. It doesn't know: - Who can see this data - How to render a form @@ -142,62 +146,70 @@ That's the job of the other layers. ### Example: Permission Rules -```typescript -// packages/crm/src/permissions/customer.permission.ts -import { Permission } from '@objectstack/spec'; +Access control is declared as **permission set** metadata (the `PermissionSet` shape from `@objectstack/spec`). A permission set maps each object to its CRUD/lifecycle flags and, optionally, per-field read/edit flags keyed by `.`: -export const CustomerPermission = Permission({ - object: 'customer', - rules: [ - { - profile: 'sales_rep', - crud: { - create: true, - read: true, - update: true, - delete: false, // Only managers can delete - }, - fieldPermissions: { - annual_revenue: { read: true, edit: false }, // Read-only - }, +```typescript +// packages/crm/src/permissions/sales_rep.permission.ts +import type { PermissionSet } from '@objectstack/spec'; + +export const salesRep: PermissionSet = { + name: 'sales_rep', + label: 'Sales Rep', + isProfile: true, + objects: { + customer: { + allowCreate: true, + allowRead: true, + allowEdit: true, + allowDelete: false, // Only managers can delete }, - { - profile: 'sales_manager', - crud: { - create: true, - read: true, - update: true, - delete: true, - }, + }, + fields: { + 'customer.annual_revenue': { readable: true, editable: false }, // Read-only + }, +}; + +// packages/crm/src/permissions/sales_manager.permission.ts +export const salesManager: PermissionSet = { + name: 'sales_manager', + label: 'Sales Manager', + isProfile: true, + objects: { + customer: { + allowCreate: true, + allowRead: true, + allowEdit: true, + allowDelete: true, }, - ], -}); + }, +}; ``` -### Example: Workflow Automation +### Example: Flow Automation -```typescript -// packages/crm/src/workflows/customer.workflow.ts -import { Workflow } from '@objectstack/spec'; +Business logic is authored as a **Flow** — a graph of nodes and edges — via `defineFlow`. A `record_change` flow runs when a record is written; a `decision` node branches on a bare CEL condition (reference fields directly, e.g. `record.annual_revenue`, and do **not** wrap them in `{…}`): -export const CustomerWorkflow = Workflow({ - object: 'customer', - trigger: 'after_create', - conditions: [ - { field: 'annual_revenue', operator: 'greaterThan', value: 1000000 }, +```typescript +// packages/crm/src/flows/high_value_customer.flow.ts +import { defineFlow } from '@objectstack/spec'; + +export const highValueCustomerFlow = defineFlow({ + name: 'high_value_customer', + label: 'High-Value Customer Alert', + type: 'record_change', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'On Create' }, + { id: 'check', type: 'decision', label: 'Is High Value?' }, + { id: 'assign', type: 'update_record', label: 'Assign Enterprise Team' }, + { id: 'notify', type: 'notify', label: 'Email Leadership' }, + { id: 'end', type: 'end', label: 'End' }, ], - actions: [ - { - type: 'assign_owner', - params: { owner: 'enterprise_sales_team' }, - }, - { - type: 'send_email', - params: { - template: 'high_value_customer_alert', - to: 'sales-leadership@company.com', - }, - }, + edges: [ + { id: 'e1', source: 'start', target: 'check' }, + { id: 'e2', source: 'check', target: 'assign', condition: 'record.annual_revenue > 1000000' }, + { id: 'e3', source: 'assign', target: 'notify' }, + { id: 'e4', source: 'notify', target: 'end' }, ], }); ``` @@ -219,64 +231,50 @@ ObjectOS **orchestrates** these rules at runtime, independent of the data struct ### Example: List View -```typescript -// packages/crm/src/views/customer_list.view.ts -import { ListView } from '@objectstack/spec'; +Views are authored with `defineView`, which aggregates an object's `list` and `form` views into one document. A list view binds to its data source (`provider: 'object'`), names its columns, and declares any default sort: -export const CustomerListView = ListView({ - object: 'customer', - label: 'All Customers', - type: 'grid', - columns: [ - { field: 'name', width: 200 }, - { field: 'industry', width: 150 }, - { field: 'annual_revenue', width: 150 }, - { field: 'primary_contact', width: 180 }, - ], - filters: [ - { field: 'industry', operator: 'equals' }, - { field: 'annual_revenue', operator: 'greaterThan' }, - ], - defaultSort: { field: 'name', direction: 'asc' }, +```typescript +// packages/crm/src/views/customer.view.ts +import { defineView } from '@objectstack/spec'; + +export const customerViews = defineView({ + list: { + label: 'All Customers', + type: 'grid', + data: { provider: 'object', object: 'customer' }, + columns: ['name', 'industry', 'annual_revenue', 'primary_contact'], + filter: [ + { field: 'annual_revenue', operator: 'greaterThan', value: 0 }, + ], + sort: [{ field: 'name', order: 'asc' }], + }, }); ``` ### Example: Form View -```typescript -// packages/crm/src/views/customer_form.view.ts -import { FormView } from '@objectstack/spec'; +The same `defineView` document carries the `form` view. A form is laid out as **sections** of fields (each section can span 1-4 columns): -export const CustomerFormView = FormView({ - object: 'customer', - label: 'Customer Details', - type: 'tabbed', - tabs: [ - { - label: 'Overview', - sections: [ - { - label: 'Company Information', - fields: ['name', 'industry', 'annual_revenue'], - }, - { - label: 'Contact', - fields: ['primary_contact'], - }, - ], - }, - { - label: 'Related Records', - sections: [ - { - label: 'Opportunities', - component: 'related_list', - object: 'opportunity', - filter: { customer: '$recordId' }, - }, - ], - }, - ], +```typescript +// packages/crm/src/views/customer.view.ts +import { defineView } from '@objectstack/spec'; + +export const customerViews = defineView({ + form: { + type: 'simple', + data: { provider: 'object', object: 'customer' }, + sections: [ + { + label: 'Company Information', + columns: 2, + fields: ['name', 'industry', 'annual_revenue'], + }, + { + label: 'Contact', + fields: ['primary_contact'], + }, + ], + }, }); ``` @@ -290,6 +288,10 @@ The UI doesn't "know" the field types. It asks ObjectQL for the schema and rende Let's trace a **real-world scenario**: A sales rep creates a new high-value customer. + +The runtime snippets in Steps 3-6 below are **conceptual pseudo-code** — names like `Auth.getCurrentUser()`, `Permission.check()`, `ObjectQL.getSchema()`, and `Workflow.getTriggersFor()` illustrate *what each layer does at runtime*; they are not importable APIs from `@objectstack/spec`. The schema, view, permission-set, and flow snippets elsewhere on this page are the real authoring surface. + + ### Step 1: User Action (ObjectUI) ``` @@ -431,15 +433,25 @@ export const Opportunity = ObjectSchema.create({ ### 2. ObjectOS: Define Business Rules ```typescript -export const OpportunityWorkflow = Workflow({ - object: 'opportunity', - trigger: 'field_update', - conditions: [ - { field: 'stage', operator: 'equals', value: 'closed_won' }, +import { defineFlow } from '@objectstack/spec'; + +export const opportunityWonFlow = defineFlow({ + name: 'opportunity_won', + label: 'Opportunity Won', + type: 'record_change', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'On Change' }, + { id: 'check', type: 'decision', label: 'Closed Won?' }, + { id: 'invoice', type: 'create_record', label: 'Create Invoice' }, + { id: 'notify', type: 'notify', label: 'Notify Sales Team' }, + { id: 'end', type: 'end', label: 'End' }, ], - actions: [ - { type: 'create_invoice', params: { object: 'invoice' } }, - { type: 'send_notification', params: { to: 'sales_team' } }, + edges: [ + { id: 'e1', source: 'start', target: 'check' }, + { id: 'e2', source: 'check', target: 'invoice', condition: "record.stage == 'closed_won'" }, + { id: 'e3', source: 'invoice', target: 'notify' }, + { id: 'e4', source: 'notify', target: 'end' }, ], }); ``` @@ -447,16 +459,19 @@ export const OpportunityWorkflow = Workflow({ ### 3. ObjectUI: Define the Kanban View ```typescript -export const OpportunityKanban = ListView({ - object: 'opportunity', - type: 'kanban', - groupBy: 'stage', - columns: [ - { field: 'title' }, - { field: 'amount' }, - { field: 'customer' }, - ], - enableDragDrop: true, +import { defineView } from '@objectstack/spec'; + +export const opportunityViews = defineView({ + list: { + type: 'kanban', + data: { provider: 'object', object: 'opportunity' }, + columns: ['title', 'amount', 'customer'], + kanban: { + groupByField: 'stage', + summarizeField: 'amount', + columns: ['title', 'amount', 'customer'], + }, + }, }); ``` @@ -546,7 +561,7 @@ The three protocols are **loosely coupled** but **tightly integrated**: ## Next Steps -- [ObjectQL: Data Protocol]((/docs/protocol/objectql)) - Full data protocol specification +- [ObjectQL: Data Protocol](/docs/protocol/objectql) - Full data protocol specification - [ObjectUI: UI Protocol](/docs/protocol/objectui) - Full view protocol specification - [ObjectOS: System Protocol](/docs/protocol/objectos) - Full control protocol specification - [Developer Guide](/docs/getting-started/quick-start) - Build your first ObjectStack application diff --git a/content/docs/concepts/metadata-driven.mdx b/content/docs/concepts/metadata-driven.mdx index f5283e38fc..709a00e5a4 100644 --- a/content/docs/concepts/metadata-driven.mdx +++ b/content/docs/concepts/metadata-driven.mdx @@ -106,9 +106,10 @@ In metadata-driven development, we embrace three core truths: The UI doesn't "build" a form; it **projects** the Object schema into visual components. ```typescript -// The schema IS the form -const TaskForm = -// No manual JSX needed +// Conceptual: the schema IS the form. The runtime renders a view +// definition (a FormView metadata object) — there is no FormView +// React component to import; you declare the view, not the JSX. +const taskForm = { type: 'form', object: 'task' } ``` ### 2. The API is a Consequence @@ -120,11 +121,11 @@ You don't write controllers or routes. ObjectOS *generates* the entire API graph ```bash # Automatically available after defining the object: -GET /api/v1/task -POST /api/v1/task -GET /api/v1/task/:id -PATCH /api/v1/task/:id -DELETE /api/v1/task/:id +GET /api/v1/data/task +POST /api/v1/data/task +GET /api/v1/data/task/:id +PATCH /api/v1/data/task/:id +DELETE /api/v1/data/task/:id ``` ### 3. The Schema is the Application @@ -230,7 +231,7 @@ export const Task = ObjectSchema.create({ ], }), - assignee: Field.lookup('user', { + assignee: Field.lookup('sys_user', { label: 'Assignee', }), }, @@ -240,6 +241,8 @@ export const Task = ObjectSchema.create({ // validation, and permission scaffolding are ready. ``` +> **📘 Note**: Built-in platform objects carry system prefixes — the standard user object is `sys_user`, not `user`. Use `Field.lookup('sys_user', ...)` to reference it. Names like `task` or `account` in these examples are illustrative custom objects. + ### 5. Agent-Ready by Construction Because objects, fields, relationships, permissions, and workflows are explicit, the runtime can expose a bounded tool surface for AI agents: @@ -364,9 +367,9 @@ export const TodoTask = ObjectSchema.create({ }); ``` -### 3. Select Field Options Must Use Label/Value Objects +### 3. Prefer Explicit Label/Value Objects for Select Options -**✅ Correct:** +**✅ Recommended:** ```typescript status: Field.select({ label: 'Status', @@ -378,20 +381,22 @@ status: Field.select({ }), ``` -**❌ Incorrect:** +**✅ Also valid (auto-normalized):** ```typescript status: Field.select({ - options: ['open', 'in_progress', 'closed'], // Wrong! + options: ['Open', 'In Progress', 'Closed'], }), +// Helper converts each string to a lowercase snake_case value: +// [{ label: 'Open', value: 'open' }, { label: 'In Progress', value: 'in_progress' }, ...] ``` -**Why?** Option values are machine identifiers stored in the database and must be lowercase to avoid case-sensitivity issues in queries. +**Why?** Option values are machine identifiers stored in the database, so they must be lowercase to avoid case-sensitivity issues in queries. `Field.select()` accepts plain string arrays and auto-normalizes them to lowercase snake_case `{ label, value }` pairs for you. Use explicit `{ label, value }` objects when the display label must differ from the stored value (or to set `default: true`). ### 4. Lookup Fields Must Specify Target Object **✅ Correct:** ```typescript -owner: Field.lookup('user', { +owner: Field.lookup('sys_user', { label: 'Owner', required: true, }), @@ -488,7 +493,7 @@ export const ExampleObject = ObjectSchema.create({ }), // Lookup field - owner: Field.lookup('user', { + owner: Field.lookup('sys_user', { label: 'Owner', required: true, }), @@ -514,7 +519,7 @@ export const ExampleObject = ObjectSchema.create({ ## Next Steps -- [Metadata Lifecycle & HMR](/concepts/metadata-lifecycle) - How writes propagate through the Repository / Change Log / Cache / Registry -- [The Stack](/docs/core-concepts/the-stack) - How the three protocols work together -- [Object Model](/docs/core-concepts/object-model) - Deep dive into the universal object model -- [ObjectQL Protocol](/docs/protocols/objectql) - Learn the data protocol specification +- [Metadata Lifecycle & HMR](/docs/concepts/metadata-lifecycle) - How writes propagate through the Repository / Change Log / Cache / Registry +- [Architecture](/docs/concepts/architecture) - How the three protocols work together +- [Core Concepts](/docs/concepts/core) - Deep dive into the kernel, services, and object model +- [ObjectQL Protocol](/docs/protocol/objectql) - Learn the data protocol specification diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index 28568f47f1..6177037ec8 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -20,13 +20,13 @@ This page documents the metadata data path introduced by [ADR-0008](/adr/0008-me │ Studio UI │ │ (useMetadataHmr / HmrStatusBadge) │ └────────────────────┬────────────────────────┘ - │ SSE /api/metadata/hmr + │ SSE /api/v1/dev/metadata-events ▼ ┌─────────────────────────────────────────────┐ │ MetadataManager (bridge) │ │ forwards Repository events → SSE channel │ └────────────────────┬────────────────────────┘ - │ MetadataEvent{seq, op, ref, ...} + │ ChangeEvent{kind, metadataType, name, seq?} ▼ ┌─────────────────────────────────────────────┐ │ LayeredRepository │ @@ -49,7 +49,7 @@ Reads walk top-to-bottom: the first non-null layer wins. Writes always route to | Primitive | Package | Purpose | | :--- | :--- | :--- | -| **Repository** | `@objectstack/metadata-core` | CRUD + watch interface over a single metadata source. Implementations: `InMemoryRepository`, `FileSystemRepository`, `SysMetadataRepository`, `LayeredRepository`. | +| **Repository** | `@objectstack/metadata-core` | CRUD + watch interface over a single metadata source. `InMemoryRepository` and `LayeredRepository` ship from `@objectstack/metadata-core`; `FileSystemRepository` from `@objectstack/metadata-fs`; `SysMetadataRepository` from `@objectstack/objectql`. | | **Change Log** | `@objectstack/metadata-core` | Append-only log of every mutation, tagged with a monotonic `seq`. Watchers can replay from any `since`. | | **Cache** | `@objectstack/metadata` | In-memory snapshot of the registry, keyed by `MetaRef`. Invalidated by change-log events. | | **Registry** | `@objectstack/metadata` | Typed registry the Kernel and plugins query. Built from the cache. | @@ -69,7 +69,7 @@ When a user edits a view in Studio: - Verifies `parentVersion` matches the current head (`ConflictError` on mismatch). - Writes the new row + appends a change-log entry with the next `seq`. - Emits a `MetadataEvent { op: 'create' | 'update' | 'delete', ref, seq, source }`. -5. The `MetadataManager` bridge forwards the event over the SSE channel `/api/metadata/hmr` with the `seq` attached. +5. The `MetadataManager` bridge forwards the event over the SSE channel `/api/v1/dev/metadata-events`. The wire payload is **not** the internal `MetadataEvent` — it is a `ChangeEvent { kind: 'metadata-change', type, metadataType, name, path?, timestamp, seq? }` (emitted as `event: metadata-change`). `seq` is the canonical repository sequence and is **absent** for FS-watcher (chokidar) dev events. 6. The Studio's `useMetadataHmr` hook receives the event, updates `lastSeq`, and triggers a refetch of the affected view. The `seq` is the single source of truth for ordering. The Studio status badge (`HmrStatusBadge`) shows `Repo seq: #N` in its tooltip. @@ -83,12 +83,12 @@ In shared-database multi-tenancy, **most metadata types must not be per-org cust | Type | `allowOrgOverride` | Rationale | | :--- | :---: | :--- | | `view`, `dashboard`, `report`, `email_template` | ✅ | Pure rendering. Per-org customization is safe. | +| `flow`, `agent` | ✅ | Per-org overlays are allowed for automation and agent definitions. | +| `permission`, `role`, `profile` | ✅ | Per-org overlays are allowed; tenant-level controls layer on top. | | `object`, `field` | ❌ | Defines the table schema. Overriding would break existing data. | -| `flow`, `workflow`, `agent` | ❌ | Stateful execution; per-org overrides need a separate execution-scoping mechanism. | -| `permission`, `role`, `profile` | ❌ | Security; tenant-level controls already exist via role assignments. | | `datasource` | ❌ | Connection strings; multi-tenant isolation is enforced at a higher layer. | -The runtime gate is implemented in `OVERLAY_ALLOWED_TYPES` (derived from the registry) and enforced by `SysMetadataRepository.put()`. Denied types return `403 not_overridable`. +There is no `workflow` metadata type (per [ADR-0020](/adr/0020-state-machine-converge-and-enforce), record state machines are a `state_machine` validation). The runtime gate is implemented in `OVERLAY_ALLOWED_TYPES` (derived from the registry) and enforced by `SysMetadataRepository.put()`. Denied types return `403 not_overridable`. See [ADR-0005](/adr/0005-metadata-customization-overlay) for the full design and amendments. @@ -105,7 +105,7 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s ## HMR end-to-end (latency & ordering) - **Latency.** A typical write-to-render round-trip is **< 100ms** on localhost (REST `PUT` → DB write → SSE flush → Studio refetch → React re-render). -- **Ordering.** The `seq` is monotonic per repository. If the Studio tab disconnects and reconnects, it can resume from `lastSeq` to replay missed events. (Replay is implemented by `InMemoryRepository`/`SysMetadataRepository`; the SSE bridge passes `since` as a query param.) +- **Ordering.** The `seq` is monotonic per repository. The repository-level `watch()` API supports replay from a `since` cursor, but the dev HMR SSE endpoint does **not** replay on reconnect — it registers a fresh listener, emits a `ready` event, and then streams only live events. A tab that disconnects and reconnects will miss any events that occurred while it was offline; it should refetch the affected metadata on reconnect. - **Multi-tab.** Each tab has its own `seq` counter. Out-of-order delivery between tabs is impossible because they share the server change log. --- @@ -126,13 +126,13 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s | :--- | :--- | | `InMemoryRepository`, `FileSystemRepository`, `LayeredRepository` | ✅ Shipped (`@objectstack/metadata-core`) | | Change log + `seq` (per-org, monotonic) | ✅ Shipped | -| SSE bridge (`/api/metadata/hmr` with `seq`) | ✅ Shipped | +| SSE bridge (`/api/v1/dev/metadata-events`, `event: metadata-change`) | ✅ Shipped | | Studio `useMetadataHmr` + `HmrStatusBadge` | ✅ Shipped | | Console dev-mode HMR reloader (`MetadataHmrReloader`) | ✅ Shipped | | `SysMetadataRepository` (overlay over `sys_metadata`) | ✅ Shipped (`@objectstack/objectql`) | | `LayeredRepository(SysMeta + artifact)` composition | ✅ Shipped | | `protocol.ts:saveMetaItem` routed through `SysMetadataRepository.put` | ✅ Shipped (PR-10d.6, flag removed) | -| `sys_metadata_history` table (durable, org-keyed change log) | ⏳ M1 | +| `sys_metadata_history` table (durable, org-keyed change log) | ✅ Shipped (`@objectstack/objectql`) | | Cache + Registry refactor against `MetadataRepository` | ⏳ Post-M0 | > **Cross-replica overlay sync is out of scope.** Single-instance deployments @@ -150,8 +150,8 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s | Where the metadata came from | Lands in `sys_metadata`? | Lands in history? | |:---|:---|:---| | `defineView(...)` / `defineFlow(...)` / any source file → compiled into `dist/objectstack.json` | ❌ Never. Loaded into the in-memory registry on boot; refreshed via HMR in dev. | ❌ The artifact's own version history *is* Git. The metadata layer does not duplicate it. | -| Editing a `.json` under `.objectstack/metadata//.json` (FS overlay) | ❌ FS layer is independent of DB. | ✅ Appended to `.objectstack/.log/main.jsonl` by `FileSystemRepository`. | -| Studio inline edit, or `PUT /api/v1/metadata/...` (REST) on an `allowOrgOverride: true` type | ✅ Written by `SysMetadataRepository.put()` as an **overlay row** scoped to `organization_id`. | ⚠️ M0: in-process watch broadcast only — no durable history table yet. M1: appended to `sys_metadata_history` with a Postgres `SERIAL seq` (single-instance scope; no cross-replica push). | +| Editing a `.json` under `//.json` (FS overlay, e.g. `/view/case_grid.json`) | ❌ FS layer is independent of DB. | ✅ Appended to the change log at `/.objectstack/.log/main.jsonl` by `FileSystemRepository`. | +| Studio inline edit, or `PUT /api/v1/metadata/...` (REST) on an `allowOrgOverride: true` type | ✅ Written by `SysMetadataRepository.put()` as an **overlay row** scoped to `organization_id`. | ✅ Appended to `sys_metadata_history` (per-org `event_seq`) in the **same transaction** as the `sys_metadata` write (single-instance scope; no cross-replica push). | | Deploying a new build (new `dist/objectstack.json`) | ❌ The artifact is loaded into memory, not synced into `sys_metadata`. | ❌ Use Git tags / your deployment platform's release log; that's where artifact "version history" lives. | ### Why artifact never enters the database diff --git a/content/docs/concepts/north-star.mdx b/content/docs/concepts/north-star.mdx index 0c63d06c59..599ddb18a2 100644 --- a/content/docs/concepts/north-star.mdx +++ b/content/docs/concepts/north-star.mdx @@ -59,8 +59,10 @@ development live outside this repo. ## Environment Artifact -`os compile` produces a deployable environment artifact. The envelope is defined -by `EnvironmentArtifactSchema` in +`os compile` produces the local runtime bundle (`dist/objectstack.json`, +validated against `ObjectStackDefinitionSchema`). When published to Cloud it is +wrapped in the immutable environment artifact envelope defined by +`EnvironmentArtifactSchema` in `packages/spec/src/system/environment-artifact.zod.ts`. The artifact contains: diff --git a/content/docs/concepts/packages.mdx b/content/docs/concepts/packages.mdx index 8fb1cd9273..c900cd0ff9 100644 --- a/content/docs/concepts/packages.mdx +++ b/content/docs/concepts/packages.mdx @@ -5,7 +5,7 @@ description: Complete reference of all ObjectStack packages in the monorepo # Package Reference -ObjectStack is distributed as a monorepo containing **70 package manifests** organized into core runtime, client SDKs, framework adapters, drivers, plugins, and platform services. +ObjectStack is distributed as a monorepo containing **~75 package manifests** organized into core runtime, client SDKs, framework adapters, drivers, plugins, connectors, triggers, and platform services. > **Note for AI Agents**: Each package's `README.md` contains a specific architectural role and usage rules section. @@ -13,14 +13,17 @@ ObjectStack is distributed as a monorepo containing **70 package manifests** org | Category | Count | Description | | :--- | :---: | :--- | -| Core runtime | 9 | `spec`, `core`, `runtime`, `types`, `metadata`, `objectql`, `rest`, `formula`, `platform-objects` | +| Core runtime | 11 | `spec`, `core`, `runtime`, `types`, `metadata`, `metadata-core`, `metadata-fs`, `objectql`, `rest`, `formula`, `platform-objects` | | Client / DX | 5 | `client`, `client-react`, `cli`, `create-objectstack`, `vscode-objectstack` | | Framework adapters | 7 | Express, Fastify, Hono, NestJS, Next.js, Nuxt, SvelteKit | | Drivers | 4 | `driver-memory`, `driver-sql`, `driver-sqlite-wasm`, `driver-mongodb` | -| Plugins | 22 | Auth, security, audit, approvals, sharing, email, webhooks, reports, Hono/MCP servers, MSW/dev, triggers, knowledge, and embedders | +| Plugins | 18 | Auth, security, audit, org-scoping, approvals, sharing, email, webhooks, reports, Hono/MCP servers, MSW/dev, record-change & schedule triggers, knowledge, and embedders | +| Connectors | 4 | `connector-rest`, `connector-mcp`, `connector-openapi`, `connector-slack` | +| Triggers | 3 | `trigger-api`, `trigger-record-change`, `trigger-schedule` | | Platform services | 17 | AI, Analytics, Automation, Cache, Cluster, Datasource, Feed, I18n, Job, Knowledge, Messaging, Package, Queue, Realtime, Settings, Storage | +| Other | ~6 | `mcp`, `console`, `cloud-connection`, `observability`, app templates, and tooling | -**Total: 70 package manifests** +**Total: ~75 package manifests.** Counts drift as packages are added — run `find packages -name package.json -not -path '*/node_modules/*'` for the exact current set. --- @@ -32,20 +35,25 @@ ObjectStack is distributed as a monorepo containing **70 package manifests** org **Purpose:** The foundational package containing all protocol definitions and schemas. This is the "DNA" of ObjectStack. -**Key Exports:** -- `Data` - Data protocol schemas (Field, Object, Query, etc.) -- `UI` - UI protocol schemas (View, App, Dashboard, etc.) -- `System` - System protocol schemas (Manifest, Plugin, Events, etc.) -- `AI` - AI protocol schemas (Agent, RAG Pipeline, etc.) -- `API` - API protocol schemas (Contract, Endpoint, etc.) -- `Automation` - Automation protocol schemas (Flow, Workflow, etc.) -- `Auth` - Authentication protocol schemas -- `Permission` - Permission protocol schemas -- `Hub` - Hub protocol schemas -- `Integration` - Integration protocol schemas -- `Shared` - Shared utilities and identifiers - -**Protocol Count:** 175 Zod schemas +**Protocol Domains (subpath imports):** + +The package does **not** export schemas from the root; import the domain you need via its subpath, e.g. `import * as Data from '@objectstack/spec/data'`. + +- `@objectstack/spec/data` - Data protocol schemas (Field, Object, Query, etc.) +- `@objectstack/spec/ui` - UI protocol schemas (View, App, Dashboard, etc.) +- `@objectstack/spec/system` - System protocol schemas (Manifest, Plugin, Events, Auth config, etc.) +- `@objectstack/spec/identity` - Identity & authentication schemas +- `@objectstack/spec/security` - Permission and security schemas +- `@objectstack/spec/ai` - AI protocol schemas (Agent, RAG Pipeline, etc.) +- `@objectstack/spec/api` - API protocol schemas (Contract, Endpoint, etc.) +- `@objectstack/spec/automation` - Automation protocol schemas (Flow, Workflow, etc.) +- `@objectstack/spec/integration` - Integration protocol schemas +- `@objectstack/spec/contracts` - Service contract interfaces +- `@objectstack/spec/kernel` - Kernel protocol schemas +- `@objectstack/spec/shared` - Shared utilities and identifiers +- Additional subpaths: `studio`, `cloud`, `qa` + +**Protocol Count:** Hundreds of Zod schemas across these domains — the set grows over time, so refer to the [Protocol Reference](/docs/references) for the current catalog. **Learn more:** [Protocol Reference](/docs/references) @@ -217,7 +225,12 @@ ObjectStack is distributed as a monorepo containing **70 package manifests** org | Development | `init`, `dev`, `serve` | | Build & Validate | `compile`, `validate`, `info` | | Scaffolding | `generate` (alias: `g`), `create` | -| Quality | `test`, `doctor` | +| Quality | `test`, `doctor`, `lint` | +| Publishing & Registry | `build`, `publish`, `register`, `rollback`, `login`, `logout`, `whoami` | +| Inspection | `diff`, `explain` | +| Command groups | `cloud`, `data`, `datasource`, `environments`, `i18n`, `meta`, `package`, `plugin` | + +> This is a representative list; run `os --help` for the authoritative set of commands and subcommands. **Key Features:** - **Project Initialization**: `os init` creates projects from templates (app, plugin, empty) @@ -254,7 +267,9 @@ ObjectStack is distributed as a monorepo containing **70 package manifests** org - **MetadataManager**: Orchestrates loading, saving, watching, caching metadata - **MetadataPlugin**: Kernel plugin adapter for metadata system - **Loaders**: - - `FilesystemLoader` - Loads metadata from disk (rootDir) + - `MemoryLoader` - In-memory metadata source + - `RemoteLoader` - Loads metadata from a remote endpoint + - `DatabaseLoader` - Loads metadata persisted in the database - **Serializers**: - `JSONSerializer` - `.json` files - `YAMLSerializer` - `.yaml` files @@ -273,6 +288,30 @@ ObjectStack is distributed as a monorepo containing **70 package manifests** org --- +### @objectstack/metadata-core + +**Description:** Metadata Repository contracts (ADR-0008) — types, canonicalization, errors, and the `MetadataRepository` interface. + +**Purpose:** The shared contract layer the metadata system and its storage backends implement. + +**Use Cases:** Building custom metadata repositories, integrating with the metadata system. + +**Implementation Status:** ✅ **IMPLEMENTED** + +--- + +### @objectstack/metadata-fs + +**Description:** `FileSystemRepository` — a Node-only `MetadataRepository` implementation backed by JSON files and a JSONL change log (ADR-0008). + +**Purpose:** Persists metadata on the filesystem with an append-only change history. + +**Use Cases:** Local development, file-based metadata storage, change auditing. + +**Implementation Status:** ✅ **IMPLEMENTED** + +--- + ### @objectstack/types **Description:** Shared Runtime Type Definitions @@ -412,18 +451,17 @@ Framework adapters that bridge ObjectStack's unified `HttpDispatcher` to specifi **Purpose:** A reference in-memory database driver used for testing and development. **Key Features:** -- **CRUD Operations**: create, read, update, delete, find -- **Pagination Support**: Offset-based pagination +- **CRUD & Bulk Operations**: create, read, update, delete, find, plus bulk insert/update/delete +- **Mingo Query Engine**: MongoDB-compatible filtering, projection, and distinct values +- **Aggregation Pipeline**: `$match`, `$group`, `$sort`, `$project`, `$unwind`, and an `IAnalyticsService` implementation (`memory-analytics`) +- **Sorting & Pagination**: `orderBy` sorting with offset-based pagination +- **Snapshot Transactions**: `beginTransaction` / `commit` / `rollback` (capability flag `transactions: true`) - **Health Checks**: Built-in health monitoring - **Array & JSON Fields**: Support for complex field types -- **Simple Implementation**: No external dependencies **Limitations:** -- ⚠️ No transaction support -- ⚠️ No advanced filtering (basic only) -- ⚠️ No aggregations -- ⚠️ No sorting -- ⚠️ No window functions or subqueries +- ⚠️ Non-persistent — all data is held in memory and lost on restart +- ⚠️ No savepoints within a transaction (`savepoints: false`) **Use Cases:** - Testing ObjectStack applications @@ -433,7 +471,7 @@ Framework adapters that bridge ObjectStack's unified `HttpDispatcher` to specifi **Status:** ✅ Reference implementation for driver developers -**Implementation Status:** ⚠️ **PARTIALLY IMPLEMENTED** - Basic CRUD only, suitable for testing +**Implementation Status:** ✅ **FULLY IMPLEMENTED** (in-memory) - Mingo-backed filtering, aggregation, sorting, and snapshot transactions; non-persistent --- @@ -494,12 +532,14 @@ Framework adapters that bridge ObjectStack's unified `HttpDispatcher` to specifi **Purpose:** Provides authentication and identity management services for ObjectStack applications with better-auth integration. **Key Features:** +- **better-auth Integration**: Wires better-auth's server-side authentication pipeline (sign-up/sign-in, sessions, tokens) - **Plugin Lifecycle**: Full init/start/destroy lifecycle implementation - **Service Registration**: Registers `auth` service in ObjectKernel -- **HTTP Route Scaffolding**: `/api/v1/auth/*` endpoints via IHttpServer +- **HTTP Routes**: `/api/v1/auth/*` endpoints via IHttpServer, plus a `/set-initial-password` route - **Configuration Support**: Uses `AuthConfig` schema from `@objectstack/spec/system` - **OAuth Provider Support**: Configuration for Google, GitHub, Microsoft, etc. -- **ObjectQL Database**: Uses ObjectQL for data persistence (no ORM required) +- **ObjectQL Adapter**: Persists users/sessions through ObjectQL (no separate ORM required) +- **Password Hashing**: Cryptographic password hashing via `@noble/hashes` - **Advanced Features**: Organization/team support, 2FA, passkeys, magic links **API Routes:** @@ -514,9 +554,7 @@ Framework adapters that bridge ObjectStack's unified `HttpDispatcher` to specifi - OAuth social login integration - Secure session management -**Status:** 🟡 **IN DEVELOPMENT** - Structure complete, authentication logic planned - -**Implementation Status:** ⚠️ **PARTIALLY IMPLEMENTED** - Plugin structure and routes scaffolded, authentication logic to be added with better-auth integration +**Implementation Status:** ✅ **FULLY IMPLEMENTED** - Shipped better-auth integration with an ObjectQL persistence adapter, password hashing, and session/token management **Learn more:** [Auth Config Reference](/docs/references/system/auth-config) @@ -550,6 +588,30 @@ Framework adapters that bridge ObjectStack's unified `HttpDispatcher` to specifi --- +### @objectstack/plugin-audit + +**Description:** Audit Plugin for ObjectStack — system audit log object and audit trail. + +**Key Features:** Records create/update/delete activity into a system audit-log object for compliance and forensics. + +**Use Cases:** Compliance audit trails, change tracking, security investigations. + +**Implementation Status:** ✅ **IMPLEMENTED** + +--- + +### @objectstack/plugin-org-scoping + +**Description:** Organization-Scoping Plugin for ObjectStack — row-level organization isolation. + +**Key Features:** Per-org row-level isolation, per-org seed replay, default-org bootstrap. + +**Use Cases:** Multi-tenant data isolation, organization-scoped applications. + +**Implementation Status:** ✅ **IMPLEMENTED** + +--- + ### @objectstack/plugin-dev **Description:** Development Mode Plugin for ObjectStack @@ -691,8 +753,98 @@ Framework adapters that bridge ObjectStack's unified `HttpDispatcher` to specifi --- +## Connectors + +Connectors register concrete `request`/action handlers on the automation engine's connector registry, letting flows call external systems (ADR-0018 Addendum, ADR-0022/0023/0024). + +### @objectstack/connector-rest + +**Description:** Generic REST connector — the reference concrete connector that registers a `request` action on the connector registry. + +**Use Cases:** Calling arbitrary REST APIs from flows. + +**Implementation Status:** ✅ **IMPLEMENTED** + +--- + +### @objectstack/connector-openapi + +**Description:** OpenAPI 3.x connector generator — turns a declarative OpenAPI document into connector actions, with a self-contained static-auth HTTP transport. + +**Use Cases:** Integrating any OpenAPI-described service as flow actions. + +**Implementation Status:** ✅ **IMPLEMENTED** + +--- + +### @objectstack/connector-mcp + +**Description:** Model Context Protocol (MCP) connector — turns any MCP server's tools into a connector's actions on the automation engine. + +**Use Cases:** Exposing MCP-server tools to flows and agents. + +**Implementation Status:** ✅ **IMPLEMENTED** + +--- + +### @objectstack/connector-slack + +**Description:** Slack Web API connector — registers `chat.postMessage` / `chat.update` / `call` actions on the connector registry. + +**Use Cases:** Posting Slack messages and triggering Slack calls from flows. + +**Implementation Status:** ✅ **IMPLEMENTED** + +--- + +## Triggers + +Trigger packages auto-launch flows in response to events (ADR-0018, ADR-0041). + +### @objectstack/trigger-record-change + +**Description:** Record-change flow trigger — auto-launches flows on object insert/update/delete via ObjectQL lifecycle hooks. + +**Use Cases:** Event-driven automation reacting to data changes. + +**Implementation Status:** ✅ **IMPLEMENTED** + +--- + +### @objectstack/trigger-schedule + +**Description:** Schedule flow trigger — auto-launches flows on a cron/interval/once schedule via the `IJobService`. + +**Use Cases:** Scheduled and recurring automation. + +**Implementation Status:** ✅ **IMPLEMENTED** + +--- + +### @objectstack/trigger-api + +**Description:** Inbound HTTP/webhook flow trigger — per-flow HMAC-verified endpoints with queue-backed ingestion. + +**Use Cases:** Launching flows from external webhooks and inbound HTTP calls. + +**Implementation Status:** ✅ **IMPLEMENTED** + +--- + ## Tools +### @objectstack/mcp + +**Description:** ObjectStack as an MCP server — exposes your app's objects (and AI tools) over the Model Context Protocol (stdio + Streamable HTTP). + +**Purpose:** Lets MCP clients (and AI agents) discover and operate on ObjectStack data and tools. + +**Use Cases:** Exposing an ObjectStack app to AI assistants and MCP-aware tooling. + +**Implementation Status:** ✅ **IMPLEMENTED** + +--- + ### @objectstack/rest **Description:** ObjectStack REST API Server — automatic REST endpoint generation from protocol @@ -829,7 +981,7 @@ pnpm add -g @objectstack/cli All packages in the monorepo are versioned together and released simultaneously to ensure compatibility. -**Current Version:** Check [CHANGELOG.md](https://github.com/objectstack-ai/spec/blob/main/CHANGELOG.md) +**Current Version:** Check [CHANGELOG.md](https://github.com/objectstack-ai/framework/blob/main/CHANGELOG.md) **Compatibility Matrix:** @@ -845,15 +997,15 @@ All packages in the monorepo are versioned together and released simultaneously diff --git a/content/docs/concepts/setup-app.mdx b/content/docs/concepts/setup-app.mdx index 76af2533f4..b1d33d1609 100644 --- a/content/docs/concepts/setup-app.mdx +++ b/content/docs/concepts/setup-app.mdx @@ -5,83 +5,105 @@ description: The platform's built-in administration UI — what it is, what it s # Setup App -The **Setup App** (`/_studio/apps/setup`) is the built-in administration -console for every ObjectStack project. It lists every `sys_*` platform -object, plus two pre-baked dashboards, behind a fixed left-hand -navigation tree. - -It used to be assembled at runtime by a dedicated `SetupPlugin` (which -walked every loaded plugin asking "do you contribute Setup entries?"). -That plugin is **gone** as of the static-Setup-App refactor — the entire -app is now a fixed metadata artifact exported from -`@objectstack/platform-objects` and registered exactly like any other -ObjectStack app. +The **Setup App** (`/apps/setup`) is the built-in administration console +for every ObjectStack project. It surfaces the platform `sys_*` objects, +the System Overview dashboard, and a set of settings pages behind a +left-hand navigation tree. + +Under ADR-0029 D7 the app is **not** a fixed navigation tree. `SETUP_APP` +ships as a thin **shell** of stable, empty navigation group anchors +("slots"); the actual menu entries are contributed at runtime by the +packages that own the underlying objects, via `navigationContributions`. +The runtime merges every contribution into the app's `navigation` tree by +group id + priority on read, so the rendered menu reflects exactly which +capability plugins are loaded — a disabled capability contributes nothing +and its slot stays empty. + +The app itself ships from the dedicated `@objectstack/setup` package +(package id `com.objectstack.setup`), which registers it at runtime. Per +ADR-0048 (one app per package), `/apps/setup` — resolvable as +`/apps/` — maps to exactly this app. ## Where it lives | File | Role | |:---|:---| -| `packages/platform-objects/src/apps/setup.app.ts` | The `App` definition — navigation tree, branding, required permissions | +| `packages/platform-objects/src/apps/setup.app.ts` | The `App` definition — the navigation **shell** (group anchors), branding, required permissions | +| `packages/platform-objects/src/apps/setup-nav.contributions.ts` | `SETUP_NAV_CONTRIBUTIONS` — the nav entries owned by `@objectstack/platform-objects`, merged into the shell at runtime | | `packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts` | "Overview → System Overview" dashboard | -| `packages/platform-objects/src/apps/dashboards/security_overview.dashboard.ts` | "Overview → Security Overview" dashboard | -| `packages/platform-objects/src/apps/views/*.view.ts` | List views referenced by the navigation entries | -| `@objectstack/plugin-auth` | Performs the runtime registration via `manifest.register({...})` (auth is always loaded alongside security + audit) | +| `packages/apps/setup/src/index.ts` | `@objectstack/setup` package — `SetupAppPlugin.start()` performs the runtime registration via `manifest.register({ apps: [SETUP_APP], navigationContributions: SETUP_NAV_CONTRIBUTIONS })` | -> Why does `plugin-auth` register Setup? Because the Setup App's -> `requiredPermissions: ['setup.access']` permission is contributed by -> `plugin-auth`. Co-locating registration with the permission source -> avoids load-order surprises. +> The `requiredPermissions: ['setup.access']` permission declared on the +> Setup App is **contributed by `plugin-security`** (in its default +> permission sets), not by the package that registers the app. Other +> capability plugins (e.g. `plugin-audit`, `plugin-webhooks`, +> `plugin-approvals`) contribute their own Setup nav entries into the +> shared group anchors. ## Navigation -Menu shape: a flat `navigation[]` array with `type: 'group'` category -nodes and `type: 'object' | 'dashboard'` leaves — the same convention used -by the CRM example app. +`setup.app.ts` defines the stable group anchors (the shell); the entries +under each group are merged in from the contributing packages. The group +anchors are: -| Group | Entries | +| Group (anchor id) | Filled by | |:---|:---| -| **Overview** | System Overview · Security Overview | -| **Administration** | Users · Organizations · Teams · API Keys · Roles · Permission Sets · **OAuth Apps** · **Signing Keys** | -| **Platform** | Objects · Views · Flows · AI Agents · AI Tools · Apps · Packages · Installations · All Metadata | -| **System** | **Sessions** · Audit Logs · **Activity** · **Comments** | - -Bold entries are recently added: - -- **Sessions** (`sys_session`) — better-auth session records (used to be - in a separate ad-hoc table; now part of the platform schema). -- **OAuth Apps** (`sys_oauth_application`) — third-party OAuth client - registrations (e.g. for the Account portal). -- **Signing Keys** (`sys_jwks`) — JWKS keys used for OIDC / JWT signing. -- **Activity** (`sys_activity`) — high-level activity feed (audit log - optimised for human reading; the technical audit trail still lives in - `sys_audit_log`). -- **Comments** (`sys_comment`) — user-authored comments on records, used - by the Studio inspector and any object that opts into a comment thread. - -## Why it is static - -The previous `SetupPlugin`-based design existed because the referenced -objects used to live in three different runtime plugins (auth / security -/ audit), each of which registered its own Setup entries dynamically. -Now that all `sys_*` objects are centralised in -`@objectstack/platform-objects`, the navigation tree is also fixed at -build time. Benefits: - -- One file to read to understand what Setup contains. -- No load-order dependency between Setup and the plugins it references. -- The artifact can be type-checked against `App` from - `@objectstack/spec/ui` without any runtime plugin context. +| **Overview** (`group_overview`) | System Overview dashboard — `platform-objects` | +| **Apps** (`group_apps`) | Capability plugins only (e.g. marketplace via cloud-connection); empty otherwise | +| **People & Organization** (`group_people_org`) | Users · Departments · Teams · Organizations · Invitations — `platform-objects` | +| **Access Control** (`group_access_control`) | Roles / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` | +| **Approvals** (`group_approvals`) | `plugin-approvals` | +| **Configuration** (`group_configuration`) | All Settings · Branding · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects` | +| **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Logs — `plugin-audit` | +| **Integrations** (`group_integrations`) | `plugin-webhooks` | +| **Advanced** (`group_advanced`) | OAuth Applications · Signing Keys (JWKS) · Verifications · Device Codes · Identity Links · User Preferences — `platform-objects` | + +The exact rendered menu depends on which capability plugins are loaded. +A few notable entries: + +- **Sessions** (`sys_session`) — better-auth session records, contributed + into `group_diagnostics`. +- **OAuth Applications** (`sys_oauth_application`) — third-party OAuth + client registrations, contributed into `group_advanced`. +- **Signing Keys (JWKS)** (`sys_jwks`) — JWKS keys used for OIDC / JWT + signing, contributed into `group_advanced`. +- **Audit Logs** (`sys_audit_log`) — contributed by `plugin-audit` into + `group_diagnostics`. (The `sys_activity` and `sys_comment` objects also + live in `plugin-audit`, but they are not contributed as Setup nav + entries.) + +## Why a shell + contributions + +The Setup App is a shell of empty group anchors rather than a fixed +navigation tree because the objects it surfaces are owned by different +capability plugins (auth / security / sharing / audit / webhooks / +approvals). Letting each package contribute its own menu entries keeps the +menu for an object shipping with the package that owns the object — the +UI-layer analog of object `extend`. Benefits: + +- The rendered menu always matches the loaded capabilities: a disabled + plugin contributes nothing and its slot stays empty. +- No code in `setup.app.ts` needs to know about objects that live in other + packages; entries are merged by group id + priority on read. +- The shell can still be type-checked against `App` from + `@objectstack/spec/ui`, and contributions against + `NavigationContribution`. ## Customising it -The static export is intentionally read-only. To add a new entry: - -1. Add the object/dashboard/view to `@objectstack/platform-objects` - (or to a project-local plugin if it is project-specific). -2. Add a `navigation[]` leaf under the appropriate group in - `packages/platform-objects/src/apps/setup.app.ts`. -3. If the entry needs its own permission, declare it in - `@objectstack/plugin-auth` so the `requiredPermissions` check picks it +You do not edit `setup.app.ts` to add entries — it only holds the empty +group anchors. To add a new entry: + +1. Register the object/dashboard in the package that owns it (or in a + project-local plugin if it is project-specific). +2. Add a `NavigationContribution` targeting the relevant group id + (`app: 'setup'`, `group: 'group_*'`) — either in + `SETUP_NAV_CONTRIBUTIONS` for `@objectstack/platform-objects`-owned + objects, or in the `navigationContributions` of the plugin that owns + the object. +3. If the entry needs its own permission, declare it where the relevant + permissions live (e.g. `setup.access` is granted in `plugin-security`'s + default permission sets) so the `requiredPermissions` check picks it up. For project-local Setup-style entries, define a separate App in your diff --git a/content/docs/concepts/skills.mdx b/content/docs/concepts/skills.mdx index 7e92c0029f..e173316623 100644 --- a/content/docs/concepts/skills.mdx +++ b/content/docs/concepts/skills.mdx @@ -60,12 +60,13 @@ Traditional AI code assistants generate generic code. They don't understand: ## Skill Architecture -Each skill can use up to a **three-layer structure** inspired by [shadcn/ui](https://ui.shadcn.com/). Only `SKILL.md` is required; richer skills add the other two: +Each skill ships `SKILL.md` plus a generated `references/` index, and richer skills layer optional `rules/` and `evals/` directories on top — up to four distinct parts. Only `SKILL.md` is required: | Layer | File | Required | Purpose | | :--- | :--- | :--- | :--- | | **Overview** | `SKILL.md` | Yes | High-level guide with decision trees and quick-start examples | -| **Rules** | `rules/*.md` | Optional | Detailed implementation rules with ✅ correct / ❌ incorrect code examples | +| **References** | `references/_index.md` | Generated | Pointers into the published `@objectstack/spec` Zod sources (present in every skill except Formula) | +| **Rules** | `rules/*.md` | Optional | Detailed implementation rules with ✅ correct / ❌ incorrect code examples (today: Data, Platform, Query) | | **Evaluations** | `evals/*.md` | Optional | Test cases to validate AI assistant understanding | ### SKILL.md — The Entry Point @@ -178,7 +179,7 @@ with name, email, industry, and annual_revenue fields. ### With Claude Code -Claude Code reads `CLAUDE.md` at the repo root, which references all skills: +Claude Code reads `AGENTS.md` at the repo root, which references all skills: ``` Read skills/objectstack-query/SKILL.md and help me build a query diff --git a/content/docs/concepts/terminology.mdx b/content/docs/concepts/terminology.mdx index 69119cf150..6320d12c1a 100644 --- a/content/docs/concepts/terminology.mdx +++ b/content/docs/concepts/terminology.mdx @@ -108,7 +108,7 @@ The fundamental unit of data modeling in ObjectStack. Roughly equivalent to a "T ### Driver An adapter plugin in the Data Protocol runtime stack that allows the Data Layer to communicate with a specific underlying storage engine. -* *Example:* `@objectstack/driver-sql` (Postgres / MySQL / SQLite via Knex), `@objectstack/driver-mongodb`, `@objectstack/driver-turso`. +* *Example:* `@objectstack/driver-sql` (Postgres / MySQL / SQLite via Knex), `@objectstack/driver-mongodb`. `@objectstack/driver-turso` (edge SQLite) ships with ObjectStack Cloud rather than the open-source framework. ### AST (Abstract Syntax Tree) The intermediate representation of a query or schema. The Data Protocol parses a JSON request into an AST before the Driver translates it into SQL/NoSQL queries. This allows for security validation and optimization before execution. @@ -145,8 +145,8 @@ A map within the UI Runtime that links a string identifier (e.g., `"chart.bar"`) ## Governance -### Space (Workspace) -A logical isolation unit in the Cloud Protocol for multi-tenancy. A single ObjectStack instance can host multiple Spaces. Data is physically segregated by tenant isolation strategies. +### Tenant (Space / Workspace) +A logical isolation unit in the Cloud Protocol for multi-tenancy, modeled by the `tenant` schema (`cloud/tenant.zod.ts`). A single ObjectStack instance can host multiple tenants. Data is segregated by tenant isolation strategies. "Space" and "Workspace" are informal aliases for the same concept. ### FLS (Field-Level Security) A granular permission model (Security Protocol) where access control is applied to individual fields (columns), not just the whole object (row). diff --git a/content/docs/concepts/webhook-delivery.mdx b/content/docs/concepts/webhook-delivery.mdx index cea5ff57cc..c312df08f3 100644 --- a/content/docs/concepts/webhook-delivery.mdx +++ b/content/docs/concepts/webhook-delivery.mdx @@ -5,15 +5,17 @@ description: How ObjectStack reliably ships outbound HTTP notifications — from # Webhook Delivery -> **Status:** Accepted · **Audience:** Plugin authors, runtime engineers, +> **Status:** Shipped · **Audience:** Plugin authors, runtime engineers, > integration partners > > **TL;DR** — Webhooks are the lingua franca of SaaS integration. ObjectStack -> already defines a `Webhook` config schema; this document defines the -> **delivery runtime** that turns config into actual HTTP requests reaching -> external systems — durably, with signatures, with retries, and safely -> across a cluster. Builds directly on the cluster primitives from -> `cluster-semantics.mdx`. +> defines a `Webhook` config schema; the **delivery runtime** that turns config +> into actual HTTP requests is provided by `@objectstack/service-messaging`'s +> shared outbound-HTTP outbox (`sys_http_delivery`). The +> `plugin-webhooks` plugin owns only the `sys_webhook` configuration object and +> an auto-enqueuer that fans record events onto that outbox; signing, retries, +> dispatch, and idempotency are inherited from the messaging substrate +> (ADR-0018 M3). This page documents the shipped behaviour. ## 1. Why this document exists @@ -66,62 +68,82 @@ the standard object/view machinery. ## 3. Data model -Two persisted objects underpin the runtime. Both are defined as -ObjectStack `defineObject()` schemas so they participate in CRUD, -permissions, audit, and Studio UI without bespoke code. +Two persisted objects underpin the runtime, owned by different packages. +`sys_webhook` (the configuration record) lives in `plugin-webhooks`; +`sys_http_delivery` (the durable outbox) lives in +`@objectstack/service-messaging` and is shared with the Flow `http` node. +Both are defined as `ObjectSchema.create()` schemas so they participate in +CRUD, permissions, audit, and Studio UI without bespoke code. ### 3.1 `sys_webhook` -The subscription record. One row per "I want to receive events of type X -at URL Y". Replaces the in-memory `WebhookSchema` for runtime use; the -static config schema remains for `defineStack({ integrations })`-style -declarative wiring. - -| Field | Type | Notes | -|--------------------|-----------------------|------------------------------------------------------------| -| `id` | uuid | Primary key. | -| `owner_org_id` | string | Tenant / org owning the subscription. Required. | -| `name` | snake_case identifier | Human-friendly name for the subscription. | -| `url` | URL | Destination. Validated against egress allowlist on save. | -| `events` | string[] | Event-type globs: `account.*`, `*.created`, `*`. | -| `object_filter` | string[] ? | Optional object-name allowlist (`['account','contact']`). | -| `headers` | record\ ? | Custom headers (no `authorization` — use `secret`). | -| `secret_encrypted` | string | Encrypted shared secret. Never returned via API in clear. | -| `secret_hint` | string | Last 4 chars of secret for UI display (`whsec_…1234`). | -| `retry_policy` | json | `{ maxAttempts, initialDelayMs, maxDelayMs, backoff }`. | -| `timeout_ms` | int | Per-attempt timeout. Default 30s. | -| `enabled` | boolean | Soft toggle; disables delivery without losing history. | -| `created_by` | string | User id of creator. | -| `created_at` | timestamp | Standard audit columns. | -| `updated_at` | timestamp | | - -### 3.2 `sys_webhook_delivery` - -The outbox + audit. One row per attempt-cycle. Persisted **before** any -HTTP call; updated after each attempt. This table is the durability -boundary — if it has a `pending` row, the dispatcher will eventually -deliver it. - -| Field | Type | Notes | -|------------------|-----------------------|--------------------------------------------------------| -| `id` | uuid | Primary key. Also used as the receiver-side idempotency key. | -| `webhook_id` | uuid | FK → `sys_webhook`. | -| `event_id` | string | Origin event id (correlation across deliveries). | -| `event_type` | string | E.g. `account.updated`. | -| `payload` | json | The full payload that will be POSTed. | -| `status` | enum | `pending` / `in_flight` / `success` / `failed` / `dead`. | -| `attempts` | int | Count of HTTP requests issued. | -| `claimed_by` | string ? | Node id holding the row (NULL when not in flight). | -| `claimed_at` | timestamp ? | When the claim was taken. | -| `next_retry_at` | timestamp ? | When the next retry is eligible. NULL once dead. | -| `last_attempted_at` | timestamp ? | Most recent attempt start. | -| `response_code` | int ? | Last HTTP status code received. | -| `response_headers` | json ? | Truncated to first ~4 KB. | -| `response_body` | text ? | Truncated to first ~16 KB. | -| `duration_ms` | int ? | Last attempt round-trip. | -| `error` | text ? | Last transport-level error (DNS, connect, timeout). | -| `created_at` | timestamp | | -| `updated_at` | timestamp | | +The subscription record. One row per "I want webhook X to fire for object Y". +The full transport configuration (headers, secret, timeout, method) is carried +in `definition_json`, a serialised `Webhook` JSON (canonical schema: +`WebhookSchema` in `@objectstack/spec/automation/webhook`). + +| Field | Type | Notes | +|-------------------|-----------|------------------------------------------------------------------------------------| +| `id` | text | Primary key. | +| `name` | text | Unique snake_case name — referenced in logs and audit. | +| `label` | text | Optional display label. | +| `object_name` | text | Short object name whose events fire this webhook (blank = manual / API-triggered). | +| `triggers` | text | Comma-separated event list: `create,update,delete,undelete,api`. | +| `url` | text | External endpoint that receives the POST. | +| `method` | text | HTTP method. Default `POST`. | +| `description` | textarea | Free-text description. | +| `active` | boolean | Inactive webhooks are skipped by the dispatcher. Default `true`. | +| `definition_json` | textarea | Serialised `Webhook` JSON (`@objectstack/spec/automation/webhook`) — carries the full headers / auth / retry / payload config, including the signing `secret`, custom `headers`, and `timeoutMs`. | +| `created_at` | datetime | Standard audit columns. | +| `updated_at` | datetime | | + +Matching at runtime is purely `object_name` + the comma-separated `triggers` +list; the headers, signing secret, and per-attempt timeout are parsed out of +`definition_json` when an event is enqueued. There is no per-row org/tenant +column, no `events[]` glob field, no stored `retry_policy`, and no +`secret_hint` — see §6 for the actual signing model and §11 for the (single) +retry budget. + +### 3.2 `sys_http_delivery` + +The outbox + audit. One row per delivery. Persisted **before** any HTTP +call; updated after each attempt. This table is the durability boundary — +if it has a `pending` row, the dispatcher will eventually deliver it. + +It is **not** webhook-specific: `sys_http_delivery` is owned by +`@objectstack/service-messaging` (ADR-0018 M3) and shared by the Flow `http` +node executor and webhook fan-out, so both inherit retry / idempotency / +dead-letter from one substrate. Webhook deliveries are the rows with +`source = 'webhook'`. It generalises the old design's per-webhook table: +`webhook_id` → `ref_id`, `event_id` → `dedup_key`, `event_type` → `label`, +`secret` → `signing_secret`. Rows are managed by the platform and not directly +writable. + +| Field | Type | Notes | +|-------------------|----------|-----------------------------------------------------------------------------| +| `id` | text | Primary key. Also doubles as the receiver-side idempotency key. | +| `source` | text | Provenance domain, e.g. `webhook` \| `flow`. `UNIQUE(source, dedup_key)`. | +| `ref_id` | text | Partition / ordering anchor within source (for webhooks, the webhook id). | +| `dedup_key` | text | `UNIQUE(source, dedup_key)` for at-most-once enqueue. | +| `label` | text | Diagnostic label / event type — surfaced on `X-Objectstack-Event`. | +| `url` | text | Target URL, snapshotted at enqueue so config edits do not rewrite live rows. | +| `method` | text | HTTP method. | +| `headers_json` | textarea | Custom headers, serialised. | +| `signing_secret` | text | HMAC secret used for the signature header (see §6). | +| `timeout_ms` | number | Per-attempt timeout. | +| `payload_json` | textarea | The full payload that will be POSTed. | +| `partition_key` | number | `hash(ref_id) mod partitionCount`, precomputed for cheap `WHERE`. | +| `status` | text | `pending` / `in_flight` / `success` / `failed` / `dead`. | +| `attempts` | number | Count of HTTP requests issued. | +| `claimed_by` | text | Node id holding the row. | +| `claimed_at` | number | When the claim was taken (epoch ms). | +| `next_retry_at` | number | When the next retry is eligible (epoch ms). | +| `last_attempted_at` | number | Most recent attempt start (epoch ms). | +| `response_code` | number | Last HTTP status code received. | +| `response_body` | textarea | Truncated to the first 16 KB. | +| `error` | textarea | Last transport-level error (DNS, connect, timeout). | +| `created_at` | number | Epoch ms. | +| `updated_at` | number | Epoch ms. | > **Why store full payload?** Receivers may be down for hours; we must > retry the *exact* bytes we promised to send. Recomputing payload from @@ -144,13 +166,13 @@ Five stages, each implemented as a thin layer over an existing primitive. └──────────────────────────┬──────────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ 3. Persist INSERT sys_webhook_delivery (status=pending) │ +│ 3. Persist INSERT sys_http_delivery (status=pending) │ │ ── this is the durability boundary ── │ └──────────────────────────┬──────────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ 4. Dispatch worker claims with │ -│ SELECT … FOR UPDATE SKIP LOCKED LIMIT N; │ +│ 4. Dispatch worker holds a per-partition cluster lock, then │ +│ atomic UPDATE pending→in_flight claims a batch; │ │ issues POST; writes back result │ └──────────────────────────┬──────────────────────────────────────┘ ▼ @@ -180,55 +202,55 @@ running on every node is fine because the INSERT in stage 3 is keyed by For each incoming event the subscriber: -1. Loads `sys_webhook` rows where `enabled = true` AND - `(events globs match event_type)` AND - `(object_filter is NULL OR object_filter contains event.object)`. -2. For each match, builds the canonical payload (§5). -3. Inserts a `sys_webhook_delivery` row. +1. Loads `sys_webhook` rows where `active = true` AND `object_name` + matches the event's object AND the event's action is in the row's + comma-separated `triggers`. +2. For each match, builds the payload (§5). +3. Enqueues a `sys_http_delivery` row (`source = 'webhook'`). ### 4.3 Stage 3 — Persist -A single `INSERT … ON CONFLICT DO NOTHING` per match. The -`(event_id, webhook_id)` uniqueness constraint absorbs duplicate event -deliveries from `at-least-once` semantics. After this stage the event -producer is no longer involved; the rest is the dispatcher's problem. +A single enqueue per match. The `(source, dedup_key)` uniqueness +constraint — where `dedup_key` is `::::` +— absorbs duplicate event deliveries from `at-least-once` semantics. After this +stage the event producer is no longer involved; the rest is the dispatcher's +problem. ### 4.4 Stage 4 — Dispatcher worker -The dispatcher is a `cluster`-scoped service with -`leaderStrategy: 'partitioned'`. Each node owns a hash-partition of -`webhook_id`s, so the same webhook's deliveries always land on the same -node — useful for per-receiver rate limiting and connection reuse. +`HttpDispatcher` (in `@objectstack/service-messaging`) ticks on a timer and, +for each partition, attempts to acquire a **per-partition cluster lock** +(`http.dispatcher.partition.`). Partition affinity is on the delivery's +`ref_id` (the webhook id), so the same webhook's deliveries always land on the +same partition — useful for in-order delivery and connection reuse. On a +single-node runtime the lock is an always-grant stub. -Loop body, run every 250ms on each node: +Within a held partition, the lock-holder claims a batch with an **atomic +conditional UPDATE** rather than `SELECT … FOR UPDATE SKIP LOCKED`: ```sql -UPDATE sys_webhook_delivery -SET status = 'in_flight', - claimed_by = $node_id, - claimed_at = now(), - attempts = attempts + 1, - last_attempted_at = now() -WHERE id IN ( - SELECT id FROM sys_webhook_delivery - WHERE status = 'pending' - AND (next_retry_at IS NULL OR next_retry_at <= now()) - AND hashtext(webhook_id::text) % $partition_count = $partition_index - ORDER BY next_retry_at NULLS FIRST - LIMIT $batch_size - FOR UPDATE SKIP LOCKED -) -RETURNING *; +-- 1. select candidate ids for this partition +SELECT id FROM sys_http_delivery +WHERE status = 'pending' + AND partition_key = $partition_index + AND (next_retry_at IS NULL OR next_retry_at <= $now) +ORDER BY next_retry_at +LIMIT $batch_size; + +-- 2. atomic claim: only rows still pending flip to in_flight +UPDATE sys_http_delivery +SET status = 'in_flight', claimed_by = $node_id, claimed_at = $now +WHERE id IN (...) AND status = 'pending'; ``` -`FOR UPDATE SKIP LOCKED` is the single mechanism that prevents two -nodes from delivering the same row. It is provided by Postgres natively -and emulated in the SQL driver layer for other engines. +The `WHERE … AND status = 'pending'` predicate on the UPDATE is the +exactly-once claim: two workers racing on the same row cannot both flip it. +The held partition lock keeps the race window small in the first place. -After each HTTP attempt the row is updated with the result. If the -request succeeded (2xx), `status = 'success'`. Otherwise `status` returns -to `pending` (with bumped `next_retry_at`) or, if `attempts >= -retry_policy.maxAttempts`, becomes `dead`. +After each HTTP attempt the row is updated with the result. If the request +succeeded (2xx), `status = 'success'`. Otherwise `status` returns to `pending` +(with bumped `attempts` and `next_retry_at`) or, once the retry budget is +exhausted, becomes `dead`. ### 4.5 Stage 5 — Retry backoff @@ -255,147 +277,99 @@ same time. ## 5. Payload format -Stable across versions. Receivers parse it once and never need to change -their parser when we add new event types. +The POST body is the realtime event payload with a small fixed prefix merged +on top: ```json { - "id": "evt_01HXXXXXXXXXXXXXX", - "type": "account.updated", - "created": 1730000000, - "api_version": "2026-05-23", "object": "account", - "tenant_id": "tnt_abc", - "data": { - "current": { "id": "acc_123", "name": "Acme Inc.", ... }, - "previous": { "id": "acc_123", "name": "Acme Co.", ... } - }, - "actor": { - "type": "user", - "id": "usr_456", - "label":"Jane Doe" - }, - "context": { - "request_id": "req_789", - "correlation_id": "corr_abc", - "project_id": "proj_xyz", - "environment": "production" - } + "recordId": "acc_123", + "action": "updated", + "timestamp": 1730000000000 + // ...remaining fields from the originating event payload } ``` Notes: -- **`id`** — globally unique event id. Also the `sys_webhook_delivery.id` - for this attempt; receivers use it as their idempotency key. -- **`type`** — dotted `{object}.{operation}`. Stable enum; new types are - additive. -- **`api_version`** — calendar versioning (`YYYY-MM-DD`). Webhook - subscriptions can pin a version so payload-shape changes are opt-in, - not forced. -- **`data.previous`** — present on `updated` events; `null` elsewhere. -- **`actor`** — distinguishes user-initiated changes from system / - flow / agent / API-key origins. -- **`context.request_id`** — links the webhook back to the originating - REST call in audit logs and traces. +- **`object`** — short object name the event came from. +- **`recordId`** — id of the affected record. +- **`action`** — `created` / `updated` / `deleted` / `undeleted`. +- **`timestamp`** — event timestamp (epoch ms). +- Any additional fields the event carried (e.g. record snapshot data) are + spread in after these four. + +Idempotency and event correlation are carried in **headers**, not the body. +Every attempt sends: + +| Header | Meaning | +|---------------------------|-----------------------------------------------------------------| +| `X-Objectstack-Delivery` | The `sys_http_delivery` row id — use as the idempotency key. | +| `X-Objectstack-Attempt` | 1-based attempt number for this delivery. | +| `X-Objectstack-Event` | The event type / label (when present). | +| `X-Objectstack-Signature` | HMAC signature (see §6), when a signing secret is configured. | + +Requests also set `Content-Type: application/json` and +`User-Agent: ObjectStack-Http/1.0`. ## 6. Signing & verification -Adapted from Stripe's signing scheme. Industry standard, well-documented -on the receiver side, includes replay protection. +GitHub-style HMAC over the raw request body. When the webhook's +`definition_json` carries a `secret`, every outbound request is signed. ### 6.1 Outbound header ``` -X-ObjectStack-Signature: - t=1730000000, - v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd +X-Objectstack-Signature: sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` -Where: - -- `t` is the Unix timestamp (seconds) when the signature was generated. -- `v1` is `HEX( HMAC-SHA256( webhook.secret, "{t}.{request_body}" ) )`. - -The `v` prefix (here `v1`) is a version marker — future signature -schemes (e.g. SHA-3, Ed25519) ship as `v2=…`, `v3=…` and may be sent -alongside `v1` during transitions. +Where the hex value is `HMAC-SHA256( secret, raw_request_body )`. There is no +timestamp, no `{t}.{body}` concatenation, no version (`v1`) prefix, and no +replay window — the signature covers the body bytes only. ### 6.2 Receiver-side verification Receivers MUST: -1. Read `t` and `v1` from the header. -2. Reject the request if `abs(now - t) > 300` (5 minutes — replay window). -3. Recompute `HMAC-SHA256(secret, "{t}.{raw_body}")` over the raw request - body bytes (no JSON re-encode — whitespace matters). -4. Compare with `v1` in **constant time** (e.g. `hmac.compare_digest`). -5. Reject if not equal. - -A reference verifier in TypeScript, Python, and Go ships in the -documentation site under `guides/webhook-receivers/`. - -### 6.3 Secret rotation - -`POST /api/v1/webhooks/:id/rotate` generates a new secret, returns it -**once** in the response, and stores both old and new for a 24-hour -grace period. During grace, outbound requests are signed with the new -secret but the API returns the old secret's `secret_hint` so customers -can identify which secret was active when a delivery was made. +1. Read the `sha256=` value from `X-Objectstack-Signature`. +2. Recompute `HMAC-SHA256(secret, raw_body)` over the raw request body bytes + (no JSON re-encode — whitespace matters). +3. Compare in **constant time** (e.g. `hmac.compare_digest`). +4. Reject if not equal. -## 7. Egress safety (SSRF defence) +Use the `X-Objectstack-Delivery` header (the delivery row id) as the +idempotency key when deduplicating retries. -Webhooks are a textbook SSRF vector: an internal user can point a webhook -at `http://169.254.169.254/latest/meta-data/` and exfiltrate cloud -credentials through the response body in `sys_webhook_delivery`. The -runtime defends in three layers. +## 7. Egress safety -### 7.1 URL validation at save time +> **Not yet implemented.** URL blocklisting (localhost / RFC 1918 / link-local / +> cloud-metadata rejection, `allowPrivate`, https-only enforcement) and +> DNS-rebinding re-validation are **not** part of the shipped runtime. The +> sender POSTs to the configured URL with no SSRF filtering. Treat webhook URL +> configuration as a privileged operation and gate it with RBAC accordingly; +> network-level egress filtering is future work. -`sys_webhook.url` is validated against a blocklist on insert/update: +### 7.1 Response body capping -- Reject `localhost`, `127.0.0.0/8`, `::1`. -- Reject RFC 1918 private ranges (`10.0.0.0/8`, `172.16.0.0/12`, - `192.168.0.0/16`) unless `cluster.egress.allowPrivate = true`. -- Reject link-local (`169.254.0.0/16`) and cloud-metadata IPs - (`169.254.169.254` AWS/Azure/GCP, `100.100.100.200` Alibaba) **always**. -- Reject schemes other than `https://` in production; `http://` allowed - only when `runtime.mode === 'development'`. +The recorded `response_body` is truncated to 16 KB before persistence. This is +the one egress safeguard that is implemented today: it bounds the worst-case +exfiltration through a single delivery row even if a hostile URL is configured. -### 7.2 DNS-time re-validation - -DNS responses are re-checked at request time, not just at save time, to -defeat DNS rebinding attacks. Resolved IPs that fall in a blocked range -abort the request with `status = failed, error = 'dns_rebound'`. - -### 7.3 Response body capping +## 8. REST surface -`response_body` is truncated to 16 KB before persistence. This bounds -the worst-case exfiltration even if the URL slips through validation — -an attacker cannot pull megabytes of internal data through a single -delivery row. +CRUD for `sys_webhook` (and read access to `sys_http_delivery`) comes for free +from the generic object/REST surface — there is no bespoke webhook CRUD router. +The plugin mounts exactly **one** custom endpoint: -## 8. REST surface +| Method | Path | Body | Purpose | +|--------|-----------------------------|-------------------|--------------------------------------------------| +| POST | `/api/v1/webhooks/redeliver` | `{ deliveryId }` | Re-queue a previously failed/dead delivery. | -All endpoints under `/api/v1/webhooks/`. CRUD comes for free from the -`sys_webhook` object; the table below lists the dispatcher-specific -extensions. - -| Method | Path | Purpose | -|--------|-----------------------------------------------|--------------------------------------------------------| -| GET | `/webhooks` | List subscriptions for the current org. | -| POST | `/webhooks` | Create. Server generates `secret`, returns it **once**. | -| GET | `/webhooks/:id` | Read. `secret` is never returned — only `secret_hint`. | -| PATCH | `/webhooks/:id` | Update url / events / filters / retry policy. | -| DELETE | `/webhooks/:id` | Delete. Pending deliveries are cancelled. | -| POST | `/webhooks/:id/test` | Send a synthetic payload to verify connectivity. | -| POST | `/webhooks/:id/rotate` | Rotate the signing secret (24h grace). | -| GET | `/webhooks/:id/deliveries` | Paginated delivery history with filters. | -| GET | `/webhooks/:id/deliveries/:delivery_id` | Full request/response for one attempt-cycle. | -| POST | `/webhooks/:id/deliveries/:delivery_id/redeliver` | Re-queue a previously failed/dead delivery. | - -The CRUD endpoints are auto-generated from the `sys_webhook` object -schema; only the last five require custom handlers in `plugin-webhooks`. +It delegates to `messaging.redeliverHttp(deliveryId)` and is guarded by the +better-auth session (any authenticated user). Responses: `200` on success, +`401` unauthenticated, `400` missing/invalid body, `404` `not_found`, `409` +`not_eligible`. There are no `/webhooks/:id/test`, `/rotate`, +`/deliveries`, or per-delivery redeliver routes. ## 9. Concurrency & rate-limiting @@ -411,35 +385,31 @@ receivers from being driven into 429s by our bulk traffic. ## 10. Observability -Three views into the runtime: - -1. **`sys_webhook_delivery` table** — the source of truth. Standard - ObjectStack list/detail views show status, latency, response, retry - schedule. Studio surfaces this under the webhook's detail page. -2. **OpenTelemetry spans** — every dispatch attempt opens a span with - `webhook.id`, `webhook.event_type`, `webhook.attempt`, - `http.url`, `http.status_code`. Spans link to the parent request via - `context.correlation_id` so a webhook delivery can be traced back to - the user click that produced the event. -3. **Prometheus metrics** — exposed by the dispatcher: - - `webhook_deliveries_total{status}` counter - - `webhook_delivery_duration_ms` histogram - - `webhook_queue_depth{partition}` gauge - - `webhook_dead_total{webhook_id}` counter (alertable) +1. **`sys_http_delivery` table** — the source of truth. It ships standard + ObjectStack list views (`Recent`, `Failures`, `Pending`) showing source, + url, status, attempts, response code, and timestamps; Studio surfaces these + under the **HTTP Deliveries** nav. The `X-Objectstack-Signature` header + (note the lowercase `s` in `stack`), `X-Objectstack-Delivery`, + `X-Objectstack-Event`, and `X-Objectstack-Attempt` headers correlate a + received request back to its row. +2. **`onAttempt` hook** — `HttpDispatcher` fires an `onAttempt(delivery, + success)` callback after every attempt, the one programmatic + observability hook. Wire it to whatever metrics/tracing backend your + deployment uses. + +> **Future work.** Built-in OpenTelemetry spans and Prometheus metrics are +> not yet emitted; the `onAttempt` hook is the integration point until they +> are. ## 11. Multi-tenancy -Every `sys_webhook` row is owned by an `owner_org_id`. The protocol -enforces: - -- **Read isolation** — users can only see webhooks belonging to orgs - they have membership in. Enforced by the standard RBAC layer. -- **Egress isolation** — when `cluster.tenantIsolation = 'channel-prefix'`, - dispatcher partitioning includes `(org_id, webhook_id)` so one tenant's - bulk import cannot starve another tenant's webhook throughput on the - same node. -- **Quota** — `sys_webhook.retry_policy.maxAttempts` is capped by the - tenant plan; free plans get `maxAttempts ≤ 3`, paid plans up to 12. +> **Not yet implemented.** `sys_webhook` has no `owner_org_id` column, there is +> no `retry_policy` field, and dispatcher partitioning is on `ref_id` (the +> webhook id) only — there is no `(org_id, webhook_id)` partitioning or +> per-tenant egress isolation. The retry budget is a single fixed 7-step +> schedule (§4.5), not a per-plan quota. Per-tenant webhook ownership and +> isolation are future work; until then, isolate access at the RBAC layer on +> `sys_webhook`. ## 12. Failure modes & guarantees @@ -448,8 +418,8 @@ A precise table of what the runtime promises and what it does not. | Failure | Guarantee | |------------------------------------------|-------------------------------------------------------------| | Producer node crashes mid-emit | Event durably in transport bus (at-least-once), redelivered on producer restart. | -| Subscriber node crashes after persist | Row exists in `sys_webhook_delivery`, another node picks it up. | -| Dispatcher node crashes mid-HTTP | Row stays `in_flight` with `claimed_by`; reaper re-pends it after `claim_ttl` (default 60s). | +| Subscriber node crashes after persist | Row exists in `sys_http_delivery`, another node picks it up. | +| Dispatcher node crashes mid-HTTP | Row stays `in_flight` with `claimed_by`; it reverts to `pending` after the claim TTL and is re-posted. The TTL derives from the dispatcher tick (`intervalMs`, default 500ms): `lockTtlMs = 5 × intervalMs`, `claimTtlMs = 2 × lockTtlMs` (so ~5s at defaults), all configurable via `HttpDispatcherOptions`. | | Receiver returns 5xx | Retry per backoff schedule until `maxAttempts`. | | Receiver returns 4xx | Treated as terminal — no retry, status `dead` immediately. Exception: 408 / 429 are retried. | | Receiver returns 2xx | `status = success`, no more attempts. | @@ -457,48 +427,47 @@ A precise table of what the runtime promises and what it does not. | Timeout (per-attempt) | Treated as 5xx — retry per backoff. | | Network partition between dispatcher and DB | Worker pauses; row stays `pending`. On reconnect, normal claim resumes. | | Two events for the same record arrive out of order | `partitionKey` ordering at the event bus prevents this. Receiver still sees deliveries in emit order. | -| Duplicate delivery (at-least-once) | Receiver gets the same `evt_…` id twice. Must dedupe — we provide the key, can't enforce. | +| Duplicate delivery (at-least-once) | Receiver gets the same `X-Objectstack-Delivery` id twice. Must dedupe on that header — we provide the key, can't enforce. | ## 13. Plugin location & layering ``` packages/plugins/plugin-webhooks/ ├── src/ -│ ├── plugin.ts # lifecycle hooks (onEnable wires subscriber + dispatcher) -│ ├── objects/ -│ │ ├── sys_webhook.ts # defineObject() — schema, RBAC, indexes -│ │ └── sys_webhook_delivery.ts # defineObject() — schema, RBAC, indexes -│ ├── subscriber.ts # EventBus subscriber → INSERT pending rows -│ ├── dispatcher.ts # cluster-scoped service, partitioned, drains queue -│ ├── signer.ts # HMAC-SHA256 signing, version v1 -│ ├── egress.ts # URL validation + DNS re-check + cap -│ ├── reaper.ts # cluster-singleton leader-elected: reclaim stuck in_flight rows -│ └── rest/ -│ ├── test.ts # POST /webhooks/:id/test -│ ├── rotate.ts # POST /webhooks/:id/rotate -│ ├── deliveries.ts # GET /webhooks/:id/deliveries -│ └── redeliver.ts # POST /webhooks/:id/deliveries/:did/redeliver +│ ├── webhook-outbox-plugin.ts # plugin: registers sys_webhook + nav, +│ │ # starts the auto-enqueuer, mounts redeliver +│ ├── sys-webhook.object.ts # ObjectSchema.create() — sys_webhook config +│ ├── auto-enqueuer.ts # realtime data.record.* → enqueue onto outbox +│ └── schema.ts # shared types └── test/ └── … ``` +The delivery runtime — `sys_http_delivery`, `HttpDispatcher`, the HTTP sender, +signing, retry classification — lives in `@objectstack/service-messaging`, not +in this plugin. `plugin-webhooks` owns only the `sys_webhook` configuration +object and the auto-enqueuer that fans record events onto the shared outbox. + The plugin depends on: -- `@objectstack/spec` for `WebhookSchema` (existing) and cluster - primitives (new in `cluster-semantics.mdx`). -- `service-cluster` for `pubsub` / `lock` / `kv`. -- `service-queue` (optional) — if present, the dispatcher delegates queue - storage to it; if absent, it uses the `sys_webhook_delivery` table - directly with `FOR UPDATE SKIP LOCKED`. +- `@objectstack/core` +- `@objectstack/service-messaging` (declared as plugin dependency + `com.objectstack.service.messaging`) — provides the outbox object and + dispatcher. +- `@objectstack/spec` for `WebhookSchema`. + +There is no dependency on `service-cluster` or `service-queue`; cross-node +coordination is the dispatcher's per-partition cluster lock inside +service-messaging. ## 14. Industry alignment Every design choice matches one or more established systems: -- **Stripe webhooks** — signing scheme (`t=…,v1=…`), 5-minute replay - window, retry schedule shape, signed-secret rotation. -- **GitHub webhooks** — event-type globs, redelivery UI, per-delivery - detail page with request/response bytes. +- **Stripe webhooks** — retry schedule shape (1s → 24h, ~1.5-day window) with + jitter. +- **GitHub webhooks** — `X-…-Signature: sha256=` signing + scheme, redelivery, per-delivery detail with request/response bytes. - **Shopify webhooks** — HMAC-SHA256 header, `X-Shopify-Topic` event hint (we use `type` in payload instead — simpler). - **AWS EventBridge / SQS** — at-least-once durability, dead-letter @@ -510,30 +479,41 @@ Every design choice matches one or more established systems: Three phases. Each phase delivers visible user value. +> **How it actually shipped.** The implementation did **not** introduce new +> `system/*` Zod schemas. There is no `system/webhook-delivery.zod.ts` and no +> `SysWebhookSchema` / `SysWebhookDeliverySchema` / +> `WebhookSignatureHeaderSchema` / `WebhookEventPayloadSchema`. Instead it +> reused the existing `WebhookSchema` (and `WebhookReceiverSchema`) in +> `automation/webhook.zod.ts`, defined `sys_webhook` as an +> `ObjectSchema.create()` in the plugin, and reused +> service-messaging's `sys_http_delivery` outbox for deliveries (ADR-0018 M3). +> The phase list below is the original plan; read it as design intent, not the +> current artifact layout. + ### Phase 1 — Spec (~1 day) -1. `system/webhook-delivery.zod.ts` (new) — `SysWebhookSchema`, - `SysWebhookDeliverySchema`, `WebhookSignatureHeaderSchema`, - `WebhookEventPayloadSchema`. These are persistence schemas distinct - from the existing `automation/webhook.zod.ts` config schema. -2. Documentation cross-links between the two ("static config" vs - "runtime subscription"). +1. Persistence + config schemas. (As shipped: `sys_webhook` is an + `ObjectSchema`, deliveries reuse `sys_http_delivery`, and the config schema + is the existing `WebhookSchema`.) +2. Documentation cross-links between "static config" and "runtime + subscription". -### Phase 2 — Plugin skeleton + memory dispatcher (~2 days) +### Phase 2 — Plugin skeleton + dispatcher (~2 days) -- `plugin-webhooks` package with the object definitions wired. -- In-process dispatcher using `service-cluster` memory driver. -- Sign + verify + egress safety + retry backoff working. -- Manual `POST /webhooks/:id/test` proves the path end-to-end. +- `plugin-webhooks` package with `sys_webhook` wired and the auto-enqueuer + fanning record events onto the shared outbox. +- Signing + retry backoff working through service-messaging's HTTP sender. ### Phase 3 — Production dispatcher (~2 days) -- `FOR UPDATE SKIP LOCKED` claim loop (Postgres driver). -- Reaper for stuck `in_flight` rows. -- Partitioned cluster scope. -- Prometheus metrics + OTel spans. -- Studio UI: deliveries list, detail drawer, redeliver button (reuses +- Atomic conditional-UPDATE claim loop under a per-partition cluster lock + (as shipped; the original plan called for `FOR UPDATE SKIP LOCKED`). +- Stuck `in_flight` rows revert after the claim TTL. +- Partitioned dispatch on `ref_id`. +- Studio UI: HTTP Deliveries list views + redeliver endpoint (reuses existing object/view machinery — zero custom React). +- *Future:* built-in Prometheus metrics + OTel spans (today only the + `onAttempt` hook). ### Phase 4 — Hardening (opportunistic) diff --git a/content/docs/getting-started/architecture.mdx b/content/docs/getting-started/architecture.mdx index 388c9cf900..ceb9c5651f 100644 --- a/content/docs/getting-started/architecture.mdx +++ b/content/docs/getting-started/architecture.mdx @@ -83,7 +83,7 @@ ObjectStack enforces **Separation of Concerns** through protocol boundaries: ### Example: Defining a Customer Object ```typescript -// packages/crm/src/objects/customer.object.ts +// src/objects/customer.object.ts import { ObjectSchema, Field } from '@objectstack/spec/data'; export const Customer = ObjectSchema.create({ @@ -143,65 +143,60 @@ That's the job of the other layers. ### Example: Permission Rules ```typescript -// packages/crm/src/permissions/customer.permission.ts -import { Permission } from '@objectstack/spec'; - -export const CustomerPermission = Permission({ - object: 'customer', - rules: [ - { - profile: 'sales_rep', - crud: { - create: true, - read: true, - update: true, - delete: false, // Only managers can delete - }, - fieldPermissions: { - annual_revenue: { read: true, edit: false }, // Read-only - }, - }, - { - profile: 'sales_manager', - crud: { - create: true, - read: true, - update: true, - delete: true, - }, +// src/permissions/sales_rep.permission.ts +import type { PermissionSet } from '@objectstack/spec'; + +// A permission set (here used as a profile) keyed by object and field. +export const SalesRepPermission: PermissionSet = { + name: 'sales_rep', + label: 'Sales Rep', + isProfile: true, + objects: { + customer: { + allowCreate: true, + allowRead: true, + allowEdit: true, + allowDelete: false, // Only managers can delete }, - ], -}); + }, + fields: { + // . -> field-level security + 'customer.annual_revenue': { readable: true, editable: false }, // Read-only + }, +}; ``` ### Example: Workflow Automation ```typescript -// packages/crm/src/workflows/customer.workflow.ts -import { Workflow } from '@objectstack/spec'; - -export const CustomerWorkflow = Workflow({ - object: 'customer', - trigger: 'after_create', - conditions: [ - { field: 'annual_revenue', operator: 'greaterThan', value: 1000000 }, +// src/flows/high_value_customer.flow.ts +import { defineFlow } from '@objectstack/spec'; + +// Automation is authored as a Flow: a graph of nodes connected by edges. +// A record_change flow runs when records of its target object change; branch +// edges carry CEL conditions evaluated against the changed record. +export const HighValueCustomerFlow = defineFlow({ + name: 'high_value_customer', + label: 'High-Value Customer Alert', + type: 'record_change', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Customer created' }, + { id: 'assign', type: 'update_record', label: 'Assign owner' }, + { id: 'notify', type: 'send_email', label: 'Alert leadership' }, + { id: 'end', type: 'end', label: 'End' }, ], - actions: [ - { - type: 'assign_owner', - params: { owner: 'enterprise_sales_team' }, - }, - { - type: 'send_email', - params: { - template: 'high_value_customer_alert', - to: 'sales-leadership@company.com', - }, - }, + edges: [ + // Only continue when annual revenue exceeds $1M. + { id: 'e1', source: 'start', target: 'assign', condition: 'record.annual_revenue > 1000000' }, + { id: 'e2', source: 'assign', target: 'notify' }, + { id: 'e3', source: 'notify', target: 'end' }, ], }); ``` +See the [Automation Protocol](/docs/protocol/objectos) for the full Flow node and edge reference. + ObjectOS **orchestrates** these rules at runtime, independent of the data structure or UI. ## Layer 3: ObjectUI (View Protocol) @@ -220,63 +215,50 @@ ObjectOS **orchestrates** these rules at runtime, independent of the data struct ### Example: List View ```typescript -// packages/crm/src/views/customer_list.view.ts -import { ListView } from '@objectstack/spec'; - -export const CustomerListView = ListView({ - object: 'customer', - label: 'All Customers', - type: 'grid', - columns: [ - { field: 'name', width: 200 }, - { field: 'industry', width: 150 }, - { field: 'annual_revenue', width: 150 }, - { field: 'primary_contact', width: 180 }, - ], - filters: [ - { field: 'industry', operator: 'equals' }, - { field: 'annual_revenue', operator: 'greaterThan' }, - ], - defaultSort: { field: 'name', direction: 'asc' }, +// src/views/customer.view.ts +import { defineView } from '@objectstack/spec'; + +// A view is authored with defineView({ list, form }); the list/form configs +// are nested, and the data source declares which object the view reads. +export const CustomerView = defineView({ + list: { + type: 'grid', + data: { provider: 'object', object: 'customer' }, + columns: [ + { field: 'name' }, + { field: 'industry' }, + { field: 'annual_revenue' }, + { field: 'primary_contact' }, + ], + filterableFields: ['industry', 'annual_revenue'], + sort: [{ field: 'name', order: 'asc' }], + }, }); ``` ### Example: Form View ```typescript -// packages/crm/src/views/customer_form.view.ts -import { FormView } from '@objectstack/spec'; - -export const CustomerFormView = FormView({ - object: 'customer', - label: 'Customer Details', - type: 'tabbed', - tabs: [ - { - label: 'Overview', - sections: [ - { - label: 'Company Information', - fields: ['name', 'industry', 'annual_revenue'], - }, - { - label: 'Contact', - fields: ['primary_contact'], - }, - ], - }, - { - label: 'Related Records', - sections: [ - { - label: 'Opportunities', - component: 'related_list', - object: 'opportunity', - filter: { customer: '$recordId' }, - }, - ], - }, - ], +// src/views/customer.view.ts +import { defineView } from '@objectstack/spec'; + +// The same defineView container also carries the form layout. A form is a +// list of sections, each holding fields. +export const CustomerView = defineView({ + form: { + type: 'simple', + data: { provider: 'object', object: 'customer' }, + sections: [ + { + label: 'Company Information', + fields: ['name', 'industry', 'annual_revenue'], + }, + { + label: 'Contact', + fields: ['primary_contact'], + }, + ], + }, }); ``` @@ -290,6 +272,13 @@ The UI doesn't "know" the field types. It asks ObjectQL for the schema and rende Let's trace a **real-world scenario**: A sales rep creates a new high-value customer. + +The TypeScript in Steps 2–6 below is **conceptual pseudo-code** illustrating the +flow of control between layers. Calls like `Auth.getCurrentUser()`, +`Permission.check()`, `ObjectQL.getSchema()`, and `Workflow.getTriggersFor()` are +not real exported APIs — they stand in for the kernel's internal orchestration. + + ### Step 1: User Action (ObjectUI) ``` @@ -431,15 +420,23 @@ export const Opportunity = ObjectSchema.create({ ### 2. ObjectOS: Define Business Rules ```typescript -export const OpportunityWorkflow = Workflow({ - object: 'opportunity', - trigger: 'field_update', - conditions: [ - { field: 'stage', operator: 'equals', value: 'closed_won' }, +import { defineFlow } from '@objectstack/spec'; + +export const OpportunityWonFlow = defineFlow({ + name: 'opportunity_won', + label: 'Opportunity Closed Won', + type: 'record_change', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Stage changed' }, + { id: 'invoice', type: 'create_record', label: 'Create invoice' }, + { id: 'notify', type: 'send_email', label: 'Notify sales team' }, + { id: 'end', type: 'end', label: 'End' }, ], - actions: [ - { type: 'create_invoice', params: { object: 'invoice' } }, - { type: 'send_notification', params: { to: 'sales_team' } }, + edges: [ + { id: 'e1', source: 'start', target: 'invoice', condition: "record.stage == 'closed_won'" }, + { id: 'e2', source: 'invoice', target: 'notify' }, + { id: 'e3', source: 'notify', target: 'end' }, ], }); ``` @@ -447,16 +444,23 @@ export const OpportunityWorkflow = Workflow({ ### 3. ObjectUI: Define the Kanban View ```typescript -export const OpportunityKanban = ListView({ - object: 'opportunity', - type: 'kanban', - groupBy: 'stage', - columns: [ - { field: 'title' }, - { field: 'amount' }, - { field: 'customer' }, - ], - enableDragDrop: true, +import { defineView } from '@objectstack/spec'; + +export const OpportunityKanbanView = defineView({ + list: { + type: 'kanban', + data: { provider: 'object', object: 'opportunity' }, + columns: [ + { field: 'title' }, + { field: 'amount' }, + { field: 'customer' }, + ], + // Kanban-specific config: group columns by the stage field. + kanban: { + groupByField: 'stage', + summarizeField: 'amount', + }, + }, }); ``` diff --git a/content/docs/getting-started/cli.mdx b/content/docs/getting-started/cli.mdx index cabdebdfef..b269c1e174 100644 --- a/content/docs/getting-started/cli.mdx +++ b/content/docs/getting-started/cli.mdx @@ -86,7 +86,8 @@ os init my-app --no-install # Skip dependency installation **Options:** - `-t, --template