Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/docs-accuracy-audit-4212-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
---

docs: implementation-accuracy audit of the #4212-family affected docs. Fixes
fabricated APIs, wrong manifest filenames, unresolvable version pins and
credential-ref formats. Releases nothing.
83 changes: 54 additions & 29 deletions content/docs/concepts/metadata-lifecycle.mdx

Large diffs are not rendered by default.

34 changes: 21 additions & 13 deletions content/docs/data-modeling/external-datasources.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export const Warehouse = defineDatasource({
schemaMode: 'external', // ObjectStack never runs DDL here
config: { host: 'db.internal', port: 5432, database: 'analytics', user: 'readonly' },
external: {
allowWrites: false, // read-only (the default)
credentialsRef: 'secret:warehouse/password', // resolved from the secret store
allowWrites: false, // read-only (the default)
credentialsRef: 'sys_secret:9f2c…', // opaque handle minted by the secret store
validation: { onMismatch: 'fail', checkOnBoot: true },
},
active: true,
Expand Down Expand Up @@ -90,11 +90,12 @@ That's it. `GET /api/v1/data/ext_customer` now returns live rows from the remote
`customers` table; filters (`?region=EU`) push down to the remote query.

<Callout type="warn">
**Do not set `field.columnName` on an external object.** For federated objects the
driver's query pipeline ignores `field.columnName`; `external.columnMap`
(`remoteColumn → localField`) is the single, authoritative column mapping.
`os build` / `os validate` rejects `field.columnName` on an external object with a
clear error (ADR-0062 D7). Managed objects are unaffected.
**`field.columnName` no longer exists.** It was removed from `FieldSchema` in the
16.x line (#2377, ADR-0049) — the SQL driver always used the field key as the
physical column, so a custom column name was silently ignored — and the
ADR-0062 D7 lint that rejected it on external objects was removed with it.
`external.columnMap` (`remoteColumn → localField`) is the single, authoritative
column mapping for a federated object.
</Callout>

## 3. Auto-connect — no `onEnable` needed
Expand All @@ -121,9 +122,10 @@ from external configuration. Auto-connect is idempotent with it (whichever
registers the datasource name first wins), so the two never conflict.
</Callout>

A connected external datasource is also visible in **Setup → Datasources** (stamped
`origin: code`, read-only in the UI) and via `GET /api/v1/datasources` and
`GET /api/v1/meta/datasource`, where an admin can run the "Sync objects" wizard.
A connected external datasource is also visible in **Setup → Integrations →
Datasources** (stamped `origin: code`, read-only in the UI) and via
`GET /api/v1/datasources` and `GET /api/v1/meta/datasource`, where an admin can
run the "Sync objects" wizard.

### When auto-connect fails

Expand Down Expand Up @@ -160,8 +162,8 @@ Every connect attempt's verdict is retained, so a datasource that is down no
longer has to be diagnosed by restarting the server and re-reading boot logs
([#3827](https://github.com/objectstack-ai/objectstack/issues/3827)).

**Setup → Datasources** and `GET /api/v1/datasources` report a `status` per
datasource:
**Setup → Integrations → Datasources** and `GET /api/v1/datasources` report a
`status` per datasource:

| `status` | Meaning |
|:---|:---|
Expand Down Expand Up @@ -202,7 +204,7 @@ const policy: DatasourceConnectPolicy = {
? { allow: true }
: {
allow: false,
// operator-facing: logs + Setup → Datasources only
// operator-facing: logs + the Setup datasource list only
reason: `egress allow-list miss for ${ds.name} (${tenant.id}, plan=${tenant.plan})`,
// tenant-facing: appended to the query-time error
publicReason: 'External datasources require the Scale plan.',
Expand All @@ -217,6 +219,12 @@ secret in the secret store (the same `SecretBinder` / `ICryptoProvider` the
runtime-admin "Add Datasource" wizard uses). The credential is resolved to
cleartext **at connect, before the driver is built**.

The shipped binder encrypts the cleartext into a `sys_secret` row and mints an
**opaque** handle — `sys_secret:<id>` — as the `credentialsRef`; that is the only
form it resolves, so a hand-written path like `secret:warehouse/password` will
not dereference. A host that wants a different scheme (a vault path, say)
injects its own resolver via `new DatasourceAdminServicePlugin({ secrets })`.

Resolution is **fail-closed**: if a `credentialsRef` is declared but no secret store
is configured, or the secret cannot be resolved/decrypted, the datasource is left
**unconnected with a clear error** — never connected without the credential. An
Expand Down
69 changes: 47 additions & 22 deletions content/docs/data-modeling/formulas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Every expression in metadata is persisted as the same envelope:
type Expression = {
dialect: 'cel' | 'cron' | 'template';
source?: string; // surface syntax
ast?: unknown; // parsed AST (filled by `objectstack compile`)
ast?: unknown; // parsed AST (filled by `os compile`)
meta?: { rationale?: string; generatedBy?: string };
};
```
Expand All @@ -66,11 +66,11 @@ to relearn the syntax across surfaces.
most common mistake (and the root cause of issue #1491) is a condition like
`{record.rating} >= 4`: in CEL, `{…}` is a **map literal**, so it is a parse
error. Write bare CEL: `record.rating >= 4`. Braces belong only in `{{ … }}`
text templates. As of 7.6 a malformed expression no longer silently does
nothing — it fails `objectstack build` and throws at runtime.
text templates. A malformed expression does not silently do nothing — it fails
`os build` and throws at runtime.
</Callout>

**Template formatting (7.6).** A `template` hole is a field path with an optional
**Template formatting.** A `template` hole is a field path with an optional
whitelisted formatter — `{{ path | formatter[:arg] }}` — with defined value→string
semantics (no arbitrary logic; put logic in a CEL field):

Expand Down Expand Up @@ -121,9 +121,14 @@ evaluates (`objectql` computes a formula virtual field from its `expression`
only). Do **not** pass `type: 'currency' | 'number' | …` to `Field.formula` — it
is rejected by the typed `FieldInput` and, untyped, would override `type:'formula'`
so the field silently never computes. To declare what the formula returns, set
**`returnType`** (`'number' | 'text' | 'boolean' | 'date'`); it is inferred and
stamped automatically by the AI build path, and consumers (dashboard measures,
formatting) read it instead of re-parsing the expression. The CEL source is the
**`returnType`** (`'number' | 'text' | 'boolean' | 'date'`). You declare it
yourself — nothing back-fills it — but the agent-callable `validate_expression`
tool reports the type it infers from the expression (`inferredType`) so an AI
author can stamp the matching value. Consumers read the declared `returnType`
instead of re-parsing the expression (record-title eligibility, for one: a
formula is title-eligible only when its `returnType` is `'text'`, which is what
lets it be *derived* as the record title — an explicit `nameField` pointer is
honored either way). The CEL source is the
**`expression`** key — it is the only key the schema and runtime read for a
formula's source; there is no separate `formula` source key.
</Callout>
Expand Down Expand Up @@ -161,7 +166,7 @@ Cheat-sheet of what you'll write daily:

Registered automatically into every `Environment` by `@objectstack/formula`.
All functions are pure given a pinned `now`, which is what makes
`objectstack build` artifacts byte-stable across runs.
`os build` artifacts byte-stable across runs.

| Function | Returns | Description |
|:---|:---|:---|
Expand Down Expand Up @@ -197,20 +202,21 @@ Keep them pure, dependency-free, and AI-readable.
| `record` | the row being evaluated | formulas, validation, sharing, visibility |
| `previous` | row before update | hooks, validation on update |
| `input` | hook payload | hooks |
| `os.user` | install / request user | seed, predicates with identity |
| `current_user` | the authenticated subject — the canonical binding (ADR-0068). `user`, `ctx.user` and `os.user` are aliases of the **same** object | predicates with identity |
| `os.user` | alias of `current_user` | seed, predicates with identity |
| `os.org` | active organization | seed, predicates |
| `os.env` | install env (`prod`, `dev`, `test`) | seed, predicates |

---

## Build-time validation

`objectstack build` type-checks every expression against the object schema, so
`os build` type-checks every expression against the object schema, so
a mistake that would silently evaluate to `null` at runtime is caught before it
ships. The same shared validator also runs when a flow is registered
(`registerFlow`), so a flow authored dynamically gets the same verdict. Findings
come at two severities: **errors** fail the build; **warnings** are advisory
(surfaced, not fatal — `objectstack validate --strict` promotes them to errors).
(surfaced, not fatal — `os validate --strict` promotes them to errors).

| Check | Example | Severity |
|:---|:---|:---|
Expand Down Expand Up @@ -330,10 +336,19 @@ invert the test (here `amount <= 0` rejects non-positive amounts).
object: 'case',
events: ['afterUpdate'],
condition: P`previous.status != 'escalated' && record.status == 'escalated'`,
body: { language: 'js', source: 'await ctx.notifyOpsTeam(record)' },
body: {
language: 'js',
source: "ctx.log.warn('case escalated', { id: ctx.input.id });",
capabilities: ['log'],
},
}
```

The `condition` is the CEL part — it binds `record` / `previous` directly. The
JS `body` is a different surface: it is wrapped as `new AsyncFunction('ctx', source)`,
so inside it the record is `ctx.input` (there is no bare `record`), and every
`ctx` API it touches must be covered by a declared capability.

---

## Formula patterns
Expand Down Expand Up @@ -375,24 +390,32 @@ Field.formula({
Field.formula({
label: 'Days to Close',
returnType: 'number',
expression: 'daysBetween(record.close_date, today())',
expression: 'daysBetween(today(), record.close_date)',
})

// Contract end in 30 days
Field.formula({
label: 'Expiring Soon',
returnType: 'boolean',
expression: 'record.status == "active" && daysBetween(record.end_date, today()) <= 30',
expression: 'record.status == "active" && daysBetween(today(), record.end_date) <= 30',
})

// Age in days
Field.formula({
label: 'Age (Days)',
returnType: 'number',
expression: 'daysBetween(today(), record.created_date)',
expression: 'daysBetween(record.created_date, today())',
})
```

<Callout type="warn">
**Argument order matters.** `daysBetween(a, b)` is `b − a`, so it counts
*forward from `a` to `b`* and goes negative when `b` is earlier. "Days
remaining" is `daysBetween(today(), record.due_date)`; "age so far" is
`daysBetween(record.created_date, today())`. Swapping the two operands is the
easiest way to ship a formula whose sign is inverted on every row.
</Callout>

<Callout type="info">
**`dateField == today()` now matches (#3183).** A `date` field reads back as a
`YYYY-MM-DD` string, and CEL treats a string and a timestamp as unequal — so the
Expand Down Expand Up @@ -479,18 +502,20 @@ Field.formula({
❌ **DON'T:**
- Create circular references
- Use formulas for frequently changing data
- Nest too many IF statements
- Nest ternaries so deeply the expression stops being readable
- Ignore null handling

---

## Determinism contract

Two consecutive `objectstack build` runs with no source changes must produce
Two consecutive `os build` runs with no source changes must produce
**byte-identical** `dist/objectstack.json`. CEL plus pinned `now` is what
makes this possible — there are zero compile-time-evaluated `Date.now()`
calls in seed metadata. CI enforces this via SHA-1 comparison; if you add a
new dialect or stdlib helper, ensure it preserves determinism.
calls in seed metadata: a CEL seed value travels into the artifact as
unevaluated source and only resolves at install. This is a design contract
held by review, not a gate — no CI job currently diffs two builds — so if you
add a new dialect or stdlib helper, ensure it preserves determinism.

---

Expand Down Expand Up @@ -524,7 +549,7 @@ Salesforce semantics rewrite their formulas in CEL.
```ts
import { ExpressionEngine } from '@objectstack/formula';

// Compile + cache
// Compile — parse + type-check, returning the engine-native AST as `value`
const compiled = ExpressionEngine.compile({ dialect: 'cel', source: 'record.x * 2' });

// Evaluate
Expand All @@ -536,11 +561,11 @@ const result = ExpressionEngine.evaluate(
```

The low-level engine never throws — `evaluate()` returns `{ ok: false, error }`.
But **call sites must not silently swallow that** (ADR-0032): `objectstack build`
But **call sites must not silently swallow that** (ADR-0032): `os build`
**fails** on an invalid expression (with a located, schema-aware message), and at
runtime the flow/rule engines **throw** a loud, attributed error instead of
treating a bad expression as `false`/`null`. The same `validateExpression`
validator backs `objectstack build` and metadata registration (and the
validator backs `os build` and metadata registration (and the
agent-callable `validate_expression` tool), so an expression is checked before it ships.

---
Expand Down
4 changes: 2 additions & 2 deletions content/docs/deployment/migration-from-objectql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Step-by-step guide for migrating from @objectql/core to @objectstac

# Migrating from `@objectql/core` to `@objectstack/objectql`

Starting with `@objectstack/objectql` v3.1, all core functionality previously provided by `@objectql/core` is now available upstream. This guide walks you through migrating your project so that `@objectql/core` can be removed.
The core functionality previously provided by `@objectql/core` — the introspection types, the utility functions, and the kernel factory — was upstreamed into `@objectstack/objectql` in **v3.0.4** and has shipped in every release since (the current release line is **v16.x**). This guide walks you through migrating your project so that `@objectql/core` can be removed.

## Why Migrate?

Expand All @@ -26,7 +26,7 @@ Starting with `@objectstack/objectql` v3.1, all core functionality previously pr
"dependencies": {
- "@objectql/core": "^4.x",
- "@objectql/types": "^4.x",
+ "@objectstack/objectql": "^3.1.0"
+ "@objectstack/objectql": "^16.1.0"
}
}
```
Expand Down
13 changes: 8 additions & 5 deletions content/docs/deployment/vercel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Deploy ObjectStack applications to Vercel with the Hono server adap

# Deploy to Vercel

ObjectStack 11 deploys to Vercel in **server mode** — serverless functions running the **Hono** adapter (`@objectstack/hono`). The published ObjectStack Studio/console ships as a static Vite SPA that points at a separate ObjectStack server (via `VITE_SERVER_URL`).
ObjectStack deploys to Vercel in **server mode** — serverless functions running the **Hono** adapter (`@objectstack/hono`). The Console SPA can also be deployed on its own as a static Vite build that points at a separate ObjectStack server (via `VITE_SERVER_URL`) — but that build comes from the Console source repo ([objectui](https://github.com/objectstack-ai/objectui)), not from the prebuilt `@objectstack/console` package.

<Callout type="warn">
**Removed in 11.** The in-browser MSW mode (`@objectstack/plugin-msw`) and the Next.js adapter (`@objectstack/nextjs`) are no longer published — use the Hono server path below.
Expand Down Expand Up @@ -43,7 +43,7 @@ In Server mode, ObjectStack runs inside Vercel Serverless Functions. API request
This is a valid self-contained pattern when you want to ship a Vite SPA *and* its API from a single Vercel project: the SPA is served as static assets, and a Hono-based serverless function handles `/api/*` requests.

<Callout type="info">
The published ObjectStack Studio/console (`@objectstack/console`) does **not** use this topology. It deploys as a pure static SPA with no `api/` function — it is meant to be embedded in, or pointed at, a separate ObjectStack server via `VITE_SERVER_URL`. For that separate server, prefer the CLI (`objectstack serve`) over a hand-rolled Hono function.
The framework-vendored Console (`@objectstack/console`) does **not** use this topology. It is a **prebuilt, dist-only** package — no source and no build step, just static assets — that an ObjectStack server mounts at `/_console` (`os serve --ui`, on by default). Its bundle is baked same-origin, and `VITE_SERVER_URL` is a build-time Vite flag, so aiming a Console at a *different* origin means building it yourself from the Console source repo ([objectui](https://github.com/objectstack-ai/objectui)). For the server itself, prefer the CLI (`os serve`) over a hand-rolled Hono function.
</Callout>

**1. Create the kernel singleton** (`api/_kernel.ts` — prefixed with `_` to prevent Vercel from creating a route):
Expand Down Expand Up @@ -82,7 +82,10 @@ export async function ensureApp(): Promise<Hono> {
`InMemoryDriver` keeps this snippet self-contained, but on serverless **all data
is lost on every cold start** — fine for a demo, wrong for anything real. Before
going past a proof of concept, swap the driver for a real database (see
[Database Drivers](/docs/data-modeling/drivers)) and point `OS_DATABASE_URL` at it.
[Database Drivers](/docs/data-modeling/drivers)). A hand-built kernel does **not**
resolve `OS_DATABASE_URL` on its own — that precedence lives in the CLI and in
`createStandaloneStack()` — so read the connection string yourself, e.g.
`new SqlDriver({ client: 'pg', connection: process.env.OS_DATABASE_URL })`.
</Callout>

**2. Create the API entrypoint** (`api/index.ts`):
Expand Down Expand Up @@ -134,7 +137,7 @@ Setting `VITE_SERVER_URL` to empty string tells the client SDK to use same-origi

Configure these in Vercel Project Settings → Environment Variables:

### Studio / SPA build
### Console / SPA build

| Variable | Description |
| :--- | :--- |
Expand Down Expand Up @@ -179,7 +182,7 @@ storage for artifacts.
- [ ] `api/_kernel.ts` boots the kernel with the correct driver
- [ ] `vercel.json` sets `VITE_USE_MOCK_SERVER=false` and `VITE_SERVER_URL=` (empty)
- [ ] Rewrite rule routes `/api/*` to `/api` and excludes `/api/` from SPA rewrite
- [ ] `OS_DATABASE_URL` is configured in Vercel environment variables (for production drivers)
- [ ] The production connection string is set in Vercel environment variables **and** read explicitly by the driver you construct in `api/_kernel.ts` (`OS_DATABASE_URL` is resolved by the CLI / `createStandaloneStack()`, not by a hand-built kernel)
- [ ] CORS is configured if frontend and API are on different origins

---
Expand Down
Loading
Loading