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
40 changes: 40 additions & 0 deletions .changeset/managed-datasource-readonly-documented.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@objectstack/spec": patch
"@objectstack/example-crm": patch
---

docs(spec): managed-datasource read-only is a database privilege, and the platform will not add a flag (#4584)

#4583 removed `datasource.capabilities.readOnly` and left a gap open in its
rejection message: `external.allowWrites: false` is the one enforced write gate
and it covers only FEDERATED datasources, so a **managed** datasource had no
read-only gate at all. The rejection pointed at #4584 and said "tracked". #4584
is now answered, and the answer is that this stays so **on purpose**:

> **方案 B —— 不建平台层只读闸门,文档明确记录**。
> 一个只拦 ObjectQL 写路径、拦不住直连/迁移/DDL 的位,是「看起来存在的能力」——
> #4583 刚删掉的 `capabilities.readOnly` 就是这个形状,不再造第二遍。真只读属于
> 数据库账号权限(GRANT SELECT),那里没有绕行面。

Read-only for a database ObjectStack owns is a **database account privilege** —
`GRANT SELECT`. An ObjectQL-level flag would stop writes on one path and leave a
direct `psql` session, a migration, a `syncSchema()` DDL statement and any
process sharing the connection string untouched. A boundary that holds in one
path is not a boundary, and one that looks like a boundary is worse than none
because it gets trusted — which is exactly the defect #4583 removed.

Documentation-only. No schema shape changes; the `capabilities.readOnly`
tombstone now carries the answer instead of an open issue reference:

- **Database Drivers** gains *Read-only: grant it at the database, not in
metadata* (a worked `GRANT SELECT` account, the DDL/schema-sync consequence, why
the platform declines the flag, and a table of what actually enforces what)
and *Read replicas: the platform does not route* — the #4479 dual conclusion:
no query path separates reads from writes, so put replicas behind pgpool /
ProxySQL / an RDS reader endpoint and point `config` there. That is the
correct answer, not a stopgap.
- **External Datasources** now says plainly that the double opt-in write gate is
federation-only, and that the parse rejects an `external` block on a `managed`
datasource.
- `example-crm`'s `crm_analytics` header comment recorded the ruling instead of
waiting on it.
107 changes: 107 additions & 0 deletions content/docs/data-modeling/drivers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -540,3 +540,110 @@ export const AuditLog = ObjectSchema.create({
fields: { /* ... */ },
});
```

### Read-only: grant it at the database, not in metadata

<Callout type="warn">
**A managed datasource has no platform-level read-only gate, and this is
deliberate.** Read-only for a database ObjectStack owns is a **database account
privilege** — `GRANT SELECT` — not a key on the datasource. There is no
metadata you can write that makes a managed connection read-only.
</Callout>

Point the datasource's `config` at an account that can only read:

```sql
-- PostgreSQL: a login that can read the schema and nothing else.
CREATE USER analytics_ro PASSWORD '…';
GRANT CONNECT ON DATABASE analytics TO analytics_ro;
GRANT USAGE ON SCHEMA public TO analytics_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO analytics_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO analytics_ro;
```

```typescript
import { defineDatasource } from '@objectstack/spec/data';

export const Analytics = defineDatasource({
name: 'analytics',
label: 'Analytics (read-only account)',
driver: 'postgres',
// The connection itself cannot write. Nothing in the app can talk past it.
config: { url: process.env.ANALYTICS_RO_URL },
active: true,
});
```

(`external.credentialsRef` is a *federation* key — the parse rejects an
`external` block on a `managed` datasource — so a managed connection carries its
credentials in `config`, from the environment as above.)

Note that a read-only account also refuses **DDL**, so such a datasource cannot
run ObjectStack's boot-time schema sync or migrations. That is the honest
consequence of a real boundary: a database you only read is a database you do
not own the schema of. If you want ObjectStack to keep the schema in step, it
needs a writable account — or the datasource belongs on the federation path
(`schemaMode: 'external'`), where DDL is forbidden by design and the write gate
is enforced (see below).

#### Why the platform does not offer the flag

The obvious-looking alternative — a `readOnly` boolean on the datasource — is
the exact shape [#4583](https://github.com/objectstack-ai/objectstack/issues/4583)
removed. `datasource.capabilities.readOnly` shipped for three releases, read as
a safety property, and gated nothing: no write path consulted it, so a
datasource labelled a read replica accepted inserts exactly like the primary.
The shipped CRM example called one of its datasources a "Read Replica" on the
strength of it.

Rebuilding it as a *working* ObjectQL check would not fix the underlying
problem, only make it harder to see. Such a gate stops writes that go through
`ObjectQLEngine`; it cannot stop a direct `psql` session, a migration, a
`syncSchema()` DDL statement, a background job holding its own driver handle, or
any other process on the same connection string. A boundary that holds in one
path and not the others is not a boundary — and a flag that *looks* like one is
worse than no flag at all, because it is trusted. The database account has no
such gap: there is no code path in ObjectStack, or anywhere else, that can write
through a connection the server will not let write
([#4584](https://github.com/objectstack-ai/objectstack/issues/4584)).

#### The one enforced write gate is federation-only

`external.allowWrites: false` **is** enforced, by
`ObjectQLEngine.assertWriteAllowed` before every insert/update/delete — but it
answers a question about *ownership*, not about connections: which side may
write to a database ObjectStack does not own. You cannot reach for it on a local
database: the parse rejects an `external` block whose `schemaMode` is `managed`,
and the engine check itself returns early for `managed` (and for a definition
that declares no `schemaMode`) before it ever reads `allowWrites`. See
[Writes (double opt-in)](/docs/data-modeling/external-datasources#5-writes-double-opt-in).

| What you want | What actually does it |
| :--- | :--- |
| A **managed** datasource that cannot be written | A database account with `SELECT` only. No metadata key. |
| A **federated** datasource that cannot be written | `external: { allowWrites: false }` — the default — enforced by the engine. |
| A federated datasource writable for **some** objects | `external.allowWrites: true` on the datasource **and** `external.writable: true` on each object. |

### Read replicas: the platform does not route

<Callout type="warn">
**There is no read/write splitting in ObjectStack.** No query path distinguishes
a read from a write, so there is nothing to route to a replica. `datasource.readReplicas`
was removed in 17.0.0 ([#4468](https://github.com/objectstack-ai/objectstack/issues/4468))
because it described replica connections nothing ever opened.
</Callout>

Put the replicas behind a single endpoint and let the database tier route:
**pgpool-II**, **ProxySQL**, or an **RDS / Aurora reader endpoint**. Point
`config` at that endpoint.

This is the correct answer, not a stopgap
([#4479](https://github.com/objectstack-ai/objectstack/issues/4479)). The hard
parts of read/write splitting are not the replica connections — they are
deciding what counts as a read (a `find` inside a transaction that just issued
an `update` must go to the primary, or the app cannot read its own writes),
declaring the staleness a query will tolerate, and ejecting a replica that falls
behind. A proxy is a component built to do exactly that, and it does it better
than a field on a datasource could. Should the platform ever need to pick a
consistency level from business semantics, the schema shape will be decided by
that routing path — it will not be bolted on ahead of it.
19 changes: 18 additions & 1 deletion content/docs/data-modeling/external-datasources.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,21 @@ ObjectSchema.create({ /* ... */ external: { remoteName: 'orders', writable: true

With either gate off, insert/update/delete on the federated object is rejected.

<Callout type="warn">
**This gate is federation-only — it does nothing on a managed datasource.**
`allowWrites` answers *who owns this external database*, not *is this connection
read-only*. You cannot even declare it on a local database — the parse rejects
an `external` block whose `schemaMode` is `managed` — and
`ObjectQLEngine.assertWriteAllowed` returns early for `managed` (or an absent
`schemaMode`) before it reads the flag at all.

A managed datasource has **no** platform read-only gate, deliberately: read-only
for a database ObjectStack owns is a database account privilege (`GRANT SELECT`).
See [Read-only: grant it at the database, not in metadata](/docs/data-modeling/drivers#read-only-grant-it-at-the-database-not-in-metadata)
for why an application-layer flag is the wrong boundary
([#4584](https://github.com/objectstack-ai/objectstack/issues/4584)).
</Callout>

## 6. Analytics over external objects

Dashboards and reports over a federated object aggregate against the **correct**
Expand All @@ -272,7 +287,9 @@ default allows all connects (subject to the gating above).

## See also

- [Database Drivers](/docs/data-modeling/drivers) — managed multi-datasource routing.
- [Database Drivers](/docs/data-modeling/drivers) — managed multi-datasource routing,
[read-only via database privileges](/docs/data-modeling/drivers#read-only-grant-it-at-the-database-not-in-metadata),
and [why the platform does not route read replicas](/docs/data-modeling/drivers#read-replicas-the-platform-does-not-route).
- [Datasource reference](/docs/references/data/datasource) — every `defineDatasource` field.
- The `examples/app-showcase` `showcase_external` datasource — a runnable end-to-end demo.
- ADR-0015 (federation spec) and ADR-0062 (external-datasource runtime).
10 changes: 7 additions & 3 deletions examples/app-crm/src/datasources/crm.datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,13 @@ export const CrmDatasource = defineDatasource({
*
* The label no longer promises read-only, because nothing here can deliver it:
* `external.allowWrites: false` is the one enforced write gate and it applies
* only to FEDERATED datasources, while this one is local and managed. Whether a
* managed datasource should have a read-only gate at all is #4584 — until that
* is answered, the honest demo is routing, not a safety claim.
* only to FEDERATED datasources, while this one is local and managed. #4584
* settled that a managed datasource gets NO platform read-only gate, on purpose:
* read-only is a database account privilege (`GRANT SELECT`), because an
* ObjectQL-only check cannot stop a direct connection, a migration or DDL, and a
* gate that holds in one path is worse than none. So the honest demo here is
* routing, not a safety claim — see "Read-only: grant it at the database, not in
* metadata" in the Database Drivers guide.
*/
export const CrmAnalyticsDatasource = defineDatasource({
name: 'crm_analytics',
Expand Down
7 changes: 5 additions & 2 deletions packages/spec/src/data/datasource.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,11 @@ const RETIRED_CAPABILITIES: Record<string, string> = {
+ 'so a datasource labelled a read replica accepted writes exactly like any other. The one '
+ 'enforced datasource-wide write gate is `external.allowWrites: false`, and it applies ONLY '
+ 'to a federated datasource (`schemaMode` other than `managed`) — for a managed datasource '
+ 'there is currently no read-only gate at all, so delete the key rather than trusting it. '
+ 'Tracked in #4584.',
+ 'there is no read-only gate at all, so delete the key rather than trusting it. #4584 '
+ 'settled that this stays so ON PURPOSE: grant the connection SELECT-only at the database '
+ '(`GRANT SELECT`), which no direct connection, migration or DDL can talk past — an '
+ 'application-layer flag holds in the ObjectQL path only, and one that looks like a boundary '
+ 'without being one is worse than none.',
};

/**
Expand Down
Loading