From c5fa6fffa5694327ba094fcef1449ccad214972e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 08:06:18 +0000 Subject: [PATCH 1/3] feat(spec,service-datasource): datasource.config is parsed against its driver's contract (#4410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `config` was the one authorable slot on a datasource with no gate at all. The module comment justified the hole by saying "the driver's own `configSchema` is what validates it". Nothing did: both bundled driver specs set `configSchema: {}`, no code read the field, and the per-driver zod schemas were not exported from the package — `data/driver/` was reachable only from its own tests. So `config: { hostname: 'db.internal' }` (the key is `host`) was accepted in silence and the datasource connected to localhost while the parse, the save and the connection probe all reported success. That is #4001's original bug verbatim, one level down, and #4001's own fix pointed authors straight into it. The frontend question the issue raised has an answer, and it is not a third false claim: objectui's DatasourceResourcePage really does render the connection form from a driver `configSchema` (`GET /api/v1/datasources/drivers`, reading properties/required/title/format). It reads DRIVER_CATALOG — a SECOND set of hand-written JSON-Schema literals in service-datasource, never checked against the spec's zod schemas and never validating anything. One live copy, one dead copy, no gate between them. So: `packages/spec/src/data/driver/` becomes the one contract, and three consumers read it. `DatasourceSchema` parses `config` — and each `readReplicas` entry — against the schema for the declared driver; `DriverDefinitionSchema .configSchema` publishes its JSON-Schema projection; the catalog serves that same projection, so the form offers exactly the fields the validator accepts. `mysql` and `sqlite` / `sqlite-wasm` had no config shape anywhere, though both were offered by the form and buildable by the factory. The wizard is the other authoring door and does not reach DatasourceSchema: createDatasource writes through `metadata.register`, whose validation is a structural name/label check. DatasourceAdminService create/update/test now consults the same registry — testConnection BEFORE probing, or a green "connection successful" gets reported against localhost. Enforcing the contract forced honouring it. A gate over `config` means every key inside it claims to be read, so each was audited against the code that reads it: - `datasource.pool` reaches every SQL driver. It was declared, strict, carried into the connection spec — then overwritten with a hardcoded { min: 0, max: 5 }. Maps onto minPoolSize/maxPoolSize for mongo. - `datasource.schemaMode` reaches the driver. It was dropped between the record and the spec, so the factory looked for it in two places that could never hold it and an `external` database — one ObjectStack must never run DDL against — was constructed as `managed`. - `datasource.ssl` reaches the SQL clients, certificates and all. It stopped at the record, so a TLS block configured nothing: the failure its own schema comment warns about. - postgres `schema` (knex searchPath), `applicationName`, `statementTimeout`. - mongo `password`, `authSource`, `options`. A mongo datasource carrying a `config.password` composed its URL with an EMPTY password. Two memory keys had nothing to wire to — `InMemoryDriverConfig` has no field for `indexes` or `maxRecordsPerObject`, the driver keeps no indexes and evicts nothing — so they are removed under ADR-0049 with the rejection carrying why. `config.ssl` is the boolean shorthand only, deliberately. A `boolean | object` union is honest about what the client accepts, but the form turns anything that is not boolean/enum/number into a TEXT INPUT: the wizard would have produced a string the new gate rejects. Certificates go in the datasource-level block, which this change makes live. One table for driver ids, in the spec. The factory kept its own copy, which meant the id selecting a DRIVER and the id selecting that driver's CONFIG CONTRACT could disagree — the same silent acceptance, reintroduced as a lookup miss. Also fixes the docs generator's one-level-deep source walk, which filed the new schemas onto a `misc` page whose "Source" line named a file that does not exist (the identical bug the strictness ledger's own coverage gate had). The recursive walk gives `data/driver/`, `integration/connector/` and `kernel/events/` real per-file pages; no documented schema was lost, six more are now covered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY --- .../datasource-config-driver-contract.md | 77 ++ content/docs/data-modeling/drivers.mdx | 50 + .../data-modeling/external-datasources.mdx | 2 +- content/docs/references/api/connector.mdx | 4 - content/docs/references/api/core-services.mdx | 4 - content/docs/references/api/http.mdx | 4 - content/docs/references/api/identity.mdx | 4 - .../docs/references/api/metadata-plugin.mdx | 4 - content/docs/references/api/notification.mdx | 4 - .../docs/references/api/package-registry.mdx | 4 - .../docs/references/automation/connector.mdx | 4 - .../references/automation/events-core.mdx | 31 + content/docs/references/automation/job.mdx | 4 - content/docs/references/automation/meta.json | 1 + .../docs/references/automation/offline.mdx | 4 - .../references/automation/state-machine.mdx | 16 +- .../docs/references/cloud/plugin-security.mdx | 4 - .../docs/references/cloud/provisioning.mdx | 4 - .../docs/references/data/driver-common.mdx | 62 ++ .../docs/references/data/driver-memory.mdx | 101 ++ content/docs/references/data/driver-mongo.mdx | 61 ++ content/docs/references/data/driver-mysql.mdx | 63 ++ .../docs/references/data/driver-postgres.mdx | 62 ++ .../docs/references/data/driver-sqlite.mdx | 106 +++ content/docs/references/data/index.mdx | 6 + content/docs/references/data/meta.json | 6 + .../references/integration/connector-auth.mdx | 4 - .../integration/connector-database.mdx | 139 +++ .../integration/connector-file-storage.mdx | 170 ++++ .../integration/connector-github.mdx | 246 +++++ .../integration/connector-message-queue.mdx | 171 ++++ .../references/integration/connector-saas.mdx | 125 +++ .../integration/connector-vercel.mdx | 300 ++++++ content/docs/references/integration/http.mdx | 4 - content/docs/references/integration/index.mdx | 6 + .../docs/references/integration/mapping.mdx | 4 - .../references/integration/message-queue.mdx | 4 - content/docs/references/integration/meta.json | 10 +- content/docs/references/integration/misc.mdx | 860 ------------------ .../references/integration/object-storage.mdx | 4 - .../docs/references/integration/offline.mdx | 4 - .../docs/references/integration/tenant.mdx | 4 - content/docs/references/kernel/events-bus.mdx | 64 ++ .../docs/references/kernel/events-core.mdx | 90 ++ content/docs/references/kernel/events-dlq.mdx | 61 ++ .../references/kernel/events-handlers.mdx | 72 ++ .../references/kernel/events-integrations.mdx | 97 ++ .../docs/references/kernel/events-queue.mdx | 92 ++ content/docs/references/kernel/index.mdx | 6 + content/docs/references/kernel/meta.json | 9 +- .../kernel/metadata-persistence.mdx | 4 - content/docs/references/kernel/misc.mdx | 271 ------ .../docs/references/kernel/state-machine.mdx | 37 - content/docs/references/security/misc.mdx | 4 - .../shared/metadata-persistence.mdx | 4 - content/docs/references/studio/action.mdx | 4 - .../references/system/metadata-loader.mdx | 4 - content/docs/references/ui/http.mdx | 4 - .../2026-07-unknown-key-strictness-ledger.md | 28 +- .../app-crm/src/datasources/crm.datasource.ts | 6 + .../showcase-external.datasource.ts | 2 +- .../__tests__/datasource-admin-plugin.test.ts | 18 +- .../datasource-admin-service.test.ts | 76 +- .../default-datasource-driver-factory.test.ts | 94 ++ .../src/__tests__/driver-catalog.test.ts | 79 ++ .../contracts/datasource-driver-factory.ts | 22 + .../src/datasource-admin-service.ts | 43 + .../src/datasource-connection-service.ts | 6 + .../src/default-datasource-driver-factory.ts | 184 +++- .../service-datasource/src/driver-catalog.ts | 84 +- packages/spec/api-surface.json | 53 ++ packages/spec/authorable-surface.json | 40 + packages/spec/json-schema.manifest.json | 12 + packages/spec/scripts/build-docs.ts | 90 +- packages/spec/src/data/datasource.test.ts | 76 +- packages/spec/src/data/datasource.zod.ts | 121 ++- packages/spec/src/data/driver/common.zod.ts | 100 ++ .../src/data/driver/config-registry.test.ts | 158 ++++ .../src/data/driver/config-registry.zod.ts | 174 ++++ packages/spec/src/data/driver/index.ts | 20 + packages/spec/src/data/driver/memory.test.ts | 37 +- packages/spec/src/data/driver/memory.zod.ts | 109 ++- packages/spec/src/data/driver/mongo.zod.ts | 165 +++- packages/spec/src/data/driver/mysql.zod.ts | 125 +++ .../spec/src/data/driver/postgres.test.ts | 86 +- packages/spec/src/data/driver/postgres.zod.ts | 217 +++-- packages/spec/src/data/driver/sqlite.zod.ts | 135 +++ packages/spec/src/data/index.ts | 6 + skills/objectstack-data/references/_index.md | 7 + .../objectstack-platform/references/_index.md | 7 + 90 files changed, 4267 insertions(+), 1683 deletions(-) create mode 100644 .changeset/datasource-config-driver-contract.md create mode 100644 content/docs/references/automation/events-core.mdx create mode 100644 content/docs/references/data/driver-common.mdx create mode 100644 content/docs/references/data/driver-memory.mdx create mode 100644 content/docs/references/data/driver-mongo.mdx create mode 100644 content/docs/references/data/driver-mysql.mdx create mode 100644 content/docs/references/data/driver-postgres.mdx create mode 100644 content/docs/references/data/driver-sqlite.mdx create mode 100644 content/docs/references/integration/connector-database.mdx create mode 100644 content/docs/references/integration/connector-file-storage.mdx create mode 100644 content/docs/references/integration/connector-github.mdx create mode 100644 content/docs/references/integration/connector-message-queue.mdx create mode 100644 content/docs/references/integration/connector-saas.mdx create mode 100644 content/docs/references/integration/connector-vercel.mdx delete mode 100644 content/docs/references/integration/misc.mdx create mode 100644 content/docs/references/kernel/events-bus.mdx create mode 100644 content/docs/references/kernel/events-core.mdx create mode 100644 content/docs/references/kernel/events-dlq.mdx create mode 100644 content/docs/references/kernel/events-handlers.mdx create mode 100644 content/docs/references/kernel/events-integrations.mdx create mode 100644 content/docs/references/kernel/events-queue.mdx delete mode 100644 content/docs/references/kernel/misc.mdx delete mode 100644 content/docs/references/kernel/state-machine.mdx create mode 100644 packages/services/service-datasource/src/__tests__/driver-catalog.test.ts create mode 100644 packages/spec/src/data/driver/common.zod.ts create mode 100644 packages/spec/src/data/driver/config-registry.test.ts create mode 100644 packages/spec/src/data/driver/config-registry.zod.ts create mode 100644 packages/spec/src/data/driver/index.ts create mode 100644 packages/spec/src/data/driver/mysql.zod.ts create mode 100644 packages/spec/src/data/driver/sqlite.zod.ts diff --git a/.changeset/datasource-config-driver-contract.md b/.changeset/datasource-config-driver-contract.md new file mode 100644 index 0000000000..f912af836a --- /dev/null +++ b/.changeset/datasource-config-driver-contract.md @@ -0,0 +1,77 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-datasource': minor +--- + +`datasource.config` is now validated against its driver's contract (#4410) + +`config` was the one authorable slot on a datasource with no gate at all. The +schema's own comment claimed "the driver's own `configSchema` is what validates +it" — nothing did: both bundled driver specs set `configSchema: {}`, no code read +the field, and the per-driver zod schemas were not even exported from the +package. So `config: { hostname: 'db.internal' }` (the key is `host`) was +accepted in silence and the datasource connected to `localhost` while the parse, +the save and the connection probe all reported success. + +`DatasourceSchema` now parses `config` — and each `readReplicas` entry — against +the contract for the declared driver, and `DatasourceAdminService` +(create/update/test, the Setup wizard's path) applies the same check. Both read +one registry in `@objectstack/spec/data`, which also projects each contract to +JSON Schema for `DriverDefinitionSchema.configSchema` and the Studio connection +form, so the form offers exactly the fields the validator accepts. + +New exports from `@objectstack/spec/data`: `PostgresConfigSchema`, +`MysqlConfigSchema`, `SqliteConfigSchema`, `SqliteWasmConfigSchema`, +`MongoConfigSchema`, `MemoryConfigSchema`, plus `resolveDriverId`, +`getDriverConfigSchema`, `getDriverConfigJsonSchemaById` and +`validateDriverConfig`. A driver the platform ships no contract for (a plugin's +`com.vendor.snowflake`) keeps an unvalidated `config`. + +**Migration.** A config that was silently ignored now fails with the correction +in the message. The renames: + +| Wrote | Write instead | Driver | +| --- | --- | --- | +| `user` | `username` | postgres, mysql, mongo | +| `connectionString` / `dsn` | `url` | postgres, mysql, mongo | +| `uri` | `url` | mongo | +| `file` / `path` / `database` | `filename` | sqlite, sqlite-wasm | +| `hostname` | `host` | postgres, mysql, mongo | +| `searchPath` | `schema` | postgres | + +And the relocations — keys that were never driver config: + +| Wrote in `config` | Write instead | +| --- | --- | +| `min` / `max` / `idleTimeoutMillis` / `connectionTimeoutMillis` | the datasource's own `pool` block | +| `schemaMode` | next to `driver`, on the datasource | +| `readOnly` | `capabilities: { readOnly: true }` | +| `ssl: { ca, cert, key, rejectUnauthorized }` | the datasource's own `ssl` block — inside `config`, `ssl` is the on/off boolean shorthand | + +Two memory-driver keys are **removed**: `indexes` and `maxRecordsPerObject`. +`InMemoryDriverConfig` has no field for either — the driver keeps no indexes and +evicts nothing — so both were inert. Drop them; for real indexing use a driver +that indexes. + +A postgres, mysql or mongo datasource must now name a connection target +(`database`, or a `url` that carries it). An empty `config` used to mean "the +client's own localhost default", which is the same defect in its most complete +form. + +**Also fixed, because the contract can only be enforced where it is honoured.** +These keys were declared and read by nothing; they now reach the driver: + +- `datasource.pool` is honoured by every SQL driver (it was declared, carried + into the connection spec, then overwritten with a hardcoded `{ min: 0, max: 5 }`), + and maps onto the Mongo client's `minPoolSize` / `maxPoolSize`. +- `datasource.schemaMode` reaches the driver. It was dropped between the + datasource record and the connection spec, so a `schemaMode: 'external'` + database — one ObjectStack must never run DDL against — was constructed as + `managed`. +- `datasource.ssl` reaches the SQL clients, certificates and all. It stopped at + the record — nothing put it on the connection spec — so a TLS block configured + nothing, which is exactly what its own schema comment warns about ("a TLS + setting that never took effect looked identical to one that did"). +- postgres `schema` (knex `searchPath`), `applicationName` and `statementTimeout`. +- mongo `password`, `authSource` and `options`. A mongo datasource carrying a + `config.password` previously composed its URL with an **empty** password. diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index 5d8de57740..ff1f22191f 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -75,6 +75,56 @@ actually connect to Turso. > Knex client name (`pg` / `mysql2` / `better-sqlite3`) when you instantiate > `SqlDriver`. +## `config` is validated per driver + +A datasource's `config` is driver-specific — a SQLite `filename` and a Postgres +`host` share no shape — so the datasource schema keeps that slot open at the top +level and parses it against the contract for the driver you named. Each built-in +driver ships that contract as a zod schema, exported from `@objectstack/spec/data`: + +| `driver` | Contract | Keys | +| :--- | :--- | :--- | +| `postgres` \| `postgresql` \| `pg` | `PostgresConfigSchema` | `url`, `host`, `port`, `database`, `username`, `password`, `ssl`, `schema`, `applicationName`, `statementTimeout`, `autoMigrate` | +| `mysql` \| `mysql2` \| `mariadb` | `MysqlConfigSchema` | `url`, `host`, `port`, `database`, `username`, `password`, `ssl`, `autoMigrate` | +| `sqlite` \| `sqlite3` | `SqliteConfigSchema` | `filename`, `autoMigrate` | +| `sqlite-wasm` \| `wasm-sqlite` | `SqliteWasmConfigSchema` | `filename`, `persist` | +| `mongo` \| `mongodb` | `MongoConfigSchema` | `url`, `host`, `port`, `database`, `username`, `password`, `authSource`, `options` | +| `memory` \| `in-memory` | `MemoryConfigSchema` | `initialData`, `strictMode`, `persistence` | + +An unrecognised key is rejected with its correction, at authoring time and in the +Setup → Datasources wizard alike: + +```text +Unrecognized key(s) on this postgres datasource's config: `hostname`. +Did you mean `hostname` → `host`? +``` + +This matters more than a typical typo check, because the failure it replaces was +silent: a misspelled key was dropped, the driver fell back to its own defaults, +and the datasource connected to `localhost` while every signal — the parse, the +save, the connection probe — reported success. + +Two things live **outside** `config`, because they are not driver-specific: + +- **Pool sizing** — the `pool` block on the datasource (`min`, `max`, + `idleTimeoutMillis`, `connectionTimeoutMillis`), honoured for every SQL driver + and mapped onto the Mongo client's `minPoolSize` / `maxPoolSize`. +- **TLS certificates** — the `ssl` block on the datasource (`enabled`, + `rejectUnauthorized`, `ca`, `cert`, `key`). Inside `config`, `ssl` is the + on/off boolean shorthand. +- **`schemaMode`** — the ADR-0015 ownership mode, declared next to `driver`. + +A plugin-contributed driver (`com.vendor.snowflake`) has no contract in this +repo, so its `config` is left unvalidated rather than judged against a shape the +platform does not have. + + +The same schemas are projected to JSON Schema for +`DriverDefinitionSchema.configSchema` and for `GET /api/v1/datasources/drivers`, +which the Studio connection form renders — so the form offers exactly the fields +the validator accepts. + + ## Startup: a driver that cannot connect aborts the boot `ObjectQLEngine.init()` connects every registered driver during kernel diff --git a/content/docs/data-modeling/external-datasources.mdx b/content/docs/data-modeling/external-datasources.mdx index d2cdc4ae38..0d40289fbe 100644 --- a/content/docs/data-modeling/external-datasources.mdx +++ b/content/docs/data-modeling/external-datasources.mdx @@ -35,7 +35,7 @@ export const Warehouse = defineDatasource({ label: 'Analytics Warehouse (Postgres)', driver: 'postgres', schemaMode: 'external', // ObjectStack never runs DDL here - config: { host: 'db.internal', port: 5432, database: 'analytics', user: 'readonly' }, + config: { host: 'db.internal', port: 5432, database: 'analytics', username: 'readonly' }, external: { allowWrites: false, // read-only (the default) credentialsRef: 'sys_secret:9f2c…', // opaque handle minted by the secret store diff --git a/content/docs/references/api/connector.mdx b/content/docs/references/api/connector.mdx index 13d4911417..aac0b373f2 100644 --- a/content/docs/references/api/connector.mdx +++ b/content/docs/references/api/connector.mdx @@ -5,10 +5,6 @@ description: Connector protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/api/connector.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/api/core-services.mdx b/content/docs/references/api/core-services.mdx index 1583948a79..5f88cac2aa 100644 --- a/content/docs/references/api/core-services.mdx +++ b/content/docs/references/api/core-services.mdx @@ -5,10 +5,6 @@ description: Core Services protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/api/core-services.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/api/http.mdx b/content/docs/references/api/http.mdx index bbbd7be6ef..6eb5ac2296 100644 --- a/content/docs/references/api/http.mdx +++ b/content/docs/references/api/http.mdx @@ -5,10 +5,6 @@ description: Http protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/api/http.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/api/identity.mdx b/content/docs/references/api/identity.mdx index f57cb9f9af..042270686d 100644 --- a/content/docs/references/api/identity.mdx +++ b/content/docs/references/api/identity.mdx @@ -5,10 +5,6 @@ description: Identity protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/api/identity.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/api/metadata-plugin.mdx b/content/docs/references/api/metadata-plugin.mdx index dc3fa4e956..75edc8ead2 100644 --- a/content/docs/references/api/metadata-plugin.mdx +++ b/content/docs/references/api/metadata-plugin.mdx @@ -5,10 +5,6 @@ description: Metadata Plugin protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/api/metadata-plugin.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/api/notification.mdx b/content/docs/references/api/notification.mdx index 063d3d6f18..d792d419f8 100644 --- a/content/docs/references/api/notification.mdx +++ b/content/docs/references/api/notification.mdx @@ -5,10 +5,6 @@ description: Notification protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/api/notification.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/api/package-registry.mdx b/content/docs/references/api/package-registry.mdx index e48585dbdc..52f810ab06 100644 --- a/content/docs/references/api/package-registry.mdx +++ b/content/docs/references/api/package-registry.mdx @@ -5,10 +5,6 @@ description: Package Registry protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/api/package-registry.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/automation/connector.mdx b/content/docs/references/automation/connector.mdx index f8baede8f5..c86ead52b8 100644 --- a/content/docs/references/automation/connector.mdx +++ b/content/docs/references/automation/connector.mdx @@ -5,10 +5,6 @@ description: Connector protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/automation/connector.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/automation/events-core.mdx b/content/docs/references/automation/events-core.mdx new file mode 100644 index 0000000000..43d096bc0c --- /dev/null +++ b/content/docs/references/automation/events-core.mdx @@ -0,0 +1,31 @@ +--- +title: Events Core +description: Events Core protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +## TypeScript Usage + +```typescript +import { Event } from '@objectstack/spec/automation'; +import type { Event } from '@objectstack/spec/automation'; + +// Validate data +const result = Event.parse(data); +``` + +--- + +## Event + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `string` | ✅ | Event Type (e.g. "APPROVE", "REJECT", "Submit") | +| **schema** | `Record` | optional | Expected event payload structure | + + +--- + diff --git a/content/docs/references/automation/job.mdx b/content/docs/references/automation/job.mdx index 8f79ea972b..d3e8b86a7e 100644 --- a/content/docs/references/automation/job.mdx +++ b/content/docs/references/automation/job.mdx @@ -5,10 +5,6 @@ description: Job protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/automation/job.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/automation/meta.json b/content/docs/references/automation/meta.json index 30a557be1d..63900061b1 100644 --- a/content/docs/references/automation/meta.json +++ b/content/docs/references/automation/meta.json @@ -21,6 +21,7 @@ "job", "---More---", "builtin-node-config", + "events-core", "flow-function", "io-node-config", "schemaless-node-config" diff --git a/content/docs/references/automation/offline.mdx b/content/docs/references/automation/offline.mdx index 00fb908d98..f6bfbe5d6d 100644 --- a/content/docs/references/automation/offline.mdx +++ b/content/docs/references/automation/offline.mdx @@ -5,10 +5,6 @@ description: Offline protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/automation/offline.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/automation/state-machine.mdx b/content/docs/references/automation/state-machine.mdx index 6ecf8900b4..59601ec4cd 100644 --- a/content/docs/references/automation/state-machine.mdx +++ b/content/docs/references/automation/state-machine.mdx @@ -18,8 +18,8 @@ Prevent AI "hallucinations" by enforcing valid valid transitions. ## TypeScript Usage ```typescript -import { ActionRef, Event, GuardRef, StateMachine, StateNode, Transition } from '@objectstack/spec/automation'; -import type { ActionRef, Event, GuardRef, StateMachine, StateNode, Transition } from '@objectstack/spec/automation'; +import { ActionRef, GuardRef, StateMachine, StateNode, Transition } from '@objectstack/spec/automation'; +import type { ActionRef, GuardRef, StateMachine, StateNode, Transition } from '@objectstack/spec/automation'; // Validate data const result = ActionRef.parse(data); @@ -53,18 +53,6 @@ Type: `string` --- ---- - -## Event - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `string` | ✅ | Event Type (e.g. "APPROVE", "REJECT", "Submit") | -| **schema** | `Record` | optional | Expected event payload structure | - - --- ## GuardRef diff --git a/content/docs/references/cloud/plugin-security.mdx b/content/docs/references/cloud/plugin-security.mdx index 877edc19be..a6501678f8 100644 --- a/content/docs/references/cloud/plugin-security.mdx +++ b/content/docs/references/cloud/plugin-security.mdx @@ -5,10 +5,6 @@ description: Plugin Security protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/cloud/plugin-security.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/cloud/provisioning.mdx b/content/docs/references/cloud/provisioning.mdx index 126b63717c..bbcf0d592a 100644 --- a/content/docs/references/cloud/provisioning.mdx +++ b/content/docs/references/cloud/provisioning.mdx @@ -5,10 +5,6 @@ description: Provisioning protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/cloud/provisioning.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/data/driver-common.mdx b/content/docs/references/data/driver-common.mdx new file mode 100644 index 0000000000..db66f93b5d --- /dev/null +++ b/content/docs/references/data/driver-common.mdx @@ -0,0 +1,62 @@ +--- +title: Driver Common +description: Driver Common protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Shared building blocks for the per-driver `datasource.config` shapes (#4410). + +Every schema under `data/driver/` describes ONE driver's `config` slot — the + +keys an author may write and the platform actually reads. They are the + +enforcement half of the `config` escape hatch `datasource.zod.ts` opens: the + +slot stays `z.record` at the top of `DatasourceSchema` because a sqlite + +`filename` and a postgres `host` share no shape, and `DatasourceSchema`'s + +refinement then parses it against the schema for the declared driver. + +The rule these files are written to: **a key is declared here only if some + +code path reads it.** A config key that no driver and no factory consumes is + +the same silent-strip defect one level down (#4001, ADR-0078), so an unread + +key is either wired or rejected with a prescription — never left in the + +contract to look supported. + + +**Source:** `packages/spec/src/data/driver/common.zod.ts` + + +## TypeScript Usage + +```typescript +import { DriverSslToggle, SqlAutoMigrate } from '@objectstack/spec/data'; +import type { DriverSslToggle, SqlAutoMigrate } from '@objectstack/spec/data'; + +// Validate data +const result = DriverSslToggle.parse(data); +``` + +--- + + +--- + +## SqlAutoMigrate + +Dev-only non-destructive schema self-heal (#2186) + +### Allowed Values + +* `off` +* `safe` + + +--- + diff --git a/content/docs/references/data/driver-memory.mdx b/content/docs/references/data/driver-memory.mdx new file mode 100644 index 0000000000..58e37f2916 --- /dev/null +++ b/content/docs/references/data/driver-memory.mdx @@ -0,0 +1,101 @@ +--- +title: Driver Memory +description: Driver Memory protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Memory Driver Configuration Schema + +Defines the configuration options for the in-memory driver. + +Reference: objectql/packages/drivers/memory (Mingo-powered production-ready driver) + +The memory driver is ideal for: + +- Unit testing (no database setup required) + +- Development & prototyping + +- Edge/Worker environments (Cloudflare Workers, Deno Deploy) + +- Client-side state management + +- Temporary data caching + +- CI/CD pipelines + + +**Source:** `packages/spec/src/data/driver/memory.zod.ts` + + +## TypeScript Usage + +```typescript +import { AutoPersistenceConfig, FilePersistenceConfig, LocalStoragePersistenceConfig, PersistenceType } from '@objectstack/spec/data'; +import type { AutoPersistenceConfig, FilePersistenceConfig, LocalStoragePersistenceConfig, PersistenceType } from '@objectstack/spec/data'; + +// Validate data +const result = AutoPersistenceConfig.parse(data); +``` + +--- + +## AutoPersistenceConfig + +Auto-detect persistence configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'auto'` | ✅ | | +| **path** | `string` | optional | File path override for Node.js environments | +| **autoSaveInterval** | `number` | optional | Auto-save interval override for Node.js environments | +| **key** | `string` | optional | localStorage key override for browser environments | + + +--- + +## FilePersistenceConfig + +File-system persistence configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'file'` | ✅ | | +| **path** | `string` | optional | File path to persist data | +| **autoSaveInterval** | `number` | ✅ | Auto-save interval in ms | + + +--- + +## LocalStoragePersistenceConfig + +localStorage persistence configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'local'` | ✅ | | +| **key** | `string` | optional | localStorage key for persisted data | + + +--- + +## PersistenceType + +Persistence backend type + +### Allowed Values + +* `file` +* `local` +* `auto` + + +--- + diff --git a/content/docs/references/data/driver-mongo.mdx b/content/docs/references/data/driver-mongo.mdx new file mode 100644 index 0000000000..102f346f06 --- /dev/null +++ b/content/docs/references/data/driver-mongo.mdx @@ -0,0 +1,61 @@ +--- +title: Driver Mongo +description: Driver Mongo protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +MongoDB Standard Driver Protocol + +Describes the MongoDB connection settings and capabilities. + +ENFORCED as of #4410. This block used to claim it was "used by the Platform + +to validate `datasource.config` when `driver: 'mongo'`", which was false: the + +config slot was a bare `z.record` and this schema had no consumer at all — + +not even an export, since `data/driver/` was reachable only from its own + +tests. It is now what `DatasourceSchema` parses `config` against for a mongo + +datasource, and the same schema is projected onto + +`MongoDriverSpec`.configSchema for the connection form. + + +**Source:** `packages/spec/src/data/driver/mongo.zod.ts` + + +## TypeScript Usage + +```typescript +import { MongoConfig } from '@objectstack/spec/data'; +import type { MongoConfig } from '@objectstack/spec/data'; + +// Validate data +const result = MongoConfig.parse(data); +``` + +--- + +## MongoConfig + +MongoDB Connection Configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **url** | `string` | optional | Connection URI (supersedes the discrete fields) | +| **database** | `string` | optional | Database name | +| **host** | `string` | ✅ | Host address | +| **port** | `integer` | ✅ | Port number | +| **username** | `string` | optional | Authentication user | +| **password** | `string` | optional | Authentication password (prefer external.credentialsRef) | +| **authSource** | `string` | optional | Authentication database | +| **options** | `Record` | optional | Extra MongoClient options (replicaSet, tls, timeouts, …) | + + +--- + diff --git a/content/docs/references/data/driver-mysql.mdx b/content/docs/references/data/driver-mysql.mdx new file mode 100644 index 0000000000..418dced513 --- /dev/null +++ b/content/docs/references/data/driver-mysql.mdx @@ -0,0 +1,63 @@ +--- +title: Driver Mysql +description: Driver Mysql protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +MySQL / MariaDB driver configuration — the `config` slot of a `datasource` + +whose `driver` resolves to `mysql` (`mysql2`). + +The driver id was offered by the connection form and buildable by the shared + +factory long before #4410, but had no config shape at all in `packages/spec` + +— postgres, mongo and memory each had one and mysql did not, so its `config` + +was the one slot with neither a gate nor a documented shape. + +Every key here is read by `createDefaultDatasourceDriverFactory` + +(→ `SqlDriver`, knex `mysql2`). Postgres-only knobs are deliberately absent: + +`mysql2` has no `application_name` and no `statement_timeout`, so declaring + +them would advertise settings the client drops. + + +**Source:** `packages/spec/src/data/driver/mysql.zod.ts` + + +## TypeScript Usage + +```typescript +import { MysqlConfig } from '@objectstack/spec/data'; +import type { MysqlConfig } from '@objectstack/spec/data'; + +// Validate data +const result = MysqlConfig.parse(data); +``` + +--- + +## MysqlConfig + +MySQL / MariaDB connection configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **url** | `string` | optional | Connection URI (supersedes the discrete fields) | +| **host** | `string` | ✅ | Host address | +| **port** | `integer` | ✅ | Port number | +| **database** | `string` | optional | Database name | +| **username** | `string` | optional | Authentication user | +| **password** | `string` | optional | Authentication password (prefer external.credentialsRef) | +| **ssl** | `boolean` | optional | Enable TLS. Certificates go in the datasource-level `ssl` block. | +| **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal (#2186) | + + +--- + diff --git a/content/docs/references/data/driver-postgres.mdx b/content/docs/references/data/driver-postgres.mdx new file mode 100644 index 0000000000..66bf74e48f --- /dev/null +++ b/content/docs/references/data/driver-postgres.mdx @@ -0,0 +1,62 @@ +--- +title: Driver Postgres +description: Driver Postgres protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +PostgreSQL driver configuration — the `config` slot of a `datasource` whose + +`driver` resolves to `postgres` (`pg` / `postgresql`). + +ENFORCED as of #4410: `DatasourceSchema` parses `config` against this schema, + +so a misspelled connection key fails at authoring time instead of leaving the + +datasource on the client's localhost defaults. Every key here is read by + +`createDefaultDatasourceDriverFactory` (→ `SqlDriver`, knex `pg`). + +Pool sizing is NOT here: it lives in the driver-agnostic `datasource.pool` + +block, which the factory now honours for every SQL driver. + + +**Source:** `packages/spec/src/data/driver/postgres.zod.ts` + + +## TypeScript Usage + +```typescript +import { PostgresConfig } from '@objectstack/spec/data'; +import type { PostgresConfig } from '@objectstack/spec/data'; + +// Validate data +const result = PostgresConfig.parse(data); +``` + +--- + +## PostgresConfig + +PostgreSQL connection configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **url** | `string` | optional | Connection URI (supersedes the discrete fields) | +| **host** | `string` | ✅ | Host address | +| **port** | `integer` | ✅ | Port number | +| **database** | `string` | optional | Database name | +| **username** | `string` | optional | Authentication user | +| **password** | `string` | optional | Authentication password (prefer external.credentialsRef) | +| **ssl** | `boolean` | optional | Enable TLS. Certificates go in the datasource-level `ssl` block. | +| **schema** | `string` | ✅ | Default schema (knex searchPath) | +| **applicationName** | `string` | optional | Postgres application_name | +| **statementTimeout** | `integer` | optional | Abort statements running longer than this (ms) | +| **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal (#2186) | + + +--- + diff --git a/content/docs/references/data/driver-sqlite.mdx b/content/docs/references/data/driver-sqlite.mdx new file mode 100644 index 0000000000..5d26675205 --- /dev/null +++ b/content/docs/references/data/driver-sqlite.mdx @@ -0,0 +1,106 @@ +--- +title: Driver Sqlite +description: Driver Sqlite protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +SQLite driver configuration — the `config` slot of a `datasource` whose + +`driver` resolves to `sqlite` (native `better-sqlite3`, with the dev-only + +step-down to wasm then in-memory, #2229) or to `sqlite-wasm` (pure-JS). + +The one key that matters is `filename`, and it is exactly the key the silent + +strip used to hide: an author who wrote `path:` got no error, the connection + +fell back to `:memory:`, and their data vanished on restart with every signal + +saying the datasource was configured. + +`file` and `database` are a different case — the factory reads them as + +undeclared `??` fallbacks, so they happened to work while being written + +nowhere down. They are named as renames here rather than blessed: one strict + +contract beats a spelling that works only because a reader is lenient + +(AGENTS.md Prime Directive #12). The factory keeps its tolerance for records + +already persisted that way; no new one can be authored. + + +**Source:** `packages/spec/src/data/driver/sqlite.zod.ts` + + +## TypeScript Usage + +```typescript +import { SqliteConfig, SqliteWasmConfig, SqliteWasmPersistMode } from '@objectstack/spec/data'; +import type { SqliteConfig, SqliteWasmConfig, SqliteWasmPersistMode } from '@objectstack/spec/data'; + +// Validate data +const result = SqliteConfig.parse(data); +``` + +--- + +## SqliteConfig + +SQLite connection configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **filename** | `string` | ✅ | Database file path, or ":memory:" for an ephemeral database | +| **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal (#2186) | + + +--- + +## SqliteWasmConfig + +SQLite (WASM) connection configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **filename** | `string` | ✅ | Database file path, or ":memory:" for an ephemeral database | +| **persist** | `'on-disconnect' \| 'on-write' \| string` | optional | When to flush a file-backed wasm database to disk | + + +--- + +## SqliteWasmPersistMode + +When to flush a file-backed wasm database to disk + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +Type: `'on-disconnect'` + +--- + +#### Option 2 + +Type: `'on-write'` + +--- + +#### Option 3 + +Type: `string` + +--- + + +--- + diff --git a/content/docs/references/data/index.mdx b/content/docs/references/data/index.mdx index 207d74e17e..ce71a76e1c 100644 --- a/content/docs/references/data/index.mdx +++ b/content/docs/references/data/index.mdx @@ -13,8 +13,14 @@ This section contains all protocol schemas for the data layer of ObjectStack. + + + + + + diff --git a/content/docs/references/data/meta.json b/content/docs/references/data/meta.json index e8e922b97e..d53d6530bc 100644 --- a/content/docs/references/data/meta.json +++ b/content/docs/references/data/meta.json @@ -28,6 +28,12 @@ "seed-loader", "---More---", "context-tokens", + "driver-common", + "driver-memory", + "driver-mongo", + "driver-mysql", + "driver-postgres", + "driver-sqlite", "field-value" ] } \ No newline at end of file diff --git a/content/docs/references/integration/connector-auth.mdx b/content/docs/references/integration/connector-auth.mdx index ac2912aaae..c74b8dfe55 100644 --- a/content/docs/references/integration/connector-auth.mdx +++ b/content/docs/references/integration/connector-auth.mdx @@ -5,10 +5,6 @@ description: Connector Auth protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/integration/connector-auth.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/integration/connector-database.mdx b/content/docs/references/integration/connector-database.mdx new file mode 100644 index 0000000000..b550393cf7 --- /dev/null +++ b/content/docs/references/integration/connector-database.mdx @@ -0,0 +1,139 @@ +--- +title: Connector Database +description: Connector Database protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Database Connector Protocol Template + +Specialized connector for database systems (PostgreSQL, MySQL, SQL Server, etc.) + +Extends the base connector with database-specific features like schema discovery, + +CDC (Change Data Capture), and connection pooling. + + +**Source:** `packages/spec/src/integration/connector/database.zod.ts` + + +## TypeScript Usage + +```typescript +import { CdcConfig, DatabaseConnector, DatabasePoolConfig, DatabaseTable, SslConfig } from '@objectstack/spec/integration'; +import type { CdcConfig, DatabaseConnector, DatabasePoolConfig, DatabaseTable, SslConfig } from '@objectstack/spec/integration'; + +// Validate data +const result = CdcConfig.parse(data); +``` + +--- + +## CdcConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | ✅ | Enable CDC | +| **method** | `Enum<'log_based' \| 'trigger_based' \| 'query_based' \| 'custom'>` | ✅ | CDC method | +| **slotName** | `string` | optional | Replication slot name (for log-based CDC) | +| **publicationName** | `string` | optional | Publication name (for PostgreSQL) | +| **startPosition** | `string` | optional | Starting position/LSN for CDC stream | +| **batchSize** | `number` | ✅ | CDC batch size | +| **pollIntervalMs** | `number` | ✅ | CDC polling interval in ms | + + +--- + +## DatabaseConnector + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Unique connector identifier | +| **label** | `string` | ✅ | Display label | +| **type** | `'database'` | ✅ | | +| **description** | `string` | optional | Connector description | +| **icon** | `string` | optional | Icon identifier | +| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | +| **provider** | `Enum<'postgresql' \| 'mysql' \| 'mariadb' \| 'mssql' \| 'oracle' \| 'mongodb' \| 'redis' \| 'cassandra' \| 'snowflake' \| 'bigquery' \| 'redshift' \| 'custom'>` | ✅ | Database provider type | +| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | +| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | +| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | +| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | +| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | +| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | +| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | +| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | +| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | +| **requestTimeoutMs** | `number` | optional | Request timeout in ms | +| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | +| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | +| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | +| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | +| **metadata** | `Record` | optional | Custom connector metadata | +| **connectionConfig** | `{ host: string; port: number; database: string; username: string; … }` | ✅ | Database connection configuration | +| **poolConfig** | `{ min?: number; max?: number; idleTimeoutMs?: number; connectionTimeoutMs?: number; … }` | optional | Connection pool configuration | +| **sslConfig** | `{ enabled?: boolean; rejectUnauthorized?: boolean; ca?: string; cert?: string; … }` | optional | SSL/TLS configuration | +| **tables** | `{ name: string; label: string; schema?: string; tableName: string; … }[]` | ✅ | Tables to sync | +| **cdcConfig** | `{ enabled?: boolean; method: Enum<'log_based' \| 'trigger_based' \| 'query_based' \| 'custom'>; slotName?: string; publicationName?: string; … }` | optional | CDC configuration | +| **readReplicaConfig** | `{ enabled?: boolean; hosts: { host: string; port: number; weight?: number }[] }` | optional | Read replica configuration | +| **queryTimeoutMs** | `number` | optional | Query timeout in ms | +| **enableQueryLogging** | `boolean` | optional | Enable SQL query logging | + + +--- + +## DatabasePoolConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **min** | `number` | ✅ | Minimum connections in pool | +| **max** | `number` | ✅ | Maximum connections in pool | +| **idleTimeoutMs** | `number` | ✅ | Idle connection timeout in ms | +| **connectionTimeoutMs** | `number` | ✅ | Connection establishment timeout in ms | +| **acquireTimeoutMs** | `number` | ✅ | Connection acquisition timeout in ms | +| **evictionRunIntervalMs** | `number` | ✅ | Connection eviction check interval in ms | +| **testOnBorrow** | `boolean` | ✅ | Test connection before use | + + +--- + +## DatabaseTable + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Table name in ObjectStack (snake_case) | +| **label** | `string` | ✅ | Display label | +| **schema** | `string` | optional | Database schema name | +| **tableName** | `string` | ✅ | Actual table name in database | +| **primaryKey** | `string` | ✅ | Primary key column | +| **enabled** | `boolean` | optional | Enable sync for this table | +| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Table-specific field mappings | +| **whereClause** | `string` | optional | SQL WHERE clause for filtering | + + +--- + +## SslConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | ✅ | Enable SSL/TLS | +| **rejectUnauthorized** | `boolean` | ✅ | Reject unauthorized certificates | +| **ca** | `string` | optional | Certificate Authority certificate | +| **cert** | `string` | optional | Client certificate | +| **key** | `string` | optional | Client private key | + + +--- + diff --git a/content/docs/references/integration/connector-file-storage.mdx b/content/docs/references/integration/connector-file-storage.mdx new file mode 100644 index 0000000000..5235de8e66 --- /dev/null +++ b/content/docs/references/integration/connector-file-storage.mdx @@ -0,0 +1,170 @@ +--- +title: Connector File Storage +description: Connector File Storage protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +File Storage Connector Protocol Template + +Specialized connector for file storage systems (S3, Azure Blob, Google Cloud Storage, etc.) + +Extends the base connector with file-specific features like multipart uploads, + +versioning, and metadata extraction. + + +**Source:** `packages/spec/src/integration/connector/file-storage.zod.ts` + + +## TypeScript Usage + +```typescript +import { FileAccessPattern, FileFilterConfig, FileMetadataConfig, FileStorageConnector, FileStorageProvider, FileVersioningConfig, StorageBucket } from '@objectstack/spec/integration'; +import type { FileAccessPattern, FileFilterConfig, FileMetadataConfig, FileStorageConnector, FileStorageProvider, FileVersioningConfig, StorageBucket } from '@objectstack/spec/integration'; + +// Validate data +const result = FileAccessPattern.parse(data); +``` + +--- + +## FileAccessPattern + +File access pattern + +### Allowed Values + +* `public_read` +* `private` +* `authenticated_read` +* `bucket_owner_read` +* `bucket_owner_full` + + +--- + +## FileFilterConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **includePatterns** | `string[]` | optional | File patterns to include (glob) | +| **excludePatterns** | `string[]` | optional | File patterns to exclude (glob) | +| **minFileSize** | `number` | optional | Minimum file size in bytes | +| **maxFileSize** | `number` | optional | Maximum file size in bytes | +| **allowedExtensions** | `string[]` | optional | Allowed file extensions | +| **blockedExtensions** | `string[]` | optional | Blocked file extensions | + + +--- + +## FileMetadataConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **extractMetadata** | `boolean` | ✅ | Extract file metadata | +| **metadataFields** | `Enum<'content_type' \| 'file_size' \| 'last_modified' \| 'etag' \| 'checksum' \| 'creator' \| 'created_at' \| 'custom'>[]` | optional | Metadata fields to extract | +| **customMetadata** | `Record` | optional | Custom metadata key-value pairs | + + +--- + +## FileStorageConnector + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Unique connector identifier | +| **label** | `string` | ✅ | Display label | +| **type** | `'file_storage'` | ✅ | | +| **description** | `string` | optional | Connector description | +| **icon** | `string` | optional | Icon identifier | +| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | +| **provider** | `Enum<'s3' \| 'azure_blob' \| 'gcs' \| 'dropbox' \| 'box' \| 'onedrive' \| 'google_drive' \| 'sharepoint' \| 'ftp' \| 'local' \| 'custom'>` | ✅ | File storage provider type | +| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | +| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | +| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | +| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | +| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | +| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | +| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | +| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | +| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | +| **requestTimeoutMs** | `number` | optional | Request timeout in ms | +| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | +| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | +| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | +| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | +| **metadata** | `Record` | optional | Custom connector metadata | +| **storageConfig** | `{ endpoint?: string; region?: string; pathStyle?: boolean }` | optional | Storage configuration | +| **buckets** | `{ name: string; label: string; bucketName: string; region?: string; … }[]` | ✅ | Buckets/containers to sync | +| **metadataConfig** | `{ extractMetadata?: boolean; metadataFields?: Enum<'content_type' \| 'file_size' \| 'last_modified' \| 'etag' \| 'checksum' \| 'creator' \| 'created_at' \| 'custom'>[]; customMetadata?: Record }` | optional | Metadata extraction configuration | +| **multipartConfig** | `{ enabled?: boolean; partSize?: number; maxConcurrentParts?: number; threshold?: number }` | optional | Multipart upload configuration | +| **versioningConfig** | `{ enabled?: boolean; maxVersions?: number; retentionDays?: number }` | optional | File versioning configuration | +| **encryption** | `{ enabled?: boolean; algorithm?: Enum<'AES256' \| 'aws:kms' \| 'custom'>; kmsKeyId?: string }` | optional | Encryption configuration | +| **lifecyclePolicy** | `{ enabled?: boolean; deleteAfterDays?: number; archiveAfterDays?: number }` | optional | Lifecycle policy | +| **contentProcessing** | `{ extractText?: boolean; generateThumbnails?: boolean; thumbnailSizes?: { width: number; height: number }[]; virusScan?: boolean }` | optional | Content processing configuration | +| **bufferSize** | `number` | optional | Buffer size in bytes | +| **transferAcceleration** | `boolean` | optional | Enable transfer acceleration | + + +--- + +## FileStorageProvider + +File storage provider type + +### Allowed Values + +* `s3` +* `azure_blob` +* `gcs` +* `dropbox` +* `box` +* `onedrive` +* `google_drive` +* `sharepoint` +* `ftp` +* `local` +* `custom` + + +--- + +## FileVersioningConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | ✅ | Enable file versioning | +| **maxVersions** | `number` | optional | Maximum versions to retain | +| **retentionDays** | `number` | optional | Version retention period in days | + + +--- + +## StorageBucket + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Bucket identifier in ObjectStack (snake_case) | +| **label** | `string` | ✅ | Display label | +| **bucketName** | `string` | ✅ | Actual bucket/container name in storage system | +| **region** | `string` | optional | Storage region | +| **enabled** | `boolean` | ✅ | Enable sync for this bucket | +| **prefix** | `string` | optional | Prefix/path within bucket | +| **accessPattern** | `Enum<'public_read' \| 'private' \| 'authenticated_read' \| 'bucket_owner_read' \| 'bucket_owner_full'>` | optional | Access pattern | +| **fileFilters** | `{ includePatterns?: string[]; excludePatterns?: string[]; minFileSize?: number; maxFileSize?: number; … }` | optional | File filter configuration | + + +--- + diff --git a/content/docs/references/integration/connector-github.mdx b/content/docs/references/integration/connector-github.mdx new file mode 100644 index 0000000000..df78fc7bbb --- /dev/null +++ b/content/docs/references/integration/connector-github.mdx @@ -0,0 +1,246 @@ +--- +title: Connector Github +description: Connector Github protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +GitHub Connector Protocol + +Specialized connector for GitHub integration enabling automated + +version control operations, CI/CD workflows, and release management. + +Use Cases: + +- Automated code commits and pull requests + +- GitHub Actions workflow management + +- Issue and project tracking + +- Release and tag management + +- Repository administration + +@example + +```typescript + +import \{ GitHubConnector \} from '@objectstack/spec/integration'; + +const githubConnector: GitHubConnector = \{ + +name: 'github_enterprise', + +label: 'GitHub Enterprise', + +type: 'saas', + +provider: 'github', + +baseUrl: 'https://api.github.com', + +authentication: \{ + +type: 'oauth2', + +clientId: '$\{GITHUB_CLIENT_ID\}', + +clientSecret: '$\{GITHUB_CLIENT_SECRET\}', + +authorizationUrl: 'https://github.com/login/oauth/authorize', + +tokenUrl: 'https://github.com/login/oauth/access_token', + +grantType: 'authorization_code', + +scopes: ['repo', 'workflow', 'admin:org'], + +\}, + +repositories: [ + +\{ + +owner: 'objectstack-ai', + +name: 'spec', + +defaultBranch: 'main', + +autoMerge: false, + +\}, + +], + +\}; + +``` + + +**Source:** `packages/spec/src/integration/connector/github.zod.ts` + + +## TypeScript Usage + +```typescript +import { GitHubActionsWorkflow, GitHubCommitConfig, GitHubConnector, GitHubIssueTracking, GitHubProvider, GitHubPullRequestConfig, GitHubReleaseConfig, GitHubRepository } from '@objectstack/spec/integration'; +import type { GitHubActionsWorkflow, GitHubCommitConfig, GitHubConnector, GitHubIssueTracking, GitHubProvider, GitHubPullRequestConfig, GitHubReleaseConfig, GitHubRepository } from '@objectstack/spec/integration'; + +// Validate data +const result = GitHubActionsWorkflow.parse(data); +``` + +--- + +## GitHubActionsWorkflow + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Workflow name | +| **path** | `string` | ✅ | Workflow file path (e.g., .github/workflows/ci.yml) | +| **enabled** | `boolean` | ✅ | Enable workflow | +| **triggers** | `Enum<'push' \| 'pull_request' \| 'release' \| 'schedule' \| 'workflow_dispatch' \| 'repository_dispatch'>[]` | optional | Workflow triggers | +| **env** | `Record` | optional | Environment variables | +| **secrets** | `string[]` | optional | Required secrets | + + +--- + +## GitHubCommitConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **authorName** | `string` | optional | Commit author name | +| **authorEmail** | `string` | optional | Commit author email | +| **signCommits** | `boolean` | ✅ | Sign commits with GPG | +| **messageTemplate** | `string` | optional | Commit message template | +| **useConventionalCommits** | `boolean` | ✅ | Use conventional commits format | + + +--- + +## GitHubConnector + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Unique connector identifier | +| **label** | `string` | ✅ | Display label | +| **type** | `'saas'` | ✅ | | +| **description** | `string` | optional | Connector description | +| **icon** | `string` | optional | Icon identifier | +| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | +| **provider** | `Enum<'github' \| 'github_enterprise'>` | ✅ | GitHub provider | +| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | +| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | +| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | +| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | +| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | +| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | +| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | +| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | +| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | +| **requestTimeoutMs** | `number` | optional | Request timeout in ms | +| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | +| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | +| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | +| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | +| **metadata** | `Record` | optional | Custom connector metadata | +| **baseUrl** | `string` | optional | GitHub API base URL | +| **repositories** | `{ owner: string; name: string; defaultBranch?: string; autoMerge?: boolean; … }[]` | ✅ | Repositories to manage | +| **commitConfig** | `{ authorName?: string; authorEmail?: string; signCommits?: boolean; messageTemplate?: string; … }` | optional | Commit configuration | +| **pullRequestConfig** | `{ titleTemplate?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; bodyTemplate?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; defaultReviewers?: string[]; defaultAssignees?: string[]; … }` | optional | Pull request configuration | +| **workflows** | `{ name: string; path: string; enabled?: boolean; triggers?: Enum<'push' \| 'pull_request' \| 'release' \| 'schedule' \| 'workflow_dispatch' \| 'repository_dispatch'>[]; … }[]` | optional | GitHub Actions workflows | +| **releaseConfig** | `{ tagPattern?: string; semanticVersioning?: boolean; autoReleaseNotes?: boolean; releaseNameTemplate?: string; … }` | optional | Release configuration | +| **issueTracking** | `{ enabled?: boolean; defaultLabels?: string[]; templatePaths?: string[]; autoAssign?: boolean; … }` | optional | Issue tracking configuration | +| **enableWebhooks** | `boolean` | optional | Enable GitHub webhooks | +| **webhookEvents** | `Enum<'push' \| 'pull_request' \| 'issues' \| 'issue_comment' \| 'release' \| 'workflow_run' \| 'deployment' \| 'deployment_status' \| 'check_run' \| 'check_suite' \| 'status'>[]` | optional | Webhook events to subscribe to | + + +--- + +## GitHubIssueTracking + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | ✅ | Enable issue tracking | +| **defaultLabels** | `string[]` | optional | Default issue labels | +| **templatePaths** | `string[]` | optional | Issue template paths | +| **autoAssign** | `boolean` | ✅ | Auto-assign issues | +| **autoCloseStale** | `{ enabled: boolean; daysBeforeStale: integer; daysBeforeClose: integer; staleLabel: string }` | optional | Auto-close stale issues configuration | + + +--- + +## GitHubProvider + +GitHub provider type + +### Allowed Values + +* `github` +* `github_enterprise` + + +--- + +## GitHubPullRequestConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **titleTemplate** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | PR title template — supports `{{var}`} interpolation | +| **bodyTemplate** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | PR body template — supports `{{var}`} interpolation | +| **defaultReviewers** | `string[]` | optional | Default reviewers (usernames) | +| **defaultAssignees** | `string[]` | optional | Default assignees (usernames) | +| **defaultLabels** | `string[]` | optional | Default labels | +| **draftByDefault** | `boolean` | optional | Create draft PRs by default | +| **deleteHeadBranch** | `boolean` | optional | Delete head branch after merge | + + +--- + +## GitHubReleaseConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **tagPattern** | `string` | ✅ | Tag name pattern (e.g., v*, release/*) | +| **semanticVersioning** | `boolean` | ✅ | Use semantic versioning | +| **autoReleaseNotes** | `boolean` | ✅ | Generate release notes automatically | +| **releaseNameTemplate** | `string` | optional | Release name template | +| **preReleasePattern** | `string` | optional | Pre-release pattern (e.g., *-alpha, *-beta) | +| **draftByDefault** | `boolean` | ✅ | Create draft releases by default | + + +--- + +## GitHubRepository + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **owner** | `string` | ✅ | Repository owner (organization or username) | +| **name** | `string` | ✅ | Repository name | +| **defaultBranch** | `string` | ✅ | Default branch name | +| **autoMerge** | `boolean` | ✅ | Enable auto-merge for pull requests | +| **branchProtection** | `{ requiredReviewers: integer; requireStatusChecks: boolean; enforceAdmins: boolean; allowForcePushes: boolean; … }` | optional | Branch protection configuration | +| **topics** | `string[]` | optional | Repository topics | + + +--- + diff --git a/content/docs/references/integration/connector-message-queue.mdx b/content/docs/references/integration/connector-message-queue.mdx new file mode 100644 index 0000000000..3af90419ab --- /dev/null +++ b/content/docs/references/integration/connector-message-queue.mdx @@ -0,0 +1,171 @@ +--- +title: Connector Message Queue +description: Connector Message Queue protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Message Queue Connector Protocol Template + +Specialized connector for message queue systems (RabbitMQ, Kafka, SQS, etc.) + +Extends the base connector with message queue-specific features like topics, + +consumer groups, and message acknowledgment patterns. + + +**Source:** `packages/spec/src/integration/connector/message-queue.zod.ts` + + +## TypeScript Usage + +```typescript +import { AckMode, DeliveryGuarantee, DlqConfig, MessageFormat, MessageQueueConnector, ProducerConfig, TopicQueue } from '@objectstack/spec/integration'; +import type { AckMode, DeliveryGuarantee, DlqConfig, MessageFormat, MessageQueueConnector, ProducerConfig, TopicQueue } from '@objectstack/spec/integration'; + +// Validate data +const result = AckMode.parse(data); +``` + +--- + +## AckMode + +Message acknowledgment mode + +### Allowed Values + +* `auto` +* `manual` +* `client` + + +--- + +## DeliveryGuarantee + +Message delivery guarantee + +### Allowed Values + +* `at_most_once` +* `at_least_once` +* `exactly_once` + + +--- + +## DlqConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | ✅ | Enable DLQ | +| **queueName** | `string` | ✅ | Dead letter queue/topic name | +| **maxRetries** | `number` | ✅ | Max retries before DLQ | +| **retryDelayMs** | `number` | ✅ | Retry delay in ms | + + +--- + +## MessageFormat + +Message format/serialization + +### Allowed Values + +* `json` +* `xml` +* `protobuf` +* `avro` +* `text` +* `binary` + + +--- + +## MessageQueueConnector + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Unique connector identifier | +| **label** | `string` | ✅ | Display label | +| **type** | `'message_queue'` | ✅ | | +| **description** | `string` | optional | Connector description | +| **icon** | `string` | optional | Icon identifier | +| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | +| **provider** | `Enum<'rabbitmq' \| 'kafka' \| 'redis_pubsub' \| 'redis_streams' \| 'aws_sqs' \| 'aws_sns' \| 'google_pubsub' \| 'azure_service_bus' \| 'azure_event_hubs' \| 'nats' \| 'pulsar' \| 'activemq' \| 'custom'>` | ✅ | Message queue provider type | +| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | +| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | +| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | +| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | +| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | +| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | +| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | +| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | +| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | +| **requestTimeoutMs** | `number` | optional | Request timeout in ms | +| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | +| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | +| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | +| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | +| **metadata** | `Record` | optional | Custom connector metadata | +| **brokerConfig** | `{ brokers: string[]; clientId?: string; connectionTimeoutMs?: number; requestTimeoutMs?: number }` | ✅ | Broker connection configuration | +| **topics** | `{ name: string; label: string; topicName: string; enabled?: boolean; … }[]` | ✅ | Topics/queues to sync | +| **deliveryGuarantee** | `Enum<'at_most_once' \| 'at_least_once' \| 'exactly_once'>` | optional | Message delivery guarantee | +| **sslConfig** | `{ enabled?: boolean; rejectUnauthorized?: boolean; ca?: string; cert?: string; … }` | optional | SSL/TLS configuration | +| **saslConfig** | `{ mechanism: Enum<'plain' \| 'scram-sha-256' \| 'scram-sha-512' \| 'aws'>; username?: string; password?: string }` | optional | SASL authentication configuration | +| **schemaRegistry** | `{ url: string; auth?: object }` | optional | Schema registry configuration | +| **preserveOrder** | `boolean` | optional | Preserve message ordering | +| **enableMetrics** | `boolean` | optional | Enable message queue metrics | +| **enableTracing** | `boolean` | optional | Enable distributed tracing | + + +--- + +## ProducerConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | ✅ | Enable producer | +| **acks** | `Enum<'0' \| '1' \| 'all'>` | ✅ | Acknowledgment level | +| **compressionType** | `Enum<'none' \| 'gzip' \| 'snappy' \| 'lz4' \| 'zstd'>` | ✅ | Compression type | +| **batchSize** | `number` | ✅ | Batch size in bytes | +| **lingerMs** | `number` | ✅ | Linger time in ms | +| **maxInFlightRequests** | `number` | ✅ | Max in-flight requests | +| **idempotence** | `boolean` | ✅ | Enable idempotent producer | +| **transactional** | `boolean` | ✅ | Enable transactional producer | +| **transactionTimeoutMs** | `number` | optional | Transaction timeout in ms | + + +--- + +## TopicQueue + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Topic/queue identifier in ObjectStack (snake_case) | +| **label** | `string` | ✅ | Display label | +| **topicName** | `string` | ✅ | Actual topic/queue name in message queue system | +| **enabled** | `boolean` | ✅ | Enable sync for this topic/queue | +| **mode** | `Enum<'consumer' \| 'producer' \| 'both'>` | ✅ | Consumer, producer, or both | +| **messageFormat** | `Enum<'json' \| 'xml' \| 'protobuf' \| 'avro' \| 'text' \| 'binary'>` | ✅ | Message format/serialization | +| **partitions** | `number` | optional | Number of partitions (for Kafka) | +| **replicationFactor** | `number` | optional | Replication factor (for Kafka) | +| **consumerConfig** | `{ enabled: boolean; consumerGroup?: string; concurrency: number; prefetchCount: number; … }` | optional | Consumer-specific configuration | +| **producerConfig** | `{ enabled: boolean; acks: Enum<'0' \| '1' \| 'all'>; compressionType: Enum<'none' \| 'gzip' \| 'snappy' \| 'lz4' \| 'zstd'>; batchSize: number; … }` | optional | Producer-specific configuration | +| **dlqConfig** | `{ enabled: boolean; queueName: string; maxRetries: number; retryDelayMs: number }` | optional | Dead letter queue configuration | +| **routingKey** | `string` | optional | Routing key pattern | +| **messageFilter** | `{ headers?: Record; attributes?: Record }` | optional | Message filter criteria | + + +--- + diff --git a/content/docs/references/integration/connector-saas.mdx b/content/docs/references/integration/connector-saas.mdx new file mode 100644 index 0000000000..2cf29adfae --- /dev/null +++ b/content/docs/references/integration/connector-saas.mdx @@ -0,0 +1,125 @@ +--- +title: Connector Saas +description: Connector Saas protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +SaaS Connector Protocol Template + +Specialized connector for SaaS applications (Salesforce, HubSpot, Stripe, etc.) + +Extends the base connector with SaaS-specific features like OAuth flows, + +object type discovery, and API version management. + + +**Source:** `packages/spec/src/integration/connector/saas.zod.ts` + + +## TypeScript Usage + +```typescript +import { ApiVersionConfig, SaasConnector, SaasObjectType, SaasProvider } from '@objectstack/spec/integration'; +import type { ApiVersionConfig, SaasConnector, SaasObjectType, SaasProvider } from '@objectstack/spec/integration'; + +// Validate data +const result = ApiVersionConfig.parse(data); +``` + +--- + +## ApiVersionConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **version** | `string` | ✅ | API version (e.g., "v2", "2023-10-01") | +| **isDefault** | `boolean` | ✅ | Is this the default version | +| **deprecationDate** | `string` | optional | API version deprecation date (ISO 8601) | +| **sunsetDate** | `string` | optional | API version sunset date (ISO 8601) | + + +--- + +## SaasConnector + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Unique connector identifier | +| **label** | `string` | ✅ | Display label | +| **type** | `'saas'` | ✅ | | +| **description** | `string` | optional | Connector description | +| **icon** | `string` | optional | Icon identifier | +| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | +| **provider** | `Enum<'salesforce' \| 'hubspot' \| 'stripe' \| 'shopify' \| 'zendesk' \| 'intercom' \| 'mailchimp' \| 'slack' \| 'microsoft_dynamics' \| 'servicenow' \| 'netsuite' \| 'custom'>` | ✅ | SaaS provider type | +| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | +| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | +| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | +| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | +| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | +| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | +| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | +| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | +| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | +| **requestTimeoutMs** | `number` | optional | Request timeout in ms | +| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | +| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | +| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | +| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | +| **metadata** | `Record` | optional | Custom connector metadata | +| **baseUrl** | `string` | ✅ | API base URL | +| **apiVersion** | `{ version: string; isDefault?: boolean; deprecationDate?: string; sunsetDate?: string }` | optional | API version configuration | +| **objectTypes** | `{ name: string; label: string; apiName: string; enabled?: boolean; … }[]` | ✅ | Syncable object types | +| **oauthSettings** | `{ scopes: string[]; refreshTokenUrl?: string; revokeTokenUrl?: string; autoRefresh?: boolean }` | optional | OAuth-specific configuration | +| **paginationConfig** | `{ type?: Enum<'cursor' \| 'offset' \| 'page'>; defaultPageSize?: number; maxPageSize?: number }` | optional | Pagination configuration | +| **sandboxConfig** | `{ enabled?: boolean; baseUrl?: string }` | optional | Sandbox environment configuration | +| **customHeaders** | `Record` | optional | Custom HTTP headers for all requests | + + +--- + +## SaasObjectType + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Object type name (snake_case) | +| **label** | `string` | ✅ | Display label | +| **apiName** | `string` | ✅ | API name in external system | +| **enabled** | `boolean` | optional | Enable sync for this object | +| **supportsCreate** | `boolean` | optional | Supports record creation | +| **supportsUpdate** | `boolean` | optional | Supports record updates | +| **supportsDelete** | `boolean` | optional | Supports record deletion | +| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Object-specific field mappings | + + +--- + +## SaasProvider + +SaaS provider type + +### Allowed Values + +* `salesforce` +* `hubspot` +* `stripe` +* `shopify` +* `zendesk` +* `intercom` +* `mailchimp` +* `slack` +* `microsoft_dynamics` +* `servicenow` +* `netsuite` +* `custom` + + +--- + diff --git a/content/docs/references/integration/connector-vercel.mdx b/content/docs/references/integration/connector-vercel.mdx new file mode 100644 index 0000000000..43e1b0fd4e --- /dev/null +++ b/content/docs/references/integration/connector-vercel.mdx @@ -0,0 +1,300 @@ +--- +title: Connector Vercel +description: Connector Vercel protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Vercel Connector Protocol + +Specialized connector for Vercel deployment platform enabling automated + +deployments, preview environments, and production releases. + +Use Cases: + +- Automated deployments from Git + +- Preview deployments for pull requests + +- Production releases + +- Environment variable management + +- Domain and SSL configuration + +- Edge function deployment + +@example + +```typescript + +import \{ VercelConnector \} from '@objectstack/spec/integration'; + +const vercelConnector: VercelConnector = \{ + +name: 'vercel_production', + +label: 'Vercel Production', + +type: 'saas', + +provider: 'vercel', + +baseUrl: 'https://api.vercel.com', + +authentication: \{ + +type: 'bearer', + +token: '$\{VERCEL_TOKEN\}', + +\}, + +projects: [ + +\{ + +name: 'objectstack-app', + +framework: 'nextjs', + +gitRepository: \{ + +type: 'github', + +repo: 'objectstack-ai/app', + +\}, + +\}, + +], + +\}; + +``` + + +**Source:** `packages/spec/src/integration/connector/vercel.zod.ts` + + +## TypeScript Usage + +```typescript +import { BuildConfig, DeploymentConfig, DomainConfig, EdgeFunctionConfig, EnvironmentVariables, GitRepositoryConfig, VercelConnector, VercelFramework, VercelMonitoring, VercelProject, VercelProvider, VercelTeam } from '@objectstack/spec/integration'; +import type { BuildConfig, DeploymentConfig, DomainConfig, EdgeFunctionConfig, EnvironmentVariables, GitRepositoryConfig, VercelConnector, VercelFramework, VercelMonitoring, VercelProject, VercelProvider, VercelTeam } from '@objectstack/spec/integration'; + +// Validate data +const result = BuildConfig.parse(data); +``` + +--- + +## BuildConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **buildCommand** | `string` | optional | Build command (e.g., npm run build) | +| **outputDirectory** | `string` | optional | Output directory (e.g., .next, dist) | +| **installCommand** | `string` | optional | Install command (e.g., npm install, pnpm install) | +| **devCommand** | `string` | optional | Development command (e.g., npm run dev) | +| **nodeVersion** | `string` | optional | Node.js version (e.g., 18.x, 20.x) | +| **env** | `Record` | optional | Build environment variables | + + +--- + +## DeploymentConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **autoDeployment** | `boolean` | ✅ | Enable automatic deployments | +| **regions** | `Enum<'iad1' \| 'sfo1' \| 'gru1' \| 'lhr1' \| 'fra1' \| 'sin1' \| 'syd1' \| 'hnd1' \| 'icn1'>[]` | optional | Deployment regions | +| **enablePreview** | `boolean` | ✅ | Enable preview deployments | +| **previewComments** | `boolean` | ✅ | Post preview URLs in PR comments | +| **productionProtection** | `boolean` | ✅ | Require approval for production deployments | +| **deployHooks** | `{ name: string; url: string; branch?: string }[]` | optional | Deploy hooks | + + +--- + +## DomainConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **domain** | `string` | ✅ | Domain name (e.g., app.example.com) | +| **httpsRedirect** | `boolean` | ✅ | Redirect HTTP to HTTPS | +| **customCertificate** | `{ cert: string; key: string; ca?: string }` | optional | Custom SSL certificate | +| **gitBranch** | `string` | optional | Git branch to deploy to this domain | + + +--- + +## EdgeFunctionConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Edge function name | +| **path** | `string` | ✅ | Function path (e.g., /api/*) | +| **regions** | `string[]` | optional | Specific regions for this function | +| **memoryLimit** | `integer` | ✅ | Memory limit in MB | +| **timeout** | `integer` | ✅ | Timeout in seconds | + + +--- + +## EnvironmentVariables + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **key** | `string` | ✅ | Environment variable name | +| **value** | `string` | ✅ | Environment variable value | +| **target** | `Enum<'production' \| 'preview' \| 'development'>[]` | ✅ | Target environments | +| **isSecret** | `boolean` | ✅ | Encrypt this variable | +| **gitBranch** | `string` | optional | Specific git branch | + + +--- + +## GitRepositoryConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `Enum<'github' \| 'gitlab' \| 'bitbucket'>` | ✅ | Git provider | +| **repo** | `string` | ✅ | Repository identifier (e.g., owner/repo) | +| **productionBranch** | `string` | ✅ | Production branch name | +| **autoDeployProduction** | `boolean` | ✅ | Auto-deploy production branch | +| **autoDeployPreview** | `boolean` | ✅ | Auto-deploy preview branches | + + +--- + +## VercelConnector + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Unique connector identifier | +| **label** | `string` | ✅ | Display label | +| **type** | `'saas'` | ✅ | | +| **description** | `string` | optional | Connector description | +| **icon** | `string` | optional | Icon identifier | +| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | +| **provider** | `Enum<'vercel'>` | ✅ | Vercel provider | +| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | +| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | +| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | +| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | +| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | +| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | +| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | +| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | +| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | +| **requestTimeoutMs** | `number` | optional | Request timeout in ms | +| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | +| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | +| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | +| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | +| **metadata** | `Record` | optional | Custom connector metadata | +| **baseUrl** | `string` | optional | Vercel API base URL | +| **team** | `{ teamId?: string; teamName?: string }` | optional | Vercel team configuration | +| **projects** | `{ name: string; framework?: Enum<'nextjs' \| 'react' \| 'vue' \| 'nuxtjs' \| 'gatsby' \| 'remix' \| 'astro' \| 'sveltekit' \| 'solid' \| 'angular' \| 'static' \| 'other'>; gitRepository?: object; buildConfig?: object; … }[]` | ✅ | Vercel projects | +| **monitoring** | `{ enableWebAnalytics?: boolean; enableSpeedInsights?: boolean; logDrains?: { name: string; url: string; headers?: Record; sources?: Enum<'static' \| 'lambda' \| 'edge'>[] }[] }` | optional | Monitoring configuration | +| **enableWebhooks** | `boolean` | optional | Enable Vercel webhooks | +| **webhookEvents** | `Enum<'deployment.created' \| 'deployment.succeeded' \| 'deployment.failed' \| 'deployment.ready' \| 'deployment.error' \| 'deployment.canceled' \| 'deployment-checks-completed' \| 'deployment-prepared' \| 'project.created' \| 'project.removed'>[]` | optional | Webhook events to subscribe to | + + +--- + +## VercelFramework + +Frontend framework + +### Allowed Values + +* `nextjs` +* `react` +* `vue` +* `nuxtjs` +* `gatsby` +* `remix` +* `astro` +* `sveltekit` +* `solid` +* `angular` +* `static` +* `other` + + +--- + +## VercelMonitoring + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enableWebAnalytics** | `boolean` | ✅ | Enable Vercel Web Analytics | +| **enableSpeedInsights** | `boolean` | ✅ | Enable Vercel Speed Insights | +| **logDrains** | `{ name: string; url: string; headers?: Record; sources?: Enum<'static' \| 'lambda' \| 'edge'>[] }[]` | optional | Log drains configuration | + + +--- + +## VercelProject + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Vercel project name | +| **framework** | `Enum<'nextjs' \| 'react' \| 'vue' \| 'nuxtjs' \| 'gatsby' \| 'remix' \| 'astro' \| 'sveltekit' \| 'solid' \| 'angular' \| 'static' \| 'other'>` | optional | Frontend framework | +| **gitRepository** | `{ type: Enum<'github' \| 'gitlab' \| 'bitbucket'>; repo: string; productionBranch: string; autoDeployProduction: boolean; … }` | optional | Git repository configuration | +| **buildConfig** | `{ buildCommand?: string; outputDirectory?: string; installCommand?: string; devCommand?: string; … }` | optional | Build configuration | +| **deploymentConfig** | `{ autoDeployment: boolean; regions?: Enum<'iad1' \| 'sfo1' \| 'gru1' \| 'lhr1' \| 'fra1' \| 'sin1' \| 'syd1' \| 'hnd1' \| 'icn1'>[]; enablePreview: boolean; previewComments: boolean; … }` | optional | Deployment configuration | +| **domains** | `{ domain: string; httpsRedirect: boolean; customCertificate?: object; gitBranch?: string }[]` | optional | Custom domains | +| **environmentVariables** | `{ key: string; value: string; target: Enum<'production' \| 'preview' \| 'development'>[]; isSecret: boolean; … }[]` | optional | Environment variables | +| **edgeFunctions** | `{ name: string; path: string; regions?: string[]; memoryLimit: integer; … }[]` | optional | Edge functions | +| **rootDirectory** | `string` | optional | Root directory (for monorepos) | + + +--- + +## VercelProvider + +Vercel provider type + +### Allowed Values + +* `vercel` + + +--- + +## VercelTeam + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **teamId** | `string` | optional | Team ID or slug | +| **teamName** | `string` | optional | Team name | + + +--- + diff --git a/content/docs/references/integration/http.mdx b/content/docs/references/integration/http.mdx index 17663c9578..3ad0fbc34a 100644 --- a/content/docs/references/integration/http.mdx +++ b/content/docs/references/integration/http.mdx @@ -5,10 +5,6 @@ description: Http protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/integration/http.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/integration/index.mdx b/content/docs/references/integration/index.mdx index f4cbea5ec3..2ac0bbf02b 100644 --- a/content/docs/references/integration/index.mdx +++ b/content/docs/references/integration/index.mdx @@ -7,4 +7,10 @@ This section contains all protocol schemas for the integration layer of ObjectSt + + + + + + diff --git a/content/docs/references/integration/mapping.mdx b/content/docs/references/integration/mapping.mdx index b82d0245a4..6ec881b433 100644 --- a/content/docs/references/integration/mapping.mdx +++ b/content/docs/references/integration/mapping.mdx @@ -5,10 +5,6 @@ description: Mapping protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/integration/mapping.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/integration/message-queue.mdx b/content/docs/references/integration/message-queue.mdx index 4e9df76ec0..8f8aae5f92 100644 --- a/content/docs/references/integration/message-queue.mdx +++ b/content/docs/references/integration/message-queue.mdx @@ -5,10 +5,6 @@ description: Message Queue protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/integration/message-queue.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/integration/meta.json b/content/docs/references/integration/meta.json index d672bdfa95..ab617bca90 100644 --- a/content/docs/references/integration/meta.json +++ b/content/docs/references/integration/meta.json @@ -11,7 +11,13 @@ "object-storage", "offline", "---Tenancy---", - "misc", - "tenant" + "tenant", + "---More---", + "connector-database", + "connector-file-storage", + "connector-github", + "connector-message-queue", + "connector-saas", + "connector-vercel" ] } \ No newline at end of file diff --git a/content/docs/references/integration/misc.mdx b/content/docs/references/integration/misc.mdx deleted file mode 100644 index cdc46335fc..0000000000 --- a/content/docs/references/integration/misc.mdx +++ /dev/null @@ -1,860 +0,0 @@ ---- -title: Misc -description: Misc protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - - -**Source:** `packages/spec/src/integration/misc.zod.ts` - - -## TypeScript Usage - -```typescript -import { AckMode, ApiVersionConfig, BuildConfig, CdcConfig, DatabaseConnector, DatabasePoolConfig, DatabaseTable, DeliveryGuarantee, DeploymentConfig, DlqConfig, DomainConfig, EdgeFunctionConfig, EnvironmentVariables, FileAccessPattern, FileFilterConfig, FileMetadataConfig, FileStorageConnector, FileStorageProvider, FileVersioningConfig, GitHubActionsWorkflow, GitHubCommitConfig, GitHubConnector, GitHubIssueTracking, GitHubProvider, GitHubPullRequestConfig, GitHubReleaseConfig, GitHubRepository, GitRepositoryConfig, MessageFormat, MessageQueueConnector, ProducerConfig, SaasConnector, SaasObjectType, SaasProvider, SslConfig, StorageBucket, TopicQueue, VercelConnector, VercelFramework, VercelMonitoring, VercelProject, VercelProvider, VercelTeam } from '@objectstack/spec/integration'; -import type { AckMode, ApiVersionConfig, BuildConfig, CdcConfig, DatabaseConnector, DatabasePoolConfig, DatabaseTable, DeliveryGuarantee, DeploymentConfig, DlqConfig, DomainConfig, EdgeFunctionConfig, EnvironmentVariables, FileAccessPattern, FileFilterConfig, FileMetadataConfig, FileStorageConnector, FileStorageProvider, FileVersioningConfig, GitHubActionsWorkflow, GitHubCommitConfig, GitHubConnector, GitHubIssueTracking, GitHubProvider, GitHubPullRequestConfig, GitHubReleaseConfig, GitHubRepository, GitRepositoryConfig, MessageFormat, MessageQueueConnector, ProducerConfig, SaasConnector, SaasObjectType, SaasProvider, SslConfig, StorageBucket, TopicQueue, VercelConnector, VercelFramework, VercelMonitoring, VercelProject, VercelProvider, VercelTeam } from '@objectstack/spec/integration'; - -// Validate data -const result = AckMode.parse(data); -``` - ---- - -## AckMode - -Message acknowledgment mode - -### Allowed Values - -* `auto` -* `manual` -* `client` - - ---- - -## ApiVersionConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **version** | `string` | ✅ | API version (e.g., "v2", "2023-10-01") | -| **isDefault** | `boolean` | ✅ | Is this the default version | -| **deprecationDate** | `string` | optional | API version deprecation date (ISO 8601) | -| **sunsetDate** | `string` | optional | API version sunset date (ISO 8601) | - - ---- - -## BuildConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **buildCommand** | `string` | optional | Build command (e.g., npm run build) | -| **outputDirectory** | `string` | optional | Output directory (e.g., .next, dist) | -| **installCommand** | `string` | optional | Install command (e.g., npm install, pnpm install) | -| **devCommand** | `string` | optional | Development command (e.g., npm run dev) | -| **nodeVersion** | `string` | optional | Node.js version (e.g., 18.x, 20.x) | -| **env** | `Record` | optional | Build environment variables | - - ---- - -## CdcConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable CDC | -| **method** | `Enum<'log_based' \| 'trigger_based' \| 'query_based' \| 'custom'>` | ✅ | CDC method | -| **slotName** | `string` | optional | Replication slot name (for log-based CDC) | -| **publicationName** | `string` | optional | Publication name (for PostgreSQL) | -| **startPosition** | `string` | optional | Starting position/LSN for CDC stream | -| **batchSize** | `number` | ✅ | CDC batch size | -| **pollIntervalMs** | `number` | ✅ | CDC polling interval in ms | - - ---- - -## DatabaseConnector - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique connector identifier | -| **label** | `string` | ✅ | Display label | -| **type** | `'database'` | ✅ | | -| **description** | `string` | optional | Connector description | -| **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | -| **provider** | `Enum<'postgresql' \| 'mysql' \| 'mariadb' \| 'mssql' \| 'oracle' \| 'mongodb' \| 'redis' \| 'cassandra' \| 'snowflake' \| 'bigquery' \| 'redshift' \| 'custom'>` | ✅ | Database provider type | -| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | -| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | -| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | -| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | -| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | -| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | -| **requestTimeoutMs** | `number` | optional | Request timeout in ms | -| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | -| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | -| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | -| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | -| **metadata** | `Record` | optional | Custom connector metadata | -| **connectionConfig** | `{ host: string; port: number; database: string; username: string; … }` | ✅ | Database connection configuration | -| **poolConfig** | `{ min?: number; max?: number; idleTimeoutMs?: number; connectionTimeoutMs?: number; … }` | optional | Connection pool configuration | -| **sslConfig** | `{ enabled?: boolean; rejectUnauthorized?: boolean; ca?: string; cert?: string; … }` | optional | SSL/TLS configuration | -| **tables** | `{ name: string; label: string; schema?: string; tableName: string; … }[]` | ✅ | Tables to sync | -| **cdcConfig** | `{ enabled?: boolean; method: Enum<'log_based' \| 'trigger_based' \| 'query_based' \| 'custom'>; slotName?: string; publicationName?: string; … }` | optional | CDC configuration | -| **readReplicaConfig** | `{ enabled?: boolean; hosts: { host: string; port: number; weight?: number }[] }` | optional | Read replica configuration | -| **queryTimeoutMs** | `number` | optional | Query timeout in ms | -| **enableQueryLogging** | `boolean` | optional | Enable SQL query logging | - - ---- - -## DatabasePoolConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **min** | `number` | ✅ | Minimum connections in pool | -| **max** | `number` | ✅ | Maximum connections in pool | -| **idleTimeoutMs** | `number` | ✅ | Idle connection timeout in ms | -| **connectionTimeoutMs** | `number` | ✅ | Connection establishment timeout in ms | -| **acquireTimeoutMs** | `number` | ✅ | Connection acquisition timeout in ms | -| **evictionRunIntervalMs** | `number` | ✅ | Connection eviction check interval in ms | -| **testOnBorrow** | `boolean` | ✅ | Test connection before use | - - ---- - -## DatabaseTable - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Table name in ObjectStack (snake_case) | -| **label** | `string` | ✅ | Display label | -| **schema** | `string` | optional | Database schema name | -| **tableName** | `string` | ✅ | Actual table name in database | -| **primaryKey** | `string` | ✅ | Primary key column | -| **enabled** | `boolean` | optional | Enable sync for this table | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Table-specific field mappings | -| **whereClause** | `string` | optional | SQL WHERE clause for filtering | - - ---- - -## DeliveryGuarantee - -Message delivery guarantee - -### Allowed Values - -* `at_most_once` -* `at_least_once` -* `exactly_once` - - ---- - -## DeploymentConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **autoDeployment** | `boolean` | ✅ | Enable automatic deployments | -| **regions** | `Enum<'iad1' \| 'sfo1' \| 'gru1' \| 'lhr1' \| 'fra1' \| 'sin1' \| 'syd1' \| 'hnd1' \| 'icn1'>[]` | optional | Deployment regions | -| **enablePreview** | `boolean` | ✅ | Enable preview deployments | -| **previewComments** | `boolean` | ✅ | Post preview URLs in PR comments | -| **productionProtection** | `boolean` | ✅ | Require approval for production deployments | -| **deployHooks** | `{ name: string; url: string; branch?: string }[]` | optional | Deploy hooks | - - ---- - -## DlqConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable DLQ | -| **queueName** | `string` | ✅ | Dead letter queue/topic name | -| **maxRetries** | `number` | ✅ | Max retries before DLQ | -| **retryDelayMs** | `number` | ✅ | Retry delay in ms | - - ---- - -## DomainConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **domain** | `string` | ✅ | Domain name (e.g., app.example.com) | -| **httpsRedirect** | `boolean` | ✅ | Redirect HTTP to HTTPS | -| **customCertificate** | `{ cert: string; key: string; ca?: string }` | optional | Custom SSL certificate | -| **gitBranch** | `string` | optional | Git branch to deploy to this domain | - - ---- - -## EdgeFunctionConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Edge function name | -| **path** | `string` | ✅ | Function path (e.g., /api/*) | -| **regions** | `string[]` | optional | Specific regions for this function | -| **memoryLimit** | `integer` | ✅ | Memory limit in MB | -| **timeout** | `integer` | ✅ | Timeout in seconds | - - ---- - -## EnvironmentVariables - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **key** | `string` | ✅ | Environment variable name | -| **value** | `string` | ✅ | Environment variable value | -| **target** | `Enum<'production' \| 'preview' \| 'development'>[]` | ✅ | Target environments | -| **isSecret** | `boolean` | ✅ | Encrypt this variable | -| **gitBranch** | `string` | optional | Specific git branch | - - ---- - -## FileAccessPattern - -File access pattern - -### Allowed Values - -* `public_read` -* `private` -* `authenticated_read` -* `bucket_owner_read` -* `bucket_owner_full` - - ---- - -## FileFilterConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **includePatterns** | `string[]` | optional | File patterns to include (glob) | -| **excludePatterns** | `string[]` | optional | File patterns to exclude (glob) | -| **minFileSize** | `number` | optional | Minimum file size in bytes | -| **maxFileSize** | `number` | optional | Maximum file size in bytes | -| **allowedExtensions** | `string[]` | optional | Allowed file extensions | -| **blockedExtensions** | `string[]` | optional | Blocked file extensions | - - ---- - -## FileMetadataConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **extractMetadata** | `boolean` | ✅ | Extract file metadata | -| **metadataFields** | `Enum<'content_type' \| 'file_size' \| 'last_modified' \| 'etag' \| 'checksum' \| 'creator' \| 'created_at' \| 'custom'>[]` | optional | Metadata fields to extract | -| **customMetadata** | `Record` | optional | Custom metadata key-value pairs | - - ---- - -## FileStorageConnector - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique connector identifier | -| **label** | `string` | ✅ | Display label | -| **type** | `'file_storage'` | ✅ | | -| **description** | `string` | optional | Connector description | -| **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | -| **provider** | `Enum<'s3' \| 'azure_blob' \| 'gcs' \| 'dropbox' \| 'box' \| 'onedrive' \| 'google_drive' \| 'sharepoint' \| 'ftp' \| 'local' \| 'custom'>` | ✅ | File storage provider type | -| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | -| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | -| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | -| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | -| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | -| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | -| **requestTimeoutMs** | `number` | optional | Request timeout in ms | -| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | -| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | -| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | -| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | -| **metadata** | `Record` | optional | Custom connector metadata | -| **storageConfig** | `{ endpoint?: string; region?: string; pathStyle?: boolean }` | optional | Storage configuration | -| **buckets** | `{ name: string; label: string; bucketName: string; region?: string; … }[]` | ✅ | Buckets/containers to sync | -| **metadataConfig** | `{ extractMetadata?: boolean; metadataFields?: Enum<'content_type' \| 'file_size' \| 'last_modified' \| 'etag' \| 'checksum' \| 'creator' \| 'created_at' \| 'custom'>[]; customMetadata?: Record }` | optional | Metadata extraction configuration | -| **multipartConfig** | `{ enabled?: boolean; partSize?: number; maxConcurrentParts?: number; threshold?: number }` | optional | Multipart upload configuration | -| **versioningConfig** | `{ enabled?: boolean; maxVersions?: number; retentionDays?: number }` | optional | File versioning configuration | -| **encryption** | `{ enabled?: boolean; algorithm?: Enum<'AES256' \| 'aws:kms' \| 'custom'>; kmsKeyId?: string }` | optional | Encryption configuration | -| **lifecyclePolicy** | `{ enabled?: boolean; deleteAfterDays?: number; archiveAfterDays?: number }` | optional | Lifecycle policy | -| **contentProcessing** | `{ extractText?: boolean; generateThumbnails?: boolean; thumbnailSizes?: { width: number; height: number }[]; virusScan?: boolean }` | optional | Content processing configuration | -| **bufferSize** | `number` | optional | Buffer size in bytes | -| **transferAcceleration** | `boolean` | optional | Enable transfer acceleration | - - ---- - -## FileStorageProvider - -File storage provider type - -### Allowed Values - -* `s3` -* `azure_blob` -* `gcs` -* `dropbox` -* `box` -* `onedrive` -* `google_drive` -* `sharepoint` -* `ftp` -* `local` -* `custom` - - ---- - -## FileVersioningConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable file versioning | -| **maxVersions** | `number` | optional | Maximum versions to retain | -| **retentionDays** | `number` | optional | Version retention period in days | - - ---- - -## GitHubActionsWorkflow - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Workflow name | -| **path** | `string` | ✅ | Workflow file path (e.g., .github/workflows/ci.yml) | -| **enabled** | `boolean` | ✅ | Enable workflow | -| **triggers** | `Enum<'push' \| 'pull_request' \| 'release' \| 'schedule' \| 'workflow_dispatch' \| 'repository_dispatch'>[]` | optional | Workflow triggers | -| **env** | `Record` | optional | Environment variables | -| **secrets** | `string[]` | optional | Required secrets | - - ---- - -## GitHubCommitConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **authorName** | `string` | optional | Commit author name | -| **authorEmail** | `string` | optional | Commit author email | -| **signCommits** | `boolean` | ✅ | Sign commits with GPG | -| **messageTemplate** | `string` | optional | Commit message template | -| **useConventionalCommits** | `boolean` | ✅ | Use conventional commits format | - - ---- - -## GitHubConnector - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique connector identifier | -| **label** | `string` | ✅ | Display label | -| **type** | `'saas'` | ✅ | | -| **description** | `string` | optional | Connector description | -| **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | -| **provider** | `Enum<'github' \| 'github_enterprise'>` | ✅ | GitHub provider | -| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | -| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | -| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | -| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | -| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | -| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | -| **requestTimeoutMs** | `number` | optional | Request timeout in ms | -| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | -| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | -| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | -| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | -| **metadata** | `Record` | optional | Custom connector metadata | -| **baseUrl** | `string` | optional | GitHub API base URL | -| **repositories** | `{ owner: string; name: string; defaultBranch?: string; autoMerge?: boolean; … }[]` | ✅ | Repositories to manage | -| **commitConfig** | `{ authorName?: string; authorEmail?: string; signCommits?: boolean; messageTemplate?: string; … }` | optional | Commit configuration | -| **pullRequestConfig** | `{ titleTemplate?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; bodyTemplate?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; defaultReviewers?: string[]; defaultAssignees?: string[]; … }` | optional | Pull request configuration | -| **workflows** | `{ name: string; path: string; enabled?: boolean; triggers?: Enum<'push' \| 'pull_request' \| 'release' \| 'schedule' \| 'workflow_dispatch' \| 'repository_dispatch'>[]; … }[]` | optional | GitHub Actions workflows | -| **releaseConfig** | `{ tagPattern?: string; semanticVersioning?: boolean; autoReleaseNotes?: boolean; releaseNameTemplate?: string; … }` | optional | Release configuration | -| **issueTracking** | `{ enabled?: boolean; defaultLabels?: string[]; templatePaths?: string[]; autoAssign?: boolean; … }` | optional | Issue tracking configuration | -| **enableWebhooks** | `boolean` | optional | Enable GitHub webhooks | -| **webhookEvents** | `Enum<'push' \| 'pull_request' \| 'issues' \| 'issue_comment' \| 'release' \| 'workflow_run' \| 'deployment' \| 'deployment_status' \| 'check_run' \| 'check_suite' \| 'status'>[]` | optional | Webhook events to subscribe to | - - ---- - -## GitHubIssueTracking - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable issue tracking | -| **defaultLabels** | `string[]` | optional | Default issue labels | -| **templatePaths** | `string[]` | optional | Issue template paths | -| **autoAssign** | `boolean` | ✅ | Auto-assign issues | -| **autoCloseStale** | `{ enabled: boolean; daysBeforeStale: integer; daysBeforeClose: integer; staleLabel: string }` | optional | Auto-close stale issues configuration | - - ---- - -## GitHubProvider - -GitHub provider type - -### Allowed Values - -* `github` -* `github_enterprise` - - ---- - -## GitHubPullRequestConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **titleTemplate** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | PR title template — supports `{{var}`} interpolation | -| **bodyTemplate** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | PR body template — supports `{{var}`} interpolation | -| **defaultReviewers** | `string[]` | optional | Default reviewers (usernames) | -| **defaultAssignees** | `string[]` | optional | Default assignees (usernames) | -| **defaultLabels** | `string[]` | optional | Default labels | -| **draftByDefault** | `boolean` | optional | Create draft PRs by default | -| **deleteHeadBranch** | `boolean` | optional | Delete head branch after merge | - - ---- - -## GitHubReleaseConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **tagPattern** | `string` | ✅ | Tag name pattern (e.g., v*, release/*) | -| **semanticVersioning** | `boolean` | ✅ | Use semantic versioning | -| **autoReleaseNotes** | `boolean` | ✅ | Generate release notes automatically | -| **releaseNameTemplate** | `string` | optional | Release name template | -| **preReleasePattern** | `string` | optional | Pre-release pattern (e.g., *-alpha, *-beta) | -| **draftByDefault** | `boolean` | ✅ | Create draft releases by default | - - ---- - -## GitHubRepository - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **owner** | `string` | ✅ | Repository owner (organization or username) | -| **name** | `string` | ✅ | Repository name | -| **defaultBranch** | `string` | ✅ | Default branch name | -| **autoMerge** | `boolean` | ✅ | Enable auto-merge for pull requests | -| **branchProtection** | `{ requiredReviewers: integer; requireStatusChecks: boolean; enforceAdmins: boolean; allowForcePushes: boolean; … }` | optional | Branch protection configuration | -| **topics** | `string[]` | optional | Repository topics | - - ---- - -## GitRepositoryConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'github' \| 'gitlab' \| 'bitbucket'>` | ✅ | Git provider | -| **repo** | `string` | ✅ | Repository identifier (e.g., owner/repo) | -| **productionBranch** | `string` | ✅ | Production branch name | -| **autoDeployProduction** | `boolean` | ✅ | Auto-deploy production branch | -| **autoDeployPreview** | `boolean` | ✅ | Auto-deploy preview branches | - - ---- - -## MessageFormat - -Message format/serialization - -### Allowed Values - -* `json` -* `xml` -* `protobuf` -* `avro` -* `text` -* `binary` - - ---- - -## MessageQueueConnector - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique connector identifier | -| **label** | `string` | ✅ | Display label | -| **type** | `'message_queue'` | ✅ | | -| **description** | `string` | optional | Connector description | -| **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | -| **provider** | `Enum<'rabbitmq' \| 'kafka' \| 'redis_pubsub' \| 'redis_streams' \| 'aws_sqs' \| 'aws_sns' \| 'google_pubsub' \| 'azure_service_bus' \| 'azure_event_hubs' \| 'nats' \| 'pulsar' \| 'activemq' \| 'custom'>` | ✅ | Message queue provider type | -| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | -| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | -| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | -| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | -| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | -| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | -| **requestTimeoutMs** | `number` | optional | Request timeout in ms | -| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | -| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | -| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | -| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | -| **metadata** | `Record` | optional | Custom connector metadata | -| **brokerConfig** | `{ brokers: string[]; clientId?: string; connectionTimeoutMs?: number; requestTimeoutMs?: number }` | ✅ | Broker connection configuration | -| **topics** | `{ name: string; label: string; topicName: string; enabled?: boolean; … }[]` | ✅ | Topics/queues to sync | -| **deliveryGuarantee** | `Enum<'at_most_once' \| 'at_least_once' \| 'exactly_once'>` | optional | Message delivery guarantee | -| **sslConfig** | `{ enabled?: boolean; rejectUnauthorized?: boolean; ca?: string; cert?: string; … }` | optional | SSL/TLS configuration | -| **saslConfig** | `{ mechanism: Enum<'plain' \| 'scram-sha-256' \| 'scram-sha-512' \| 'aws'>; username?: string; password?: string }` | optional | SASL authentication configuration | -| **schemaRegistry** | `{ url: string; auth?: object }` | optional | Schema registry configuration | -| **preserveOrder** | `boolean` | optional | Preserve message ordering | -| **enableMetrics** | `boolean` | optional | Enable message queue metrics | -| **enableTracing** | `boolean` | optional | Enable distributed tracing | - - ---- - -## ProducerConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable producer | -| **acks** | `Enum<'0' \| '1' \| 'all'>` | ✅ | Acknowledgment level | -| **compressionType** | `Enum<'none' \| 'gzip' \| 'snappy' \| 'lz4' \| 'zstd'>` | ✅ | Compression type | -| **batchSize** | `number` | ✅ | Batch size in bytes | -| **lingerMs** | `number` | ✅ | Linger time in ms | -| **maxInFlightRequests** | `number` | ✅ | Max in-flight requests | -| **idempotence** | `boolean` | ✅ | Enable idempotent producer | -| **transactional** | `boolean` | ✅ | Enable transactional producer | -| **transactionTimeoutMs** | `number` | optional | Transaction timeout in ms | - - ---- - -## SaasConnector - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique connector identifier | -| **label** | `string` | ✅ | Display label | -| **type** | `'saas'` | ✅ | | -| **description** | `string` | optional | Connector description | -| **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | -| **provider** | `Enum<'salesforce' \| 'hubspot' \| 'stripe' \| 'shopify' \| 'zendesk' \| 'intercom' \| 'mailchimp' \| 'slack' \| 'microsoft_dynamics' \| 'servicenow' \| 'netsuite' \| 'custom'>` | ✅ | SaaS provider type | -| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | -| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | -| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | -| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | -| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | -| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | -| **requestTimeoutMs** | `number` | optional | Request timeout in ms | -| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | -| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | -| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | -| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | -| **metadata** | `Record` | optional | Custom connector metadata | -| **baseUrl** | `string` | ✅ | API base URL | -| **apiVersion** | `{ version: string; isDefault?: boolean; deprecationDate?: string; sunsetDate?: string }` | optional | API version configuration | -| **objectTypes** | `{ name: string; label: string; apiName: string; enabled?: boolean; … }[]` | ✅ | Syncable object types | -| **oauthSettings** | `{ scopes: string[]; refreshTokenUrl?: string; revokeTokenUrl?: string; autoRefresh?: boolean }` | optional | OAuth-specific configuration | -| **paginationConfig** | `{ type?: Enum<'cursor' \| 'offset' \| 'page'>; defaultPageSize?: number; maxPageSize?: number }` | optional | Pagination configuration | -| **sandboxConfig** | `{ enabled?: boolean; baseUrl?: string }` | optional | Sandbox environment configuration | -| **customHeaders** | `Record` | optional | Custom HTTP headers for all requests | - - ---- - -## SaasObjectType - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Object type name (snake_case) | -| **label** | `string` | ✅ | Display label | -| **apiName** | `string` | ✅ | API name in external system | -| **enabled** | `boolean` | optional | Enable sync for this object | -| **supportsCreate** | `boolean` | optional | Supports record creation | -| **supportsUpdate** | `boolean` | optional | Supports record updates | -| **supportsDelete** | `boolean` | optional | Supports record deletion | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Object-specific field mappings | - - ---- - -## SaasProvider - -SaaS provider type - -### Allowed Values - -* `salesforce` -* `hubspot` -* `stripe` -* `shopify` -* `zendesk` -* `intercom` -* `mailchimp` -* `slack` -* `microsoft_dynamics` -* `servicenow` -* `netsuite` -* `custom` - - ---- - -## SslConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable SSL/TLS | -| **rejectUnauthorized** | `boolean` | ✅ | Reject unauthorized certificates | -| **ca** | `string` | optional | Certificate Authority certificate | -| **cert** | `string` | optional | Client certificate | -| **key** | `string` | optional | Client private key | - - ---- - -## StorageBucket - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Bucket identifier in ObjectStack (snake_case) | -| **label** | `string` | ✅ | Display label | -| **bucketName** | `string` | ✅ | Actual bucket/container name in storage system | -| **region** | `string` | optional | Storage region | -| **enabled** | `boolean` | ✅ | Enable sync for this bucket | -| **prefix** | `string` | optional | Prefix/path within bucket | -| **accessPattern** | `Enum<'public_read' \| 'private' \| 'authenticated_read' \| 'bucket_owner_read' \| 'bucket_owner_full'>` | optional | Access pattern | -| **fileFilters** | `{ includePatterns?: string[]; excludePatterns?: string[]; minFileSize?: number; maxFileSize?: number; … }` | optional | File filter configuration | - - ---- - -## TopicQueue - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Topic/queue identifier in ObjectStack (snake_case) | -| **label** | `string` | ✅ | Display label | -| **topicName** | `string` | ✅ | Actual topic/queue name in message queue system | -| **enabled** | `boolean` | ✅ | Enable sync for this topic/queue | -| **mode** | `Enum<'consumer' \| 'producer' \| 'both'>` | ✅ | Consumer, producer, or both | -| **messageFormat** | `Enum<'json' \| 'xml' \| 'protobuf' \| 'avro' \| 'text' \| 'binary'>` | ✅ | Message format/serialization | -| **partitions** | `number` | optional | Number of partitions (for Kafka) | -| **replicationFactor** | `number` | optional | Replication factor (for Kafka) | -| **consumerConfig** | `{ enabled: boolean; consumerGroup?: string; concurrency: number; prefetchCount: number; … }` | optional | Consumer-specific configuration | -| **producerConfig** | `{ enabled: boolean; acks: Enum<'0' \| '1' \| 'all'>; compressionType: Enum<'none' \| 'gzip' \| 'snappy' \| 'lz4' \| 'zstd'>; batchSize: number; … }` | optional | Producer-specific configuration | -| **dlqConfig** | `{ enabled: boolean; queueName: string; maxRetries: number; retryDelayMs: number }` | optional | Dead letter queue configuration | -| **routingKey** | `string` | optional | Routing key pattern | -| **messageFilter** | `{ headers?: Record; attributes?: Record }` | optional | Message filter criteria | - - ---- - -## VercelConnector - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique connector identifier | -| **label** | `string` | ✅ | Display label | -| **type** | `'saas'` | ✅ | | -| **description** | `string` | optional | Connector description | -| **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | -| **provider** | `Enum<'vercel'>` | ✅ | Vercel provider | -| **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | -| **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | -| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | -| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | -| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | -| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | -| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | -| **requestTimeoutMs** | `number` | optional | Request timeout in ms | -| **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional | Connector status | -| **enabled** | `boolean` | optional | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | -| **errorMapping** | `{ rules: { sourceCode: string \| number; sourceMessage?: string; targetCode: string; targetCategory: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; … }[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'timeout' \| 'server_error' \| 'integration_error'>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | -| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | -| **metadata** | `Record` | optional | Custom connector metadata | -| **baseUrl** | `string` | optional | Vercel API base URL | -| **team** | `{ teamId?: string; teamName?: string }` | optional | Vercel team configuration | -| **projects** | `{ name: string; framework?: Enum<'nextjs' \| 'react' \| 'vue' \| 'nuxtjs' \| 'gatsby' \| 'remix' \| 'astro' \| 'sveltekit' \| 'solid' \| 'angular' \| 'static' \| 'other'>; gitRepository?: object; buildConfig?: object; … }[]` | ✅ | Vercel projects | -| **monitoring** | `{ enableWebAnalytics?: boolean; enableSpeedInsights?: boolean; logDrains?: { name: string; url: string; headers?: Record; sources?: Enum<'static' \| 'lambda' \| 'edge'>[] }[] }` | optional | Monitoring configuration | -| **enableWebhooks** | `boolean` | optional | Enable Vercel webhooks | -| **webhookEvents** | `Enum<'deployment.created' \| 'deployment.succeeded' \| 'deployment.failed' \| 'deployment.ready' \| 'deployment.error' \| 'deployment.canceled' \| 'deployment-checks-completed' \| 'deployment-prepared' \| 'project.created' \| 'project.removed'>[]` | optional | Webhook events to subscribe to | - - ---- - -## VercelFramework - -Frontend framework - -### Allowed Values - -* `nextjs` -* `react` -* `vue` -* `nuxtjs` -* `gatsby` -* `remix` -* `astro` -* `sveltekit` -* `solid` -* `angular` -* `static` -* `other` - - ---- - -## VercelMonitoring - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enableWebAnalytics** | `boolean` | ✅ | Enable Vercel Web Analytics | -| **enableSpeedInsights** | `boolean` | ✅ | Enable Vercel Speed Insights | -| **logDrains** | `{ name: string; url: string; headers?: Record; sources?: Enum<'static' \| 'lambda' \| 'edge'>[] }[]` | optional | Log drains configuration | - - ---- - -## VercelProject - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Vercel project name | -| **framework** | `Enum<'nextjs' \| 'react' \| 'vue' \| 'nuxtjs' \| 'gatsby' \| 'remix' \| 'astro' \| 'sveltekit' \| 'solid' \| 'angular' \| 'static' \| 'other'>` | optional | Frontend framework | -| **gitRepository** | `{ type: Enum<'github' \| 'gitlab' \| 'bitbucket'>; repo: string; productionBranch: string; autoDeployProduction: boolean; … }` | optional | Git repository configuration | -| **buildConfig** | `{ buildCommand?: string; outputDirectory?: string; installCommand?: string; devCommand?: string; … }` | optional | Build configuration | -| **deploymentConfig** | `{ autoDeployment: boolean; regions?: Enum<'iad1' \| 'sfo1' \| 'gru1' \| 'lhr1' \| 'fra1' \| 'sin1' \| 'syd1' \| 'hnd1' \| 'icn1'>[]; enablePreview: boolean; previewComments: boolean; … }` | optional | Deployment configuration | -| **domains** | `{ domain: string; httpsRedirect: boolean; customCertificate?: object; gitBranch?: string }[]` | optional | Custom domains | -| **environmentVariables** | `{ key: string; value: string; target: Enum<'production' \| 'preview' \| 'development'>[]; isSecret: boolean; … }[]` | optional | Environment variables | -| **edgeFunctions** | `{ name: string; path: string; regions?: string[]; memoryLimit: integer; … }[]` | optional | Edge functions | -| **rootDirectory** | `string` | optional | Root directory (for monorepos) | - - ---- - -## VercelProvider - -Vercel provider type - -### Allowed Values - -* `vercel` - - ---- - -## VercelTeam - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **teamId** | `string` | optional | Team ID or slug | -| **teamName** | `string` | optional | Team name | - - ---- - diff --git a/content/docs/references/integration/object-storage.mdx b/content/docs/references/integration/object-storage.mdx index c2f2e95aca..8c5c882c5e 100644 --- a/content/docs/references/integration/object-storage.mdx +++ b/content/docs/references/integration/object-storage.mdx @@ -5,10 +5,6 @@ description: Object Storage protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/integration/object-storage.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/integration/offline.mdx b/content/docs/references/integration/offline.mdx index 75736d3b07..5ddceaf3ee 100644 --- a/content/docs/references/integration/offline.mdx +++ b/content/docs/references/integration/offline.mdx @@ -5,10 +5,6 @@ description: Offline protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/integration/offline.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/integration/tenant.mdx b/content/docs/references/integration/tenant.mdx index d7a420d1ff..e0624b519d 100644 --- a/content/docs/references/integration/tenant.mdx +++ b/content/docs/references/integration/tenant.mdx @@ -5,10 +5,6 @@ description: Tenant protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/integration/tenant.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/kernel/events-bus.mdx b/content/docs/references/kernel/events-bus.mdx new file mode 100644 index 0000000000..872fd0a2e0 --- /dev/null +++ b/content/docs/references/kernel/events-bus.mdx @@ -0,0 +1,64 @@ +--- +title: Events Bus +description: Events Bus protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Event Bus Configuration Schema + +Complete configuration for the event bus system + +@example + +\{ + +"persistence": \{ "enabled": true, "retention": 365 \}, + +"queue": \{ "concurrency": 20 \}, + +"eventSourcing": \{ "enabled": true \}, + +"webhooks": [], + +"messageQueue": \{ "provider": "kafka", "topic": "events" \}, + +"realtime": \{ "enabled": true, "protocol": "websocket" \} + +\} + + +**Source:** `packages/spec/src/kernel/events/bus.zod.ts` + + +## TypeScript Usage + +```typescript +import { EventBusConfig } from '@objectstack/spec/kernel'; +import type { EventBusConfig } from '@objectstack/spec/kernel'; + +// Validate data +const result = EventBusConfig.parse(data); +``` + +--- + +## EventBusConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **persistence** | `{ enabled: boolean; retention: integer; filter?: any; storage: Enum<'database' \| 'file' \| 's3' \| 'custom'> }` | optional | Event persistence configuration | +| **queue** | `{ name: string; concurrency: integer; retryPolicy?: object; deadLetterQueue?: string; … }` | optional | Event queue configuration | +| **eventSourcing** | `{ enabled: boolean; snapshotInterval: integer; snapshotRetention: integer; retention: integer; … }` | optional | Event sourcing configuration | +| **replay** | `{ enabled: boolean }` | optional | Event replay configuration | +| **webhooks** | `{ id?: string; eventPattern: string; url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH'>; … }[]` | optional | Webhook configurations | +| **messageQueue** | `{ provider: Enum<'kafka' \| 'rabbitmq' \| 'aws-sqs' \| 'redis-pubsub' \| 'google-pubsub' \| 'azure-service-bus'>; topic: string; eventPattern: string; partitionKey?: string; … }` | optional | Message queue integration | +| **realtime** | `{ enabled: boolean; protocol: Enum<'websocket' \| 'sse' \| 'long-polling'>; eventPattern: string; userFilter: boolean; … }` | optional | Real-time notification configuration | +| **eventTypes** | `{ name: string; version: string; schema?: any; description?: string; … }[]` | optional | Event type definitions | +| **handlers** | `{ id?: string; eventName: string; handler: any; priority: integer; … }[]` | optional | Global event handlers | + + +--- + diff --git a/content/docs/references/kernel/events-core.mdx b/content/docs/references/kernel/events-core.mdx new file mode 100644 index 0000000000..83478875fb --- /dev/null +++ b/content/docs/references/kernel/events-core.mdx @@ -0,0 +1,90 @@ +--- +title: Events Core +description: Events Core protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Event Priority Enum + +Priority levels for event processing + +Lower numbers = higher priority + + +**Source:** `packages/spec/src/kernel/events/core.zod.ts` + + +## TypeScript Usage + +```typescript +import { Event, EventMetadata, EventPriority, EventTypeDefinition } from '@objectstack/spec/kernel'; +import type { Event, EventMetadata, EventPriority, EventTypeDefinition } from '@objectstack/spec/kernel'; + +// Validate data +const result = Event.parse(data); +``` + +--- + +## Event + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | optional | Unique event identifier | +| **name** | `string` | ✅ | Event name (lowercase with dots, e.g., user.created, order.paid) | +| **payload** | `any` | ✅ | Event payload schema | +| **metadata** | `{ source: string; timestamp: string; userId?: string; tenantId?: string; … }` | ✅ | Event metadata | + + +--- + +## EventMetadata + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **source** | `string` | ✅ | Event source (e.g., plugin name, system component) | +| **timestamp** | `string` | ✅ | ISO 8601 datetime when event was created | +| **userId** | `string` | optional | User who triggered the event | +| **tenantId** | `string` | optional | Tenant identifier for multi-tenant systems | +| **correlationId** | `string` | optional | Correlation ID for event tracing | +| **causationId** | `string` | optional | ID of the event that caused this event | +| **priority** | `Enum<'critical' \| 'high' \| 'normal' \| 'low' \| 'background'>` | ✅ | Event priority | +| **cluster** | `{ scope: Enum<'local' \| 'cluster' \| 'tenant'>; deliverySemantics?: Enum<'best-effort' \| 'at-least-once' \| 'exactly-once'>; partitionKey?: string }` | optional | Per-emit cluster routing & delivery options. See cluster-semantics.mdx §4. | + + +--- + +## EventPriority + +### Allowed Values + +* `critical` +* `high` +* `normal` +* `low` +* `background` + + +--- + +## EventTypeDefinition + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Event type name (lowercase with dots) | +| **version** | `string` | ✅ | Event schema version | +| **schema** | `any` | optional | JSON Schema for event payload validation | +| **description** | `string` | optional | Event type description | +| **deprecated** | `boolean` | ✅ | Whether this event type is deprecated | +| **tags** | `string[]` | optional | Event type tags | + + +--- + diff --git a/content/docs/references/kernel/events-dlq.mdx b/content/docs/references/kernel/events-dlq.mdx new file mode 100644 index 0000000000..3c8affddee --- /dev/null +++ b/content/docs/references/kernel/events-dlq.mdx @@ -0,0 +1,61 @@ +--- +title: Events Dlq +description: Events Dlq protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Dead Letter Queue Entry Schema + +Represents a failed event in the dead letter queue + + +**Source:** `packages/spec/src/kernel/events/dlq.zod.ts` + + +## TypeScript Usage + +```typescript +import { DeadLetterQueueEntry, EventLogEntry } from '@objectstack/spec/kernel'; +import type { DeadLetterQueueEntry, EventLogEntry } from '@objectstack/spec/kernel'; + +// Validate data +const result = DeadLetterQueueEntry.parse(data); +``` + +--- + +## DeadLetterQueueEntry + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique entry identifier | +| **event** | `{ id?: string; name: string; payload: any; metadata: object }` | ✅ | Original event | +| **error** | `{ message: string; stack?: string; code?: string }` | ✅ | Failure details | +| **retries** | `integer` | ✅ | Number of retry attempts | +| **firstFailedAt** | `string` | ✅ | When event first failed | +| **lastFailedAt** | `string` | ✅ | When event last failed | +| **failedHandler** | `string` | optional | Handler ID that failed | + + +--- + +## EventLogEntry + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique log entry identifier | +| **event** | `{ id?: string; name: string; payload: any; metadata: object }` | ✅ | The event | +| **status** | `Enum<'pending' \| 'processing' \| 'completed' \| 'failed'>` | ✅ | Processing status | +| **handlersExecuted** | `{ handlerId: string; status: Enum<'success' \| 'failed' \| 'timeout'>; durationMs?: integer; error?: string }[]` | optional | Handlers that processed this event | +| **receivedAt** | `string` | ✅ | When event was received | +| **processedAt** | `string` | optional | When event was processed | +| **totalDurationMs** | `integer` | optional | Total processing time | + + +--- + diff --git a/content/docs/references/kernel/events-handlers.mdx b/content/docs/references/kernel/events-handlers.mdx new file mode 100644 index 0000000000..bd3f67001f --- /dev/null +++ b/content/docs/references/kernel/events-handlers.mdx @@ -0,0 +1,72 @@ +--- +title: Events Handlers +description: Events Handlers protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Event Handler Schema + +Defines how to handle a specific event + + +**Source:** `packages/spec/src/kernel/events/handlers.zod.ts` + + +## TypeScript Usage + +```typescript +import { EventHandler, EventPersistence, EventRoute } from '@objectstack/spec/kernel'; +import type { EventHandler, EventPersistence, EventRoute } from '@objectstack/spec/kernel'; + +// Validate data +const result = EventHandler.parse(data); +``` + +--- + +## EventHandler + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | optional | Unique handler identifier | +| **eventName** | `string` | ✅ | Name of event to handle (supports wildcards like user.*) | +| **handler** | `any` | ✅ | Handler function | +| **priority** | `integer` | ✅ | Execution priority (lower numbers execute first) | +| **async** | `boolean` | ✅ | Execute in background (true) or block (false) | +| **retry** | `{ maxRetries: integer; backoffMs: integer; backoffMultiplier: number }` | optional | Retry policy for failed handlers | +| **timeoutMs** | `integer` | optional | Handler timeout in milliseconds | +| **filter** | `any` | optional | Optional filter to determine if handler should execute | + + +--- + +## EventPersistence + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | ✅ | Enable event persistence | +| **retention** | `integer` | ✅ | Days to retain persisted events | +| **filter** | `any` | optional | Optional filter function to select which events to persist | +| **storage** | `Enum<'database' \| 'file' \| 's3' \| 'custom'>` | ✅ | Storage backend for persisted events | + + +--- + +## EventRoute + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **from** | `string` | ✅ | Source event pattern (supports wildcards, e.g., user.* or *.created) | +| **to** | `string[]` | ✅ | Target event names to route to | +| **transform** | `any` | optional | Optional function to transform payload | + + +--- + diff --git a/content/docs/references/kernel/events-integrations.mdx b/content/docs/references/kernel/events-integrations.mdx new file mode 100644 index 0000000000..8757af711e --- /dev/null +++ b/content/docs/references/kernel/events-integrations.mdx @@ -0,0 +1,97 @@ +--- +title: Events Integrations +description: Events Integrations protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Event Webhook Configuration Schema + +Configuration for sending events to webhooks + +@example + +\{ + +"eventPattern": "order.*", + +"url": "https://api.example.com/webhooks/orders", + +"method": "POST", + +"headers": \{ "Authorization": "Bearer token" \} + +\} + + +**Source:** `packages/spec/src/kernel/events/integrations.zod.ts` + + +## TypeScript Usage + +```typescript +import { EventMessageQueueConfig, EventWebhookConfig, RealTimeNotificationConfig } from '@objectstack/spec/kernel'; +import type { EventMessageQueueConfig, EventWebhookConfig, RealTimeNotificationConfig } from '@objectstack/spec/kernel'; + +// Validate data +const result = EventMessageQueueConfig.parse(data); +``` + +--- + +## EventMessageQueueConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `Enum<'kafka' \| 'rabbitmq' \| 'aws-sqs' \| 'redis-pubsub' \| 'google-pubsub' \| 'azure-service-bus'>` | ✅ | Message queue provider | +| **topic** | `string` | ✅ | Topic or queue name | +| **eventPattern** | `string` | ✅ | Event name pattern to publish (supports wildcards) | +| **partitionKey** | `string` | optional | JSON path for partition key (e.g., "metadata.tenantId") | +| **format** | `Enum<'json' \| 'avro' \| 'protobuf'>` | ✅ | Message serialization format | +| **includeMetadata** | `boolean` | ✅ | Include event metadata in message | +| **compression** | `Enum<'none' \| 'gzip' \| 'snappy' \| 'lz4'>` | ✅ | Message compression | +| **batchSize** | `integer` | ✅ | Batch size for publishing | +| **flushIntervalMs** | `integer` | ✅ | Flush interval for batching | + + +--- + +## EventWebhookConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | optional | Unique webhook identifier | +| **eventPattern** | `string` | ✅ | Event name pattern (supports wildcards) | +| **url** | `string` | ✅ | Webhook endpoint URL | +| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH'>` | ✅ | HTTP method | +| **headers** | `Record` | optional | HTTP headers | +| **authentication** | `{ type: Enum<'none' \| 'bearer' \| 'basic' \| 'api-key'>; credentials?: Record }` | optional | Authentication configuration | +| **retryPolicy** | `{ maxRetries: integer; backoffStrategy: Enum<'fixed' \| 'linear' \| 'exponential'>; initialDelayMs: integer; maxDelayMs: integer }` | optional | Retry policy | +| **timeoutMs** | `integer` | ✅ | Request timeout in milliseconds | +| **transform** | `any` | optional | Transform event before sending | +| **enabled** | `boolean` | ✅ | Whether webhook is enabled | + + +--- + +## RealTimeNotificationConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | ✅ | Enable real-time notifications | +| **protocol** | `Enum<'websocket' \| 'sse' \| 'long-polling'>` | ✅ | Real-time protocol | +| **eventPattern** | `string` | ✅ | Event pattern to broadcast | +| **userFilter** | `boolean` | ✅ | Filter events by user | +| **tenantFilter** | `boolean` | ✅ | Filter events by tenant | +| **channels** | `{ name: string; eventPattern: string; filter?: any }[]` | optional | Named channels for event broadcasting | +| **rateLimit** | `{ maxEventsPerSecond: integer; windowMs: integer }` | optional | Rate limiting configuration | + + +--- + diff --git a/content/docs/references/kernel/events-queue.mdx b/content/docs/references/kernel/events-queue.mdx new file mode 100644 index 0000000000..d262c5db13 --- /dev/null +++ b/content/docs/references/kernel/events-queue.mdx @@ -0,0 +1,92 @@ +--- +title: Events Queue +description: Events Queue protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Event Queue Configuration Schema + +Configuration for async event processing queue + +@example + +\{ + +"name": "event_queue", + +"concurrency": 10, + +"retryPolicy": \{ + +"maxRetries": 3, + +"backoffStrategy": "exponential" + +\} + +\} + + +**Source:** `packages/spec/src/kernel/events/queue.zod.ts` + + +## TypeScript Usage + +```typescript +import { EventQueueConfig, EventReplayConfig, EventSourcingConfig } from '@objectstack/spec/kernel'; +import type { EventQueueConfig, EventReplayConfig, EventSourcingConfig } from '@objectstack/spec/kernel'; + +// Validate data +const result = EventQueueConfig.parse(data); +``` + +--- + +## EventQueueConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Event queue name | +| **concurrency** | `integer` | ✅ | Max concurrent event handlers | +| **retryPolicy** | `{ maxRetries: integer; backoffStrategy: Enum<'fixed' \| 'linear' \| 'exponential'>; initialDelayMs: integer; maxDelayMs: integer }` | optional | Default retry policy for events | +| **deadLetterQueue** | `string` | optional | Dead letter queue name for failed events | +| **priorityEnabled** | `boolean` | ✅ | Process events based on priority | + + +--- + +## EventReplayConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **fromTimestamp** | `string` | ✅ | Start timestamp for replay (ISO 8601) | +| **toTimestamp** | `string` | optional | End timestamp for replay (ISO 8601) | +| **eventTypes** | `string[]` | optional | Event types to replay (empty = all) | +| **filters** | `Record` | optional | Additional filters for event selection | +| **speed** | `number` | ✅ | Replay speed multiplier (1 = real-time) | +| **targetHandlers** | `string[]` | optional | Handler IDs to execute (empty = all) | + + +--- + +## EventSourcingConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | ✅ | Enable event sourcing | +| **snapshotInterval** | `integer` | ✅ | Create snapshot every N events | +| **snapshotRetention** | `integer` | ✅ | Number of snapshots to retain | +| **retention** | `integer` | ✅ | Days to retain events | +| **aggregateTypes** | `string[]` | optional | Aggregate types to enable event sourcing for | +| **storage** | `{ type: Enum<'database' \| 'file' \| 's3' \| 'eventstore'>; options?: Record }` | optional | Event store configuration | + + +--- + diff --git a/content/docs/references/kernel/index.mdx b/content/docs/references/kernel/index.mdx index c4d3f3c9a0..5eb897a29f 100644 --- a/content/docs/references/kernel/index.mdx +++ b/content/docs/references/kernel/index.mdx @@ -10,6 +10,12 @@ This section contains all protocol schemas for the kernel layer of ObjectStack. + + + + + + diff --git a/content/docs/references/kernel/meta.json b/content/docs/references/kernel/meta.json index a44766a69c..83c2af0c5a 100644 --- a/content/docs/references/kernel/meta.json +++ b/content/docs/references/kernel/meta.json @@ -30,9 +30,14 @@ "metadata-persistence", "metadata-plugin", "metadata-protection", - "misc", "service-registry", "startup-orchestrator", - "state-machine" + "---More---", + "events-bus", + "events-core", + "events-dlq", + "events-handlers", + "events-integrations", + "events-queue" ] } \ No newline at end of file diff --git a/content/docs/references/kernel/metadata-persistence.mdx b/content/docs/references/kernel/metadata-persistence.mdx index 9c6c423978..89b9cb20a8 100644 --- a/content/docs/references/kernel/metadata-persistence.mdx +++ b/content/docs/references/kernel/metadata-persistence.mdx @@ -5,10 +5,6 @@ description: Metadata Persistence protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/kernel/metadata-persistence.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/kernel/misc.mdx b/content/docs/references/kernel/misc.mdx deleted file mode 100644 index 29792ea516..0000000000 --- a/content/docs/references/kernel/misc.mdx +++ /dev/null @@ -1,271 +0,0 @@ ---- -title: Misc -description: Misc protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - - -**Source:** `packages/spec/src/kernel/misc.zod.ts` - - -## TypeScript Usage - -```typescript -import { DeadLetterQueueEntry, EventBusConfig, EventHandler, EventLogEntry, EventMessageQueueConfig, EventMetadata, EventPersistence, EventPriority, EventQueueConfig, EventReplayConfig, EventRoute, EventSourcingConfig, EventTypeDefinition, EventWebhookConfig, RealTimeNotificationConfig } from '@objectstack/spec/kernel'; -import type { DeadLetterQueueEntry, EventBusConfig, EventHandler, EventLogEntry, EventMessageQueueConfig, EventMetadata, EventPersistence, EventPriority, EventQueueConfig, EventReplayConfig, EventRoute, EventSourcingConfig, EventTypeDefinition, EventWebhookConfig, RealTimeNotificationConfig } from '@objectstack/spec/kernel'; - -// Validate data -const result = DeadLetterQueueEntry.parse(data); -``` - ---- - -## DeadLetterQueueEntry - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique entry identifier | -| **event** | `{ id?: string; name: string; payload: any; metadata: object }` | ✅ | Original event | -| **error** | `{ message: string; stack?: string; code?: string }` | ✅ | Failure details | -| **retries** | `integer` | ✅ | Number of retry attempts | -| **firstFailedAt** | `string` | ✅ | When event first failed | -| **lastFailedAt** | `string` | ✅ | When event last failed | -| **failedHandler** | `string` | optional | Handler ID that failed | - - ---- - -## EventBusConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **persistence** | `{ enabled: boolean; retention: integer; filter?: any; storage: Enum<'database' \| 'file' \| 's3' \| 'custom'> }` | optional | Event persistence configuration | -| **queue** | `{ name: string; concurrency: integer; retryPolicy?: object; deadLetterQueue?: string; … }` | optional | Event queue configuration | -| **eventSourcing** | `{ enabled: boolean; snapshotInterval: integer; snapshotRetention: integer; retention: integer; … }` | optional | Event sourcing configuration | -| **replay** | `{ enabled: boolean }` | optional | Event replay configuration | -| **webhooks** | `{ id?: string; eventPattern: string; url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH'>; … }[]` | optional | Webhook configurations | -| **messageQueue** | `{ provider: Enum<'kafka' \| 'rabbitmq' \| 'aws-sqs' \| 'redis-pubsub' \| 'google-pubsub' \| 'azure-service-bus'>; topic: string; eventPattern: string; partitionKey?: string; … }` | optional | Message queue integration | -| **realtime** | `{ enabled: boolean; protocol: Enum<'websocket' \| 'sse' \| 'long-polling'>; eventPattern: string; userFilter: boolean; … }` | optional | Real-time notification configuration | -| **eventTypes** | `{ name: string; version: string; schema?: any; description?: string; … }[]` | optional | Event type definitions | -| **handlers** | `{ id?: string; eventName: string; handler: any; priority: integer; … }[]` | optional | Global event handlers | - - ---- - -## EventHandler - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | optional | Unique handler identifier | -| **eventName** | `string` | ✅ | Name of event to handle (supports wildcards like user.*) | -| **handler** | `any` | ✅ | Handler function | -| **priority** | `integer` | ✅ | Execution priority (lower numbers execute first) | -| **async** | `boolean` | ✅ | Execute in background (true) or block (false) | -| **retry** | `{ maxRetries: integer; backoffMs: integer; backoffMultiplier: number }` | optional | Retry policy for failed handlers | -| **timeoutMs** | `integer` | optional | Handler timeout in milliseconds | -| **filter** | `any` | optional | Optional filter to determine if handler should execute | - - ---- - -## EventLogEntry - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique log entry identifier | -| **event** | `{ id?: string; name: string; payload: any; metadata: object }` | ✅ | The event | -| **status** | `Enum<'pending' \| 'processing' \| 'completed' \| 'failed'>` | ✅ | Processing status | -| **handlersExecuted** | `{ handlerId: string; status: Enum<'success' \| 'failed' \| 'timeout'>; durationMs?: integer; error?: string }[]` | optional | Handlers that processed this event | -| **receivedAt** | `string` | ✅ | When event was received | -| **processedAt** | `string` | optional | When event was processed | -| **totalDurationMs** | `integer` | optional | Total processing time | - - ---- - -## EventMessageQueueConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `Enum<'kafka' \| 'rabbitmq' \| 'aws-sqs' \| 'redis-pubsub' \| 'google-pubsub' \| 'azure-service-bus'>` | ✅ | Message queue provider | -| **topic** | `string` | ✅ | Topic or queue name | -| **eventPattern** | `string` | ✅ | Event name pattern to publish (supports wildcards) | -| **partitionKey** | `string` | optional | JSON path for partition key (e.g., "metadata.tenantId") | -| **format** | `Enum<'json' \| 'avro' \| 'protobuf'>` | ✅ | Message serialization format | -| **includeMetadata** | `boolean` | ✅ | Include event metadata in message | -| **compression** | `Enum<'none' \| 'gzip' \| 'snappy' \| 'lz4'>` | ✅ | Message compression | -| **batchSize** | `integer` | ✅ | Batch size for publishing | -| **flushIntervalMs** | `integer` | ✅ | Flush interval for batching | - - ---- - -## EventMetadata - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **source** | `string` | ✅ | Event source (e.g., plugin name, system component) | -| **timestamp** | `string` | ✅ | ISO 8601 datetime when event was created | -| **userId** | `string` | optional | User who triggered the event | -| **tenantId** | `string` | optional | Tenant identifier for multi-tenant systems | -| **correlationId** | `string` | optional | Correlation ID for event tracing | -| **causationId** | `string` | optional | ID of the event that caused this event | -| **priority** | `Enum<'critical' \| 'high' \| 'normal' \| 'low' \| 'background'>` | ✅ | Event priority | -| **cluster** | `{ scope: Enum<'local' \| 'cluster' \| 'tenant'>; deliverySemantics?: Enum<'best-effort' \| 'at-least-once' \| 'exactly-once'>; partitionKey?: string }` | optional | Per-emit cluster routing & delivery options. See cluster-semantics.mdx §4. | - - ---- - -## EventPersistence - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable event persistence | -| **retention** | `integer` | ✅ | Days to retain persisted events | -| **filter** | `any` | optional | Optional filter function to select which events to persist | -| **storage** | `Enum<'database' \| 'file' \| 's3' \| 'custom'>` | ✅ | Storage backend for persisted events | - - ---- - -## EventPriority - -### Allowed Values - -* `critical` -* `high` -* `normal` -* `low` -* `background` - - ---- - -## EventQueueConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Event queue name | -| **concurrency** | `integer` | ✅ | Max concurrent event handlers | -| **retryPolicy** | `{ maxRetries: integer; backoffStrategy: Enum<'fixed' \| 'linear' \| 'exponential'>; initialDelayMs: integer; maxDelayMs: integer }` | optional | Default retry policy for events | -| **deadLetterQueue** | `string` | optional | Dead letter queue name for failed events | -| **priorityEnabled** | `boolean` | ✅ | Process events based on priority | - - ---- - -## EventReplayConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **fromTimestamp** | `string` | ✅ | Start timestamp for replay (ISO 8601) | -| **toTimestamp** | `string` | optional | End timestamp for replay (ISO 8601) | -| **eventTypes** | `string[]` | optional | Event types to replay (empty = all) | -| **filters** | `Record` | optional | Additional filters for event selection | -| **speed** | `number` | ✅ | Replay speed multiplier (1 = real-time) | -| **targetHandlers** | `string[]` | optional | Handler IDs to execute (empty = all) | - - ---- - -## EventRoute - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **from** | `string` | ✅ | Source event pattern (supports wildcards, e.g., user.* or *.created) | -| **to** | `string[]` | ✅ | Target event names to route to | -| **transform** | `any` | optional | Optional function to transform payload | - - ---- - -## EventSourcingConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable event sourcing | -| **snapshotInterval** | `integer` | ✅ | Create snapshot every N events | -| **snapshotRetention** | `integer` | ✅ | Number of snapshots to retain | -| **retention** | `integer` | ✅ | Days to retain events | -| **aggregateTypes** | `string[]` | optional | Aggregate types to enable event sourcing for | -| **storage** | `{ type: Enum<'database' \| 'file' \| 's3' \| 'eventstore'>; options?: Record }` | optional | Event store configuration | - - ---- - -## EventTypeDefinition - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Event type name (lowercase with dots) | -| **version** | `string` | ✅ | Event schema version | -| **schema** | `any` | optional | JSON Schema for event payload validation | -| **description** | `string` | optional | Event type description | -| **deprecated** | `boolean` | ✅ | Whether this event type is deprecated | -| **tags** | `string[]` | optional | Event type tags | - - ---- - -## EventWebhookConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | optional | Unique webhook identifier | -| **eventPattern** | `string` | ✅ | Event name pattern (supports wildcards) | -| **url** | `string` | ✅ | Webhook endpoint URL | -| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH'>` | ✅ | HTTP method | -| **headers** | `Record` | optional | HTTP headers | -| **authentication** | `{ type: Enum<'none' \| 'bearer' \| 'basic' \| 'api-key'>; credentials?: Record }` | optional | Authentication configuration | -| **retryPolicy** | `{ maxRetries: integer; backoffStrategy: Enum<'fixed' \| 'linear' \| 'exponential'>; initialDelayMs: integer; maxDelayMs: integer }` | optional | Retry policy | -| **timeoutMs** | `integer` | ✅ | Request timeout in milliseconds | -| **transform** | `any` | optional | Transform event before sending | -| **enabled** | `boolean` | ✅ | Whether webhook is enabled | - - ---- - -## RealTimeNotificationConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable real-time notifications | -| **protocol** | `Enum<'websocket' \| 'sse' \| 'long-polling'>` | ✅ | Real-time protocol | -| **eventPattern** | `string` | ✅ | Event pattern to broadcast | -| **userFilter** | `boolean` | ✅ | Filter events by user | -| **tenantFilter** | `boolean` | ✅ | Filter events by tenant | -| **channels** | `{ name: string; eventPattern: string; filter?: any }[]` | optional | Named channels for event broadcasting | -| **rateLimit** | `{ maxEventsPerSecond: integer; windowMs: integer }` | optional | Rate limiting configuration | - - ---- - diff --git a/content/docs/references/kernel/state-machine.mdx b/content/docs/references/kernel/state-machine.mdx deleted file mode 100644 index a0d9a5e15c..0000000000 --- a/content/docs/references/kernel/state-machine.mdx +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: State Machine -description: State Machine protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - - -**Source:** `packages/spec/src/kernel/state-machine.zod.ts` - - -## TypeScript Usage - -```typescript -import { Event } from '@objectstack/spec/kernel'; -import type { Event } from '@objectstack/spec/kernel'; - -// Validate data -const result = Event.parse(data); -``` - ---- - -## Event - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | optional | Unique event identifier | -| **name** | `string` | ✅ | Event name (lowercase with dots, e.g., user.created, order.paid) | -| **payload** | `any` | ✅ | Event payload schema | -| **metadata** | `{ source: string; timestamp: string; userId?: string; tenantId?: string; … }` | ✅ | Event metadata | - - ---- - diff --git a/content/docs/references/security/misc.mdx b/content/docs/references/security/misc.mdx index af3e3fd36d..e2fdb6bad1 100644 --- a/content/docs/references/security/misc.mdx +++ b/content/docs/references/security/misc.mdx @@ -5,10 +5,6 @@ description: Misc protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/security/misc.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/shared/metadata-persistence.mdx b/content/docs/references/shared/metadata-persistence.mdx index 184a882589..30669611cf 100644 --- a/content/docs/references/shared/metadata-persistence.mdx +++ b/content/docs/references/shared/metadata-persistence.mdx @@ -5,10 +5,6 @@ description: Metadata Persistence protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/shared/metadata-persistence.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/studio/action.mdx b/content/docs/references/studio/action.mdx index 492761a89f..7f76880e66 100644 --- a/content/docs/references/studio/action.mdx +++ b/content/docs/references/studio/action.mdx @@ -5,10 +5,6 @@ description: Action protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/studio/action.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/system/metadata-loader.mdx b/content/docs/references/system/metadata-loader.mdx index 43bc2a5c7d..534e801be6 100644 --- a/content/docs/references/system/metadata-loader.mdx +++ b/content/docs/references/system/metadata-loader.mdx @@ -5,10 +5,6 @@ description: Metadata Loader protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/system/metadata-loader.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/content/docs/references/ui/http.mdx b/content/docs/references/ui/http.mdx index 5d5b81ce53..dab575e0eb 100644 --- a/content/docs/references/ui/http.mdx +++ b/content/docs/references/ui/http.mdx @@ -5,10 +5,6 @@ description: Http protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -**Source:** `packages/spec/src/ui/http.zod.ts` - - ## TypeScript Usage ```typescript diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 7e3e3c46a0..5e6549f7e8 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -110,9 +110,24 @@ dropped at parse, and nothing failed. now catches it was directed, with the platform's authority, at a slot where the same mistake is silent again: `config: { hostname: … }` is stripped and the datasource connects on localhost — #4001's original bug verbatim, one - level down. Corrected to name the per-driver shape instead of promising a - gate; enforcement is #4410. **A wrong instruction is worse than none**, and - worst for an AI author, whose only signal is whether the parse complained. + level down. First corrected to name the per-driver shape instead of promising + a gate; **#4410 then built the gate**, so the prescription makes a validation + claim again and the claim is true. **A wrong instruction is worse than + none**, and worst for an AI author, whose only signal is whether the parse + complained. + + Two things #4410 had to fix before the sentence was safe to write, both + instructive beyond this schema. The prescription has to name the key the + contract *lands on*, not the one the author typed — pointing a misplaced + `user` at `config: { user: … }` when postgres spells it `username` would + swap a one-step correction for a two-step one. And a gate over `config` + means every key inside it now claims to be honoured, which forced a per-key + audit against the code that reads them: `indexes` / `maxRecordsPerObject` + (memory) were removed as inert, while `datasource.pool`, `schemaMode`, + postgres `schema` / `applicationName` / `statementTimeout` and mongo + `password` / `authSource` / `options` were **wired**, having been declared + and dropped on the floor. Enforcing a contract and honouring it are the same + task from two directions. This is the empirical argument for the ratchet: the inference "no metadata in the repo carries unknown keys" was **false three times over**, and only the @@ -158,7 +173,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). | `notification.zod.ts` / `offline.zod.ts` / `report.zod.ts` | 3 ea | authorable (p) | | | `sharing.zod.ts` | 2 | authorable (p) | public-sharing config | -### `data/` — 158 sites +### `data/` — 160 sites | File | Sites | Class | Note | |---|---|---|---| @@ -169,8 +184,9 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). | `field.zod.ts` | 11 | authorable | partially strict | | `filter.zod.ts` / `query.zod.ts` | 11+5 | open | query dialect — user data flows through; validated semantically elsewhere. `query.zod.ts` dropped one site in #4196: `FieldNodeSchema`'s nested-select object form was declared-but-inert and narrowed to `z.string()`, so the union's second member is gone. Four more left in #4286 with the `joins`/`windowFunctions` removals: `JoinNodeBaseSchema`, `WindowFunctionNodeSchema`, and `WindowSpecSchema`'s two blocks (outer + `frame`) were deleted with their clusters. Class unchanged | | `driver-nosql.zod.ts` / `driver.zod.ts` / `driver-sql.zod.ts` | 10+9+2 | wire | driver capability contracts | -| `datasource.zod.ts` | 9 | authorable | **strict as of #4001 data step** — all 9: `DatasourceSchema` (+ `pool` / `healthCheck` / `ssl` / `retryPolicy`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DatasourceCapabilities`, `DriverDefinitionSchema`. `config` + `readReplicas` stay `z.record` by construction (per-driver shapes — see the `driver/` row below). This row used to add "the driver's own `configSchema` validates them"; **it does not, and never did** — corrected, and the gap is #4410. Which is precisely why the top level had to close: a connection key written one level too high was stripped, and the datasource then connected on driver defaults instead of failing | -| `driver/memory.zod.ts` / `driver/mongo.zod.ts` / `driver/postgres.zod.ts` | 6+1+2 | authorable | The per-driver shapes for the `config` slot — what an author actually writes under `datasource.config` (`host`, `port`, `filename`, pool sizes). **Undeclared here until the coverage walk went recursive** (see below): a subdirectory was invisible to the gate, so these nine sites sat outside the map while the map reported full coverage. Authorable by the rule, but they are **contract-only exports today** — nothing parses `datasource.config` against them and both `*DriverSpec.configSchema` literals are `{}` (#4410). Strictness here would therefore enforce nothing; this row is blocked on #4410 giving it a parse site, not on a verification pass | +| `datasource.zod.ts` | 9 | authorable | **strict as of #4001 data step** — all 9: `DatasourceSchema` (+ `pool` / `healthCheck` / `ssl` / `retryPolicy`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DatasourceCapabilities`, `DriverDefinitionSchema`. `config` + `readReplicas` stay `z.record` **at this level** by construction (per-driver shapes), but are no longer unchecked: **#4410** made `DatasourceSchema`'s refinement parse both against the contract for the declared driver (`driver/config-registry.zod.ts`), so the openness here is a shape this level cannot express rather than the absence of one. This row used to add "the driver's own `configSchema` validates them", which was false until #4410 landed the parse site it names | +| `driver/memory.zod.ts` / `driver/mongo.zod.ts` / `driver/postgres.zod.ts` | 6+1+1 | authorable | The per-driver shapes for the `config` slot — what an author actually writes under `datasource.config` (`host`, `port`, `filename`). **Undeclared here until the coverage walk went recursive** (see below): a subdirectory was invisible to the gate, so these sites sat outside the map while the map reported full coverage. **Strict as of #4410**, which is also what unblocked them: this row previously read "strictness here would enforce nothing" because nothing parsed `datasource.config` against these schemas and both `*DriverSpec.configSchema` literals were `{}`. Now `DatasourceSchema` parses `config` (and each `readReplicas` entry) against them, and the same schemas project onto `configSchema` and onto the Studio connection form. `postgres.zod.ts` drops a site: its `ssl` was a `boolean | {ca, cert, key, …}` union, and the object arm is gone — certificates now live in the datasource-level `ssl` block (declared, strict, and until #4410 read by nobody), leaving `config.ssl` as the on/off shorthand. That narrowing is forced by the same projection: the Studio form renders anything that is not boolean/enum/number as a TEXT INPUT, so a union here would have produced a wizard whose every `ssl` value the new gate rejects. `memory.zod.ts` keeps 6 but loses two KEYS — `indexes` / `maxRecordsPerObject`, which `InMemoryDriverConfig` has no field for, removed under ADR-0049 rather than blessed by the new gate | +| `driver/mysql.zod.ts` / `driver/sqlite.zod.ts` | 1+2 | authorable | The rest of the `config` contract, added by #4410. `mysql.zod.ts` and `sqlite.zod.ts` (sqlite + sqlite-wasm) are shapes that **never existed** — both driver ids were offered by the connection form and buildable by the shared factory, with no config contract anywhere, so `driver: 'sqlite'` + a misspelled `filename` was an ephemeral `:memory:` database reported as configured. All three sites strict, same error factory as the rest of the campaign. (Their sibling `driver/common.zod.ts` holds shared enums and prescription strings and has no `z.object(` site, so the coverage gate skips it) | | `analytics.zod.ts` | 8 | mixed (p) | | | `document.zod.ts` | 8 | wire (p) | | | `hook.zod.ts` / `hook-body.zod.ts` | 6+2 | mixed | **strict as of #4001 data step** for the AUTHORING shapes: `HookSchema` (+ `retryPolicy`) and both body branches (`ExpressionBodySchema` / `ScriptBodySchema`). `HookContextSchema` and its `session` / `provenance` / `user` blocks are the RUNTIME shape the engine hands a handler — they stay tolerant, and must: strictness there would make an engine-internal enrichment (as `provenance` was in #3712) a breaking change for anyone parsing a context they were given. The file's old blanket `authorable (p)` was too wide — verification split it | diff --git a/examples/app-crm/src/datasources/crm.datasource.ts b/examples/app-crm/src/datasources/crm.datasource.ts index 09c210a7f0..14b5b58253 100644 --- a/examples/app-crm/src/datasources/crm.datasource.ts +++ b/examples/app-crm/src/datasources/crm.datasource.ts @@ -22,6 +22,10 @@ export const CrmDatasource = defineDatasource({ /** * Read-replica for analytics queries — demonstrates datasource routing. + * + * `readOnly` is a datasource CAPABILITY, not sqlite config. It sat inside + * `config` here until #4410 gave that slot a gate — a key no driver read, so + * the "read replica" was writable while every signal said it was not. */ export const CrmAnalyticsDatasource = defineDatasource({ name: 'crm_analytics', @@ -29,6 +33,8 @@ export const CrmAnalyticsDatasource = defineDatasource({ driver: 'sqlite', config: { filename: ':memory:', + }, + capabilities: { readOnly: true, }, active: true, diff --git a/examples/app-showcase/src/system/datasources/showcase-external.datasource.ts b/examples/app-showcase/src/system/datasources/showcase-external.datasource.ts index a2c3d95ab8..71a8625fbb 100644 --- a/examples/app-showcase/src/system/datasources/showcase-external.datasource.ts +++ b/examples/app-showcase/src/system/datasources/showcase-external.datasource.ts @@ -52,7 +52,7 @@ export const ShowcaseExternalDatasource = defineDatasource({ // label: 'Analytics Warehouse (Postgres)', // driver: 'postgres', // schemaMode: 'external', -// config: { host: 'localhost', port: 5432, database: 'analytics', user: 'readonly' }, +// config: { host: 'localhost', port: 5432, database: 'analytics', username: 'readonly' }, // external: { // allowWrites: false, // credentialsRef: 'secret:warehouse/password', diff --git a/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts b/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts index 49122259b5..af1911ed0b 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts @@ -73,7 +73,7 @@ describe('DatasourceAdminServicePlugin: probe', () => { driverFactory: fakeFactory(), }); const res = await service.testConnection( - { name: 'reporting', driver: 'postgres', config: { host: 'db' } }, + { name: 'reporting', driver: 'postgres', config: { host: 'db', database: 'analytics' } }, { value: 's3cret' }, ); expect(res.ok).toBe(true); @@ -91,7 +91,7 @@ describe('DatasourceAdminServicePlugin: probe', () => { it('returns ok:false when no factory is registered at all', async () => { const { service } = await boot(); - const res = await service.testConnection({ name: 'x', driver: 'postgres', config: {} }); + const res = await service.testConnection({ name: 'x', driver: 'postgres', config: { database: 'analytics' } }); expect(res.ok).toBe(false); expect(res.error).toMatch(/no driver factory is registered/i); }); @@ -101,7 +101,10 @@ describe('DatasourceAdminServicePlugin: secret fail-closed', () => { it('refuses to create a secret-bearing datasource without a secret binder', async () => { const { service, registry } = await boot({ driverFactory: fakeFactory() }); await expect( - service.createDatasource({ name: 'reporting', driver: 'postgres', config: {} }, { value: 'pw' }), + service.createDatasource( + { name: 'reporting', driver: 'postgres', config: { database: 'analytics' } }, + { value: 'pw' }, + ), ).rejects.toThrow(/no secret store configured/i); // nothing persisted expect(registry.get('datasource')?.size ?? 0).toBe(0); @@ -118,7 +121,10 @@ describe('DatasourceAdminServicePlugin: secret fail-closed', () => { }, }, }); - await service.createDatasource({ name: 'reporting', driver: 'postgres', config: {} }, { value: 'pw' }); + await service.createDatasource( + { name: 'reporting', driver: 'postgres', config: { database: 'analytics' } }, + { value: 'pw' }, + ); const rec = registry.get('datasource')?.get('reporting') as any; expect(rec.origin).toBe('runtime'); expect(rec.external?.credentialsRef).toBe('sys_secret://datasource/reporting#1'); @@ -181,7 +187,7 @@ describe('DatasourceAdminServicePlugin: boot rehydration', () => { driver: 'postgres', origin: 'runtime', active: true, - config: { host: 'db' }, + config: { host: 'db', database: 'analytics' }, external: { credentialsRef: 'sys_secret:abc' }, }, ], @@ -220,7 +226,7 @@ describe('DatasourceAdminServicePlugin: persistence + bound count', () => { // seed an object bound to a runtime datasource registry.set('object', new Map([['lead', { name: 'lead', datasource: 'reporting' }]])); - await service.createDatasource({ name: 'reporting', driver: 'postgres', config: {} }); + await service.createDatasource({ name: 'reporting', driver: 'postgres', config: { database: 'analytics' } }); const list = await service.listDatasources(); expect(list.find((d) => d.name === 'crm_primary')?.origin).toBe('code'); diff --git a/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts b/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts index 454f3209eb..28e72bbbbe 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts @@ -115,7 +115,7 @@ describe('testConnection', () => { it('probes with the cleartext secret without persisting anything', async () => { const { service, store, probed } = makeHarness(); const res = await service.testConnection( - { name: 'tmp', driver: 'postgres', config: { host: 'db.internal' } }, + { name: 'tmp', driver: 'postgres', config: { host: 'db.internal', database: 'analytics' } }, { value: 's3cret' }, ); expect(res.ok).toBe(true); @@ -136,10 +136,43 @@ describe('testConnection', () => { throw new Error('ECONNREFUSED'); }, }); - const res = await service.testConnection({ name: 'x', driver: 'postgres' }); + const res = await service.testConnection({ + name: 'x', + driver: 'postgres', + config: { database: 'app' }, + }); expect(res.ok).toBe(false); expect(res.error).toMatch(/ECONNREFUSED/); }); + + // #4410. A probe is the wizard's evidence that a connection works, so it must + // not run against a config the driver would silently discard: `hostname` is + // dropped, `pg` opens its own localhost default, and a green "Connection + // successful" is reported for a datasource pointing somewhere else. + it('refuses to probe a config the driver would silently ignore', async () => { + const { service, probed } = makeHarness(); + const res = await service.testConnection({ + name: 'x', + driver: 'postgres', + config: { hostname: 'db.internal', database: 'app' }, + }); + + expect(res.ok).toBe(false); + expect(res.error).toContain('`hostname` → `host`'); + expect(probed).toHaveLength(0); + }); + + it('probes a driver the platform ships no contract for, unchanged', async () => { + const { service, probed } = makeHarness(); + const res = await service.testConnection({ + name: 'x', + driver: 'com.vendor.snowflake', + config: { account: 'xy12345' }, + }); + + expect(res.ok).toBe(true); + expect(probed).toHaveLength(1); + }); }); describe('createDatasource', () => { @@ -168,16 +201,39 @@ describe('createDatasource', () => { it('hot-registers the pool after create', async () => { const { service, registered } = makeHarness(); - await service.createDatasource({ name: 'reporting', driver: 'postgres' }); + await service.createDatasource({ + name: 'reporting', + driver: 'postgres', + config: { database: 'analytics' }, + }); expect(registered).toContain('reporting'); }); + // The wizard is the OTHER authoring door: `createDatasource` writes through + // `metadata.register`, whose validation is a structural name/label check, so + // a bad config reached the store even after DatasourceSchema's gate landed. + it('rejects a config its driver would not honour (#4410)', async () => { + const { service, store } = makeHarness(); + await expect( + service.createDatasource({ + name: 'reporting', + driver: 'postgres', + config: { hostname: 'db.internal', database: 'analytics' }, + }), + ).rejects.toThrow(/`hostname` → `host`/); + expect(store.size).toBe(0); + }); + it('rejects a name owned by a code-defined datasource', async () => { const { service } = makeHarness({ seed: [{ name: 'crm_primary', driver: 'sqlite', origin: 'code' }], }); await expect( - service.createDatasource({ name: 'crm_primary', driver: 'postgres' }), + service.createDatasource({ + name: 'crm_primary', + driver: 'postgres', + config: { database: 'analytics' }, + }), ).rejects.toThrow(/code-defined/i); }); @@ -186,14 +242,22 @@ describe('createDatasource', () => { seed: [{ name: 'reporting', driver: 'postgres', origin: 'runtime' }], }); await expect( - service.createDatasource({ name: 'reporting', driver: 'postgres' }), + service.createDatasource({ + name: 'reporting', + driver: 'postgres', + config: { database: 'analytics' }, + }), ).rejects.toThrow(/already exists/i); }); it('rejects an invalid name', async () => { const { service } = makeHarness(); await expect( - service.createDatasource({ name: 'Bad-Name', driver: 'postgres' }), + service.createDatasource({ + name: 'Bad-Name', + driver: 'postgres', + config: { database: 'analytics' }, + }), ).rejects.toThrow(/must match/i); }); }); diff --git a/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts b/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts index bb310013f9..c10a0150bd 100644 --- a/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts +++ b/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts @@ -169,3 +169,97 @@ describe('createDefaultDatasourceDriverFactory — memory construction (#4083)', await explicit.handle.disconnect?.(); }); }); + +// #4410 — the keys that were DECLARED and dropped on the floor. Each of these +// was authorable, strict, documented and read by nothing, so a datasource that +// set it behaved exactly like one that did not. The gate over `datasource.config` +// is only honest if the contract it enforces is one the factory honours, which +// is what these pin. +describe('createDefaultDatasourceDriverFactory — declared keys reach the driver (#4410)', () => { + /** The knex config a constructed SqlDriver was built from. */ + function knexConfigOf(driver: any): any { + return driver?.config ?? driver?.knexConfig ?? driver?.options ?? {}; + } + + it('honours the datasource `pool` block instead of the hardcoded min0/max5', async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics' }, + pool: { min: 2, max: 20, idleTimeoutMillis: 45_000 }, + }); + const cfg = knexConfigOf(handle.driver ?? handle); + expect(cfg.pool).toMatchObject({ min: 2, max: 20, idleTimeoutMillis: 45_000 }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); + + it('keeps the previous defaults when no pool is declared', async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics' }, + }); + expect(knexConfigOf(handle.driver ?? handle).pool).toMatchObject({ min: 0, max: 5 }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); + + it('carries postgres schema / applicationName / statementTimeout onto the connection', async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { + host: 'db.internal', + database: 'analytics', + schema: 'reporting', + applicationName: 'objectstack', + statementTimeout: 30_000, + }, + }); + const cfg = knexConfigOf(handle.driver ?? handle); + expect(cfg.searchPath).toBe('reporting'); + expect(cfg.connection).toMatchObject({ + application_name: 'objectstack', + statement_timeout: 30_000, + }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); + + it('carries the datasource `ssl` block onto the connection, certificates and all', async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics' }, + ssl: { enabled: true, rejectUnauthorized: false, ca: 'CA-PEM' }, + }); + expect(knexConfigOf(handle.driver ?? handle).connection).toMatchObject({ + ssl: { rejectUnauthorized: false, ca: 'CA-PEM' }, + }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); + + it('reads `ssl: false` on the block as TLS off, not as absent', async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics', ssl: true }, + ssl: { enabled: false }, + }); + expect(knexConfigOf(handle.driver ?? handle).connection).toMatchObject({ ssl: false }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); + + it('falls back to the per-driver boolean shorthand when no block is declared', async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics', ssl: true }, + }); + expect(knexConfigOf(handle.driver ?? handle).connection).toMatchObject({ ssl: true }); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); + + it("applies the datasource's own schemaMode, which never reached the driver before", async () => { + const handle: any = await factory().create({ + driver: 'postgres', + config: { host: 'db.internal', database: 'analytics' }, + schemaMode: 'external', + }); + const driver: any = handle.driver ?? handle; + expect(knexConfigOf(driver).schemaMode ?? driver.schemaMode).toBe('external'); + try { await handle.disconnect?.(); } catch { /* pool never opened */ } + }); +}); diff --git a/packages/services/service-datasource/src/__tests__/driver-catalog.test.ts b/packages/services/service-datasource/src/__tests__/driver-catalog.test.ts new file mode 100644 index 0000000000..4452074515 --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/driver-catalog.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The connection form and the config gate must describe ONE shape (#4410). + * + * Before this, the catalog carried hand-written JSON-Schema literals while + * `packages/spec` carried zod schemas for the same drivers — two descriptions, + * neither checked against the other and neither validating anything, so the + * drift was invisible. Now that the zod side is the gate `DatasourceSchema` and + * `DatasourceAdminService` both parse `config` against, a divergence stops being + * cosmetic: a form field the gate rejects is a Save that cannot succeed, and a + * gate key the form omits is a setting only hand-written JSON can reach. + * + * The catalog is derived rather than reconciled, so these are proofs that the + * derivation is real — the failure they exist to catch is someone "simplifying" + * it back into literals. + */ + +import { describe, it, expect } from 'vitest'; +import { + getDriverConfigJsonSchemaById, + validateDriverConfig, + type BuiltinDriverId, +} from '@objectstack/spec/data'; + +import { DRIVER_CATALOG } from '../driver-catalog.js'; + +describe('DRIVER_CATALOG', () => { + it('serves the spec projection for every offered driver', () => { + expect(DRIVER_CATALOG.length).toBeGreaterThan(0); + for (const entry of DRIVER_CATALOG) { + expect(entry.configSchema, entry.id) + .toBe(getDriverConfigJsonSchemaById(entry.id as BuiltinDriverId)); + } + }); + + it('offers only drivers the platform can build and validate', () => { + for (const entry of DRIVER_CATALOG) { + expect(validateDriverConfig(entry.id, {}).known, entry.id).toBe(true); + } + }); + + it('keeps its curation — label, description and icon per entry', () => { + for (const entry of DRIVER_CATALOG) { + expect(entry.label, entry.id).toBeTruthy(); + expect(entry.description, entry.id).toBeTruthy(); + expect(entry.icon, entry.id).toBeTruthy(); + } + expect(DRIVER_CATALOG.map((d) => d.id)).toEqual(['memory', 'sqlite', 'postgres', 'mysql', 'mongo']); + }); + + /** + * The form renders the AUTHOR-facing shape, so a field carrying a default + * must not be marked required — an input-mode projection, not output-mode. + * Getting this backwards would make the wizard demand a `host` the driver + * already defaults. + */ + it('projects the input shape, so defaulted fields stay optional', () => { + const postgres = DRIVER_CATALOG.find((d) => d.id === 'postgres')!; + const schema = postgres.configSchema as { required?: string[]; properties: Record }; + + expect(Object.keys(schema.properties)).toContain('host'); + expect(schema.required ?? []).not.toContain('host'); + }); + + it('every field the form offers is a field the gate accepts', () => { + for (const entry of DRIVER_CATALOG) { + const schema = entry.configSchema as { properties: Record }; + for (const key of Object.keys(schema.properties)) { + const result = validateDriverConfig(entry.id, { [key]: undefined }); + expect(result.known, `${entry.id}.${key}`).toBe(true); + const issues = (result as { issues: Array<{ message: string }> }).issues; + const unknownKey = issues.some((i) => i.message.includes('Unrecognized key')); + expect(unknownKey, `${entry.id}.${key} is offered by the form but rejected by the gate`) + .toBe(false); + } + } + }); +}); diff --git a/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts b/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts index cc83273d75..500e496218 100644 --- a/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts @@ -29,12 +29,34 @@ export interface DatasourceConnectionSpec { driver: string; /** Driver-specific connection config (host, port, database, …). No secrets. */ config: Record; + /** + * Schema ownership mode (ADR-0015) — whether ObjectStack owns this schema or + * is a guest in a database it must never run DDL against. + * + * Carried here since #4410. The factory used to look for it on `external` + * (the federation-settings block, which has no such key) and then inside + * `config` (which nothing ever wrote), so a datasource's own declared + * `schemaMode` never reached the driver: an `external` database was + * constructed as `managed`, with DDL ungated at the driver level. + */ + schemaMode?: 'managed' | 'external' | 'validate-only'; /** Cleartext secret (password / DSN) injected for this connection only. */ secret?: string; /** External federation settings (timeouts, allowed schemas, …). */ external?: Record; /** Connection pool settings. */ pool?: Record; + /** + * Datasource-level TLS block (`enabled`, `rejectUnauthorized`, `ca`, `cert`, + * `key`). + * + * Carried here since #4410. It was declared on the datasource, strict, + * documented — and never reached a driver, because it stopped at the record: + * nothing put it on this spec. So a TLS configuration that never took effect + * looked exactly like one that did, which is the failure `datasource.ssl`'s + * own schema comment warns about. + */ + ssl?: Record; } /** diff --git a/packages/services/service-datasource/src/datasource-admin-service.ts b/packages/services/service-datasource/src/datasource-admin-service.ts index bf80f3a348..09a12a64a1 100644 --- a/packages/services/service-datasource/src/datasource-admin-service.ts +++ b/packages/services/service-datasource/src/datasource-admin-service.ts @@ -21,6 +21,7 @@ * - Removal is refused while objects are still bound to the datasource. */ +import { validateDriverConfig } from '@objectstack/spec/data'; import type { IDatasourceAdminService, DatasourceDraft, @@ -207,6 +208,15 @@ export class DatasourceAdminService implements IDatasourceAdminService { if (!input?.driver) { return { ok: false, error: 'A driver is required to test a connection.' }; } + // Checked BEFORE the probe: a misspelled key makes the driver fall back to + // its own defaults, so the probe would open a connection to localhost and + // report a green "Connection successful" for a datasource that points + // somewhere else entirely — the wizard's version of #4410's core bug. + try { + this.assertValidConfig(input.driver, input.config); + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } const queryTimeoutMs = (input.external as { queryTimeoutMs?: number } | undefined)?.queryTimeoutMs; try { return await this.config.probe({ @@ -224,6 +234,7 @@ export class DatasourceAdminService implements IDatasourceAdminService { async createDatasource(input: DatasourceDraft, secret?: SecretInput): Promise { this.assertValidName(input?.name); if (!input.driver) throw new Error('A driver is required to create a datasource.'); + this.assertValidConfig(input.driver, input.config); const existing = await this.config.getDatasourceRecord(input.name); if (existing) { @@ -278,6 +289,16 @@ export class DatasourceAdminService implements IDatasourceAdminService { merged.external = { ...patch.external, credentialsRef: existing.external?.credentialsRef }; } + // Judged on the MERGED record, but only when this write actually touches + // the pairing: a new `config`, or a new `driver` that reinterprets the + // stored one. An edit that renames a datasource or flips `active` must not + // be blocked by a config it is not touching — a record written before + // #4410 would otherwise become uneditable, including the `active: false` + // that takes a misconfigured datasource out of service. + if (patch.config !== undefined || patch.driver !== undefined) { + this.assertValidConfig(merged.driver, merged.config); + } + if (secret) { const prevRef = existing.external?.credentialsRef; const credentialsRef = await this.config.writeSecret(secret, { name }); @@ -311,6 +332,28 @@ export class DatasourceAdminService implements IDatasourceAdminService { // --- internals ----------------------------------------------------------- + /** + * Reject a `config` that does not satisfy its driver's contract (#4410). + * + * The wizard is the OTHER authoring surface for a datasource, and it does not + * reach `DatasourceSchema`: `createDatasource` writes through + * `metadata.register`, whose validation is a structural `name`/`label` check, + * not a zod parse. So a `config` typed into the Setup form was accepted here + * even after the spec gate landed — the same silent acceptance, one door + * along. Both doors now consult the same registry. + * + * A driver the platform ships no contract for passes untouched, matching the + * spec gate's boundary rather than inventing a stricter one for the UI. + */ + private assertValidConfig(driver: string, config: unknown): void { + const result = validateDriverConfig(driver, config); + if (!result.known || result.issues.length === 0) return; + const detail = result.issues + .map((issue) => (issue.path.length ? `config.${issue.path.join('.')}: ${issue.message}` : issue.message)) + .join('\n'); + throw new Error(`Invalid configuration for driver '${driver}'.\n${detail}`); + } + private assertValidName(name: string | undefined): void { if (!name || !NAME_RE.test(name)) { throw new Error( diff --git a/packages/services/service-datasource/src/datasource-connection-service.ts b/packages/services/service-datasource/src/datasource-connection-service.ts index b9ae5bddd5..36a5914f41 100644 --- a/packages/services/service-datasource/src/datasource-connection-service.ts +++ b/packages/services/service-datasource/src/datasource-connection-service.ts @@ -53,6 +53,8 @@ export interface ConnectableDatasource { validation?: { onMismatch?: 'fail' | 'warn' | 'ignore' }; }) | undefined; pool?: Record; + /** Datasource-level TLS block — carried to the driver since #4410. */ + ssl?: Record; active?: boolean; origin?: 'code' | 'runtime'; /** @@ -673,8 +675,12 @@ function toSpec(record: ConnectableDatasource): DatasourceConnectionSpec { name: record.name, driver: record.driver, config: record.config ?? {}, + // #4410: dropped here before, which is why the factory went looking for + // `schemaMode` in two places that could never hold it. + ...(record.schemaMode ? { schemaMode: record.schemaMode } : {}), external: record.external, pool: record.pool, + ssl: record.ssl, }; } diff --git a/packages/services/service-datasource/src/default-datasource-driver-factory.ts b/packages/services/service-datasource/src/default-datasource-driver-factory.ts index 72c5ec14b5..a2e1c0671b 100644 --- a/packages/services/service-datasource/src/default-datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/default-datasource-driver-factory.ts @@ -15,10 +15,13 @@ * - `sqlite` / `sqlite3` → `@objectstack/driver-sql` (better-sqlite3) * - `sqlite-wasm` / `wasm-sqlite` → `@objectstack/driver-sqlite-wasm` (pure-JS) * - `mysql` / `mysql2` → `@objectstack/driver-sql` (client `mysql2`) - * - `mongodb` / `mongo` → `@objectstack/driver-mongodb` (peer dep) - * - `memory` / `inmemory` → `@objectstack/driver-memory` (ephemeral, + * - `mongo` / `mongodb` → `@objectstack/driver-mongodb` (peer dep) + * - `memory` / `inmemory` → `@objectstack/driver-memory` (ephemeral, * per-datasource — see {@link buildMemoryConfig}) * + * The full alias table lives in `@objectstack/spec` (`resolveDriverId`), which + * is also what selects each driver's config contract — see {@link resolveKind}. + * * `sqlite-wasm` joined for ADR-0062 D1 (#3826): the standalone stack's * `default` datasource is a *declared definition* connected through the shared * `DatasourceConnectionService`, and its CI-safe wasm default must therefore be @@ -32,34 +35,28 @@ */ import { join } from 'node:path'; +import { resolveDriverId, type BuiltinDriverId } from '@objectstack/spec/data'; import type { IDatasourceDriverFactory, DatasourceConnectionSpec, DatasourceDriverHandle, } from './contracts/index.js'; -type ResolvedKind = 'postgres' | 'sqlite' | 'sqlite-wasm' | 'mysql' | 'mongodb' | 'memory'; - -const DRIVER_ID_ALIASES: Record = { - postgres: 'postgres', - postgresql: 'postgres', - pg: 'postgres', - sqlite: 'sqlite', - sqlite3: 'sqlite', - 'better-sqlite3': 'sqlite', - 'sqlite-wasm': 'sqlite-wasm', - 'wasm-sqlite': 'sqlite-wasm', - mysql: 'mysql', - mysql2: 'mysql', - mongodb: 'mongodb', - mongo: 'mongodb', - memory: 'memory', - inmemory: 'memory', - 'in-memory': 'memory', -}; +/** + * Driver-id resolution comes from the spec since #4410 — this file used to keep + * its own copy of the alias table. + * + * Two tables meant the id that selects a DRIVER and the id that selects that + * driver's CONFIG CONTRACT could disagree: a spelling only this table knew + * would be built while its config was validated against nothing — the exact + * silent acceptance the config gate exists to end, reintroduced as a lookup + * miss. One table, so "buildable" and "has a contract" are the same set by + * construction. + */ +type ResolvedKind = BuiltinDriverId; function resolveKind(driverId: string): ResolvedKind | undefined { - return DRIVER_ID_ALIASES[String(driverId ?? '').toLowerCase()]; + return resolveDriverId(driverId); } /** @@ -78,11 +75,64 @@ function toHandle(driver: any, serverVersion?: () => Promise }; } +/** + * Postgres connection options that are neither the target nor the credentials — + * declared on `PostgresConfigSchema` and carried onto every connection shape + * (DSN or discrete fields alike), since `pg` accepts them next to a + * `connectionString`. + * + * These were declared in the spec and read by nothing until #4410. Giving + * `config` a gate means every key inside it now claims to be honoured, so each + * one is either wired (here) or removed from the contract — a declared key that + * silently does nothing is the defect this whole campaign is about. + */ +function pgConnectionExtras(cfg: Record): Record { + return { + ...(cfg.applicationName ? { application_name: cfg.applicationName } : {}), + ...(cfg.statementTimeout != null ? { statement_timeout: cfg.statementTimeout } : {}), + }; +} + +/** + * The `ssl` value to hand a SQL client, from the datasource's TLS block or the + * per-driver on/off shorthand. + * + * `datasource.ssl` is declared, strict, documented — and until #4410 stopped at + * the record: nothing put it on the connection spec, so a TLS block with a CA + * certificate in it configured precisely nothing, which is the failure its own + * schema comment warns about ("a TLS setting that never took effect looked + * identical to one that did"). The block wins when present because it is the + * more specific statement; `config.ssl` remains the boolean shorthand. + */ +function resolveSslOption(spec: DatasourceConnectionSpec): unknown { + const block = spec.ssl as + | { enabled?: boolean; rejectUnauthorized?: boolean; ca?: string; cert?: string; key?: string } + | undefined; + if (block) { + if (block.enabled === false) return false; + const options = { + ...(block.rejectUnauthorized !== undefined ? { rejectUnauthorized: block.rejectUnauthorized } : {}), + ...(block.ca ? { ca: block.ca } : {}), + ...(block.cert ? { cert: block.cert } : {}), + ...(block.key ? { key: block.key } : {}), + }; + // `ssl: {}` would read as "TLS with default options" to `pg`, which is what + // `enabled: true` with nothing else means anyway — but an empty object is + // an odd thing to hand a client, so collapse it to the boolean. + return Object.keys(options).length > 0 ? options : true; + } + const shorthand = (spec.config ?? {}).ssl; + return shorthand == null ? undefined : shorthand; +} + /** Build the Knex `connection` for a SQL driver from a spec's config + secret. */ function buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'better-sqlite3'): unknown { const cfg = (spec.config ?? {}) as Record; if (client === 'better-sqlite3') { + // `file` / `database` are pre-#4410 tolerance for shapes already persisted + // by the runtime store. Authoring rejects both with a rename hint + // (`SqliteConfigSchema`), so nothing new can arrive spelled this way. const filename = (cfg.filename as string | undefined) ?? (cfg.file as string | undefined) ?? @@ -93,10 +143,18 @@ function buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'bett // pg — accept either a connection string (`url`/`connectionString`) or // discrete fields. The secret is the password and is never part of `config`. + const ssl = resolveSslOption(spec); const url = (cfg.url as string | undefined) ?? (cfg.connectionString as string | undefined); if (url) { // For a DSN, a separately-supplied secret overrides the embedded password. - return spec.secret ? { connectionString: url, password: spec.secret } : { connectionString: url }; + // TLS still applies: `sslmode` in a DSN and the `ssl` option are separate + // channels to `pg`, and a datasource that declares one should get it. + return { + connectionString: url, + ...(spec.secret ? { password: spec.secret } : {}), + ...(ssl !== undefined ? { ssl } : {}), + ...pgConnectionExtras(cfg), + }; } return { host: cfg.host, @@ -104,7 +162,29 @@ function buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'bett database: cfg.database, user: cfg.user ?? cfg.username, ...(spec.secret ? { password: spec.secret } : cfg.password ? { password: cfg.password } : {}), - ...(cfg.ssl != null ? { ssl: cfg.ssl } : {}), + ...(ssl !== undefined ? { ssl } : {}), + ...pgConnectionExtras(cfg), + }; +} + +/** + * Knex pool options for a SQL driver, from the datasource's own `pool` block. + * + * `datasource.pool` is declared, strict, documented and — until #4410 — read by + * nobody: `toSpec` carried it into the connection spec and this factory then + * hardcoded `{ min: 0, max: 5 }` over the top, so an author who sized their pool + * got the defaults and no indication. Those defaults are preserved for the + * unspecified case, so nothing that did not set `pool` changes behaviour. + */ +function buildSqlPool(spec: DatasourceConnectionSpec): Record { + const pool = (spec.pool ?? {}) as Record; + return { + min: typeof pool.min === 'number' ? pool.min : 0, + max: typeof pool.max === 'number' ? pool.max : 5, + ...(typeof pool.idleTimeoutMillis === 'number' ? { idleTimeoutMillis: pool.idleTimeoutMillis } : {}), + ...(typeof pool.connectionTimeoutMillis === 'number' + ? { acquireTimeoutMillis: pool.connectionTimeoutMillis } + : {}), }; } @@ -116,6 +196,7 @@ function buildSqlConnection(spec: DatasourceConnectionSpec, client: 'pg' | 'bett */ function buildMysqlConnection(spec: DatasourceConnectionSpec): unknown { const cfg = (spec.config ?? {}) as Record; + const mysqlSsl = resolveSslOption(spec); const url = (cfg.url as string | undefined) ?? (cfg.connectionString as string | undefined); if (url) return url; return { @@ -124,7 +205,7 @@ function buildMysqlConnection(spec: DatasourceConnectionSpec): unknown { database: cfg.database, user: cfg.user ?? cfg.username, ...(spec.secret ? { password: spec.secret } : cfg.password ? { password: cfg.password } : {}), - ...(cfg.ssl != null ? { ssl: cfg.ssl } : {}), + ...(mysqlSsl !== undefined ? { ssl: mysqlSsl } : {}), }; } @@ -200,17 +281,31 @@ function buildMemoryConfig(spec: DatasourceConnectionSpec): Record; + // `uri` is pre-#4410 tolerance for already-persisted shapes; authoring + // rejects it with a rename hint to `url` (`MongoConfigSchema`). const explicit = (cfg.url as string | undefined) ?? (cfg.uri as string | undefined); if (explicit) return explicit; const host = (cfg.host as string | undefined) ?? 'localhost'; const port = (cfg.port as number | string | undefined) ?? 27017; const db = (cfg.database as string | undefined) ?? ''; const user = (cfg.user as string | undefined) ?? (cfg.username as string | undefined); - const auth = user ? `${encodeURIComponent(user)}:${encodeURIComponent(spec.secret ?? '')}@` : ''; - return `mongodb://${auth}${host}:${port}/${db}`; + const password = spec.secret ?? (cfg.password as string | undefined) ?? ''; + const auth = user ? `${encodeURIComponent(user)}:${encodeURIComponent(password)}@` : ''; + const authSource = cfg.authSource as string | undefined; + const query = authSource ? `?authSource=${encodeURIComponent(authSource)}` : ''; + return `mongodb://${auth}${host}:${port}/${db}${query}`; } /** @@ -242,7 +337,15 @@ export function createDefaultDatasourceDriverFactory( throw new Error(`Unsupported driver id '${spec.driver}'.`); } - const schemaMode = (spec.external as { schemaMode?: string } | undefined)?.schemaMode + // ADR-0015's ownership mode. `spec.schemaMode` — the datasource's own + // declared key — is FIRST since #4410; before that the first two arms + // were all there was, and neither could ever hold it: `external` is the + // federation-settings block (no `schemaMode` key), and nothing wrote the + // `config` copy. So `schemaMode: 'external'` on a datasource reached the + // driver as `undefined` and a database ObjectStack is a guest in was + // treated as managed — DDL ungated at the driver. + const schemaMode = spec.schemaMode + ?? (spec.external as { schemaMode?: string } | undefined)?.schemaMode ?? ((spec.config as Record | undefined)?.schemaMode as string | undefined); // Host-composition passthroughs (#3826): the CLI's declared `default` // definition carries the dev loosen-only self-heal (#2186) and the wasm @@ -254,10 +357,15 @@ export function createDefaultDatasourceDriverFactory( if (kind === 'postgres') { const { SqlDriver } = await import('@objectstack/driver-sql'); + // `searchPath` is knex's own key for postgres' default schema — the + // landing site for `config.schema`, declared since the protocol's first + // postgres shape and read by nothing until #4410. + const searchPath = cfg.schema as string | undefined; const driver = new SqlDriver({ client: 'pg', connection: buildSqlConnection(spec, 'pg') as any, - pool: { min: 0, max: 5 }, + pool: buildSqlPool(spec), + ...(searchPath ? { searchPath } : {}), ...(schemaMode ? { schemaMode: schemaMode as any } : {}), ...(autoMigrate ? { autoMigrate } : {}), } as any); @@ -310,14 +418,14 @@ export function createDefaultDatasourceDriverFactory( const driver = new SqlDriver({ client: 'mysql2', connection: buildMysqlConnection(spec) as any, - pool: { min: 0, max: 5 }, + pool: buildSqlPool(spec), ...(schemaMode ? { schemaMode: schemaMode as any } : {}), ...(autoMigrate ? { autoMigrate } : {}), } as any); return toHandle(driver); } - if (kind === 'mongodb') { + if (kind === 'mongo') { let MongoDBDriver: any; try { ({ MongoDBDriver } = await import('@objectstack/driver-mongodb' as any)); @@ -326,7 +434,17 @@ export function createDefaultDatasourceDriverFactory( `mongodb driver requested but @objectstack/driver-mongodb is not installed (${err?.message ?? err}).`, ); } - const driver = new MongoDBDriver({ url: buildMongoUrl(spec) }); + // `options` (the MongoClient passthrough) and the datasource's `pool` + // block reach the client since #4410 — the driver has always read + // `options` / `minPoolSize` / `maxPoolSize`; only `url` was ever passed. + const pool = (spec.pool ?? {}) as Record; + const driver = new MongoDBDriver({ + url: buildMongoUrl(spec), + ...(cfg.database ? { database: cfg.database } : {}), + ...(cfg.options && typeof cfg.options === 'object' ? { options: cfg.options } : {}), + ...(typeof pool.min === 'number' ? { minPoolSize: pool.min } : {}), + ...(typeof pool.max === 'number' ? { maxPoolSize: pool.max } : {}), + }); return toHandle(driver); } diff --git a/packages/services/service-datasource/src/driver-catalog.ts b/packages/services/service-datasource/src/driver-catalog.ts index 0d275717fc..ba0ba7f359 100644 --- a/packages/services/service-datasource/src/driver-catalog.ts +++ b/packages/services/service-datasource/src/driver-catalog.ts @@ -11,8 +11,26 @@ * Served by `GET /api/v1/datasources/drivers`. This is the curated set of * connection drivers the connection form offers; a future runtime driver * registry can supersede this list without changing the route contract. + * + * ## The schemas are PROJECTED, not written here (#4410) + * + * They used to be JSON-Schema literals maintained in this file, in parallel + * with `packages/spec`'s per-driver zod schemas — two descriptions of one shape, + * neither checked against the other, and neither validating anything. #4410 + * made the zod side the gate `DatasourceSchema` parses `config` against, which + * turns that duplication from untidy into dangerous: a form offering a field the + * gate rejects is a Setup wizard whose Save cannot succeed, with the platform's + * own form as the thing at fault. + * + * So the form renders the projection of the same schema that judges the save. + * What stays local is CURATION — which drivers the form offers, and their + * label/description/icon. `sqlite-wasm` is deliberately absent: it is + * constructible and has a config contract, but it exists for CI and + * no-native-build environments rather than as something an admin picks here. */ +import { getDriverConfigJsonSchemaById, type BuiltinDriverId } from '@objectstack/spec/data'; + export interface DriverCatalogEntry { /** Unique driver identifier used as `datasource.driver`. */ id: string; @@ -26,88 +44,46 @@ export interface DriverCatalogEntry { configSchema: Record; } -const SSL_PROP = { - ssl: { type: 'boolean', title: 'Use SSL/TLS', default: false }, -} as const; - -export const DRIVER_CATALOG: DriverCatalogEntry[] = [ +/** The curated part — everything except the shape, which comes from the spec. */ +const CURATED: ReadonlyArray<{ + id: BuiltinDriverId; + label: string; + description: string; + icon: string; +}> = [ { id: 'memory', label: 'In-Memory', description: 'Ephemeral in-memory driver for dev, tests, and prototyping. No connection settings.', icon: 'memory-stick', - configSchema: { type: 'object', properties: {}, additionalProperties: false }, }, { id: 'sqlite', label: 'SQLite', description: 'File-backed (or in-memory) SQL database. Great for local dev and small deployments.', icon: 'database', - configSchema: { - type: 'object', - properties: { - filename: { - type: 'string', - title: 'Filename', - description: 'Database file path, or ":memory:" for an ephemeral in-memory database.', - default: ':memory:', - }, - }, - required: ['filename'], - additionalProperties: false, - }, }, { id: 'postgres', label: 'PostgreSQL', description: 'PostgreSQL connection. Supply host/port/database or a connection URL.', icon: 'database', - configSchema: { - type: 'object', - properties: { - url: { type: 'string', title: 'Connection URL', description: 'postgres://user:pass@host:5432/db (overrides the fields below when set).' }, - host: { type: 'string', title: 'Host', default: 'localhost' }, - port: { type: 'number', title: 'Port', default: 5432 }, - database: { type: 'string', title: 'Database' }, - username: { type: 'string', title: 'User' }, - password: { type: 'string', title: 'Password', format: 'password' }, - schema: { type: 'string', title: 'Schema', default: 'public' }, - ...SSL_PROP, - }, - additionalProperties: true, - }, }, { id: 'mysql', label: 'MySQL / MariaDB', description: 'MySQL or MariaDB connection.', icon: 'database', - configSchema: { - type: 'object', - properties: { - host: { type: 'string', title: 'Host', default: 'localhost' }, - port: { type: 'number', title: 'Port', default: 3306 }, - database: { type: 'string', title: 'Database' }, - username: { type: 'string', title: 'User' }, - password: { type: 'string', title: 'Password', format: 'password' }, - ...SSL_PROP, - }, - additionalProperties: true, - }, }, { id: 'mongo', label: 'MongoDB', description: 'MongoDB connection via a connection URI.', icon: 'database', - configSchema: { - type: 'object', - properties: { - url: { type: 'string', title: 'Connection URI', description: 'mongodb://host:27017' }, - database: { type: 'string', title: 'Database' }, - }, - required: ['url'], - additionalProperties: true, - }, }, ]; + +export const DRIVER_CATALOG: DriverCatalogEntry[] = CURATED.map((entry) => ({ + ...entry, + configSchema: getDriverConfigJsonSchemaById(entry.id), +})); diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index c24fc55e53..a319b9168f 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -201,11 +201,15 @@ "ApiPrimitive (type)", "AuditProvenanceField (type)", "AuthoringKeySurface (type)", + "AutoPersistenceConfig (type)", + "AutoPersistenceConfigSchema (const)", "AutonumberToken (type)", "BOOLEAN_VALUE_TYPES (const)", + "BUILTIN_DRIVER_IDS (const)", "BaseEngineOptions (type)", "BaseEngineOptionsSchema (const)", "BaseValidationRuleShape (interface)", + "BuiltinDriverId (type)", "CALENDAR_DATE_TYPES (const)", "CLOCK_TIME_TYPES (const)", "COMPUTED_VALUE_TYPES (const)", @@ -237,6 +241,8 @@ "CurrencyConfigSchema (const)", "CurrencyValue (type)", "CurrencyValueSchema (const)", + "CustomPersistenceConfig (type)", + "CustomPersistenceConfigSchema (const)", "DATA_ACTION_TO_API_OPERATION (const)", "DATE_MACRO_ALIAS_TOKENS (const)", "DATE_MACRO_DESCRIPTIONS (const)", @@ -246,6 +252,8 @@ "DATE_MACRO_TOKENS (const)", "DATE_MACRO_UNITS (const)", "DATE_MACRO_WRAPPED_RE (const)", + "DRIVER_CONFIG_SCHEMAS (const)", + "DRIVER_ID_ALIASES (const)", "DataEngineAggregateOptions (type)", "DataEngineAggregateOptionsSchema (const)", "DataEngineAggregateRequestSchema (const)", @@ -305,12 +313,15 @@ "DriverCapabilities (type)", "DriverCapabilitiesSchema (const)", "DriverConfig (type)", + "DriverConfigIssue (interface)", "DriverConfigSchema (const)", + "DriverDefinition (type)", "DriverDefinitionSchema (const)", "DriverInterface (type)", "DriverInterfaceSchema (const)", "DriverOptions (type)", "DriverOptionsSchema (const)", + "DriverSslToggleSchema (const)", "DriverType (const)", "DroppedFieldsEvent (type)", "DroppedFieldsEventSchema (const)", @@ -369,6 +380,8 @@ "FieldSchema (const)", "FieldType (type)", "FileLikeValueSchema (const)", + "FilePersistenceConfig (type)", + "FilePersistenceConfigSchema (const)", "FileReferenceIdValueSchema (const)", "FileValueSchema (const)", "Filter (type)", @@ -409,6 +422,8 @@ "LifecycleClass (type)", "LifecycleClassSchema (const)", "LifecycleSchema (const)", + "LocalStoragePersistenceConfig (type)", + "LocalStoragePersistenceConfigSchema (const)", "LocationCoordinates (type)", "LocationCoordinatesSchema (const)", "LocationValueSchema (const)", @@ -419,8 +434,18 @@ "Mapping (type)", "MappingInput (type)", "MappingSchema (const)", + "MemoryConfig (type)", + "MemoryConfigSchema (const)", + "MemoryDriverSpec (const)", + "MemoryPersistenceConfig (type)", + "MemoryPersistenceConfigSchema (const)", "Metric (type)", "MetricSchema (const)", + "MongoConfig (type)", + "MongoConfigSchema (const)", + "MongoDriverSpec (const)", + "MysqlConfig (type)", + "MysqlConfigSchema (const)", "NUMERIC_VALUE_TYPES (const)", "NoSQLDataTypeMapping (type)", "NoSQLDataTypeMappingSchema (const)", @@ -473,8 +498,14 @@ "PaginationConformanceRow (interface)", "PerOperationRequiredPermissions (type)", "PerOperationRequiredPermissionsSchema (const)", + "PersistenceAdapter (type)", + "PersistenceAdapterSchema (const)", + "PersistenceType (type)", + "PersistenceTypeSchema (const)", "PoolConfig (type)", "PoolConfigSchema (const)", + "PostgresConfig (type)", + "PostgresConfigSchema (const)", "ProvisionPrimaryOptions (interface)", "QUERY_CURSOR_REMOVED (const)", "QUERY_DISTINCT_REMOVED (const)", @@ -486,6 +517,7 @@ "QueryInput (type)", "QuerySchema (const)", "RAW_FILE_VALUES_CONTEXT_KEY (const)", + "READ_ONLY_BELONGS_ON_DATASOURCE (const)", "RECORD_SURFACE_PAGE_THRESHOLD (const)", "REFERENCE_VALUE_TYPES (const)", "RPC_QUERY_ALIAS_SLOTS (const)", @@ -512,6 +544,7 @@ "RowCrudActionOverrideInput (type)", "RowCrudActionOverrideSchema (const)", "RowCrudPredicates (interface)", + "SCHEMA_MODE_BELONGS_ON_DATASOURCE (const)", "SEARCHABLE_ENUM_TYPES (const)", "SEARCHABLE_TEXTUAL_TYPES (const)", "SEARCH_AUTO_EXCLUDED_FIELDS (const)", @@ -525,6 +558,7 @@ "SQLiteDataTypeMappingDefaults (const)", "SSLConfig (type)", "SSLConfigSchema (const)", + "SSL_DETAIL_BELONGS_ON_DATASOURCE (const)", "STACK_KEY_GUIDANCE (const)", "STACK_RUNTIME_MEMBERS (const)", "STRING_VALUE_TYPES (const)", @@ -565,7 +599,15 @@ "SortNode (type)", "SortNodeSchema (const)", "SpecialOperatorSchema (const)", + "SqlAutoMigrate (type)", + "SqlAutoMigrateSchema (const)", "SqlDialect (type)", + "SqliteConfig (type)", + "SqliteConfigSchema (const)", + "SqliteWasmConfig (type)", + "SqliteWasmConfigSchema (const)", + "SqliteWasmPersistMode (type)", + "SqliteWasmPersistModeSchema (const)", "StateMachineValidation (type)", "StateMachineValidationSchema (const)", "StringOperatorSchema (const)", @@ -611,10 +653,19 @@ "deriveFieldGroupLayout (function)", "deriveRecordFlowSurface (function)", "deriveRecordSurface (function)", + "driverConfigJsonSchema (function)", "effectiveOperationsArray (function)", "fieldForm (const)", "foldQueryAliasSlots (function)", "formatUnknownAuthoringKey (function)", + "getDriverConfigJsonSchemaById (function)", + "getDriverConfigSchema (function)", + "getMemoryConfigJsonSchema (const)", + "getMongoConfigJsonSchema (const)", + "getMysqlConfigJsonSchema (const)", + "getPostgresConfigJsonSchema (const)", + "getSqliteConfigJsonSchema (const)", + "getSqliteWasmConfigJsonSchema (const)", "hasDynamicTokens (function)", "hookForm (const)", "isApiOperationAllowed (function)", @@ -646,6 +697,7 @@ "renderAutonumber (function)", "resolveCrudAffordances (function)", "resolveDisplayField (function)", + "resolveDriverId (function)", "resolveEffectiveApiMethods (function)", "resolveRecordDisplayName (function)", "resolveSearchFieldResolution (function)", @@ -654,6 +706,7 @@ "stripLegacyApiMethods (function)", "suggestFieldType (function)", "utcInstantMs (function)", + "validateDriverConfig (function)", "valueSchemaFor (function)" ], "./system": [ diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index fb905f2dd6..be9a921172 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -3184,6 +3184,10 @@ "data/AnalyticsQuery:timeDimensions", "data/AnalyticsQuery:timezone", "data/AnalyticsQuery:where", + "data/AutoPersistenceConfig:autoSaveInterval", + "data/AutoPersistenceConfig:key", + "data/AutoPersistenceConfig:path", + "data/AutoPersistenceConfig:type", "data/BaseEngineOptions:context", "data/ConditionalValidation:active", "data/ConditionalValidation:description", @@ -3560,6 +3564,9 @@ "data/FieldMapping:target", "data/FieldMapping:transform", "data/FieldReference:$field", + "data/FilePersistenceConfig:autoSaveInterval", + "data/FilePersistenceConfig:path", + "data/FilePersistenceConfig:type", "data/FileValue:alt", "data/FileValue:duration", "data/FileValue:mimeType", @@ -3622,6 +3629,8 @@ "data/Lifecycle:retention", "data/Lifecycle:storage", "data/Lifecycle:ttl", + "data/LocalStoragePersistenceConfig:key", + "data/LocalStoragePersistenceConfig:type", "data/LocationCoordinates:accuracy", "data/LocationCoordinates:altitude", "data/LocationCoordinates:latitude", @@ -3647,6 +3656,22 @@ "data/Metric:name", "data/Metric:sql", "data/Metric:type", + "data/MongoConfig:authSource", + "data/MongoConfig:database", + "data/MongoConfig:host", + "data/MongoConfig:options", + "data/MongoConfig:password", + "data/MongoConfig:port", + "data/MongoConfig:url", + "data/MongoConfig:username", + "data/MysqlConfig:autoMigrate", + "data/MysqlConfig:database", + "data/MysqlConfig:host", + "data/MysqlConfig:password", + "data/MysqlConfig:port", + "data/MysqlConfig:ssl", + "data/MysqlConfig:url", + "data/MysqlConfig:username", "data/NoSQLDataTypeMapping:array", "data/NoSQLDataTypeMapping:binary", "data/NoSQLDataTypeMapping:boolean", @@ -3782,6 +3807,17 @@ "data/PoolConfig:idleTimeoutMillis", "data/PoolConfig:max", "data/PoolConfig:min", + "data/PostgresConfig:applicationName", + "data/PostgresConfig:autoMigrate", + "data/PostgresConfig:database", + "data/PostgresConfig:host", + "data/PostgresConfig:password", + "data/PostgresConfig:port", + "data/PostgresConfig:schema", + "data/PostgresConfig:ssl", + "data/PostgresConfig:statementTimeout", + "data/PostgresConfig:url", + "data/PostgresConfig:username", "data/Query:aggregations", "data/Query:cursor [RETIRED]", "data/Query:distinct [RETIRED]", @@ -3899,6 +3935,10 @@ "data/SortNode:order", "data/SpecialOperator:$exists", "data/SpecialOperator:$null", + "data/SqliteConfig:autoMigrate", + "data/SqliteConfig:filename", + "data/SqliteWasmConfig:filename", + "data/SqliteWasmConfig:persist", "data/StateMachineValidation:active", "data/StateMachineValidation:description", "data/StateMachineValidation:events", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 4022e4eb8d..991c7e8330 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -703,6 +703,7 @@ "data/AnalyticsQuery", "data/ApiMethod", "data/ApiOperation", + "data/AutoPersistenceConfig", "data/BaseEngineOptions", "data/CalendarDateValue", "data/ClockTimeValue", @@ -750,6 +751,7 @@ "data/DriverConfig", "data/DriverDefinition", "data/DriverOptions", + "data/DriverSslToggle", "data/DriverType", "data/DroppedFieldsEvent", "data/ESignatureConfig", @@ -775,6 +777,7 @@ "data/FieldReference", "data/FieldType", "data/FileLikeValue", + "data/FilePersistenceConfig", "data/FileReferenceIdValue", "data/FileValue", "data/FilterCondition", @@ -790,11 +793,14 @@ "data/JSONValidation", "data/Lifecycle", "data/LifecycleClass", + "data/LocalStoragePersistenceConfig", "data/LocationCoordinates", "data/LocationValue", "data/Mapping", "data/Metric", "data/ModeSchema", + "data/MongoConfig", + "data/MysqlConfig", "data/NoSQLDataTypeMapping", "data/NoSQLDatabaseType", "data/NoSQLDriverConfig", @@ -814,7 +820,9 @@ "data/ObjectOwnershipEnum", "data/ObjectRequiredPermissions", "data/PerOperationRequiredPermissions", + "data/PersistenceType", "data/PoolConfig", + "data/PostgresConfig", "data/Query", "data/QueryFilter", "data/ReferenceIdValue", @@ -839,6 +847,10 @@ "data/ShardingConfig", "data/SortNode", "data/SpecialOperator", + "data/SqlAutoMigrate", + "data/SqliteConfig", + "data/SqliteWasmConfig", + "data/SqliteWasmPersistMode", "data/StateMachineValidation", "data/StringOperator", "data/TenancyConfig", diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index ba8c70b401..bfc50b8e1b 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -61,6 +61,41 @@ const schemaZodFileMap = new Map(); const categoryZodFiles = new Map>(); // Track Zod File collisions const zodFileCounts = new Map(); +/** + * Page slug -> its real path under `packages/spec/src//`. + * + * A page named after a NESTED file (`driver-postgres`) does not sit at + * `/driver-postgres.zod.ts`, so the "Source:" line has to be looked up + * rather than reassembled from the slug. Naming a file that does not exist is + * the same defect as a schema that does not validate: a reader following it + * finds nothing and has no way to tell the pointer was invented. + */ +const zodFileSourceRel = new Map(); + +/** + * `.zod.ts` files under a category, RECURSIVELY, keyed by the slug their page + * takes (`driver/postgres.zod.ts` → `driver-postgres`). + * + * The walk used to be one level deep, which made every schema under + * `data/driver/` invisible: those twelve landed in the catch-all `misc` bucket + * the moment they were exported (#4410), on a page whose "Source" line named + * `data/misc.zod.ts` — a file that does not exist. Same one-level-deep bug the + * strictness ledger's own coverage gate had, and the same lesson: a generator + * that under-reports produces confident output about surface it never saw. + */ +function collectZodFiles(dir: string, prefix = ''): Array<{ slug: string; rel: string }> { + const out: Array<{ slug: string; rel: string }> = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + out.push(...collectZodFiles(path.join(dir, entry.name), `${prefix}${entry.name}/`)); + continue; + } + if (!entry.name.endsWith('.zod.ts')) continue; + const rel = `${prefix}${entry.name}`; + out.push({ slug: rel.replace(/\.zod\.ts$/, '').replace(/\//g, '-'), rel }); + } + return out; +} // Scan source files to build maps function scanCategories() { @@ -69,33 +104,42 @@ function scanCategories() { if (!fs.existsSync(dir)) return; const zodFiles = new Set(); - const files = fs.readdirSync(dir).filter(f => f.endsWith('.zod.ts')); - - for (const file of files) { - const zodFileName = file.replace('.zod.ts', ''); - zodFiles.add(zodFileName); - - const count = zodFileCounts.get(zodFileName) || 0; - zodFileCounts.set(zodFileName, count + 1); - - const content = fs.readFileSync(path.join(dir, file), 'utf-8'); - + + for (const { slug, rel } of collectZodFiles(dir)) { + zodFiles.add(slug); + zodFileSourceRel.set(`${category}/${slug}`, rel); + + const count = zodFileCounts.get(slug) || 0; + zodFileCounts.set(slug, count + 1); + + const content = fs.readFileSync(path.join(dir, rel), 'utf-8'); + // Match export const Name = ... OR export const Name: Type = ... const regex = /export const (\w+)\s*(?:[:=])/g; - + let match; while ((match = regex.exec(content)) !== null) { const rawName = match[1]; const finalName = rawName.endsWith('Schema') ? rawName.replace('Schema', '') : rawName; schemaCategoryMap.set(finalName, category); - schemaZodFileMap.set(finalName, zodFileName); + schemaZodFileMap.set(finalName, slug); } } - + categoryZodFiles.set(category, zodFiles); }); } +/** + * Repo-relative source path for a page slug, or `undefined` when the slug has + * no file behind it (the `misc` catch-all bucket). Callers must omit the + * "Source:" pointer in that case rather than print a plausible-looking path. + */ +function sourcePathFor(category: string, zodFile: string): string | undefined { + const rel = zodFileSourceRel.get(`${category}/${zodFile}`); + return rel ? `packages/spec/src/${category}/${rel}` : undefined; +} + scanCategories(); /** @@ -387,9 +431,10 @@ function generateZodFileMarkdown(zodFile: string, schemas: Array<{name: string, const zodTitle = zodFile.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); // Get source description - const sourcePath = path.join(SRC_DIR, category, `${zodFile}.zod.ts`); + const sourceRel = sourcePathFor(category, zodFile); + const sourcePath = sourceRel ? path.join(REPO_ROOT, sourceRel) : undefined; let fileDesc = ''; - if (fs.existsSync(sourcePath)) { + if (sourcePath && fs.existsSync(sourcePath)) { fileDesc = getFileDescription(fs.readFileSync(sourcePath, 'utf-8')); } @@ -403,9 +448,13 @@ function generateZodFileMarkdown(zodFile: string, schemas: Array<{name: string, md += `${fileDesc}\n\n`; } - md += `\n`; - md += `**Source:** \`packages/spec/src/${category}/${zodFile}.zod.ts\`\n`; - md += `\n\n`; + // Only when there IS one — the `misc` catch-all has no file behind it, and a + // reassembled `packages/spec/src//misc.zod.ts` points at nothing. + if (sourceRel) { + md += `\n`; + md += `**Source:** \`${sourceRel}\`\n`; + md += `\n\n`; + } // Add TypeScript usage example const schemaNames = schemas.map(s => s.name).join(', '); @@ -634,8 +683,9 @@ Object.entries(CATEGORIES).forEach(([category, title]) => { // written and the stale files are still lying around.) if (!wasEmitted(path.join(DOCS_ROOT, category, `${zodFile}.mdx`))) return; const fileTitle = zodFile.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); + const cardSource = sourcePathFor(category, zodFile); // Link relative to the category folder (where index.mdx lives) - mdx += ` \n`; + mdx += ` \n`; }); mdx += `\n`; diff --git a/packages/spec/src/data/datasource.test.ts b/packages/spec/src/data/datasource.test.ts index 72d1265d4f..ba54a9844c 100644 --- a/packages/spec/src/data/datasource.test.ts +++ b/packages/spec/src/data/datasource.test.ts @@ -190,19 +190,19 @@ describe('DatasourceSchema', () => { expect(() => DatasourceSchema.parse({ name: 'valid_datasource_name', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, })).not.toThrow(); expect(() => DatasourceSchema.parse({ name: 'InvalidDatasource', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, })).toThrow(); expect(() => DatasourceSchema.parse({ name: 'invalid-datasource', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, })).toThrow(); }); @@ -210,7 +210,7 @@ describe('DatasourceSchema', () => { const datasource = DatasourceSchema.parse({ name: 'test_db', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, }); expect(datasource.active).toBe(true); @@ -278,13 +278,29 @@ describe('DatasourceSchema', () => { name: 'mongo_db', driver: 'mongo', config: { - connectionString: 'mongodb://localhost:27017/mydb', + url: 'mongodb://localhost:27017/mydb', }, }); expect(datasource.driver).toBe('mongo'); }); + // This fixture used to spell the URI `connectionString`, a key the mongo + // builder never read — so the datasource it described would have connected to + // mongodb://localhost:27017 with no database, and the test asserting it was + // "accepted" was asserting the silence #4410 removed. + it('rejects a mongo config that spells the URI `connectionString`', () => { + const result = DatasourceSchema.safeParse({ + name: 'mongo_db', + driver: 'mongo', + config: { connectionString: 'mongodb://localhost:27017/mydb' }, + }); + + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.path).toEqual(['config']); + expect(result.error!.issues[0]!.message).toContain('`connectionString` → `url`'); + }); + it('should accept Redis datasource', () => { const datasource = DatasourceSchema.parse({ name: 'redis_cache', @@ -341,7 +357,7 @@ describe('DatasourceSchema', () => { const datasource = DatasourceSchema.parse({ name: 'disabled_db', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, active: false, }); @@ -352,7 +368,7 @@ describe('DatasourceSchema', () => { const datasource = DatasourceSchema.parse({ name: 'custom_db', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, capabilities: { queryWindowFunctions: false, querySubqueries: false, @@ -386,26 +402,40 @@ describe('DatasourceSchema', () => { host: 'localhost', port: 5432, database: 'mydb', - pool: { - min: 2, - max: 10, - idleTimeoutMillis: 30000, - }, ssl: { rejectUnauthorized: false, ca: 'certificate_content', }, }, + pool: { + min: 2, + max: 10, + idleTimeoutMillis: 30000, + }, }); - expect(datasource.config.pool).toBeDefined(); + expect(datasource.pool).toBeDefined(); expect(datasource.config.ssl).toBeDefined(); }); + // The fixture above used to nest `pool` INSIDE `config`, where no driver + // reads it — so it asserted a pooled datasource that was running on the + // factory's hardcoded defaults. The rejection now carries the relocation. + it('rejects pool sizing nested inside `config`', () => { + const result = DatasourceSchema.safeParse({ + name: 'complex_db', + driver: 'postgres', + config: { database: 'mydb', pool: { min: 2, max: 10 } }, + }); + + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('`pool: { max: … }`'); + }); + it('should reject datasource without required fields', () => { expect(() => DatasourceSchema.parse({ driver: 'postgres', - config: {}, + config: { database: 'mydb' }, })).toThrow(); expect(() => DatasourceSchema.parse({ @@ -429,7 +459,7 @@ describe('DatasourceSchema - ssl', () => { const result = DatasourceSchema.parse({ name: 'secure_db', driver: 'postgres', - config: { host: 'db.example.com', port: 5432 }, + config: { host: 'db.example.com', port: 5432, database: 'mydb' }, ssl: { enabled: true, rejectUnauthorized: true, @@ -445,7 +475,7 @@ describe('DatasourceSchema - ssl', () => { const result = DatasourceSchema.parse({ name: 'mtls_db', driver: 'postgres', - config: { host: 'db.secure.com' }, + config: { host: 'db.secure.com', database: 'mydb' }, ssl: { enabled: true, ca: '/certs/ca.pem', @@ -461,7 +491,7 @@ describe('DatasourceSchema - ssl', () => { const result = DatasourceSchema.parse({ name: 'ssl_db', driver: 'mysql', - config: {}, + config: { database: 'mydb' }, ssl: { enabled: true }, }); expect(result.ssl?.rejectUnauthorized).toBe(true); @@ -471,7 +501,7 @@ describe('DatasourceSchema - ssl', () => { const result = DatasourceSchema.parse({ name: 'local_db', driver: 'sqlite', - config: { path: './data.db' }, + config: { filename: './data.db' }, }); expect(result.ssl).toBeUndefined(); }); @@ -482,7 +512,7 @@ describe('SchemaMode & External Federation (ADR-0015)', () => { const ds = DatasourceSchema.parse({ name: 'default', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, }); expect(ds.schemaMode).toBe('managed'); expect(ds.external).toBeUndefined(); @@ -502,7 +532,7 @@ describe('SchemaMode & External Federation (ADR-0015)', () => { const ds = DatasourceSchema.parse({ name: 'warehouse', driver: 'postgres', - config: { connectionString: 'postgres://...' }, + config: { url: 'postgres://user@warehouse.internal/analytics' }, schemaMode: 'external', external: { label: 'Analytics Warehouse' }, }); @@ -518,7 +548,7 @@ describe('SchemaMode & External Federation (ADR-0015)', () => { const result = DatasourceSchema.safeParse({ name: 'warehouse', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, schemaMode: 'external', }); expect(result.success).toBe(false); @@ -531,7 +561,7 @@ describe('SchemaMode & External Federation (ADR-0015)', () => { const result = DatasourceSchema.safeParse({ name: 'default', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, schemaMode: 'managed', external: { allowWrites: true }, }); @@ -545,7 +575,7 @@ describe('SchemaMode & External Federation (ADR-0015)', () => { const result = DatasourceSchema.safeParse({ name: 'warehouse', driver: 'postgres', - config: {}, + config: { database: 'mydb' }, schemaMode: 'validate-only', }); expect(result.success).toBe(false); diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index f21e5c4018..c60f5e1c5c 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -9,28 +9,34 @@ import { z } from 'zod'; */ import { lazySchema } from '../shared/lazy-schema'; import { strictUnknownKeyError } from '../shared/suggestions.zod'; +import { validateDriverConfig } from './driver/config-registry.zod'; /* - * ── Unknown-key strictness (#4001 data step) ──────────────────────────────── + * ── Unknown-key strictness (#4001 data step, closed out by #4410) ─────────── * * Every AUTHORING shape in this module is `.strict()`. `datasource` is a * registered metadata type (BUILTIN_METADATA_TYPE_SCHEMAS), so one shape backs * `defineDatasource()`, `defineStack({ datasources })`, the * `/api/v1/meta/datasource` endpoint, and the Setup → Datasources form. * - * TWO ESCAPE HATCHES STAY OPEN, and must: - * - `config` is per-driver by construction (a sqlite `filename` and a - * postgres `host`/`port` share no shape), so it stays `z.record`. - * NOTHING VALIDATES INSIDE IT TODAY — see {@link belongsInConfig}, which - * used to claim otherwise. Tracked as #4410. - * - `readReplicas` carries the same per-driver config objects. + * `config` and `readReplicas` stay `z.record` HERE, because they are per-driver + * by construction: a sqlite `filename` and a postgres `host`/`port` share no + * shape. What they are no longer is unchecked. Since #4410 the refinement on + * {@link DatasourceSchema} parses both against the contract for the declared + * driver (`data/driver/config-registry.zod.ts`), so the openness at this level + * is a shape this level cannot express — not the absence of one. * - * That openness is exactly why the TOP level had to close. Before this, a + * That openness is exactly why the TOP level had to close first. Before #4001, a * connection key written one level too high — `host` next to `driver` instead * of inside `config` — was stripped in silence, and the datasource then * connected on driver defaults (localhost, default port) rather than failing. * A misplaced `password` is the same bug wearing a worse hat, which is why it * is prescribed toward `external.credentialsRef` rather than merely relocated. + * + * A driver the platform ships no contract for (a plugin's + * `com.vendor.snowflake`) keeps an unvalidated `config`. That is the honest + * boundary, not a leftover hole — see the registry's own note on why inventing + * a verdict against a shape we do not have would be worse than the silence. */ /** Keys {@link DriverDefinitionSchema} declares (drift-guarded by datasource.test.ts). */ @@ -67,28 +73,31 @@ const DATASOURCE_RETRY_POLICY_KEYS = ['maxRetries', 'baseDelayMs', 'maxDelayMs', /** * A connection detail written one level too high — it belongs inside `config`. * - * This prescription stops at *where to put it* and deliberately does NOT promise - * that the move gets validated. It used to: the sentence read "the driver's own - * configSchema validates it there", and that was false twice over — - * {@link DriverDefinitionSchema}'s `configSchema` is a `z.record` that both - * bundled driver specs set to `{}`, and nothing in this repo reads it (#4410). - * - * Which made this the worst line in the module: it took an author who had made a - * recoverable mistake at a place that now catches it, and pointed them — with the - * platform's authority — at a slot where the same mistake is silent again. - * `config: { hostname: … }` is stripped in silence and the datasource connects on - * localhost, which is #4001's original bug verbatim, one level down. A wrong - * instruction is worse than none, and worst of all for an AI author, whose only - * check on "did that work?" is whether the parse complained. + * This prescription makes a validation claim again, and #4410 is what made the + * claim true. Between #4001 and #4410 it did not: the sentence read "the + * driver's own configSchema validates it there", which was false twice over — + * {@link DriverDefinitionSchema}'s `configSchema` was a `z.record` both bundled + * driver specs set to `{}`, and nothing read it. That made this the worst line + * in the module: it took an author who had made a *recoverable* mistake at a + * place that catches it, and pointed them — with the platform's authority — at a + * slot where the same mistake was silent again. `config: { hostname: … }` was + * stripped in silence and the datasource connected on localhost, which is + * #4001's original bug verbatim, one level down. A wrong instruction is worse + * than none, and worst of all for an AI author, whose only check on "did that + * work?" is whether the parse complained. * - * Naming the per-driver schema is the honest form: it is the shape to write - * against, and a reader can check themselves against it even while nothing - * enforces it. Restore a validation claim here only when #4410 makes one true. + * Note the SECOND thing #4410 had to fix for this line to be safe: the target + * must be the key the driver contract actually declares. Prescribing + * `config: { user: … }` when the postgres contract spells it `username` would + * have swapped a one-step correction for a two-step one — reject at the top, + * reject again inside — so `canonical` names the landing key, not the one the + * author happened to type. */ -const belongsInConfig = (key: string) => +const belongsInConfig = (key: string, canonical: string = key) => `\`${key}\` is a driver connection detail — it belongs inside \`config\`, not at the top ` - + `level. Move it to \`config: { ${key}: … }\`, matching your driver's config shape ` - + `(\`PostgresConfigSchema\` / \`MongoConfigSchema\` / \`MemoryConfigSchema\` in \`data/driver/\`).`; + + `level. Move it to \`config: { ${canonical}: … }\`, which is parsed against your driver's ` + + `config contract (\`PostgresConfigSchema\` / \`MysqlConfigSchema\` / \`SqliteConfigSchema\` / ` + + `\`MongoConfigSchema\` / \`MemoryConfigSchema\`, exported from \`@objectstack/spec/data\`).`; const driverDefinitionUnknownKeyError = strictUnknownKeyError({ surface: 'this driver definition', @@ -166,11 +175,11 @@ const datasourceUnknownKeyError = strictUnknownKeyError({ host: belongsInConfig('host'), port: belongsInConfig('port'), database: belongsInConfig('database'), - user: belongsInConfig('user'), + user: belongsInConfig('user', 'username'), username: belongsInConfig('username'), filename: belongsInConfig('filename'), url: belongsInConfig('url'), - connectionString: belongsInConfig('connectionString'), + connectionString: belongsInConfig('connectionString', 'url'), password: '`password` must never be inlined on a datasource. Interpolate it from the environment ' + 'inside `config`, or for an external datasource reference the secrets store via ' @@ -300,11 +309,21 @@ export const DriverDefinitionSchema = lazySchema(() => z.object({ /** * Configuration Schema (JSON Schema) - * Describes the structure of the `config` object needed for this driver. - * Used by the UI to generate the connection form. + * + * The structure of the `config` object this driver needs — rendered by the + * Studio connection form (`GET /api/v1/datasources/drivers`) and, for the + * built-in drivers, the JSON-Schema projection of the very zod schema + * `DatasourceSchema` parses `config` against. Form and gate therefore describe + * one shape by construction. + * + * Both bundled driver specs used to set this to `{}`, one of them with a + * comment promising it would be "populated at runtime" by code that did not + * exist; nothing read the field either (#4410). Fill it from a real schema — + * an empty object here means the connection form has nothing to render and + * says so, which is the loud version of the same absence. */ configSchema: z.record(z.string(), z.unknown()).describe('JSON Schema for connection configuration'), - + /** * Default Capabilities * What this driver supports out-of-the-box. @@ -312,6 +331,9 @@ export const DriverDefinitionSchema = lazySchema(() => z.object({ capabilities: z.lazy(() => DatasourceCapabilities).optional(), }, { error: driverDefinitionUnknownKeyError }).strict()); +/** A driver definition — {@link DriverDefinitionSchema}'s parsed shape. */ +export type DriverDefinition = z.infer; + /** * Datasource Capabilities Schema * Declares what this datasource naturally supports. @@ -416,6 +438,30 @@ export const ExternalDatasourceSettingsSchema = z.object({ export type ExternalDatasourceSettings = z.infer; +/** + * Replay a driver-config parse onto the datasource's own issue list (#4410). + * + * A no-op for a driver the platform ships no contract for — `known: false` is + * the registry saying "nothing to check against", which is deliberately NOT the + * same answer as "checked and clean". + */ +function reportDriverConfigIssues( + ctx: z.RefinementCtx, + driver: unknown, + config: unknown, + basePath: (string | number)[], +): void { + const result = validateDriverConfig(driver, config); + if (!result.known) return; + for (const issue of result.issues) { + ctx.addIssue({ + code: 'custom', + path: [...basePath, ...issue.path], + message: issue.message, + }); + } +} + /** * Datasource Schema * Represents a connection to an external data store. @@ -531,6 +577,17 @@ export const DatasourceSchema = lazySchema(() => z.object({ origin: z.enum(['code', 'runtime']).default('code') .describe('Datasource provenance (server-managed, read-only)'), }, { error: datasourceUnknownKeyError }).strict().superRefine((ds, ctx) => { + // The `config` gate (#4410). `config` and each `readReplicas` entry carry the + // same per-driver shape, so both are parsed against the contract for the + // declared driver and every issue is re-pathed under the slot it came from — + // the author sees `config.hostname`, not a detached message. + reportDriverConfigIssues(ctx, ds.driver, ds.config, ['config']); + if (Array.isArray(ds.readReplicas)) { + ds.readReplicas.forEach((replica, index) => { + reportDriverConfigIssues(ctx, ds.driver, replica, ['readReplicas', index]); + }); + } + if (ds.schemaMode !== 'managed' && !ds.external) { ctx.addIssue({ code: 'custom', diff --git a/packages/spec/src/data/driver/common.zod.ts b/packages/spec/src/data/driver/common.zod.ts new file mode 100644 index 0000000000..d026221e45 --- /dev/null +++ b/packages/spec/src/data/driver/common.zod.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +/** + * Shared building blocks for the per-driver `datasource.config` shapes (#4410). + * + * Every schema under `data/driver/` describes ONE driver's `config` slot — the + * keys an author may write and the platform actually reads. They are the + * enforcement half of the `config` escape hatch `datasource.zod.ts` opens: the + * slot stays `z.record` at the top of `DatasourceSchema` because a sqlite + * `filename` and a postgres `host` share no shape, and `DatasourceSchema`'s + * refinement then parses it against the schema for the declared driver. + * + * The rule these files are written to: **a key is declared here only if some + * code path reads it.** A config key that no driver and no factory consumes is + * the same silent-strip defect one level down (#4001, ADR-0078), so an unread + * key is either wired or rejected with a prescription — never left in the + * contract to look supported. + */ + +/** + * Dev-only, loosen-only schema self-heal (#2186), honoured by the SQL drivers. + * + * Read by `createDefaultDatasourceDriverFactory` and passed to + * `SqlDriver.autoMigrate`; force-disabled under `NODE_ENV=production`. `'safe'` + * applies only non-destructive alters (relax NOT NULL, widen varchar). + */ +export const SqlAutoMigrateSchema = z.enum(['off', 'safe']) + .describe('Dev-only non-destructive schema self-heal (#2186)'); + +export type SqlAutoMigrate = z.infer; + +/** + * `schemaMode` written inside `config`. Shared by every SQL driver: the factory + * used to look for it there because the datasource-level key was dropped + * between the record and the connection spec, so the nested copy was the only + * spelling that reached a driver. #4410 carries the declared key down instead. + */ +export const SCHEMA_MODE_BELONGS_ON_DATASOURCE = + '`schemaMode` is a datasource-level key, not driver config. Write it next to `driver` ' + + "(`schemaMode: 'external'`) — the connection service now carries it down to the driver, so " + + 'the copy inside `config` is gone rather than duplicated.'; + +/** `readOnly` written inside `config`. Shared by every driver. */ +export const READ_ONLY_BELONGS_ON_DATASOURCE = + '`readOnly` is not driver config. Use `capabilities: { readOnly: true }` on the datasource to ' + + 'declare the connection read-only, or `external.allowWrites: false` for a federated database.'; + +/** + * TLS on/off for a SQL driver — the shorthand, and deliberately ONLY the + * shorthand. + * + * Certificates live in the datasource's own `ssl` block (`enabled`, + * `rejectUnauthorized`, `ca`, `cert`, `key`), which #4410 wired through to the + * client; before that it was declared, strict, documented and read by nobody, + * so the only TLS setting that did anything was this per-driver one. Two slots + * for the same setting is one too many, and this is the one that has to stay + * narrow: it is what the Studio connection form renders from, and the form + * turns anything that is not a boolean / enum / number into a TEXT INPUT. A + * `boolean | object` union here would hand the wizard a text box whose every + * value the gate then rejects — a form that cannot produce a saveable record. + */ +export const DriverSslToggleSchema = z.boolean() + .describe('Enable TLS. Certificates go in the datasource-level `ssl` block.'); + +/** Where the certificate-bearing form of TLS lives. */ +export const SSL_DETAIL_BELONGS_ON_DATASOURCE = + 'Certificates and verification live in the datasource-level `ssl` block, not in driver config: ' + + '`ssl: { enabled: true, rejectUnauthorized: false, ca: … }` next to `driver`. Inside `config`, ' + + '`ssl` is the on/off shorthand only.'; + +/** Options every driver-config JSON-Schema projection is built with. */ +const TO_JSON_SCHEMA = { + target: 'draft-2020-12', + // The AUTHOR-facing shape: a key with a `.default()` is optional to write. + io: 'input', + // The memory driver's `persistence` accepts a custom adapter — an object of + // functions, which has no JSON-Schema form. Emitting `{}` for it keeps the + // connection form renderable instead of throwing at boot (#3746 hazard). + unrepresentable: 'any', +} as const; + +/** + * Memoized JSON-Schema projection of a driver-config schema. + * + * One projection per schema, computed on first use and cached: this is what + * `DriverDefinitionSchema.configSchema` publishes and what the Studio + * connection form renders, so the form and the parse gate cannot describe + * different shapes — they are the same zod object seen twice. + */ +export function driverConfigJsonSchema(schema: z.ZodType): () => Record { + let cached: Record | undefined; + return () => { + if (cached === undefined) { + cached = z.toJSONSchema(schema, TO_JSON_SCHEMA) as Record; + } + return cached; + }; +} diff --git a/packages/spec/src/data/driver/config-registry.test.ts b/packages/spec/src/data/driver/config-registry.test.ts new file mode 100644 index 0000000000..a6c0700a20 --- /dev/null +++ b/packages/spec/src/data/driver/config-registry.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import { DatasourceSchema } from '../datasource.zod'; +import { + BUILTIN_DRIVER_IDS, + DRIVER_CONFIG_SCHEMAS, + DRIVER_ID_ALIASES, + getDriverConfigJsonSchemaById, + getDriverConfigSchema, + resolveDriverId, + validateDriverConfig, +} from './config-registry.zod'; + +describe('driver config registry', () => { + it('ships a schema and a JSON-Schema projection for every canonical id', () => { + for (const id of BUILTIN_DRIVER_IDS) { + expect(DRIVER_CONFIG_SCHEMAS[id], id).toBeTruthy(); + const json = getDriverConfigJsonSchemaById(id) as { type?: string; properties?: object }; + expect(json.type, id).toBe('object'); + expect(json.properties, id).toBeTruthy(); + } + }); + + it('memoizes each projection so the form and the gate share one object', () => { + expect(getDriverConfigJsonSchemaById('postgres')).toBe(getDriverConfigJsonSchemaById('postgres')); + }); + + it('resolves every alias onto a canonical id that has a contract', () => { + for (const [alias, canonical] of Object.entries(DRIVER_ID_ALIASES)) { + expect(resolveDriverId(alias), alias).toBe(canonical); + expect(BUILTIN_DRIVER_IDS).toContain(canonical); + } + }); + + it('resolves case- and whitespace-insensitively', () => { + expect(resolveDriverId(' PostgreSQL ')).toBe('postgres'); + expect(resolveDriverId('MongoDB')).toBe('mongo'); + }); + + /** + * The distinction the whole gate rests on: "nothing to check against" is not + * the same answer as "checked and clean", and a caller that conflates them + * reintroduces the silence #4410 removed. + */ + it('reports an unknown driver as unknown rather than as valid', () => { + expect(resolveDriverId('com.vendor.snowflake')).toBeUndefined(); + expect(getDriverConfigSchema('com.vendor.snowflake')).toBeUndefined(); + expect(validateDriverConfig('com.vendor.snowflake', { whatever: 1 })).toEqual({ known: false }); + }); + + it('validates a known driver and returns path-relative issues', () => { + const result = validateDriverConfig('pg', { database: 'app', hostname: 'db.internal' }); + + expect(result.known).toBe(true); + expect(result).toHaveProperty('issues'); + const issues = (result as { issues: Array<{ path: unknown[]; message: string }> }).issues; + expect(issues).toHaveLength(1); + expect(issues[0]!.message).toContain('`hostname` → `host`'); + }); +}); + +describe('DatasourceSchema × driver config (#4410)', () => { + const base = { name: 'warehouse', driver: 'postgres' }; + + /** + * The reported bug, verbatim: the correct key is `host`, `hostname` was + * accepted in silence, and the datasource then connected to localhost while + * reporting success — which for an AI author is indistinguishable from having + * configured it. + */ + it('rejects a misspelled connection key inside config, pathed at the key', () => { + const result = DatasourceSchema.safeParse({ + ...base, + config: { hostname: 'db.internal', database: 'analytics' }, + }); + + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.path).toEqual(['config']); + expect(result.error!.issues[0]!.message).toContain('`hostname` → `host`'); + }); + + it('accepts the corrected config', () => { + const result = DatasourceSchema.safeParse({ + ...base, + config: { host: 'db.internal', database: 'analytics' }, + }); + + expect(result.success).toBe(true); + }); + + it('leaves a plugin-contributed driver`s config alone', () => { + const result = DatasourceSchema.safeParse({ + name: 'warehouse', + driver: 'com.vendor.snowflake', + config: { account: 'xy12345', warehouse: 'COMPUTE_WH' }, + }); + + expect(result.success).toBe(true); + }); + + it('validates every driver id alias the same way', () => { + for (const alias of ['pg', 'postgresql', 'POSTGRES']) { + const result = DatasourceSchema.safeParse({ + name: 'warehouse', + driver: alias, + config: { hostname: 'db.internal', database: 'analytics' }, + }); + expect(result.success, alias).toBe(false); + } + }); + + /** + * `readReplicas` is the sibling escape hatch — same per-driver shape, same + * silence before #4410. Reported per index so the author is told WHICH + * replica is wrong. + */ + it('validates each readReplicas entry and paths issues by index', () => { + const result = DatasourceSchema.safeParse({ + ...base, + config: { host: 'db.internal', database: 'analytics' }, + readReplicas: [ + { host: 'replica-1.internal', database: 'analytics' }, + { hostname: 'replica-2.internal', database: 'analytics' }, + ], + }); + + expect(result.success).toBe(false); + expect(result.error!.issues).toHaveLength(1); + expect(result.error!.issues[0]!.path).toEqual(['readReplicas', 1]); + }); + + it('rejects a sqlite datasource whose filename is misspelled', () => { + const result = DatasourceSchema.safeParse({ + name: 'local', + driver: 'sqlite', + config: { file: './data.db' }, + }); + + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('`file` → `filename`'); + }); + + it('still reports the schemaMode/external coherence rule alongside a config problem', () => { + const result = DatasourceSchema.safeParse({ + name: 'warehouse', + driver: 'postgres', + config: { hostname: 'db.internal', database: 'analytics' }, + schemaMode: 'external', + }); + + expect(result.success).toBe(false); + const paths = result.error!.issues.map((i) => i.path.join('.')); + expect(paths).toContain('config'); + expect(paths).toContain('external'); + }); +}); diff --git a/packages/spec/src/data/driver/config-registry.zod.ts b/packages/spec/src/data/driver/config-registry.zod.ts new file mode 100644 index 0000000000..b0babf21ff --- /dev/null +++ b/packages/spec/src/data/driver/config-registry.zod.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { z } from 'zod'; + +import { getMemoryConfigJsonSchema, MemoryConfigSchema } from './memory.zod'; +import { getMongoConfigJsonSchema, MongoConfigSchema } from './mongo.zod'; +import { getMysqlConfigJsonSchema, MysqlConfigSchema } from './mysql.zod'; +import { getPostgresConfigJsonSchema, PostgresConfigSchema } from './postgres.zod'; +import { + getSqliteConfigJsonSchema, + getSqliteWasmConfigJsonSchema, + SqliteConfigSchema, + SqliteWasmConfigSchema, +} from './sqlite.zod'; + +/** + * The driver-id → `datasource.config` shape registry (#4410). + * + * ## Why this exists + * + * `DatasourceSchema` went `.strict()` in #4207 with `config` deliberately left + * open — per-driver by construction, a sqlite `filename` and a postgres `host` + * share no shape. The module comment justified the hole by saying "the driver's + * own `configSchema` is what validates it". Nothing did: the two bundled driver + * specs set `configSchema: {}`, no code read the field, and the three per-driver + * zod schemas were not even exported from the package. So the one slot an author + * writes by hand was the one slot with no gate, and `config: { hostname: … }` + * connected to localhost while reporting success. + * + * This registry closes that: it maps every driver id the platform can actually + * BUILD onto the schema for that driver's config, and `DatasourceSchema` parses + * `config` (and each `readReplicas` entry) against it. + * + * ## Where the boundary is + * + * An id this registry does not know stays unvalidated, and that is deliberate + * rather than a remaining hole: `driver` is an open namespace — a plugin ships + * `com.vendor.snowflake` with its own config shape, and rejecting keys against a + * shape we do not have would be worse than the silence it replaces. The honest + * line is "we validate what we can construct", and + * {@link BUILTIN_DRIVER_IDS} is exactly the set the shared + * `createDefaultDatasourceDriverFactory` builds. + * + * ## Why the alias table lives HERE + * + * The factory had its own copy. Two tables meant the id that selects a driver + * and the id that selects its config schema could disagree — validating a `pg` + * datasource against nothing while building it as postgres — so the factory now + * imports {@link resolveDriverId} instead of keeping a second list. + */ + +/** Canonical driver ids the platform ships a config contract for. */ +export const BUILTIN_DRIVER_IDS = [ + 'memory', + 'sqlite', + 'sqlite-wasm', + 'postgres', + 'mysql', + 'mongo', +] as const; + +export type BuiltinDriverId = (typeof BUILTIN_DRIVER_IDS)[number]; + +/** + * Accepted spellings of each canonical driver id, matched case-insensitively. + * + * These are DRIVER SELECTORS, not config keys: `driver: 'pg'` and + * `driver: 'postgres'` build the same driver, so they must resolve to the same + * config contract. (Unknown-key tolerance inside `config` is a different + * question, and the answer there is a rejection with a rename hint.) + */ +export const DRIVER_ID_ALIASES: Readonly> = { + memory: 'memory', + inmemory: 'memory', + 'in-memory': 'memory', + mingo: 'memory', + sqlite: 'sqlite', + sqlite3: 'sqlite', + 'better-sqlite3': 'sqlite', + 'sqlite-wasm': 'sqlite-wasm', + 'wasm-sqlite': 'sqlite-wasm', + postgres: 'postgres', + postgresql: 'postgres', + pg: 'postgres', + mysql: 'mysql', + mysql2: 'mysql', + mariadb: 'mysql', + mongo: 'mongo', + mongodb: 'mongo', +}; + +/** + * Resolve an authored `datasource.driver` onto its canonical id, or `undefined` + * when the platform ships no contract for it (a plugin-contributed driver). + */ +export function resolveDriverId(driver: unknown): BuiltinDriverId | undefined { + if (typeof driver !== 'string') return undefined; + return DRIVER_ID_ALIASES[driver.trim().toLowerCase()]; +} + +/** Canonical driver id → the schema its `datasource.config` must satisfy. */ +export const DRIVER_CONFIG_SCHEMAS: Readonly> = { + memory: MemoryConfigSchema, + sqlite: SqliteConfigSchema, + 'sqlite-wasm': SqliteWasmConfigSchema, + postgres: PostgresConfigSchema, + mysql: MysqlConfigSchema, + mongo: MongoConfigSchema, +}; + +/** + * The config schema for an authored `driver` value, following aliases. + * `undefined` means "no contract shipped" — the caller must leave the config + * alone rather than invent a verdict for it. + */ +export function getDriverConfigSchema(driver: unknown): z.ZodType | undefined { + const id = resolveDriverId(driver); + return id ? DRIVER_CONFIG_SCHEMAS[id] : undefined; +} + +/** Canonical driver id → the memoized JSON-Schema projection of its config shape. */ +const DRIVER_CONFIG_JSON_SCHEMAS: Readonly Record>> = { + memory: getMemoryConfigJsonSchema, + sqlite: getSqliteConfigJsonSchema, + 'sqlite-wasm': getSqliteWasmConfigJsonSchema, + postgres: getPostgresConfigJsonSchema, + mysql: getMysqlConfigJsonSchema, + mongo: getMongoConfigJsonSchema, +}; + +/** + * JSON-Schema projection of a built-in driver's config contract — what + * `DriverDefinitionSchema.configSchema` publishes and what the Studio + * connection form renders. + * + * Takes a CANONICAL id (not an alias) so a caller enumerating drivers cannot + * quietly get `undefined` for a spelling it thought was covered; use + * {@link resolveDriverId} first when the id came from authored metadata. + */ +export function getDriverConfigJsonSchemaById(id: BuiltinDriverId): Record { + return DRIVER_CONFIG_JSON_SCHEMAS[id](); +} + +/** One problem found in a `datasource.config`, path-relative to the config object. */ +export interface DriverConfigIssue { + /** Property path inside `config` (empty for a whole-object problem). */ + path: (string | number)[]; + message: string; +} + +/** + * Validate a `datasource.config` against its driver's contract. + * + * Returns `{ known: false }` for a driver the platform ships no contract for, + * so callers can distinguish "checked and clean" from "nothing to check + * against" — a distinction the silent-strip failure mode depends on nobody + * making. Never throws. + */ +export function validateDriverConfig( + driver: unknown, + config: unknown, +): { known: false } | { known: true; issues: DriverConfigIssue[] } { + const schema = getDriverConfigSchema(driver); + if (!schema) return { known: false }; + const result = schema.safeParse(config ?? {}); + if (result.success) return { known: true, issues: [] }; + return { + known: true, + issues: result.error.issues.map((issue) => ({ + path: [...issue.path] as (string | number)[], + message: issue.message, + })), + }; +} diff --git a/packages/spec/src/data/driver/index.ts b/packages/spec/src/data/driver/index.ts new file mode 100644 index 0000000000..16a8b904d0 --- /dev/null +++ b/packages/spec/src/data/driver/index.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Per-driver `datasource.config` contracts (#4410). + * + * These shapes existed since the protocol's early days and were reachable from + * nothing — no barrel exported `data/driver/`, so the schemas `datasource.zod.ts` + * told authors to write against could not even be imported. They are exported + * here because they are now load-bearing: `DatasourceSchema` parses `config` + * against them, `DriverDefinitionSchema.configSchema` publishes their JSON-Schema + * projection, and the Studio connection form renders from that same projection. + */ + +export * from './common.zod'; +export * from './config-registry.zod'; +export * from './memory.zod'; +export * from './mongo.zod'; +export * from './mysql.zod'; +export * from './postgres.zod'; +export * from './sqlite.zod'; diff --git a/packages/spec/src/data/driver/memory.test.ts b/packages/spec/src/data/driver/memory.test.ts index b5098ea798..5a2c8a351c 100644 --- a/packages/spec/src/data/driver/memory.test.ts +++ b/packages/spec/src/data/driver/memory.test.ts @@ -25,8 +25,6 @@ describe('MemoryConfigSchema', () => { expect(config.strictMode).toBe(false); expect(config.initialData).toBeUndefined(); expect(config.persistence).toBe(false); - expect(config.indexes).toBeUndefined(); - expect(config.maxRecordsPerObject).toBeUndefined(); }); it('still accepts "auto" as an explicit opt-in to the previous behaviour', () => { @@ -179,25 +177,26 @@ describe('MemoryConfigSchema', () => { expect(typeof p.adapter.flush).toBe('function'); }); - it('should accept config with indexes', () => { - const config = MemoryConfigSchema.parse({ - indexes: { - users: ['email', 'role'], - posts: ['author_id', 'status'], - }, + // `indexes` and `maxRecordsPerObject` were declared here and read by nobody: + // `InMemoryDriverConfig` has no field for either, the driver keeps no indexes + // (every read is a linear Mingo scan) and evicts nothing. These tests used to + // assert they were "accepted" — which was true, and meant nothing. #4410 gave + // `config` a gate, so a key inside it now claims to be honoured; both were + // removed rather than blessed, and the rejection carries the reason. + it('rejects `indexes`, which the memory driver never kept', () => { + const result = MemoryConfigSchema.safeParse({ + indexes: { users: ['email', 'role'] }, }); - expect(config.indexes).toBeDefined(); - expect(config.indexes!.users).toEqual(['email', 'role']); - expect(config.indexes!.posts).toEqual(['author_id', 'status']); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('the memory driver keeps no indexes'); }); - it('should accept config with maxRecordsPerObject', () => { - const config = MemoryConfigSchema.parse({ - maxRecordsPerObject: 10000, - }); + it('rejects `maxRecordsPerObject`, which the memory driver never enforced', () => { + const result = MemoryConfigSchema.safeParse({ maxRecordsPerObject: 10000 }); - expect(config.maxRecordsPerObject).toBe(10000); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('the memory driver evicts nothing'); }); it('should accept full config with all options', () => { @@ -211,18 +210,12 @@ describe('MemoryConfigSchema', () => { path: '/var/data/memory.json', autoSaveInterval: 3000, }, - indexes: { - users: ['email'], - }, - maxRecordsPerObject: 50000, }); expect(config.strictMode).toBe(true); expect(config.initialData!.users).toHaveLength(1); const p = config.persistence as { type: 'file'; path?: string }; expect(p.path).toBe('/var/data/memory.json'); - expect(config.indexes!.users).toEqual(['email']); - expect(config.maxRecordsPerObject).toBe(50000); }); it('should reject file persistence with invalid autoSaveInterval', () => { diff --git a/packages/spec/src/data/driver/memory.zod.ts b/packages/spec/src/data/driver/memory.zod.ts index 9a732840f9..321fe233f5 100644 --- a/packages/spec/src/data/driver/memory.zod.ts +++ b/packages/spec/src/data/driver/memory.zod.ts @@ -1,11 +1,18 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { DriverDefinitionSchema } from '../datasource.zod'; + +import { strictUnknownKeyError } from '../../shared/suggestions.zod'; +import type { DriverDefinition } from '../datasource.zod'; +import { + driverConfigJsonSchema, + READ_ONLY_BELONGS_ON_DATASOURCE, + SCHEMA_MODE_BELONGS_ON_DATASOURCE, +} from './common.zod'; /** * Memory Driver Configuration Schema - * + * * Defines the configuration options for the in-memory driver. * Reference: objectql/packages/drivers/memory (Mingo-powered production-ready driver) * @@ -158,6 +165,50 @@ export const MemoryPersistenceConfigSchema = lazySchema(() => z.union([ // 2. Connection Configuration // ========================================================================== +const MEMORY_CONFIG_KEYS = ['initialData', 'strictMode', 'persistence'] as const; + +/** + * Two keys were declared here and read by nobody: `indexes` and + * `maxRecordsPerObject`. `InMemoryDriverConfig` (`driver-memory`) has no field + * for either — the driver indexes nothing (its reads are a linear Mingo scan) + * and evicts nothing (there is no LRU) — so an author who bounded a store or + * asked for an index got a clean parse and no behaviour. #4410's enforce step + * is what surfaced them: giving `config` a gate means every key inside it now + * claims to be honoured, so a key that is not gets removed rather than blessed. + * Both are rejected with the prescription below (ADR-0049 enforce-or-remove). + */ +const memoryConfigUnknownKeyError = strictUnknownKeyError({ + surface: "this memory datasource's config", + knownKeys: MEMORY_CONFIG_KEYS, + aliases: { + data: 'initialData', + seed: 'initialData', + seeddata: 'initialData', + strict: 'strictMode', + persist: 'persistence', + persistent: 'persistence', + }, + guidance: { + indexes: + '`indexes` was declared but never read: the memory driver keeps no indexes — every read is ' + + 'a linear Mingo scan — so it changed nothing. Drop it, or move the datasource to a ' + + 'driver that indexes (`sqlite` / `postgres`), where object-level `indexes` apply.', + maxRecordsPerObject: + '`maxRecordsPerObject` was declared but never read: the memory driver evicts nothing, so a ' + + 'bound here was never enforced and the store grew unbounded regardless. Drop it and bound ' + + 'the data you load, or use a driver with real storage limits.', + filename: + '`filename` is a sqlite key. For a memory datasource that survives restarts set ' + + "`persistence: 'file'` (the file is scoped per datasource); for a real file-backed SQL " + + "database set `driver: 'sqlite'`.", + schemaMode: SCHEMA_MODE_BELONGS_ON_DATASOURCE, + readOnly: READ_ONLY_BELONGS_ON_DATASOURCE, + }, + history: + 'Until #4410 nothing validated `datasource.config` at all — an unrecognised key was accepted ' + + 'in silence and the store came up on the driver defaults instead.', +}); + export const MemoryConfigSchema = lazySchema(() => z.object({ /** * Initial data to pre-populate the in-memory store. @@ -238,45 +289,41 @@ export const MemoryConfigSchema = lazySchema(() => z.object({ * so two pools that DO opt in still need it to avoid aliasing one file. */ persistence: MemoryPersistenceConfigSchema.or(z.literal(false)).default(false).describe('Persistence configuration (opt-in; defaults to pure in-memory)'), +}, { error: memoryConfigUnknownKeyError }).strict() + .describe('Memory Driver Connection Configuration')); - /** - * Fields to index for faster lookups. - * Maps object names to arrays of field names to index. - * - * @example - * { - * users: ['email', 'role'], - * posts: ['author_id', 'status'] - * } - */ - indexes: z.record( - z.string(), - z.array(z.string()) - ).optional().describe('Index configuration per object'), - - /** - * Maximum number of records per object type. - * When exceeded, oldest records may be evicted (LRU). - * Useful for caching or bounded memory usage. - */ - maxRecordsPerObject: z.number().min(1).optional().describe('Max records per object (memory bound)'), - -}).describe('Memory Driver Connection Configuration')); +/** + * JSON-Schema projection of {@link MemoryConfigSchema}, memoized — what + * {@link MemoryDriverSpec} publishes as its `configSchema`. + * + * The custom-adapter branch of `persistence` is an object of functions, which + * has no JSON-Schema form; the shared projection emits `{}` for it rather than + * throwing, so the connection form stays renderable. + */ +export const getMemoryConfigJsonSchema = driverConfigJsonSchema(MemoryConfigSchema); // ========================================================================== // 3. Driver Definition (Metadata) // ========================================================================== /** - * The static definition of the Memory driver's capabilities and default metadata. - * Implements the `DriverDefinitionSchema` contract. + * The static definition of the Memory driver's capabilities and default + * metadata, satisfying the `DriverDefinitionSchema` contract (proved by + * `memory.test.ts`, which parses this constant). + * + * `configSchema` was `{}` here — a declared slot that nothing filled and + * nothing read (#4410). It now projects {@link MemoryConfigSchema}, lazily, so + * the shape the connection form renders and the shape `DatasourceSchema` + * enforces are the same object seen twice. */ -export const MemoryDriverSpec = DriverDefinitionSchema.parse({ +export const MemoryDriverSpec = { id: 'memory', label: 'In-Memory', description: 'High-performance in-memory driver powered by Mingo (MongoDB-compatible query engine). Supports filtering, aggregation pipelines, sorting, projection.', icon: 'memory', - configSchema: {}, + get configSchema() { + return getMemoryConfigJsonSchema(); + }, capabilities: { transactions: true, // Query @@ -290,10 +337,12 @@ export const MemoryDriverSpec = DriverDefinitionSchema.parse({ querySubqueries: false, // No full-text search (linear scan) fullTextSearch: false, + // Not read-only + readOnly: false, // Dynamic schema (no DDL needed) dynamicSchema: true, }, -}); +} satisfies DriverDefinition; // ========================================================================== // 4. Derived Types diff --git a/packages/spec/src/data/driver/mongo.zod.ts b/packages/spec/src/data/driver/mongo.zod.ts index c41c6a00a6..a41922dbb2 100644 --- a/packages/spec/src/data/driver/mongo.zod.ts +++ b/packages/spec/src/data/driver/mongo.zod.ts @@ -1,78 +1,163 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { DriverDefinitionSchema } from '../datasource.zod'; + +import { lazySchema } from '../../shared/lazy-schema'; +import { strictUnknownKeyError } from '../../shared/suggestions.zod'; +import type { DriverDefinition } from '../datasource.zod'; +import { + driverConfigJsonSchema, + READ_ONLY_BELONGS_ON_DATASOURCE, + SCHEMA_MODE_BELONGS_ON_DATASOURCE, +} from './common.zod'; /** * MongoDB Standard Driver Protocol * * Describes the MongoDB connection settings and capabilities. * - * CONTRACT ONLY — nothing parses `datasource.config` against this. This block - * used to claim it was "used by the Platform to validate `datasource.config` - * when `driver: 'mongo'`", which was never true: the config slot is a `z.record` - * and this schema has no consumer (#4410). It is the shape to author against, - * not a gate that runs. Say "validates" here again only once #4410 lands. + * ENFORCED as of #4410. This block used to claim it was "used by the Platform + * to validate `datasource.config` when `driver: 'mongo'`", which was false: the + * config slot was a bare `z.record` and this schema had no consumer at all — + * not even an export, since `data/driver/` was reachable only from its own + * tests. It is now what `DatasourceSchema` parses `config` against for a mongo + * datasource, and the same schema is projected onto + * {@link MongoDriverSpec}.configSchema for the connection form. */ // ========================================================================== // 1. Connection Configuration // ========================================================================== -import { lazySchema } from '../../shared/lazy-schema'; +const MONGO_CONFIG_KEYS = [ + 'url', 'host', 'port', 'database', 'username', 'password', 'authSource', 'options', +] as const; + +const mongoConfigUnknownKeyError = strictUnknownKeyError({ + surface: "this mongo datasource's config", + knownKeys: MONGO_CONFIG_KEYS, + aliases: { + uri: 'url', + connectionstring: 'url', + dsn: 'url', + hostname: 'host', + server: 'host', + dbname: 'database', + db: 'database', + user: 'username', + passwd: 'password', + pwd: 'password', + authdb: 'authSource', + authdatabase: 'authSource', + replicaset: 'options', + }, + guidance: { + pool: + '`pool` is not driver config — connection pooling is configured once for every driver in ' + + "the datasource's own `pool` block, which the factory maps onto the Mongo client's " + + '`minPoolSize`/`maxPoolSize`. Move it next to `driver`.', + schemaMode: SCHEMA_MODE_BELONGS_ON_DATASOURCE, + readOnly: READ_ONLY_BELONGS_ON_DATASOURCE, + ssl: + '`ssl` is not a top-level mongo key. TLS is a connection-string concern here: put it in ' + + '`url` (`?tls=true`) or in the `options` passthrough the Mongo client reads.', + }, + history: + 'Until #4410 nothing validated `datasource.config` at all — an unrecognised connection key ' + + 'was accepted in silence and the datasource then connected to mongodb://localhost:27017 ' + + 'rather than failing.', +}); + export const MongoConfigSchema = lazySchema(() => z.object({ /** - * Connection URI (Standard Connection String) - * If provided, host/port/username/password fields may be ignored or merged depending on driver logic. - * Format: mongodb://[username:password@]host1[:port1][,...hostN[:portN]][/[defaultauthdb][?options]] + * Connection URI (standard connection string). When present it supersedes + * `host`/`port`/`database`/`username`/`authSource` — those are only used to + * COMPOSE a URI when none is given. + * Format: `mongodb://[username:password@]host1[:port1][,…][/[db][?options]]` */ - url: z.string().describe('Connection URI').optional(), + url: z.string().optional().describe('Connection URI (supersedes the discrete fields)') + .meta({ title: 'Connection URI' }), /** - * Database Name (Required) - * The logical database to store collections. + * Database name — the logical database holding the collections. + * Required unless `url` carries it. */ - database: z.string().min(1).describe('Database Name'), + database: z.string().min(1).optional().describe('Database name').meta({ title: 'Database' }), + + /** Hostname. Used only when `url` is absent. */ + host: z.string().default('localhost').describe('Host address').meta({ title: 'Host' }), - /** Hostname (Optional if url is provided) */ - host: z.string().default('127.0.0.1').describe('Host address').optional(), + /** Port. Used only when `url` is absent. */ + port: z.number().int().default(27017).describe('Port number').meta({ title: 'Port' }), - /** Port (Optional, default 27017) */ - port: z.number().int().default(27017).describe('Port number').optional(), + /** Authentication user. Used only when `url` is absent. */ + username: z.string().optional().describe('Authentication user').meta({ title: 'User' }), - /** Username for authentication */ - username: z.string().describe('Authentication Username').optional(), + /** + * Authentication password. Prefer `external.credentialsRef` — a datasource + * secret always wins over this value. + */ + password: z.string().optional() + .describe('Authentication password (prefer external.credentialsRef)') + .meta({ title: 'Password', format: 'password' }), - /** Password for authentication */ - password: z.string().describe('Authentication Password').optional(), - - /** Authentication Database (Defaults to admin or database name) */ - authSource: z.string().describe('Authentication Database').optional(), + /** Authentication database, when it differs from `database`. */ + authSource: z.string().optional().describe('Authentication database') + .meta({ title: 'Auth source' }), /** - * Connection Options - * Passthrough options to the underlying MongoDB driver (e.g. valid certs, timeouts) + * Passthrough options handed to the MongoDB client verbatim + * (`replicaSet`, `tls`, timeouts, …). */ - options: z.record(z.string(), z.unknown()).describe('Extra driver options (ssl, poolSize, etc)').optional(), -}).describe('MongoDB Connection Configuration')); + options: z.record(z.string(), z.unknown()).optional() + .describe('Extra MongoClient options (replicaSet, tls, timeouts, …)'), +}, { error: mongoConfigUnknownKeyError }).strict() + .describe('MongoDB Connection Configuration') + .superRefine((cfg, ctx) => { + if (!cfg.url && !cfg.database) { + ctx.addIssue({ + code: 'custom', + path: ['database'], + message: + 'A mongo datasource needs a connection target: set `database` (with `host`/`port`) or ' + + 'a full `url`. Neither was given, so the connection would fall back to ' + + 'mongodb://localhost:27017 with no database selected.', + }); + } + })); + +/** + * JSON-Schema projection of {@link MongoConfigSchema}, memoized — what + * {@link MongoDriverSpec} publishes as its `configSchema`. + */ +export const getMongoConfigJsonSchema = driverConfigJsonSchema(MongoConfigSchema); // ========================================================================== // 2. Driver Definition (Metadata) // ========================================================================== /** - * The static definition of the Mongo driver's capabilities and default metadata. - * This implements the `DriverDefinitionSchema` contract. + * The static definition of the Mongo driver's capabilities and default + * metadata, satisfying the `DriverDefinitionSchema` contract (proved by + * `mongo.test.ts`, which parses this constant). + * + * `configSchema` is a getter so the JSON-Schema projection is computed on first + * read rather than at module load — the same deferral `lazySchema` exists for, + * and what lets this constant drop its runtime import of `DatasourceSchema`'s + * module (a `.parse()` at module scope would have made the config registry and + * this file a cycle). It used to be `{}` with a comment promising it would be + * "populated with a JSON Schema version of MongoConfigSchema at runtime"; no + * such code ever existed (#4410), so the promise is discharged here rather than + * described. */ -export const MongoDriverSpec = DriverDefinitionSchema.parse({ +export const MongoDriverSpec = { id: 'mongo', label: 'MongoDB', description: 'Official MongoDB Driver for ObjectStack. Supports rich queries, aggregation, and atomic updates.', icon: 'database', - // Empty, and nothing fills it. This comment used to promise the field would be - // "populated with a JSON Schema version of MongoConfigSchema at runtime" — no - // such code exists here or in any consumer (#4410). - configSchema: {}, + get configSchema() { + return getMongoConfigJsonSchema(); + }, capabilities: { transactions: true, // Query @@ -80,11 +165,15 @@ export const MongoDriverSpec = DriverDefinitionSchema.parse({ queryAggregations: true, querySorting: true, queryPagination: true, + queryWindowFunctions: false, + querySubqueries: false, + joins: false, fullTextSearch: true, + readOnly: false, // Schema dynamicSchema: true, - } -}); + }, +} satisfies DriverDefinition; /** * Derived Types diff --git a/packages/spec/src/data/driver/mysql.zod.ts b/packages/spec/src/data/driver/mysql.zod.ts new file mode 100644 index 0000000000..9bb6f6fcb3 --- /dev/null +++ b/packages/spec/src/data/driver/mysql.zod.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +import { lazySchema } from '../../shared/lazy-schema'; +import { strictUnknownKeyError } from '../../shared/suggestions.zod'; +import { + driverConfigJsonSchema, + DriverSslToggleSchema, + READ_ONLY_BELONGS_ON_DATASOURCE, + SCHEMA_MODE_BELONGS_ON_DATASOURCE, + SqlAutoMigrateSchema, + SSL_DETAIL_BELONGS_ON_DATASOURCE, +} from './common.zod'; + +/** + * MySQL / MariaDB driver configuration — the `config` slot of a `datasource` + * whose `driver` resolves to `mysql` (`mysql2`). + * + * The driver id was offered by the connection form and buildable by the shared + * factory long before #4410, but had no config shape at all in `packages/spec` + * — postgres, mongo and memory each had one and mysql did not, so its `config` + * was the one slot with neither a gate nor a documented shape. + * + * Every key here is read by `createDefaultDatasourceDriverFactory` + * (→ `SqlDriver`, knex `mysql2`). Postgres-only knobs are deliberately absent: + * `mysql2` has no `application_name` and no `statement_timeout`, so declaring + * them would advertise settings the client drops. + */ +const MYSQL_CONFIG_KEYS = [ + 'url', 'host', 'port', 'database', 'username', 'password', 'ssl', 'autoMigrate', +] as const; + +const mysqlConfigUnknownKeyError = strictUnknownKeyError({ + surface: "this mysql datasource's config", + knownKeys: MYSQL_CONFIG_KEYS, + aliases: { + hostname: 'host', + server: 'host', + dbname: 'database', + db: 'database', + schema: 'database', + user: 'username', + passwd: 'password', + pwd: 'password', + connectionstring: 'url', + dsn: 'url', + uri: 'url', + sslmode: 'ssl', + tls: 'ssl', + usessl: 'ssl', + }, + guidance: { + pool: + '`pool` is not driver config — connection pooling is configured once for every driver in ' + + "the datasource's own `pool` block. Move it next to `driver`.", + schemaMode: SCHEMA_MODE_BELONGS_ON_DATASOURCE, + readOnly: READ_ONLY_BELONGS_ON_DATASOURCE, + ca: SSL_DETAIL_BELONGS_ON_DATASOURCE, + cert: SSL_DETAIL_BELONGS_ON_DATASOURCE, + key: SSL_DETAIL_BELONGS_ON_DATASOURCE, + rejectUnauthorized: SSL_DETAIL_BELONGS_ON_DATASOURCE, + charset: + '`charset` is not honoured: the factory builds the mysql2 connection from the keys listed ' + + 'here only. Put it in the `url` as a query parameter (`?charset=utf8mb4`) so the client ' + + 'actually receives it.', + }, + history: + 'Until #4410 nothing validated `datasource.config` at all — an unrecognised connection key ' + + 'was accepted in silence and the datasource then connected on the client defaults ' + + '(localhost:3306) rather than failing.', +}); + +export const MysqlConfigSchema = lazySchema(() => z.object({ + /** + * Connection URI, passed to `mysql2` as-is when present. + * Format: `mysql://[user[:password]@][host][:port]/[dbname][?params]` + */ + url: z.string().optional().describe('Connection URI (supersedes the discrete fields)') + .meta({ title: 'Connection URL' }), + + /** Hostname or IP address. */ + host: z.string().default('localhost').describe('Host address').meta({ title: 'Host' }), + + /** Port number. */ + port: z.number().int().default(3306).describe('Port number').meta({ title: 'Port' }), + + /** Database (schema) name. Required unless `url` carries it. */ + database: z.string().optional().describe('Database name').meta({ title: 'Database' }), + + /** Authentication user. Passed to `mysql2` as `user`. */ + username: z.string().optional().describe('Authentication user').meta({ title: 'User' }), + + /** + * Authentication password. Prefer `external.credentialsRef`; a datasource + * secret always wins over this value. + */ + password: z.string().optional() + .describe('Authentication password (prefer external.credentialsRef)') + .meta({ title: 'Password', format: 'password' }), + + /** TLS settings, passed to `mysql2` verbatim. */ + ssl: DriverSslToggleSchema.optional().meta({ title: 'Use SSL/TLS' }), + + /** Dev-only, loosen-only schema self-heal (#2186). */ + autoMigrate: SqlAutoMigrateSchema.optional(), +}, { error: mysqlConfigUnknownKeyError }).strict() + .describe('MySQL / MariaDB connection configuration') + .superRefine((cfg, ctx) => { + if (!cfg.url && !cfg.database) { + ctx.addIssue({ + code: 'custom', + path: ['database'], + message: + 'A mysql datasource needs a connection target: set `database` (with `host`/`port`) or ' + + 'a full `url`. Neither was given, so the connection would fall back to the client ' + + 'defaults and silently open a different database than the one intended.', + }); + } + })); + +export type MysqlConfig = z.infer; + +/** JSON-Schema projection of {@link MysqlConfigSchema}, memoized. */ +export const getMysqlConfigJsonSchema = driverConfigJsonSchema(MysqlConfigSchema); diff --git a/packages/spec/src/data/driver/postgres.test.ts b/packages/spec/src/data/driver/postgres.test.ts index 8a52587abd..d12156b71b 100644 --- a/packages/spec/src/data/driver/postgres.test.ts +++ b/packages/spec/src/data/driver/postgres.test.ts @@ -11,8 +11,6 @@ describe('PostgresConfigSchema', () => { expect(config.host).toBe('localhost'); expect(config.port).toBe(5432); expect(config.schema).toBe('public'); - expect(config.max).toBe(10); - expect(config.min).toBe(0); }); it('should accept config with connection URI', () => { @@ -35,10 +33,6 @@ describe('PostgresConfigSchema', () => { schema: 'app_schema', ssl: true, applicationName: 'objectstack', - max: 50, - min: 5, - idleTimeoutMillis: 60000, - connectionTimeoutMillis: 10000, statementTimeout: 30000, }); @@ -47,10 +41,6 @@ describe('PostgresConfigSchema', () => { expect(config.schema).toBe('app_schema'); expect(config.ssl).toBe(true); expect(config.applicationName).toBe('objectstack'); - expect(config.max).toBe(50); - expect(config.min).toBe(5); - expect(config.idleTimeoutMillis).toBe(60000); - expect(config.connectionTimeoutMillis).toBe(10000); expect(config.statementTimeout).toBe(30000); }); @@ -62,15 +52,11 @@ describe('PostgresConfigSchema', () => { expect(config.host).toBe('localhost'); expect(config.port).toBe(5432); expect(config.schema).toBe('public'); - expect(config.max).toBe(10); - expect(config.min).toBe(0); expect(config.url).toBeUndefined(); expect(config.username).toBeUndefined(); expect(config.password).toBeUndefined(); expect(config.ssl).toBeUndefined(); expect(config.applicationName).toBeUndefined(); - expect(config.idleTimeoutMillis).toBeUndefined(); - expect(config.connectionTimeoutMillis).toBeUndefined(); expect(config.statementTimeout).toBeUndefined(); }); @@ -113,11 +99,22 @@ describe('PostgresConfigSchema', () => { expect(sslObj.rejectUnauthorized).toBe(true); }); - it('should reject config without database', () => { + it('should reject a config with no connection target at all', () => { + // Neither `database` nor `url`: the pg client would then open its own + // default (localhost, the OS user's database), so an empty config is a + // datasource pointing somewhere nobody chose. expect(() => PostgresConfigSchema.parse({})).toThrow(); expect(() => PostgresConfigSchema.parse({ host: 'localhost' })).toThrow(); }); + it('accepts a `url` on its own as the connection target', () => { + const config = PostgresConfigSchema.parse({ + url: 'postgresql://user@db.example.com:5432/analytics', + }); + + expect(config.database).toBeUndefined(); + }); + it('should reject config with invalid port type', () => { expect(() => PostgresConfigSchema.parse({ database: 'mydb', @@ -132,11 +129,37 @@ describe('PostgresConfigSchema', () => { })).toThrow(); }); - it('should reject config with invalid max pool type', () => { - expect(() => PostgresConfigSchema.parse({ + it('rejects pool sizing, and says where it belongs', () => { + // `max` / `min` / `idleTimeoutMillis` / `connectionTimeoutMillis` were + // declared here and read by nothing: the factory hardcoded its own pool. The + // datasource-level `pool` block is the one the factory now honours, so the + // rejection relocates rather than merely refusing (#4410). + const result = PostgresConfigSchema.safeParse({ database: 'mydb', - max: 'ten', - })).toThrow(); + max: 100, + min: 10, + }); + + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('`pool: { max: … }`'); + expect(result.error!.issues[0]!.message).toContain('`pool: { min: … }`'); + }); + + it('rejects an unknown key with a rename suggestion', () => { + const result = PostgresConfigSchema.safeParse({ + database: 'mydb', + hostname: 'db.internal', + }); + + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('`hostname` → `host`'); + }); + + it('rejects `user`, pointing at the canonical `username`', () => { + const result = PostgresConfigSchema.safeParse({ database: 'mydb', user: 'app' }); + + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('`user` → `username`'); }); it('should accept config with environment variable patterns', () => { @@ -151,25 +174,10 @@ describe('PostgresConfigSchema', () => { expect(config.host).toBe('${DB_HOST}'); }); - it('should accept zero as min pool size', () => { - const config = PostgresConfigSchema.parse({ - database: 'mydb', - min: 0, - }); - - expect(config.min).toBe(0); - }); - - it('should accept custom pool configuration', () => { - const config = PostgresConfigSchema.parse({ - database: 'mydb', - max: 100, - min: 10, - idleTimeoutMillis: 120000, - connectionTimeoutMillis: 5000, - }); - - expect(config.max).toBe(100); - expect(config.min).toBe(10); + it('accepts the dev-only autoMigrate passthrough', () => { + expect(PostgresConfigSchema.parse({ database: 'mydb', autoMigrate: 'safe' }).autoMigrate) + .toBe('safe'); + expect(() => PostgresConfigSchema.parse({ database: 'mydb', autoMigrate: 'destructive' })) + .toThrow(); }); }); diff --git a/packages/spec/src/data/driver/postgres.zod.ts b/packages/spec/src/data/driver/postgres.zod.ts index 9954e3bd6d..6593cca1b0 100644 --- a/packages/spec/src/data/driver/postgres.zod.ts +++ b/packages/spec/src/data/driver/postgres.zod.ts @@ -2,103 +2,148 @@ import { z } from 'zod'; -/** - * PostgreSQL Driver Configuration Schema - * Defines the connection settings specific to PostgreSQL. - */ import { lazySchema } from '../../shared/lazy-schema'; -export const PostgresConfigSchema = lazySchema(() => z.object({ - /** - * Connection URI. - * If provided, it takes precedence over host/port/database. - * Format: postgresql://[user[:password]@][netloc][:port][/dbname][?param1=value1&...] - */ - url: z.string().optional().describe('Connection URI'), - - /** - * Database Name. - */ - database: z.string().describe('Database Name'), - - /** - * Hostname or IP address. - * Defaults to localhost. - */ - host: z.string().default('localhost').describe('Host address'), - - /** - * Port number. - * Defaults to 5432. - */ - port: z.number().default(5432).describe('Port number'), - - /** - * Authentication Username. - */ - username: z.string().optional().describe('Auth User'), - - /** - * Authentication Password. - */ - password: z.string().optional().describe('Auth Password'), +import { strictUnknownKeyError } from '../../shared/suggestions.zod'; +import { + driverConfigJsonSchema, + DriverSslToggleSchema, + READ_ONLY_BELONGS_ON_DATASOURCE, + SCHEMA_MODE_BELONGS_ON_DATASOURCE, + SqlAutoMigrateSchema, + SSL_DETAIL_BELONGS_ON_DATASOURCE, +} from './common.zod'; - /** - * Default Schema. - * The schema to use for tables that do not specify a schema. - * Defaults to 'public'. - */ - schema: z.string().default('public').describe('Default Schema'), +/** + * PostgreSQL driver configuration — the `config` slot of a `datasource` whose + * `driver` resolves to `postgres` (`pg` / `postgresql`). + * + * ENFORCED as of #4410: `DatasourceSchema` parses `config` against this schema, + * so a misspelled connection key fails at authoring time instead of leaving the + * datasource on the client's localhost defaults. Every key here is read by + * `createDefaultDatasourceDriverFactory` (→ `SqlDriver`, knex `pg`). + * + * Pool sizing is NOT here: it lives in the driver-agnostic `datasource.pool` + * block, which the factory now honours for every SQL driver. + */ +const POSTGRES_CONFIG_KEYS = [ + 'url', 'host', 'port', 'database', 'username', 'password', 'ssl', + 'schema', 'applicationName', 'statementTimeout', 'autoMigrate', +] as const; + +/** Prescription for a pool knob written inside `config` instead of `pool`. */ +const poolBelongsOnDatasource = (key: string, canonical: string) => + `\`${key}\` is not driver config — connection pooling is configured once for every driver in ` + + `the datasource's own \`pool\` block. Move it to \`pool: { ${canonical}: … }\`. ` + + `(It was declared here and read by nothing until #4410.)`; + +const postgresConfigUnknownKeyError = strictUnknownKeyError({ + surface: "this postgres datasource's config", + knownKeys: POSTGRES_CONFIG_KEYS, + aliases: { + hostname: 'host', + server: 'host', + dbname: 'database', + db: 'database', + user: 'username', + passwd: 'password', + pwd: 'password', + connectionstring: 'url', + dsn: 'url', + uri: 'url', + searchpath: 'schema', + applicationname: 'applicationName', + statementtimeout: 'statementTimeout', + sslmode: 'ssl', + tls: 'ssl', + usessl: 'ssl', + }, + guidance: { + pool: poolBelongsOnDatasource('pool', 'max'), + min: poolBelongsOnDatasource('min', 'min'), + max: poolBelongsOnDatasource('max', 'max'), + idleTimeoutMillis: poolBelongsOnDatasource('idleTimeoutMillis', 'idleTimeoutMillis'), + connectionTimeoutMillis: poolBelongsOnDatasource( + 'connectionTimeoutMillis', + 'connectionTimeoutMillis', + ), + schemaMode: SCHEMA_MODE_BELONGS_ON_DATASOURCE, + readOnly: READ_ONLY_BELONGS_ON_DATASOURCE, + ca: SSL_DETAIL_BELONGS_ON_DATASOURCE, + cert: SSL_DETAIL_BELONGS_ON_DATASOURCE, + key: SSL_DETAIL_BELONGS_ON_DATASOURCE, + rejectUnauthorized: SSL_DETAIL_BELONGS_ON_DATASOURCE, + }, + history: + 'Until #4410 nothing validated `datasource.config` at all — an unrecognised connection key ' + + 'was accepted in silence and the datasource then connected on the client defaults ' + + "(localhost:5432), which is #4001's original bug one level down.", +}); +export const PostgresConfigSchema = lazySchema(() => z.object({ /** - * Enable SSL/TLS. - * Can be a boolean or an object with specific SSL configuration (ca, cert, key, rejectUnauthorized). + * Connection URI. When present it supersedes `host`/`port`/`database`/ + * `username`, and a datasource secret (`external.credentialsRef`) still + * overrides any password embedded in it. + * Format: `postgresql://[user[:password]@][host][:port][/dbname][?params]` */ - ssl: z.union([ - z.boolean(), - z.object({ - rejectUnauthorized: z.boolean().optional(), - ca: z.string().optional(), - key: z.string().optional(), - cert: z.string().optional(), - }) - ]).optional().describe('Enable SSL'), + url: z.string().optional().describe('Connection URI (supersedes the discrete fields)') + .meta({ title: 'Connection URL' }), - /** - * Application Name. - * Sets the application_name configuration parameter. - */ - applicationName: z.string().optional().describe('Application Name'), + /** Hostname or IP address. */ + host: z.string().default('localhost').describe('Host address').meta({ title: 'Host' }), - /** - * Connection Pool: Max Clients. - * Maximum number of clients the pool should contain. - */ - max: z.number().default(10).describe('Max Pool Size'), + /** Port number. */ + port: z.number().int().default(5432).describe('Port number').meta({ title: 'Port' }), - /** - * Connection Pool: Min Clients. - * Minimum number of clients to keep in the pool. - */ - min: z.number().default(0).describe('Min Pool Size'), + /** Database name. Required unless `url` carries it. */ + database: z.string().optional().describe('Database name').meta({ title: 'Database' }), - /** - * Idle Timeout (ms). - * The number of milliseconds a client must sit idle in the pool and not be checked out - * before it is disconnected from the backend and discarded. - */ - idleTimeoutMillis: z.number().optional().describe('Idle Timeout (ms)'), + /** Authentication user. Passed to `pg` as `user`. */ + username: z.string().optional().describe('Authentication user').meta({ title: 'User' }), /** - * Connection Timeout (ms). - * The number of milliseconds to wait before timing out when connecting a new client. - */ - connectionTimeoutMillis: z.number().optional().describe('Connection Timeout (ms)'), - - /** - * Statement Timeout (ms). - * Abort any statement that takes more than the specified number of milliseconds. + * Authentication password. Prefer `external.credentialsRef` — a secret-store + * reference — or an environment placeholder; a datasource secret always wins + * over this value. */ - statementTimeout: z.number().optional().describe('Statement Timeout (ms)'), -})); + password: z.string().optional() + .describe('Authentication password (prefer external.credentialsRef)') + .meta({ title: 'Password', format: 'password' }), + + /** TLS settings, passed to `pg` verbatim. */ + ssl: DriverSslToggleSchema.optional().meta({ title: 'Use SSL/TLS' }), + + /** Default schema for tables that do not name one — knex `searchPath`. */ + schema: z.string().default('public').describe('Default schema (knex searchPath)') + .meta({ title: 'Schema' }), + + /** `application_name` on the connection — how this stack shows up in `pg_stat_activity`. */ + applicationName: z.string().optional().describe('Postgres application_name') + .meta({ title: 'Application name' }), + + /** `statement_timeout` in milliseconds — aborts any statement that runs longer. */ + statementTimeout: z.number().int().positive().optional() + .describe('Abort statements running longer than this (ms)') + .meta({ title: 'Statement timeout (ms)' }), + + /** Dev-only, loosen-only schema self-heal (#2186). */ + autoMigrate: SqlAutoMigrateSchema.optional(), +}, { error: postgresConfigUnknownKeyError }).strict() + .describe('PostgreSQL connection configuration') + .superRefine((cfg, ctx) => { + if (!cfg.url && !cfg.database) { + ctx.addIssue({ + code: 'custom', + path: ['database'], + message: + 'A postgres datasource needs a connection target: set `database` (with `host`/`port`) ' + + 'or a full `url`. Neither was given, so the connection would fall back to the client ' + + 'defaults and silently open a different database than the one intended.', + }); + } + })); export type PostgresConfig = z.infer; + +/** JSON-Schema projection of {@link PostgresConfigSchema}, memoized. */ +export const getPostgresConfigJsonSchema = driverConfigJsonSchema(PostgresConfigSchema); diff --git a/packages/spec/src/data/driver/sqlite.zod.ts b/packages/spec/src/data/driver/sqlite.zod.ts new file mode 100644 index 0000000000..97b27b4604 --- /dev/null +++ b/packages/spec/src/data/driver/sqlite.zod.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +import { lazySchema } from '../../shared/lazy-schema'; +import { strictUnknownKeyError } from '../../shared/suggestions.zod'; +import { + driverConfigJsonSchema, + READ_ONLY_BELONGS_ON_DATASOURCE, + SCHEMA_MODE_BELONGS_ON_DATASOURCE, + SqlAutoMigrateSchema, +} from './common.zod'; + +/** + * SQLite driver configuration — the `config` slot of a `datasource` whose + * `driver` resolves to `sqlite` (native `better-sqlite3`, with the dev-only + * step-down to wasm then in-memory, #2229) or to `sqlite-wasm` (pure-JS). + * + * The one key that matters is `filename`, and it is exactly the key the silent + * strip used to hide: an author who wrote `path:` got no error, the connection + * fell back to `:memory:`, and their data vanished on restart with every signal + * saying the datasource was configured. + * + * `file` and `database` are a different case — the factory reads them as + * undeclared `??` fallbacks, so they happened to work while being documented + * nowhere. They are named as renames here rather than blessed: one strict + * contract beats a spelling that works only because a reader is lenient + * (AGENTS.md Prime Directive #12). The factory keeps its tolerance for records + * already persisted that way; no new one can be authored. + */ +const SQLITE_CONFIG_KEYS = ['filename', 'autoMigrate'] as const; + +const FILENAME_ALIASES = { + file: 'filename', + filepath: 'filename', + path: 'filename', + database: 'filename', + db: 'filename', + url: 'filename', + connectionstring: 'filename', +} as const; + +const sqliteHistory = + 'Until #4410 nothing validated `datasource.config` at all — a misspelled `filename` was ' + + 'accepted in silence and the database silently became an ephemeral `:memory:` one, so the ' + + 'data was gone on the next boot with nothing having reported a problem.'; + +const IN_MEMORY_GUIDANCE = + '`memory` is not a sqlite key. An ephemeral database is `filename: \':memory:\'`; for the ' + + 'mingo in-memory engine (a different driver entirely) set `driver: \'memory\'`.'; + +const sqliteConfigUnknownKeyError = strictUnknownKeyError({ + surface: "this sqlite datasource's config", + knownKeys: SQLITE_CONFIG_KEYS, + aliases: FILENAME_ALIASES, + guidance: { + schemaMode: SCHEMA_MODE_BELONGS_ON_DATASOURCE, + readOnly: READ_ONLY_BELONGS_ON_DATASOURCE, + memory: IN_MEMORY_GUIDANCE, + persist: + '`persist` is a `sqlite-wasm` key — the native sqlite driver writes through on every ' + + "statement and has nothing to schedule. Set `driver: 'sqlite-wasm'` to use it.", + }, + history: sqliteHistory, +}); + +export const SqliteConfigSchema = lazySchema(() => z.object({ + /** + * Database file path, or `:memory:` for an ephemeral in-process database. + * A relative path resolves against the server's working directory. + */ + filename: z.string().default(':memory:') + .describe('Database file path, or ":memory:" for an ephemeral database') + .meta({ title: 'Filename' }), + + /** Dev-only, loosen-only schema self-heal (#2186). */ + autoMigrate: SqlAutoMigrateSchema.optional(), +}, { error: sqliteConfigUnknownKeyError }).strict() + .describe('SQLite connection configuration')); + +export type SqliteConfig = z.infer; + +/** JSON-Schema projection of {@link SqliteConfigSchema}, memoized. */ +export const getSqliteConfigJsonSchema = driverConfigJsonSchema(SqliteConfigSchema); + +/** + * When a file-backed wasm database is flushed back to disk. `debounced:` + * batches writes; `:memory:` databases ignore this entirely. + */ +export const SqliteWasmPersistModeSchema = z.union([ + z.literal('on-disconnect'), + z.literal('on-write'), + z.string().regex(/^debounced:\d+$/, 'Expected `debounced:`'), +]).describe('When to flush a file-backed wasm database to disk'); + +export type SqliteWasmPersistMode = z.infer; + +const SQLITE_WASM_CONFIG_KEYS = ['filename', 'persist'] as const; + +const sqliteWasmConfigUnknownKeyError = strictUnknownKeyError({ + surface: "this sqlite-wasm datasource's config", + knownKeys: SQLITE_WASM_CONFIG_KEYS, + aliases: FILENAME_ALIASES, + guidance: { + schemaMode: SCHEMA_MODE_BELONGS_ON_DATASOURCE, + readOnly: READ_ONLY_BELONGS_ON_DATASOURCE, + memory: IN_MEMORY_GUIDANCE, + autoMigrate: + '`autoMigrate` is honoured by the native sqlite / postgres / mysql drivers only — the ' + + 'wasm driver is constructed without it, so writing it here would change nothing.', + }, + history: sqliteHistory, +}); + +export const SqliteWasmConfigSchema = lazySchema(() => z.object({ + /** + * Database file path, or `:memory:` for an ephemeral in-process database. + * A file-backed wasm database persists according to {@link SqliteWasmPersistModeSchema}. + */ + filename: z.string().default(':memory:') + .describe('Database file path, or ":memory:" for an ephemeral database') + .meta({ title: 'Filename' }), + + /** + * Flush policy for a file-backed database. Defaults to `on-write` when a + * filename is given; `:memory:` never persists. + */ + persist: SqliteWasmPersistModeSchema.optional().meta({ title: 'Persist mode' }), +}, { error: sqliteWasmConfigUnknownKeyError }).strict() + .describe('SQLite (WASM) connection configuration')); + +export type SqliteWasmConfig = z.infer; + +/** JSON-Schema projection of {@link SqliteWasmConfigSchema}, memoized. */ +export const getSqliteWasmConfigJsonSchema = driverConfigJsonSchema(SqliteWasmConfigSchema); diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 08c39bc149..9880134882 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -60,6 +60,12 @@ export * from './document.zod'; export * from './external-lookup.zod'; export * from './datasource.zod'; +// Per-driver `datasource.config` contracts (#4410) — the enforcement half of +// the `config` escape hatch DatasourceSchema leaves open at the top level. +// Exported because they are now load-bearing; nothing could import them while +// they were merely the shapes authors were TOLD to write against. +export * from './driver/index'; + // External Datasource Federation — SQL↔field type compatibility (ADR-0015) export * from './type-compat'; export * from './external-catalog.zod'; diff --git a/skills/objectstack-data/references/_index.md b/skills/objectstack-data/references/_index.md index 4594076b10..3127aca64e 100644 --- a/skills/objectstack-data/references/_index.md +++ b/skills/objectstack-data/references/_index.md @@ -19,6 +19,13 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies +- `node_modules/@objectstack/spec/src/data/driver/common.zod.ts` — Shared building blocks for the per-driver `datasource.config` shapes (#4410). +- `node_modules/@objectstack/spec/src/data/driver/config-registry.zod.ts` — The driver-id → `datasource.config` shape registry (#4410). +- `node_modules/@objectstack/spec/src/data/driver/memory.zod.ts` — Memory Driver Configuration Schema +- `node_modules/@objectstack/spec/src/data/driver/mongo.zod.ts` — MongoDB Standard Driver Protocol +- `node_modules/@objectstack/spec/src/data/driver/mysql.zod.ts` — MySQL / MariaDB driver configuration — the `config` slot of a `datasource` +- `node_modules/@objectstack/spec/src/data/driver/postgres.zod.ts` — PostgreSQL driver configuration — the `config` slot of a `datasource` whose +- `node_modules/@objectstack/spec/src/data/driver/sqlite.zod.ts` — SQLite driver configuration — the `config` slot of a `datasource` whose - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Capability tokens a script body may request. - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) diff --git a/skills/objectstack-platform/references/_index.md b/skills/objectstack-platform/references/_index.md index bd86e85676..6946d58129 100644 --- a/skills/objectstack-platform/references/_index.md +++ b/skills/objectstack-platform/references/_index.md @@ -21,6 +21,13 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies +- `node_modules/@objectstack/spec/src/data/driver/common.zod.ts` — Shared building blocks for the per-driver `datasource.config` shapes (#4410). +- `node_modules/@objectstack/spec/src/data/driver/config-registry.zod.ts` — The driver-id → `datasource.config` shape registry (#4410). +- `node_modules/@objectstack/spec/src/data/driver/memory.zod.ts` — Memory Driver Configuration Schema +- `node_modules/@objectstack/spec/src/data/driver/mongo.zod.ts` — MongoDB Standard Driver Protocol +- `node_modules/@objectstack/spec/src/data/driver/mysql.zod.ts` — MySQL / MariaDB driver configuration — the `config` slot of a `datasource` +- `node_modules/@objectstack/spec/src/data/driver/postgres.zod.ts` — PostgreSQL driver configuration — the `config` slot of a `datasource` whose +- `node_modules/@objectstack/spec/src/data/driver/sqlite.zod.ts` — SQLite driver configuration — the `config` slot of a `datasource` whose - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Capability tokens a script body may request. From d8a896793eb2a8094faeb03db966124537df6496 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 08:10:50 +0000 Subject: [PATCH 2/3] test(spec): pin the ssl split the config gate forced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `config.ssl` narrowed to the on/off shorthand when the connection form turned out to render a non-boolean/enum/number prop as a TEXT INPUT — a `boolean | object` union there would have produced a wizard whose every `ssl` value the new gate rejects. These fixtures still passed the object form, so they asserted a shape the contract no longer has. They now assert the prescription instead: the certificate-bearing form is rejected and named toward the datasource-level `ssl` block, which #4410 wired through to the client. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY --- packages/spec/src/data/datasource.test.ts | 15 ++++++--- .../spec/src/data/driver/postgres.test.ts | 31 +++++++++---------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/packages/spec/src/data/datasource.test.ts b/packages/spec/src/data/datasource.test.ts index ba54a9844c..5ef3d93492 100644 --- a/packages/spec/src/data/datasource.test.ts +++ b/packages/spec/src/data/datasource.test.ts @@ -402,20 +402,25 @@ describe('DatasourceSchema', () => { host: 'localhost', port: 5432, database: 'mydb', - ssl: { - rejectUnauthorized: false, - ca: 'certificate_content', - }, + ssl: true, }, pool: { min: 2, max: 10, idleTimeoutMillis: 30000, }, + ssl: { + enabled: true, + rejectUnauthorized: false, + ca: 'certificate_content', + }, }); expect(datasource.pool).toBeDefined(); - expect(datasource.config.ssl).toBeDefined(); + expect(datasource.config.ssl).toBe(true); + // Certificates belong to the datasource-level block, which the factory now + // carries down to the client (#4410). Inside `config`, `ssl` is on/off. + expect(datasource.ssl?.ca).toBe('certificate_content'); }); // The fixture above used to nest `pool` INSIDE `config`, where no driver diff --git a/packages/spec/src/data/driver/postgres.test.ts b/packages/spec/src/data/driver/postgres.test.ts index d12156b71b..45315638ae 100644 --- a/packages/spec/src/data/driver/postgres.test.ts +++ b/packages/spec/src/data/driver/postgres.test.ts @@ -69,34 +69,33 @@ describe('PostgresConfigSchema', () => { expect(config.ssl).toBe(false); }); - it('should accept ssl as detailed object', () => { - const config = PostgresConfigSchema.parse({ + // `config.ssl` is the on/off shorthand; certificates live in the + // datasource-level `ssl` block, which #4410 wired through to the client (it + // was declared, strict and read by nobody before that). The narrowing is + // forced by the connection form: it renders anything that is not + // boolean/enum/number as a text input, so a `boolean | object` union here + // would produce a wizard whose every `ssl` value the gate rejects. + it('rejects the certificate-bearing object form, naming where it belongs', () => { + const result = PostgresConfigSchema.safeParse({ database: 'mydb', ssl: { rejectUnauthorized: false, ca: '-----BEGIN CERTIFICATE-----\nMIIB...', - key: '-----BEGIN PRIVATE KEY-----\nMIIE...', - cert: '-----BEGIN CERTIFICATE-----\nMIIC...', }, }); - expect(config.ssl).toBeDefined(); - expect(typeof config.ssl).toBe('object'); - const sslObj = config.ssl as { rejectUnauthorized?: boolean; ca?: string }; - expect(sslObj.rejectUnauthorized).toBe(false); - expect(sslObj.ca).toBeDefined(); + expect(result.success).toBe(false); + expect(result.error!.issues.some((i) => i.path.join('.') === 'ssl')).toBe(true); }); - it('should accept ssl object with partial fields', () => { - const config = PostgresConfigSchema.parse({ + it('points a misplaced certificate key at the datasource-level block', () => { + const result = PostgresConfigSchema.safeParse({ database: 'mydb', - ssl: { - rejectUnauthorized: true, - }, + ca: '-----BEGIN CERTIFICATE-----\nMIIB...', }); - const sslObj = config.ssl as { rejectUnauthorized?: boolean }; - expect(sslObj.rejectUnauthorized).toBe(true); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('datasource-level `ssl` block'); }); it('should reject a config with no connection target at all', () => { From 9ad76d57a373039ef594c17e67fe02e619efb365 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 08:32:08 +0000 Subject: [PATCH 3/3] docs(spec): regenerate driver-sqlite reference after the comment fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated page carried the pre-fix wording of the module comment — the `check:docs` gate caught it on the post-merge re-run, which is what that gate is for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY --- content/docs/references/data/driver-sqlite.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/data/driver-sqlite.mdx b/content/docs/references/data/driver-sqlite.mdx index 5d26675205..ba941d67bf 100644 --- a/content/docs/references/data/driver-sqlite.mdx +++ b/content/docs/references/data/driver-sqlite.mdx @@ -21,9 +21,9 @@ saying the datasource was configured. `file` and `database` are a different case — the factory reads them as -undeclared `??` fallbacks, so they happened to work while being written +undeclared `??` fallbacks, so they happened to work while being documented -nowhere down. They are named as renames here rather than blessed: one strict +nowhere. They are named as renames here rather than blessed: one strict contract beats a spelling that works only because a reader is lenient