|
| 1 | +--- |
| 2 | +title: External Datasources (Federation) |
| 3 | +description: Declare an external database as a datasource and query its tables as ObjectStack objects — visible, auto-connected, and queryable with zero app code. |
| 4 | +--- |
| 5 | + |
| 6 | +# External Datasources (Federation) |
| 7 | + |
| 8 | +ObjectStack can treat a **mature external database** — one it does not own — as a |
| 9 | +read-only (or, with explicit opt-in, writable) datasource, and expose its tables |
| 10 | +as normal objects. This is *federation*: the data stays in the remote database; |
| 11 | +ObjectStack queries it live through the same ObjectQL / REST surface as native |
| 12 | +objects. |
| 13 | + |
| 14 | +The headline guarantee: **declare an external datasource and it is visible, |
| 15 | +auto-connected, validated at boot, and queryable — with no application code.** |
| 16 | + |
| 17 | +<Callout type="info"> |
| 18 | +This guide is about reading/writing the *live* remote tables (federation). To |
| 19 | +*copy* rows into ObjectStack-owned tables instead, see seed data / data sync. For |
| 20 | +the plain multi-datasource routing of **managed** databases ObjectStack owns, see |
| 21 | +[Database Drivers](/docs/guides/driver-configuration). |
| 22 | +</Callout> |
| 23 | + |
| 24 | +## 1. Declare the datasource |
| 25 | + |
| 26 | +Use `defineDatasource` with `schemaMode: 'external'`. The `external` block carries |
| 27 | +the federation policy (write gate, boot validation, credentials). |
| 28 | + |
| 29 | +```typescript |
| 30 | +import { defineDatasource } from '@objectstack/spec/data'; |
| 31 | + |
| 32 | +export const Warehouse = defineDatasource({ |
| 33 | + name: 'warehouse', |
| 34 | + label: 'Analytics Warehouse (Postgres)', |
| 35 | + driver: 'postgres', |
| 36 | + schemaMode: 'external', // ObjectStack never runs DDL here |
| 37 | + config: { host: 'db.internal', port: 5432, database: 'analytics', user: 'readonly' }, |
| 38 | + external: { |
| 39 | + allowWrites: false, // read-only (the default) |
| 40 | + credentialsRef: 'secret:warehouse/password', // resolved from the secret store |
| 41 | + validation: { onMismatch: 'fail', checkOnBoot: true }, |
| 42 | + }, |
| 43 | + active: true, |
| 44 | +}); |
| 45 | +``` |
| 46 | + |
| 47 | +Register it on the stack (array or name-keyed map both work): |
| 48 | + |
| 49 | +```typescript |
| 50 | +export default defineStack({ |
| 51 | + datasources: [Warehouse], |
| 52 | + // ... |
| 53 | +}); |
| 54 | +``` |
| 55 | + |
| 56 | +`schemaMode`: |
| 57 | + |
| 58 | +| Mode | Meaning | |
| 59 | +| --- | --- | |
| 60 | +| `managed` (default) | ObjectStack owns the schema — DDL + migrations allowed. | |
| 61 | +| `external` | A mature external DB — **DDL forbidden**; a schema mismatch **fails** boot. | |
| 62 | +| `validate-only` | Like `external`, but a mismatch **warns** instead of failing. | |
| 63 | + |
| 64 | +## 2. Bind objects to the remote tables |
| 65 | + |
| 66 | +A federated object sets `datasource` to the external datasource and declares its |
| 67 | +remote binding in `external`. When the remote table or column names differ from |
| 68 | +your object/field names, map them with `external.remoteName` / `external.remoteSchema` |
| 69 | +and `external.columnMap`. |
| 70 | + |
| 71 | +```typescript |
| 72 | +export const Customer = ObjectSchema.create({ |
| 73 | + name: 'ext_customer', |
| 74 | + datasource: 'warehouse', |
| 75 | + external: { |
| 76 | + remoteName: 'customers', // remote TABLE name (object name may differ) |
| 77 | + // remoteSchema: 'public', // optional schema/namespace (pg/mysql) |
| 78 | + // columnMap: { cust_region: 'region' }, // remoteColumn → localField |
| 79 | + }, |
| 80 | + fields: { |
| 81 | + id: { type: 'text' }, |
| 82 | + name: { type: 'text' }, |
| 83 | + region: { type: 'text' }, |
| 84 | + }, |
| 85 | +}); |
| 86 | +``` |
| 87 | + |
| 88 | +That's it. `GET /api/v1/data/ext_customer` now returns live rows from the remote |
| 89 | +`customers` table; filters (`?region=EU`) push down to the remote query. |
| 90 | + |
| 91 | +<Callout type="warn"> |
| 92 | +**Do not set `field.columnName` on an external object.** For federated objects the |
| 93 | +driver's query pipeline ignores `field.columnName`; `external.columnMap` |
| 94 | +(`remoteColumn → localField`) is the single, authoritative column mapping. |
| 95 | +`os build` / `os validate` rejects `field.columnName` on an external object with a |
| 96 | +clear error (ADR-0062 D7). Managed objects are unaffected. |
| 97 | +</Callout> |
| 98 | + |
| 99 | +## 3. Auto-connect — no `onEnable` needed |
| 100 | + |
| 101 | +At boot the runtime builds a live driver for the datasource, connects it, and |
| 102 | +registers its federated objects' read metadata — automatically. You do **not** |
| 103 | +need an `onEnable` hook or `ctx.drivers.register(...)`. |
| 104 | + |
| 105 | +A declared datasource auto-connects when it is **meaningfully addressed**: |
| 106 | + |
| 107 | +1. it is **external** (`schemaMode !== 'managed'`), **or** |
| 108 | +2. an object **explicitly** binds to it via `object.datasource === <name>`, **or** |
| 109 | +3. it sets **`autoConnect: true`**. |
| 110 | + |
| 111 | +A `managed` datasource that nothing explicitly binds to (for example one that is |
| 112 | +only referenced by a `datasourceMapping` rule) stays *metadata-only* — visible in |
| 113 | +Setup, but not connected — so existing apps are unchanged. Use `autoConnect: true` |
| 114 | +to opt such a datasource into a live connection at boot. |
| 115 | + |
| 116 | +<Callout type="info"> |
| 117 | +**Escape hatch.** An `onEnable` hook calling `ctx.drivers.register(driver)` is |
| 118 | +still supported for advanced cases — e.g. a driver built dynamically at runtime |
| 119 | +from external configuration. Auto-connect is idempotent with it (whichever |
| 120 | +registers the datasource name first wins), so the two never conflict. |
| 121 | +</Callout> |
| 122 | + |
| 123 | +A connected external datasource is also visible in **Setup → Datasources** (stamped |
| 124 | +`origin: code`, read-only in the UI) and via `GET /api/v1/datasources` and |
| 125 | +`GET /api/v1/meta/datasource`, where an admin can run the "Sync objects" wizard. |
| 126 | + |
| 127 | +## 4. Credentials |
| 128 | + |
| 129 | +Never inline a password. Put a reference in `external.credentialsRef` and store the |
| 130 | +secret in the secret store (the same `SecretBinder` / `ICryptoProvider` the |
| 131 | +runtime-admin "Add Datasource" wizard uses). The credential is resolved to |
| 132 | +cleartext **at connect, before the driver is built**. |
| 133 | + |
| 134 | +Resolution is **fail-closed**: if a `credentialsRef` is declared but no secret store |
| 135 | +is configured, or the secret cannot be resolved/decrypted, the datasource is left |
| 136 | +**unconnected with a clear error** — never connected without the credential. For a |
| 137 | +code-defined external datasource with `validation.onMismatch: 'fail'` this fails |
| 138 | +the boot; otherwise it degrades with a warning. (Credential-less drivers such as |
| 139 | +SQLite simply have no `credentialsRef`.) |
| 140 | + |
| 141 | +## 5. Writes (double opt-in) |
| 142 | + |
| 143 | +Federation is read-only by default. To allow writes, **both** the datasource and the |
| 144 | +object must opt in: |
| 145 | + |
| 146 | +```typescript |
| 147 | +defineDatasource({ /* ... */ external: { allowWrites: true } }); // datasource gate |
| 148 | +ObjectSchema.create({ /* ... */ external: { remoteName: 'orders', writable: true } }); // object gate |
| 149 | +``` |
| 150 | + |
| 151 | +With either gate off, insert/update/delete on the federated object is rejected. |
| 152 | + |
| 153 | +## 6. Analytics over external objects |
| 154 | + |
| 155 | +Dashboards and reports over a federated object aggregate against the **correct** |
| 156 | +remote table/columns: the analytics layer routes such queries through the driver's |
| 157 | +physical-table resolution (honoring `remoteName` / `remoteSchema`) rather than the |
| 158 | +object name, so they never hit the wrong table. |
| 159 | + |
| 160 | +## Multi-tenant hosts |
| 161 | + |
| 162 | +A self-hosted single-environment runtime connects external datasources out of the |
| 163 | +box. A multi-tenant host can bind a stricter **connect policy** (egress allow-list / |
| 164 | +per-tenant quota) that is consulted before any connection is opened — the same |
| 165 | +single connect path, no fork. This is a host-composition concern; the open-core |
| 166 | +default allows all connects (subject to the gating above). |
| 167 | + |
| 168 | +## See also |
| 169 | + |
| 170 | +- [Database Drivers](/docs/guides/driver-configuration) — managed multi-datasource routing. |
| 171 | +- [Datasource reference](/docs/references/data/datasource) — every `defineDatasource` field. |
| 172 | +- The `examples/app-showcase` `showcase_external` datasource — a runnable end-to-end demo. |
| 173 | +- ADR-0015 (federation spec) and ADR-0062 (external-datasource runtime). |
0 commit comments