From 2c09e36b12fefd0c7f6e946d0026c630018c1421 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Tue, 6 Oct 2026 16:38:11 +1100 Subject: [PATCH 1/8] docs: regenerate with @stackql/provider-utils 0.7.11 and the source repo link Bumps @stackql/provider-utils to 0.7.11, passes --source-project to the doc generation target and regenerates the markdown docs, so the provider summary links to the source repository. Co-Authored-By: Claude Fable 5.1 --- Makefile | 4 ++- package-lock.json | 68 ++++++++++++++++++++++++++++++++++--------- package.json | 2 +- website/docs/index.md | 1 + 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index 7929037..395adbd 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,7 @@ SHELL := bash .DEFAULT_GOAL := help PROVIDER := supabase +SOURCE_PROJECT ?= https://github.com/stackql-registry/stackql-provider-$(PROVIDER) SERVICES_DIR := provider-dev/openapi/src/$(PROVIDER) # The project-scoped server template ({ref} resolved from SUPABASE_PROJECT_ID # via x-stackQL-envVar) is the single source of truth in @@ -141,7 +142,8 @@ docs: ## generate the website docs (snake_case surface), then sanitize --provider-dir ./$(SERVICES_DIR)/v00.00.00000 \ --output-dir ./website \ --provider-data-dir ./provider-dev/docgen/provider-data \ - --snake-case-aliases + --snake-case-aliases \ + --source-project $(SOURCE_PROJECT) node website/scripts/sanitize-docs.mjs website: ## build the docusaurus microsite (vendors shared config first) diff --git a/package-lock.json b/package-lock.json index 3dbc360..8ccc31c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@apidevtools/swagger-parser": "^12.1.0", "@stackql/pgwire-lite": "^1.0.2", - "@stackql/provider-utils": "^0.7.8", + "@stackql/provider-utils": "^0.7.11", "js-yaml": "^4.1.0", "pluralize": "^8.0.0" }, @@ -92,6 +92,30 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -104,11 +128,11 @@ }, "node_modules/@stackql/deno-openapi-dereferencer": { "name": "@jsr/stackql__deno-openapi-dereferencer", - "version": "0.3.1", - "resolved": "https://npm.jsr.io/~/11/@jsr/stackql__deno-openapi-dereferencer/0.3.1.tgz", - "integrity": "sha512-7Ucdom3SYxvzp7VwzulQMe66E+1LeCZIprFQ70PwRPIUfL90bYNQDrLfe5L1WaB+X7StWdHmoFSFxoa9RDlN7w==", + "version": "0.3.2", + "resolved": "https://npm.jsr.io/~/11/@jsr/stackql__deno-openapi-dereferencer/0.3.2.tgz", + "integrity": "sha512-Qj5B0pl2Rsec+GHwGGIh9254AQfEplkD82HW9FEhQR+vZvzhEgF2Ap72hmdLq8rWOGV4soSnQ4sFYorE2pIT5Q==", "dependencies": { - "jsonpath-plus": "7.0.0" + "jsonpath-plus": "^10.3.0" } }, "node_modules/@stackql/pgwire-lite": { @@ -121,13 +145,13 @@ } }, "node_modules/@stackql/provider-utils": { - "version": "0.7.8", - "resolved": "https://registry.npmjs.org/@stackql/provider-utils/-/provider-utils-0.7.8.tgz", - "integrity": "sha512-eJJ27ceMo+eTT4OTuJ7e0Cvft21u+SSGh+2s3THeoDxImndiNlg4W0wd4Sv7k+5ZbhDDbcIKcka6RJFZybkVCw==", + "version": "0.7.11", + "resolved": "https://registry.npmjs.org/@stackql/provider-utils/-/provider-utils-0.7.11.tgz", + "integrity": "sha512-yRy0nbWzI8IkAZlN2WCMxz/9pdD11EXTx85U+e6pQzIq61CtUpVLklW70Z4fMTTGwLa9nzLqWCNPS5+LBBY0+g==", "license": "MIT", "dependencies": { "@apidevtools/swagger-parser": "^10.1.1", - "@stackql/deno-openapi-dereferencer": "npm:@jsr/stackql__deno-openapi-dereferencer@^0.3.1", + "@stackql/deno-openapi-dereferencer": "npm:@jsr/stackql__deno-openapi-dereferencer@^0.3.2", "csv-parser": "^3.2.0", "js-yaml": "^4.1.0", "pluralize": "^8.0.0" @@ -373,6 +397,15 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -380,12 +413,21 @@ "license": "MIT" }, "node_modules/jsonpath-plus": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-7.0.0.tgz", - "integrity": "sha512-MH4UnrWrU1hJGVEyEyjvYgONkzNTO6Yol0nq18EMnUQ/ZC5cTuJheirXXIwu1b9mZ6t3XL0P79gPsu+zlTnDIQ==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, "engines": { - "node": ">=12.0.0" + "node": ">=18.0.0" } }, "node_modules/kuler": { diff --git a/package.json b/package.json index 1b81db1..85a30fb 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "dependencies": { "@apidevtools/swagger-parser": "^12.1.0", "@stackql/pgwire-lite": "^1.0.2", - "@stackql/provider-utils": "^0.7.8", + "@stackql/provider-utils": "^0.7.11", "js-yaml": "^4.1.0", "pluralize": "^8.0.0" }, diff --git a/website/docs/index.md b/website/docs/index.md index b78605c..680c45d 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -27,6 +27,7 @@ Query, provision and operate the Supabase control plane using SQL - organization total services: __14__ total resources: __65__ +source project: __[stackql-provider-supabase](https://github.com/stackql-registry/stackql-provider-supabase)__ ::: From 5fa25120185da5d2466d343120d78f5248616a98 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Tue, 6 Oct 2026 16:38:12 +1100 Subject: [PATCH 2/8] website: add the Back to StackQL Docs sidebar link Opens the docs sidebar with a link to /stackqldocs, the redirect to the main stackql.io docs, matching the query library site and the provider template. Styled by the new .sidebar-back-link rules in the site stylesheet. No generated docs or dependencies change. Co-Authored-By: Claude Fable 5.1 --- website/sidebars.js | 13 +++++++++++++ website/src/css/global.css | 22 +++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/website/sidebars.js b/website/sidebars.js index f719984..fe6cbb3 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -2,6 +2,19 @@ import { providerTitle } from './provider.js'; const sidebars = { mainSidebar: [ + // Way back to the main stackql.io docs, as on the query library site. + // '/stackqldocs' is a shared-config redirect route (registered on this + // site by the vendored redirects plugin) that forwards to + // https://stackql.io/, so the link renders as internal - no external-link + // icon - and the broken-link checker validates it. The arrow and the + // divider come from the .sidebar-back-link rules in src/css/global.css. + { + type: 'link', + label: 'Back to StackQL Docs', + href: '/stackqldocs', + className: 'sidebar-back-link', + }, + // '/providers' is likewise a shared redirect, to https://stackql.io/providers. { type: 'link', label: 'All Providers', href: '/providers' }, { type: 'category', diff --git a/website/src/css/global.css b/website/src/css/global.css index 3e50218..0b80357 100644 --- a/website/src/css/global.css +++ b/website/src/css/global.css @@ -284,4 +284,24 @@ div:has(> .vhsImage) { .navbar__logo img[src$='stackql-registry-logo-white.svg'] { content: url('/img/stackql-registry-logo-white-mobile.svg'); } -} \ No newline at end of file +} + +/* +* sidebar: back link to the main stackql.io docs (the first item in +* sidebars.js). Same treatment as the query library site: bold label, +* leading arrow drawn here so the label stays plain text, divider below. +*/ +.sidebar-back-link { + border-bottom: 1px solid var(--ifm-color-emphasis-300); + padding-bottom: 0.5rem; + margin-bottom: 0.5rem !important; +} + +.sidebar-back-link .menu__link { + font-weight: 600; +} + +.sidebar-back-link .menu__link::before { + content: '\2190'; + margin-right: 0.5rem; +} From 687231eb8a0dc3d7170d882c79c1f285b44bca18 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Tue, 6 Oct 2026 18:48:13 +1100 Subject: [PATCH 3/8] docs: bump @stackql/provider-utils to 0.7.12 Keeps the doc generation dependency on the current release. 0.7.12 only changes the generate-docs-v2 index page (Markdown hard breaks in the Provider Summary); this provider uses the v1 generator, whose output is unchanged, so the regenerated docs are identical. Co-Authored-By: Claude Fable 5.1 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8ccc31c..6e700e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@apidevtools/swagger-parser": "^12.1.0", "@stackql/pgwire-lite": "^1.0.2", - "@stackql/provider-utils": "^0.7.11", + "@stackql/provider-utils": "^0.7.12", "js-yaml": "^4.1.0", "pluralize": "^8.0.0" }, @@ -145,9 +145,9 @@ } }, "node_modules/@stackql/provider-utils": { - "version": "0.7.11", - "resolved": "https://registry.npmjs.org/@stackql/provider-utils/-/provider-utils-0.7.11.tgz", - "integrity": "sha512-yRy0nbWzI8IkAZlN2WCMxz/9pdD11EXTx85U+e6pQzIq61CtUpVLklW70Z4fMTTGwLa9nzLqWCNPS5+LBBY0+g==", + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/@stackql/provider-utils/-/provider-utils-0.7.12.tgz", + "integrity": "sha512-cwlnLkW/AZ86YVthU/HmVDVlwLHsuJtiCx+w0peqO7t/NpsgLsUneH5vG2PPt+yPv3euZeQg1HeO7NOAgDQdBw==", "license": "MIT", "dependencies": { "@apidevtools/swagger-parser": "^10.1.1", diff --git a/package.json b/package.json index 85a30fb..5d2db09 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "dependencies": { "@apidevtools/swagger-parser": "^12.1.0", "@stackql/pgwire-lite": "^1.0.2", - "@stackql/provider-utils": "^0.7.11", + "@stackql/provider-utils": "^0.7.12", "js-yaml": "^4.1.0", "pluralize": "^8.0.0" }, From 88fea1b9e249e3ffc2bc6fa39e551d2ed83d22eb Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Mon, 12 Oct 2026 12:04:36 +1100 Subject: [PATCH 4/8] smoke: first live run - 52 passed, 0 failed, 2 skipped Harness fixes from the run, all classification and pacing, no provider defects: API key names use the vendor-validated stackql_smoke_ form (sweep matches both prefixes); API key writes pause 6 s for the stricter per-endpoint limit; profile.profiles SKIPs under an organization-scoped token (probed once, since pystackql returns an empty list for a failed SELECT); config.postgres_configs SKIPs when the project has no overrides (the API returns {} and the engine cannot bind an empty flat object - reproduced on v0.12.778). NOTES.md findings 18 and 19 and the live-run record; tests/live_probe.sh for one-off live checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- NOTES.md | 15 +++++++++-- README.md | 4 +-- tests/live_probe.sh | 15 +++++++++++ tests/smoke_test.py | 63 ++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 86 insertions(+), 11 deletions(-) create mode 100644 tests/live_probe.sh diff --git a/NOTES.md b/NOTES.md index aacdcec..6e354b2 100644 --- a/NOTES.md +++ b/NOTES.md @@ -111,10 +111,21 @@ The wire is snake_case almost everywhere. camelCase appears on: `ssl-enforcement The word "experimental" does not appear in the Terraform provider's README or docs. The vendor's own label is Public Alpha (the Supabase features page lists "Terraform provider" at stage "Public Alpha"); the registry tier is "community". The provider covers 7 resources and 4 data sources (project, settings with api/auth/database/network/storage/ssl_enforcement blocks, branch, edge_function via the multipart deploy, edge_function_secrets, apikey, third_party_auth); the `pooler` settings block is declared but never read or written. It reads `SUPABASE_ACCESS_TOKEN` (and `SUPABASE_API_ENDPOINT` for the endpoint). CLAUDE.md and the README copy use the Public Alpha label. +## 18. Empty flat-object responses fail in the engine - `postgres_configs` on an untuned project + +Live run 2026-10-12: `GET /v1/projects/{ref}/config/database/postgres` returns `{}` for a project with no Postgres overrides (the default state of every new project). stackql fails the row insert with `sql insert error: 'missing argument with index 6'` - the engine binds the five `iql_*` columns and the `ref` server variable, then nothing for the schema's 38 declared properties. Reproduced against the mock with a `{}` body on v0.12.732 and v0.12.778 (the current release); a sparsely populated object (the mock's 8 of 38 fields) binds fine, so it is the zero-property case, not the dotted `cron.log_statement` column. pystackql swallows the stderr and returns an empty list, so the smoke step classifies an empty result as a SKIP with this finding cited. No provider-side mitigation exists (an `objectKey` cannot manufacture a row); the fix belongs in the engine (`stackql/stackql`). The same applies to any flat-object resource whose response can be `{}`. + +## 19. Token scope and the API key endpoints - two things the 2026-10-12 live run established + +Supabase issues organization-scoped access tokens as well as personal ones. An organization-scoped token works everywhere in the provider except `profile.profiles`, which answers `403 {"message":"This endpoint requires a user-scoped access token"}`. pystackql returns an empty list (no error text) for a `SELECT` that fails, so the harness cannot classify that 403 from the result; it probes `/v1/profile` once (paced, counted) to establish the scope and marks the step SKIP for an organization-scoped token. API key names are vendor-validated to `^[a-z_][a-z0-9_]*$` (`stackql-smoke-` was rejected with 400); the harness names API keys `stackql_smoke_` and sweeps both prefixes. The API key endpoints also carry a stricter per-endpoint limit than the 120/min budget: create, list, get, update, delete at 1.2 s spacing drew `429 {"message":"Please wait a few seconds ..."}` on the delete. Writes in that lifecycle (and the sweep) pause `API_KEY_WRITE_DELAY_S = 6` s - the pacing rule (finding 6) applied per endpoint, not relaxed. + +## Live smoke run - 2026-10-12 + +`make smoke` (local registry, pystackql with stackql v0.12.718, the standing free-tier project): 46 passed, 4 failed on the first run; the four were the two environment properties above (profile scope, untuned Postgres config) and the API key name validation, and the second run surfaced the API key endpoint limit - all harness classification and pacing, no provider defect. Final run: 52 passed, 0 failed, 2 skipped (profile, postgres config), 61 statements. Established on this run: the string-typed UPDATE coercion holds (auth-config `disable_signup` toggled and restored, findings 4 and 14); the deprecated JSON edge function create still works (create, update, delete); the query endpoint round trip (`queries.run` INSERT ... RETURNING rows, fixture create/insert/select/drop, `run_read_only` EXEC and the `read_only` flag) passes; `network_restrictions.apply` re-applies idempotently with the camelCase body; secrets create/delete through the wrapped bare-array bodies. The venv must live off the DrvFs mount under WSL (`make smoke VENV=$HOME/.venvs/stackql-supabase`) - pip cannot set permissions on `/mnt/c`. + ## Blockers / for the live smoke run -- **No `SUPABASE_ACCESS_TOKEN` or standing dev project in this environment.** The repository is prepared for a colleague with a Supabase account to run `make smoke` (see `.env.example`). The first live run establishes: the string-typed UPDATE coercion (findings 4 and 14 - the auth-config toggle), whether the JSON edge function create still works (deprecated), the authenticated rate-limit headers (finding 6), `parameters` binding on the query endpoint (finding 1), and project-creation timing for the gated lifecycle (`make smoke-project-lifecycle`; free tier: two active projects, paused projects do not count). -- **Gated project lifecycle**: create (free plan, the standing project's org and region) -> wait `ACTIVE_HEALTHY` (the Terraform provider waits up to 5 minutes) -> `pause` -> wait `INACTIVE` -> delete. Never outside the gated target. +- **Gated project lifecycle** (not yet exercised): create (free plan, the standing project's org and region) -> wait `ACTIVE_HEALTHY` (the Terraform provider waits up to 5 minutes) -> `pause` -> wait `INACTIVE` -> delete. Never outside the gated target (`make smoke-project-lifecycle`; free tier: two active projects, paused projects do not count). ## Testing requirements (carried from CLAUDE.md) diff --git a/README.md b/README.md index fe2f621..3b2456d 100644 --- a/README.md +++ b/README.md @@ -208,9 +208,9 @@ make smoke-project-lifecycle # additionally the gated project create / pause / d make smoke-cleanup # sweep stackql-smoke-* breadcrumbs ``` -[tests/smoke_test.py](tests/smoke_test.py) (pystackql) runs against the standing free-tier dev project named by `SUPABASE_PROJECT_ID`: read smokes (profile, organizations, the project estate, the posture set, secrets, API keys, edge functions, branches, health, add-ons, security lints, backups, storage, migrations, snippets) and self-cleaning write lifecycles - a secret `INSERT`/`SELECT`/`DELETE`, an API key `INSERT`/`SELECT`/`UPDATE`/`DELETE`, an edge function `INSERT`/`UPDATE`/`DELETE` (the vendor-deprecated JSON create), an auth-config toggle-and-restore (the string-typed `UPDATE` probe), an idempotent network-restrictions re-apply, and the query round trip (fixture table created, populated, read through `RETURNING rows`, dropped). Everything is named `stackql-smoke-` / `STACKQL_SMOKE_` and swept first. Free-tier cost: nothing. Statements are paced at 1.2 s; a 429 fails the run. The harness upgrades pystackql's managed stackql binary to >= v0.10.601 when older. Never run this against a production organization or project. +[tests/smoke_test.py](tests/smoke_test.py) (pystackql) runs against the standing free-tier dev project named by `SUPABASE_PROJECT_ID`: read smokes (profile, organizations, the project estate, the posture set, secrets, API keys, edge functions, branches, health, add-ons, security lints, backups, storage, migrations, snippets) and self-cleaning write lifecycles - a secret `INSERT`/`SELECT`/`DELETE`, an API key `INSERT`/`SELECT`/`UPDATE`/`DELETE`, an edge function `INSERT`/`UPDATE`/`DELETE` (the vendor-deprecated JSON create), an auth-config toggle-and-restore (the string-typed `UPDATE` probe), an idempotent network-restrictions re-apply, and the query round trip (fixture table created, populated, read through `RETURNING rows`, dropped). Everything is named `stackql-smoke-` / `stackql_smoke_` (API keys, vendor-validated to `^[a-z_][a-z0-9_]*$`) / `STACKQL_SMOKE_` and swept first. Free-tier cost: nothing. Statements are paced at 1.2 s (6 s between API key writes, which carry a stricter per-endpoint limit); a 429 fails the run. Two steps SKIP on environment properties rather than fail: `profile.profiles` under an organization-scoped token, and `config.postgres_configs` on a project with no overrides (NOTES.md findings 18 and 19). The harness upgrades pystackql's managed stackql binary to >= v0.10.601 when older. Never run this against a production organization or project. -The live suite has not yet been run from this repository (no token in the build environment); NOTES.md lists what the first run establishes. +Last run 2026-10-12: 52 passed, 0 failed, 2 skipped (NOTES.md, "Live smoke run"). Under WSL the venv must live off the DrvFs mount: `make smoke VENV=$HOME/.venvs/stackql-supabase`. For one-off live checks, `bash tests/live_probe.sh "" [--raw ]` runs a statement against the local registry with the `.env` credentials and optionally prints the raw API response beside it. ### UAT diff --git a/tests/live_probe.sh b/tests/live_probe.sh new file mode 100644 index 0000000..55b90cf --- /dev/null +++ b/tests/live_probe.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Ad-hoc live probe: runs one SQL statement against the local provider-dev +# registry with credentials from .env and prints the stackql JSON output. +# Usage: bash tests/live_probe.sh "" [--raw ] +set -euo pipefail +cd "$(dirname "$0")/.." +set -a; source <(tr -d '\r' < .env); set +a +ROOT="$(pwd)/provider-dev/openapi" +REG="{\"url\":\"file://$ROOT\",\"localDocRoot\":\"$ROOT\",\"verifyConfig\":{\"nopVerify\":true}}" +stackql exec --registry "$REG" -o json "$1" 2>&1 +if [ "${2:-}" = "--raw" ]; then + echo "--- raw GET $3 ---" + curl -s -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" "https://api.supabase.com$3" + echo +fi diff --git a/tests/smoke_test.py b/tests/smoke_test.py index ec7e276..f1a397a 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -54,13 +54,21 @@ import secrets as pysecrets import sys import time +import urllib.error +import urllib.request from pathlib import Path BASE_DIR = Path(__file__).resolve().parents[1] SMOKE_PREFIX = "stackql-smoke-" +# API key names must match ^[a-z_][a-z0-9_]*$ (vendor validation, live run 2026-10-12) +API_KEY_PREFIX = "stackql_smoke_" SECRET_PREFIX = "STACKQL_SMOKE_" FIXTURE_TABLE = "public.stackql_smoke_fixture" INTER_REQUEST_DELAY_S = 1.2 # ~50 req/min, under the documented limit with margin (NOTES.md finding 6) +# The API key endpoints carry a stricter per-endpoint limit than the 120/min +# budget: back-to-back writes at 1.2 s answer 429 "Please wait a few seconds" +# (live run 2026-10-12, NOTES.md finding 19). Writes in that lifecycle pause longer. +API_KEY_WRITE_DELAY_S = 6.0 # x-stackQL-envVar server variable resolution (SUPABASE_PROJECT_ID) landed in # stackql v0.10.601 (any-sdk v0.5.4-alpha01, stackql/stackql#707). pystackql # manages its own stackql binary, so the harness upgrades it when older. @@ -81,6 +89,7 @@ def __init__(self, args: argparse.Namespace) -> None: self.args = args self.stamp = str(int(time.time()))[-6:] self.name = f"{SMOKE_PREFIX}{self.stamp}" + self.api_key_name = f"{API_KEY_PREFIX}{self.stamp}" self.secret_name = f"{SECRET_PREFIX}{self.stamp}" self.results: list[tuple[str, str, str]] = [] self.requests = 0 @@ -131,6 +140,30 @@ def parse(v: str) -> tuple[int, ...]: sys.exit(f"stackql {self.sq.version} is still too old after upgrade") # ------------------------------------------------------------------ core + def token_is_user_scoped(self) -> bool: + # pystackql returns an empty list (no error text) for a SELECT that + # fails, so a 403 on /v1/profile is indistinguishable from no rows. + # One paced GET establishes the token scope instead: organization- + # scoped tokens answer 403 "requires a user-scoped access token" + # (NOTES.md finding 19). + if self.requests: + time.sleep(INTER_REQUEST_DELAY_S) + self.requests += 1 + req = urllib.request.Request( + "https://api.supabase.com/v1/profile", + headers={"Authorization": f"Bearer {os.environ['SUPABASE_ACCESS_TOKEN']}"}, + ) + try: + with urllib.request.urlopen(req, timeout=30): + return True + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", "replace") + if exc.code == 403 and "user-scoped" in body: + return False + return True # any other failure: let the provider step report it + except urllib.error.URLError: + return True + def q(self, sql: str): # serial pacing under the per-user rate limit if self.requests: @@ -152,13 +185,20 @@ def q(self, sql: str): return out, text return out if isinstance(out, list) else [out], None - def step(self, name: str, sql: str, expect_rows: bool = False, contains: str | None = None): + def step(self, name: str, sql: str, expect_rows: bool = False, contains: str | None = None, + skip_if_empty: str | None = None): + # skip_if_empty=reason: an empty result with no error is a SKIP (an + # environment property, not a provider defect) rather than a FAIL. rows, err = self.q(sql) if err: self.results.append((name, "FAIL", err[:200])) print(f" FAIL {name} [{err[:140]}]") return None blob = json.dumps(rows, default=str) + if expect_rows and not rows and skip_if_empty: + self.results.append((name, "SKIP", skip_if_empty)) + print(f" SKIP {name} [{skip_if_empty}]") + return None if expect_rows and not rows: self.results.append((name, "FAIL", "expected rows, got none")) print(f" FAIL {name} [no rows]") @@ -225,9 +265,10 @@ def cleanup_breadcrumbs(self) -> None: print(f" WARN api keys sweep list failed: {err[:120]}") else: for r in rows: - if str(r.get("name", "")).startswith(SMOKE_PREFIX): + if str(r.get("name", "")).startswith((SMOKE_PREFIX, API_KEY_PREFIX)): print(f" sweeping api key {r['name']}") self.q(f"DELETE FROM supabase.secrets.api_keys WHERE id = '{r['id']}'") + time.sleep(API_KEY_WRITE_DELAY_S) self.q(f"INSERT INTO supabase.database.queries (query) SELECT 'drop table if exists {FIXTURE_TABLE}' RETURNING rows") if self.args.with_project_lifecycle or self.args.cleanup_only: rows, err = self.q("SELECT id, name, status FROM supabase.projects.projects") @@ -243,7 +284,12 @@ def cleanup_breadcrumbs(self) -> None: def read_smokes(self) -> None: print("== read smokes ==") self.step("show services", "SHOW SERVICES IN supabase", expect_rows=True, contains="projects") - self.step("profile (the PAT identity)", "SELECT primary_email, username FROM supabase.profile.profiles", expect_rows=True) + if self.token_is_user_scoped(): + self.step("profile (the PAT identity)", "SELECT primary_email, username FROM supabase.profile.profiles", expect_rows=True) + else: + reason = "organization-scoped token: /v1/profile needs a user-scoped (personal) token" + self.results.append(("profile (the PAT identity)", "SKIP", reason)) + print(f" SKIP profile (the PAT identity) [{reason}]") orgs = self.step("organizations list", "SELECT id, slug, name FROM supabase.organizations.organizations", expect_rows=True) self.step("projects estate inventory", "SELECT id, name, region, status, organization_slug FROM supabase.projects.projects", expect_rows=True, contains=self.ref) self.step("project get (WHERE ref)", f"SELECT name, status, json_extract(\"database\", '$.version') AS pg FROM supabase.projects.projects WHERE ref = '{self.ref}'", expect_rows=True) @@ -253,7 +299,8 @@ def read_smokes(self) -> None: self.step("organization members", f"SELECT user_name, role_name, mfa_enabled FROM supabase.organizations.members WHERE slug = '{slug}'", expect_rows=True) # the posture set (ref resolved from SUPABASE_PROJECT_ID - no WHERE) self.step("auth config posture (signups, MFA, password policy)", "SELECT disable_signup, mfa_totp_enroll_enabled, password_min_length, site_url, mailer_otp_exp FROM supabase.config.auth_configs", expect_rows=True) - self.step("postgres config (flat columns)", "SELECT max_connections, statement_timeout, work_mem FROM supabase.config.postgres_configs", expect_rows=True) + self.step("postgres config (flat columns)", "SELECT max_connections, statement_timeout, work_mem FROM supabase.config.postgres_configs", expect_rows=True, + skip_if_empty="project has no Postgres overrides (API returns {}); the engine cannot bind an empty flat object - NOTES.md finding 18") self.step("ssl enforcement (snake aliases)", "SELECT applied_successfully, json_extract(current_config, '$.database') AS db_ssl FROM supabase.config.ssl_enforcement_configs", expect_rows=True) self.step("network restrictions", "SELECT entitlement, status, json_extract(config, '$.dbAllowedCidrs') AS cidrs FROM supabase.network.network_restrictions", expect_rows=True) self.step("network bans (POST-backed read)", "SELECT banned_address, identifier FROM supabase.network.network_bans") @@ -284,7 +331,7 @@ def secret_lifecycle(self) -> None: self.note("secret gone after DELETE", not err and all(r.get("name") != name for r in rows), err or "") def api_key_lifecycle(self) -> None: - name = self.name + name = self.api_key_name print(f"== API key lifecycle ({name}) ==") self.step("api key INSERT (type secret)", f"INSERT INTO supabase.secrets.api_keys (type, name, description) SELECT 'secret', '{name}', 'stackql smoke'") rows, err = self.q("SELECT id, name FROM supabase.secrets.api_keys") @@ -293,7 +340,9 @@ def api_key_lifecycle(self) -> None: if not key: return self.step("api key get", f"SELECT name, type FROM supabase.secrets.api_keys WHERE id = '{key['id']}'", expect_rows=True, contains="secret") + time.sleep(API_KEY_WRITE_DELAY_S) self.step("api key UPDATE (description)", f"UPDATE supabase.secrets.api_keys SET description = 'stackql smoke v2' WHERE id = '{key['id']}'") + time.sleep(API_KEY_WRITE_DELAY_S) self.step("api key DELETE", f"DELETE FROM supabase.secrets.api_keys WHERE id = '{key['id']}'") rows, err = self.q("SELECT id FROM supabase.secrets.api_keys") self.note("api key gone after DELETE", not err and all(r.get("id") != key["id"] for r in rows), err or "") @@ -397,12 +446,12 @@ def seen(rows): # ---------------------------------------------------------------- summary def summary(self) -> int: print("\n== summary ==") - counts = {"PASS": 0, "FAIL": 0} + counts = {"PASS": 0, "FAIL": 0, "SKIP": 0} for name, status, note in self.results: counts[status] = counts.get(status, 0) + 1 if status != "PASS": print(f" {status:5s} {name} [{note[:110]}]") - print(f" {counts['PASS']} passed, {counts['FAIL']} failed; {self.requests} statements, paced at {INTER_REQUEST_DELAY_S}s " + print(f" {counts['PASS']} passed, {counts['FAIL']} failed, {counts['SKIP']} skipped; {self.requests} statements, paced at {INTER_REQUEST_DELAY_S}s " f"(registry: {'public' if self.args.live else 'local'}, project: {self.ref})") return 1 if counts["FAIL"] else 0 From 2ee5288d1d3bcbd7a706d561f2a39d0e036d7683 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Mon, 12 Oct 2026 12:04:38 +1100 Subject: [PATCH 5/8] website: Supabase featured image, edit links, docs header corrections Featured image composed in the house style (STACKQL>> wordmark plus the Supabase mark, 1200x627) replacing the generic placeholder; projectName and editUrl set as on the snowflake site; the index copy corrected to 158 operations / 14 services / 65 resources, with notes on token scope and the empty postgres_configs case. Docs regenerated with make docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docgen/provider-data/headerContent2.txt | 8 +++++++- website/docs/index.md | 8 +++++++- website/docusaurus.config.js | 8 ++++++++ ...ackql-supabase-provider-featured-image.png | Bin 29955 -> 23803 bytes 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/provider-dev/docgen/provider-data/headerContent2.txt b/provider-dev/docgen/provider-data/headerContent2.txt index ad0a500..13a70f0 100644 --- a/provider-dev/docgen/provider-data/headerContent2.txt +++ b/provider-dev/docgen/provider-data/headerContent2.txt @@ -13,7 +13,7 @@ REGISTRY PULL supabase; ## Scope -This provider covers the Supabase Management API at `https://api.supabase.com` (the control plane): organizations, projects, branches, functions, secrets, configuration, domains, networking, backups, billing add-ons, advisors, analytics and the project SQL query endpoint. The per-project data APIs (PostgREST at `.supabase.co/rest/v1`, Realtime, Storage object I/O, GoTrue user-facing auth) are per-project hosts with per-project keys - a different surface, reserved as a possible future `supabase_project` sibling provider. Supabase's official Terraform provider is labelled Public Alpha by the vendor and covers seven resources; this provider's surface is generated mechanically from the vendor's published OpenAPI document (159 operations across 15 services). +This provider covers the Supabase Management API at `https://api.supabase.com` (the control plane): organizations, projects, branches, functions, secrets, configuration, domains, networking, backups, billing add-ons, advisors, analytics and the project SQL query endpoint. The per-project data APIs (PostgREST at `.supabase.co/rest/v1`, Realtime, Storage object I/O, GoTrue user-facing auth) are per-project hosts with per-project keys - a different surface, reserved as a possible future `supabase_project` sibling provider. Supabase's official Terraform provider is labelled Public Alpha by the vendor and covers seven resources; this provider's surface is generated mechanically from the vendor's published OpenAPI document (158 operations across 14 services and 65 resources). ## Authentication @@ -31,6 +31,8 @@ $env:SUPABASE_ACCESS_TOKEN = 'sbp_...' $env:SUPABASE_PROJECT_ID = 'abcdefghijklmnopqrst' ``` +Organization-scoped access tokens work throughout the provider except `profile.profiles`, which requires a user-scoped (personal) token. + ## Project scope Most resources are scoped to a project, addressed by its reference (`ref` - the Project ID shown in the dashboard under Settings -> General). `ref` is a server variable resolved from the environment variable when it is set, so queries against one project need no `WHERE ref` clause: @@ -56,6 +58,10 @@ The Management API allows a fixed number of requests per minute per user (docume The vendor labels part of the surface `[Beta]` (and one endpoint `[Alpha]`); the label is carried through as the first line of each method's description. The query endpoint, network restrictions and bans, custom domains, SSL enforcement, read replicas, JIT access and the upgrade surface are beta. A few operations are deprecated by the vendor (the advisors reads, `logs.all`, the database context read and the JSON edge function create) and stay mapped with the deprecation noted. Refreshes of the provider are reviewed spec diffs against a content-hash pin. +## Known limitations + +`config.postgres_configs` returns the Postgres settings a project has overridden. On a project with no overrides the API returns an empty object, which the current StackQL engine cannot materialise as a row - the query returns no rows (an `sql insert error` in verbose output) rather than a row of nulls. Projects with at least one override return the row as expected. + ## Example queries ### Project estate inventory diff --git a/website/docs/index.md b/website/docs/index.md index 680c45d..ebc3369 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -46,7 +46,7 @@ REGISTRY PULL supabase; ## Scope -This provider covers the Supabase Management API at `https://api.supabase.com` (the control plane): organizations, projects, branches, functions, secrets, configuration, domains, networking, backups, billing add-ons, advisors, analytics and the project SQL query endpoint. The per-project data APIs (PostgREST at `.supabase.co/rest/v1`, Realtime, Storage object I/O, GoTrue user-facing auth) are per-project hosts with per-project keys - a different surface, reserved as a possible future `supabase_project` sibling provider. Supabase's official Terraform provider is labelled Public Alpha by the vendor and covers seven resources; this provider's surface is generated mechanically from the vendor's published OpenAPI document (159 operations across 15 services). +This provider covers the Supabase Management API at `https://api.supabase.com` (the control plane): organizations, projects, branches, functions, secrets, configuration, domains, networking, backups, billing add-ons, advisors, analytics and the project SQL query endpoint. The per-project data APIs (PostgREST at `.supabase.co/rest/v1`, Realtime, Storage object I/O, GoTrue user-facing auth) are per-project hosts with per-project keys - a different surface, reserved as a possible future `supabase_project` sibling provider. Supabase's official Terraform provider is labelled Public Alpha by the vendor and covers seven resources; this provider's surface is generated mechanically from the vendor's published OpenAPI document (158 operations across 14 services and 65 resources). ## Authentication @@ -64,6 +64,8 @@ $env:SUPABASE_ACCESS_TOKEN = 'sbp_...' $env:SUPABASE_PROJECT_ID = 'abcdefghijklmnopqrst' ``` +Organization-scoped access tokens work throughout the provider except `profile.profiles`, which requires a user-scoped (personal) token. + ## Project scope Most resources are scoped to a project, addressed by its reference (`ref` - the Project ID shown in the dashboard under Settings -> General). `ref` is a server variable resolved from the environment variable when it is set, so queries against one project need no `WHERE ref` clause: @@ -89,6 +91,10 @@ The Management API allows a fixed number of requests per minute per user (docume The vendor labels part of the surface `[Beta]` (and one endpoint `[Alpha]`); the label is carried through as the first line of each method's description. The query endpoint, network restrictions and bans, custom domains, SSL enforcement, read replicas, JIT access and the upgrade surface are beta. A few operations are deprecated by the vendor (the advisors reads, `logs.all`, the database context read and the JSON edge function create) and stay mapped with the deprecation noted. Refreshes of the provider are reviewed spec diffs against a content-hash pin. +## Known limitations + +`config.postgres_configs` returns the Postgres settings a project has overridden. On a project with no overrides the API returns an empty object, which the current StackQL engine cannot materialise as a row - the query returns no rows (an `sql insert error` in verbose output) rather than a row of nulls. Projects with at least one override return the row as expected. + ## Example queries ### Project estate inventory diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index bca8f76..37ac93c 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -16,6 +16,14 @@ const config = createConfig({ }, }); +// This provider's website lives at website/ within the canonical +// stackql-registry/stackql-provider-supabase repo, so the "Edit this page" +// links point at that subdirectory (the shared config default omits the +// website/ path). +config.projectName = 'stackql-provider-supabase'; +config.presets[0][1].docs.editUrl = + 'https://github.com/stackql-registry/stackql-provider-supabase/edit/main/website/'; + // Use the locally vendored registry-branded logos (STACKQL>> | REGISTRY) // instead of the shared config's hotlinked main-site wordmark - // self-contained assets, no cross-origin fetch. global.css swaps in the diff --git a/website/static/img/stackql-supabase-provider-featured-image.png b/website/static/img/stackql-supabase-provider-featured-image.png index 872999ade8cefbb87d897c0662d8a9b41c0eae5e..4d1e5c33bf92cdb7b1721a221ac7c7876e534a1c 100644 GIT binary patch literal 23803 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

E=l?P^hl2{^Cn2S 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 V@L~9hP+*Z^h literal 29955 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 Y#5x*y>S=)4?+3-TD|S|u7G9742QxT=#{d8T From e589b1b1543caa3ff3ea8fc3e9a040578538f820 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Mon, 12 Oct 2026 13:12:45 +1100 Subject: [PATCH 6/8] post_process: drop orphan path parameters (registry PR review finding) An in-path parameter must have a matching {name} placeholder in its path key. The ref rebase strips ref from the mapped verbs only, so the skip-coded HEAD action-run count on /actions kept a ref path parameter the path no longer carries (stackql/stackql-provider-registry#476 review). New rule 8 checks every path-item and operation parameter list against its path key on all OpenAPI operation verbs and drops orphans - no names, verbs or paths special-cased. One parameter dropped (branches.yaml HEAD /actions); idempotent; make test green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- NOTES.md | 2 +- README.md | 2 +- .../v00.00.00000/services/branches.yaml | 11 ----- provider-dev/scripts/post_process.mjs | 42 +++++++++++++++++++ 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/NOTES.md b/NOTES.md index 6e354b2..d7f030a 100644 --- a/NOTES.md +++ b/NOTES.md @@ -85,7 +85,7 @@ Resource naming decisions beyond the mechanical derivation (all rules in `map_op ## 13. Project scope as a server variable - ref from SUPABASE_PROJECT_ID; joins do not fan out -141 of 170 operations live under `/v1/projects/{ref}/...`. The split rebases them onto the server template `https://api.supabase.com/v1/projects/{ref}` (`provider-dev/config/servers.json`) with `x-stackQL-envVar: SUPABASE_PROJECT_ID` on the `ref` variable - the clickhouse organization precedent (any-sdk v0.5.4-alpha01 / stackql v0.10.601+). Every other path (the projects root and create, available regions, the organization surface, branch-by-id, snippets, profile, and `/v1/projects/{ref}` itself) keeps its full path and `post_process.mjs` pins it to the bare API base with a path-level `servers` override (any-sdk resolves servers operation -> path item -> document). Proven: with the variable set `ref` disappears from `SHOW METHODS` required params; `WHERE ref = ...` beats the environment; unset with no WHERE fails with "cannot find any viable servers"; `projects.get` keeps `ref` as a path parameter. The env var name follows the dashboard's "Project ID" label (Terraform reads only `SUPABASE_ACCESS_TOKEN`; the CLI CI examples use `SUPABASE_PROJECT_ID`). +141 of 170 operations live under `/v1/projects/{ref}/...`. The split rebases them onto the server template `https://api.supabase.com/v1/projects/{ref}` (`provider-dev/config/servers.json`) with `x-stackQL-envVar: SUPABASE_PROJECT_ID` on the `ref` variable - the clickhouse organization precedent (any-sdk v0.5.4-alpha01 / stackql v0.10.601+). Every other path (the projects root and create, available regions, the organization surface, branch-by-id, snippets, profile, and `/v1/projects/{ref}` itself) keeps its full path and `post_process.mjs` pins it to the bare API base with a path-level `servers` override (any-sdk resolves servers operation -> path item -> document). Proven: with the variable set `ref` disappears from `SHOW METHODS` required params; `WHERE ref = ...` beats the environment; unset with no WHERE fails with "cannot find any viable servers"; `projects.get` keeps `ref` as a path parameter. The env var name follows the dashboard's "Project ID" label (Terraform reads only `SUPABASE_ACCESS_TOKEN`; the CLI CI examples use `SUPABASE_PROJECT_ID`). The rebase drops the `ref` path parameter from the mapped verbs only; the HEAD action-run count (skip-coded) kept its `ref` declaration against a path with no `{ref}` - flagged on the registry PR review. `post_process.mjs` now drops orphan path parameters generically (every OpenAPI operation verb, any name, matched against the path key's placeholders) rather than special-casing `ref` or HEAD. Consequence: **a JOIN cannot fan out over projects on `ref`**. The config singletons do not echo `ref` in their rows, so `projects p JOIN auth_configs a ON a.ref = p.ref` returns nothing (the inner read runs once, for the environment's project). The estate posture pattern is two statements - list projects, then per-`ref` reads composed with `UNION ALL` - and the docs teach it that way. Recorded so it is not rediscovered. diff --git a/README.md b/README.md index 3b2456d..96166aa 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ npm run generate-provider -- \ node provider-dev/scripts/post_process.mjs ``` -`--naive-req-body-translate` exposes top-level request body properties as columns, so `INSERT INTO supabase.secrets.secrets (name, value) ...` and `UPDATE supabase.config.auth_configs SET disable_signup = 'true'` render the wire bodies as written. `post_process.mjs` pins the non-project paths to the API base (path-level `servers`), configures cursor pagination on `snippets.list`, sets `request.nativeCasing: camel` on the three camelCase-body methods, attaches the query endpoint's result binding, the POST-backed `network_bans.list` objectKey, the secrets request transforms (single-item object -> the bare array the wire expects) and naive body translation on the two DELETEs with bodies, and validates that every other path is project-relative. +`--naive-req-body-translate` exposes top-level request body properties as columns, so `INSERT INTO supabase.secrets.secrets (name, value) ...` and `UPDATE supabase.config.auth_configs SET disable_signup = 'true'` render the wire bodies as written. `post_process.mjs` pins the non-project paths to the API base (path-level `servers`), configures cursor pagination on `snippets.list`, sets `request.nativeCasing: camel` on the three camelCase-body methods, attaches the query endpoint's result binding, the POST-backed `network_bans.list` objectKey, the secrets request transforms (single-item object -> the bare array the wire expects) and naive body translation on the two DELETEs with bodies, drops orphan path parameters (an `in: path` parameter with no `{name}` placeholder in its path key, checked on every OpenAPI operation verb), and validates that every other path is project-relative. ### Server parameters diff --git a/provider-dev/openapi/src/supabase/v00.00.00000/services/branches.yaml b/provider-dev/openapi/src/supabase/v00.00.00000/services/branches.yaml index 248cdec..684094e 100644 --- a/provider-dev/openapi/src/supabase/v00.00.00000/services/branches.yaml +++ b/provider-dev/openapi/src/supabase/v00.00.00000/services/branches.yaml @@ -428,17 +428,6 @@ paths: head: description: Returns the total number of action runs of the specified project. operationId: v1-count-action-runs - parameters: - - name: ref - required: true - in: path - description: Project ref - schema: - minLength: 20 - maxLength: 20 - pattern: ^[a-z]+$ - example: abcdefghijklmnopqrst - type: string responses: '200': headers: diff --git a/provider-dev/scripts/post_process.mjs b/provider-dev/scripts/post_process.mjs index f7a3445..9ae8353 100644 --- a/provider-dev/scripts/post_process.mjs +++ b/provider-dev/scripts/post_process.mjs @@ -54,6 +54,16 @@ // bans) get the naive translation here so their attributes are plain // WHERE keys. // +// 8. Orphan path parameters. OpenAPI requires every `in: path` parameter to +// have a matching {name} placeholder in its path key. The ref rebase moves +// {ref} out of the path key into the server template, and anything the +// rebase did not reach (an operation verb outside the mapped set, such as +// the HEAD action-run count) is left declaring a path parameter the path +// no longer carries. Every path-item and operation parameter list is +// checked against its path key here, for all OpenAPI operation verbs, and +// orphans are dropped. Generic by construction: no parameter names, verbs +// or paths are special-cased. +// // Usage: node provider-dev/scripts/post_process.mjs import fs from 'fs'; @@ -65,6 +75,8 @@ import { API_BASE_URL } from './lib/spec_helpers.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); const servicesDir = path.join(repoRoot, 'provider-dev', 'openapi', 'src', 'supabase', 'v00.00.00000', 'services'); const QUERY_RESULT_SCHEMA_NAME = 'V1RunQueryResultRows'; +// every operation verb OpenAPI 3 allows on a path item +const OPENAPI_OPERATION_VERBS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; // methods whose request bodies carry camelCase attributes const CAMEL_BODY_METHODS = [ @@ -177,6 +189,34 @@ for (const [f, resource, name] of [['secrets.yaml', 'secrets', 'delete'], ['netw naiveDeletes++; } +// 8. orphan path parameters: drop `in: path` parameters with no {name} in the path key +const orphanPathParams = []; +for (const [f, doc] of docs) { + const componentParams = doc.components?.parameters || {}; + const resolve = (p) => { + if (!p?.$ref) return p; + const m = /^#\/components\/parameters\/(.+)$/.exec(p.$ref); + return m ? componentParams[decodeURIComponent(m[1])] : undefined; + }; + for (const [pathKey, item] of Object.entries(doc.paths || {})) { + const placeholders = new Set([...pathKey.matchAll(/\{([^}]+)\}/g)].map((x) => x[1])); + const prune = (owner, where) => { + if (!Array.isArray(owner.parameters) || owner.parameters.length === 0) return; + owner.parameters = owner.parameters.filter((p) => { + const r = resolve(p); + if (!r || r.in !== 'path' || placeholders.has(r.name)) return true; + orphanPathParams.push(`${f}: ${where} ${pathKey} declares path parameter '${r.name}' with no {${r.name}} in the path`); + return false; + }); + if (owner.parameters.length === 0) delete owner.parameters; + }; + prune(item, 'path item'); + for (const verb of OPENAPI_OPERATION_VERBS) { + if (item[verb]) prune(item[verb], verb.toUpperCase()); + } + } +} + if (errors.length > 0) { console.error(`FAILED with ${errors.length} error(s), nothing written:`); for (const e of errors) console.error(` ${e}`); @@ -185,3 +225,5 @@ if (errors.length > 0) { for (const [f, d] of docs) fs.writeFileSync(path.join(servicesDir, f), yaml.dump(d, { lineWidth: -1, noRefs: true })); console.log(`post_process: pinned ${counts.rootPathsPinned} root path item(s) to ${API_BASE_URL} across ${docs.size} services (${counts.refPaths} project-scoped paths on the server template)`); console.log(`post_process: snippets cursor pagination; request.nativeCasing: camel on ${counts.nativeCasing} methods; query result binding on queries.run / run_read_only; objectKey on network_bans.list; secrets bulk-body request transforms; naive body translation on ${naiveDeletes} DELETE methods`); +console.log(`post_process: dropped ${orphanPathParams.length} orphan path parameter(s)`); +for (const o of orphanPathParams) console.log(` ${o}`); From d551cedca824c7995ef46def15b07060b555b342 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Mon, 12 Oct 2026 14:20:56 +1100 Subject: [PATCH 7/8] smoke: --live pulls the published provider first; post-publish run green make smoke-live against supabase v26.10.00477 in the public registry: 52 passed, 0 failed, 2 skipped, matching the local-registry run. The binary does not auto-pull a provider under pystackql, so the first --live attempt read no rows everywhere; --live now runs REGISTRY PULL supabase and fails fast if SHOW SERVICES is empty. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- NOTES.md | 2 ++ tests/smoke_test.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/NOTES.md b/NOTES.md index d7f030a..a645c03 100644 --- a/NOTES.md +++ b/NOTES.md @@ -121,6 +121,8 @@ Supabase issues organization-scoped access tokens as well as personal ones. An o ## Live smoke run - 2026-10-12 +`make smoke-live` against the published provider (`supabase v26.10.00477` in the public registry, pystackql with stackql v0.12.718): 52 passed, 0 failed, 2 skipped - identical to the local-registry result below. The first `--live` attempt reported "no rows" on every read including `SHOW SERVICES`: the binary does not auto-pull a provider under pystackql, so `--live` now runs `REGISTRY PULL supabase` first and fails fast if the provider does not load (a post-publish check should pull the current registry version anyway). + `make smoke` (local registry, pystackql with stackql v0.12.718, the standing free-tier project): 46 passed, 4 failed on the first run; the four were the two environment properties above (profile scope, untuned Postgres config) and the API key name validation, and the second run surfaced the API key endpoint limit - all harness classification and pacing, no provider defect. Final run: 52 passed, 0 failed, 2 skipped (profile, postgres config), 61 statements. Established on this run: the string-typed UPDATE coercion holds (auth-config `disable_signup` toggled and restored, findings 4 and 14); the deprecated JSON edge function create still works (create, update, delete); the query endpoint round trip (`queries.run` INSERT ... RETURNING rows, fixture create/insert/select/drop, `run_read_only` EXEC and the `read_only` flag) passes; `network_restrictions.apply` re-applies idempotently with the camelCase body; secrets create/delete through the wrapped bare-array bodies. The venv must live off the DrvFs mount under WSL (`make smoke VENV=$HOME/.venvs/stackql-supabase`) - pip cannot set permissions on `/mnt/c`. ## Blockers / for the live smoke run diff --git a/tests/smoke_test.py b/tests/smoke_test.py index f1a397a..c4aa3f6 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -242,6 +242,22 @@ def rows_of(blob) -> list: return blob or [] # ------------------------------------------------------- breadcrumb sweep + def pull_published_provider(self) -> None: + # --live verifies the published provider; pull the current registry + # version explicitly (the binary does not auto-pull under pystackql: + # SHOW/SELECT return no rows, and every step reads as "no rows") and + # fail fast if the pull or the first SHOW does not succeed. + print("== registry pull ==") + try: + out = self.sq.executeStmt("REGISTRY PULL supabase") + except Exception as exc: # noqa: BLE001 + sys.exit(f"REGISTRY PULL supabase failed: {exc}") + print(f" {json.dumps(out, default=str)[:160]}") + rows, err = self.q("SHOW SERVICES IN supabase") + if err or not rows: + sys.exit(f"published supabase provider did not load after REGISTRY PULL: {err or 'SHOW SERVICES returned no rows'}") + print(f" {len(rows)} services") + def cleanup_breadcrumbs(self) -> None: print("== breadcrumb sweep ==") rows, err = self.q("SELECT name FROM supabase.secrets.secrets") @@ -468,6 +484,8 @@ def main() -> int: smoke = Smoke(args) print(f"supabase smoke test registry={'public' if args.live else 'local'} project={smoke.ref} " f"name={smoke.name} stackql={smoke.sq.version}") + if args.live: + smoke.pull_published_provider() smoke.cleanup_breadcrumbs() if args.cleanup_only: return 0 From f6499337206c49e322bc2fc1f50eff32b0d293ee Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Mon, 12 Oct 2026 15:05:19 +1100 Subject: [PATCH 8/8] supabase release --- .github/copilot-instructions.md | 3 ++ AGENTS.md | 44 ++++++++++++++++ CLAUDE.md | 90 +-------------------------------- 3 files changed, 49 insertions(+), 88 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 AGENTS.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..84f6bac --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +# GitHub Copilot entry point + +Read and follow the repository's canonical guidance in [AGENTS.md](../AGENTS.md) for all work in this repository. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4f337ea --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,44 @@ +# Project guidance + +This file is the canonical, tool-neutral guidance for work in this repository. Claude Code and GitHub Copilot entry points refer here; Codex should use this file directly. Keep project rules here rather than duplicating them in tool-specific files. + +## Project scope and architecture + +- This repository builds the StackQL `supabase` provider for the Supabase Management API at `https://api.supabase.com`. Per-project data APIs (PostgREST, Realtime, Storage object I/O, and GoTrue user-facing auth) use separate hosts and keys and are out of scope for this provider. +- The provider is a type 1 (DIRECT) build from the vendor's published OpenAPI document using `@stackql/provider-utils`. Use [stackql-provider-clickhouse](../../C/stackql-provider-clickhouse) as the reference for structure, scripts, tests, and docs. +- Reuse established findings from sibling provider builds rather than re-deriving them. Read [NOTES.md](NOTES.md) before changing a mapping; document any justified deviation from prior findings there. +- Describe the official Supabase Terraform provider as Public Alpha, factually and without editorializing. Ground coverage claims in the published-spec inventory. Explain the project's SQL endpoint as a capability beyond the resource model through capability statements and runnable examples, not editorializing. + +## Spec and generated artifacts + +- The source spec is `https://api.supabase.com/api/v1-json`. `bin/fetch-spec.sh` applies deterministic fixes in `provider-dev/scripts/record_spec_pin.mjs`, validates the result, and verifies the URL, date, and hash in `provider-dev/config/spec_pin.json`. Fix classes are counted in the pin; the downloaded snapshot is committed. +- Treat spec refreshes as reviewed diffs (`make refresh-spec`), not silent regeneration. If a refresh introduces a new JSON Schema 2019-09/2020-12 construct, add a deterministic fix class in `record_spec_pin.mjs`; never hand-edit the downloaded spec. +- Mapping decisions belong in `provider-dev/scripts/map_operations.mjs` (`RESOURCE_RULES`, `METHOD_RULES`) and skip codes in `provider-dev/scripts/lib/spec_helpers.mjs`. Do not hand-edit generated specs, provider output, or mapping CSVs. +- `provider-dev/config/all_services.csv` is the durable operation-to-resource/method record. Review any changed mapping, resource name, or method name as a potentially breaking change. +- Build scripts must remain deterministic and validate inputs before writing output. After any regeneration, run `make test` before committing. + +## Settled provider behavior + +- Use bearer authentication from `SUPABASE_ACCESS_TOKEN` and the fixed API base `https://api.supabase.com`. +- The split rebases project-scoped operations onto the project server template in `provider-dev/config/servers.json` with `x-stackQL-envVar: SUPABASE_PROJECT_ID`; `provider-dev/scripts/post_process.mjs` pins non-project paths to the API base. A `WHERE ref` value takes precedence over the environment. A join cannot fan out across projects on `ref`; document and use the two-statement pattern. +- The project SQL endpoint is `database.queries.run`, using `INSERT ... RETURNING rows`; it returns one row whose `rows` value contains the result set. The read-only sibling is EXEC-only. +- Keep the SQL surface snake_case (`snake_case_aliases: true`). Retain `request.nativeCasing: camel` for the three camelCase-body methods. +- StackQL marshals UPDATE values as strings; INSERT and EXEC remain typed. Do not work around this in the provider; document the behavior. Map both PUT and PATCH as UPDATE unless full-replacement semantics are proven for a resource. +- Preserve request transforms for the secrets create/delete single-item bodies. The function bulk update remains skip-coded; DELETE bodies use the existing naive translation in `post_process.mjs`. +- Test harness requests are paced at 1.2 seconds per statement. A 429 in CI indicates a harness problem, not a retry case. +- Preserve vendor `[Beta]`/`[Alpha]` labels and deprecation markers. +- Preserve the 12 skipped operations and their codes: `oauth_user_agent_flow` (the service is excluded), `non_json_text_response`, `multipart_eszip_deploy`, `untyped_function_body`, `untyped_json_response`, `bare_array_bulk_body`, and `head_count_endpoint`. + +## Toolchain and validation + +- Use the latest `@stackql/provider-utils` and `@stackql/pgwire-lite`; check npm before work and do not pin an old minor. Use Node.js >= 20 and `type: module`. +- The microsite uses Docusaurus 3.10.x; preserve `showLastUpdateTime` in `website/docusaurus.config.js`. +- The Makefile is the operator surface (`make help`). Use its targets rather than invoking pipeline steps ad hoc. The npm entry points `provider-dev-utils.mjs` and `docgen-utils.mjs` are invoked through `node`. +- `make test` runs the credential-free offline, integration, and meta-route test layers. The live smoke suite uses the standing free-tier development project and credentials from the environment or gitignored `.env`; see [README.md](README.md) and [.env.example](.env.example). +- Never run tests against a production organization or project. The project create/pause/delete lifecycle runs only through its gated target. Smoke tests must restore configuration they toggle and clean up everything they create. + +## Documentation and publishing + +- Keep README and docs copy measured and precise. Use third-person or passive framing for descriptive copy; avoid hyperbole. +- Use hyphens instead of em dashes and `->` for arrows. Use only characters available on a QWERTY keyboard. Sample queries should be realistic and runnable, use `json_extract` for nested fields, and double-quote `database` when selecting that column. +- See [README.md](README.md) for prerequisites, the full build and test workflow, docs generation, and publishing. See [NOTES.md](NOTES.md) for mapping evidence and settled technical findings. diff --git a/CLAUDE.md b/CLAUDE.md index e926072..386fff3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,89 +1,3 @@ -# CLAUDE.md +# Claude Code entry point -## Project - -This repository builds and documents the `supabase` provider for [StackQL](https://github.com/stackql/stackql), enabling SQL-based query and provisioning operations against the Supabase Management API - organizations and members, projects, preview branches, edge functions, secrets and API keys, project configuration (auth/GoTrue settings, Postgres settings, pooler, API/PostgREST settings, storage, realtime, SSL enforcement), custom domains and vanity subdomains, network restrictions and bans, backups and restore points, read replicas, add-ons, advisors, analytics, and the project SQL query endpoint. - -**Scope notes, recorded so they are never relitigated**: the per-project data APIs (PostgREST at `.supabase.co/rest/v1`, Realtime, Storage object I/O, GoTrue user-facing auth) are per-project hosts with per-project keys - a different surface, reserved as a possible future `supabase_project` sibling, out of scope here. The Management API is the provider. - -The provider is a type 1 (DIRECT) build from the vendor's published OpenAPI document using `@stackql/provider-utils`, in the lean fixed-host mould of the clickhouse/hetzner sibling repos (the reference implementation for structure, scripts, tests and docs is [`stackql-provider-clickhouse`](../../C/stackql-provider-clickhouse)). Sibling-build NOTES.md findings are reused, not re-derived - snowflake (the statement-endpoint mapping framework), clickhouse (rate limit as a design input, the org-scoped server template, gated smoke lifecycles), keycloak (PUT is not REPLACE without evidence), newrelic (the JSON-blob posture for query-dependent shapes), hetzner (deterministic spec fixes counted in the pin). Supabase-specific findings live in [NOTES.md](NOTES.md) - read it before changing a mapping. - -## Positioning context - -Supabase's official Terraform provider is labelled Public Alpha by the vendor (the "experimental" wording in earlier drafts was wrong - NOTES.md finding 17) and covers seven resources. State the label once, factually. This provider's counter is mechanical completeness from the vendor's published spec (158 operations, 14 services, 65 resources) and one capability the resource model does not attempt: the project SQL query endpoint surfaced in-session, so the control plane and the project database are queryable in one place. Comparisons are expressed through capability statements and runnable examples, never editorializing. - -## Spec source - -The Management API serves its own OpenAPI document (NestJS-generated) at `https://api.supabase.com/api/v1-json`. `bin/fetch-spec.sh` downloads, applies the deterministic fixes in `provider-dev/scripts/record_spec_pin.mjs` (six defect classes so far, counted in the pin), validates with `@apidevtools/swagger-parser`, and pins (URL, date, hash) in `provider-dev/config/spec_pin.json`. The snapshot in `provider-dev/downloaded/` is committed. Supabase ships fast - the weekly drift job opens an issue, and refreshes are reviewed diffs (`make refresh-spec`), never silent regenerations. A refresh that introduces a new 2019-09/2020-12 JSON Schema construct needs a new fix class in `record_spec_pin.mjs`, not a hand edit. - -## Design decisions (settled - see NOTES.md for the evidence) - -- **Bearer auth from `SUPABASE_ACCESS_TOKEN`** - the CLI's and the Terraform provider's variable. Fixed API base `https://api.supabase.com`. -- **Project scope is a server variable** - the split rebases the 141 operations under `/v1/projects/{ref}/` onto `https://api.supabase.com/v1/projects/{ref}` with `x-stackQL-envVar: SUPABASE_PROJECT_ID` (`provider-dev/config/servers.json`); the non-project paths keep their full path and are pinned to the API base by `post_process.mjs`. A `WHERE ref` value beats the environment. A JOIN cannot fan out over projects on `ref` - the docs teach the two-statement pattern (finding 13). -- **The query endpoint is `database.queries.run`, INSERT ... RETURNING rows** - one row whose `rows` column carries the result set (finding 1). The read-only sibling is EXEC-only. -- **snake_case surface** - `snake_case_aliases: true` on the provider config plus `request.nativeCasing: camel` on the three camelCase-body methods (finding 16). Everything else on the wire is already snake_case. -- **UPDATE values are strings** in the stackql engine (finding 14); INSERT and EXEC are typed. Document it; do not work around it in the provider. -- **Bare-array bodies** - secrets create/delete are single-item bodies wrapped by request transforms; the function bulk update is skip-coded (finding 8). DELETE bodies get naive translation in `post_process.mjs`. -- **Rate limit as a design input** - harness pacing is 1.2 s per statement; a 429 in CI is a harness bug (finding 6). -- **Labels** - `[Beta]`/`[Alpha]` and deprecations flow through from the vendor summaries. -- **Skip codes** (12 operations): `oauth_user_agent_flow` (the service is excluded from the provider entirely), `non_json_text_response`, `multipart_eszip_deploy`, `untyped_function_body`, `untyped_json_response`, `bare_array_bulk_body`, `head_count_endpoint`. - -## Toolchain rules - -- Use the **latest** `@stackql/provider-utils` and `@stackql/pgwire-lite` (check npm before starting work; do not pin to an old minor). Node.js >= 20, `type: module`. -- Docusaurus 3.10.x for the microsite; `showLastUpdateTime` is flipped on in `website/docusaurus.config.js`. -- WSL is the execution environment on this machine (GNU make, bash, a `stackql` binary on PATH, Python 3, yarn). Node steps also run from Windows. -- The two CLI entry points (`provider-dev-utils.mjs`, `docgen-utils.mjs`) are npm scripts invoked through `node`; the Makefile is the operator surface (`make help`). - -## Repository layout - -``` -Makefile # the pipeline: make all / make test / make smoke ... -bin/ # fetch-spec.sh, split.mjs, server lifecycle, test-meta-routes.cjs -provider-dev/ - downloaded/ # pinned spec snapshot (committed) - config/ # spec_pin.json, service_names.json, servers.json, endpoint_inventory.csv, all_services.csv - scripts/ # record_spec_pin, build_inventory, map_operations, pre_normalize, post_process, lib/spec_helpers - source/ # split + normalized per-service specs (build artifacts, committed) - openapi/src/supabase # generated provider output (committed) - docgen/provider-data # headerContent1.txt / headerContent2.txt (landing page) -tests/ - offline_validation.mjs - integration/ # mock_supabase_server.mjs, run_integration_tests.mjs, probe.mjs - smoke_test.py # pystackql live suite (--live, --read-only, --with-project-lifecycle, --cleanup-only) -website/ # Docusaurus microsite (shared stackql/docusaurus-config vendored at build) -.github/workflows/ # build-and-test.yml (pin check, build, drift check, 3 test layers, gated smoke, weekly spec-drift), web deploys -``` - -## Build pipeline - -`make all` runs deps -> fetch-spec (pin verify) -> inventory -> split -> mappings -> pre-normalize -> normalize -> generate (+ post-process) -> test-offline -> test-integration -> test-meta -> docs -> website. Every step is deterministic and re-runnable; manual mapping decisions are rules in `map_operations.mjs` (`RESOURCE_RULES`, `METHOD_RULES`) and skip codes in `lib/spec_helpers.mjs`, never hand-edits to CSVs or specs. `all_services.csv` is committed as the durable record of every operation -> resource.method mapping; a diff there on a regeneration is a breaking-change review (a method moving resource, a resource renamed), not noise. Validate-and-fail-without-writing is the standard for every script. - -## Tests - -1. `make test-offline` - `SHOW`/`DESCRIBE` against the local file registry (services, resources, verbs, env-var behaviour, snake aliases). -2. `make test-integration` - the mock Management API (`tests/integration/mock_supabase_server.mjs`, real wire shapes, bearer enforced) with row-level assertions per archetype. `tests/integration/probe.mjs ""` prints stackql output and the wire call for ad-hoc binding checks. -3. `make test-meta` - the meta-route walk over a local server. -4. `make smoke` / `make smoke-live` / `make smoke-read-only` / `make smoke-project-lifecycle` / `make smoke-cleanup` - live, against the standing free-tier dev project from `.env` (`SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROJECT_ID`). Free-tier cost: nothing. The project lifecycle only runs in its gated target. - -Never run tests against a production organization or project. - -## Publish and docs - -Push the `supabase` dir to `providers/src` in a feature branch of [`stackql-provider-registry`](https://github.com/stackql/stackql-provider-registry) and follow the registry release flow; verify with `registry pull supabase` from the dev registry and `make smoke-live`. Docs: `make docs` (generate + sanitize, including the "required unless SUPABASE_PROJECT_ID is set" annotation) then `make website`; GitHub Pages with `supabase-provider.stackql.io` CNAME -> `stackql.github.io.`. - -## Writing conventions - -- README and docs copy: measured, precise, no hyperbole. Third-person or passive framing for descriptive copy. -- No em dashes; use `-`. No characters not on a QWERTY keyboard; use `->` for arrows. -- Sample queries follow the k8s README style: realistic, runnable, `json_extract` for nested fields; `"database"` double-quoted when selecting that column. - -## Non-negotiables - -1. Latest `@stackql/provider-utils`, always -2. The clickhouse repo is the reference pattern; sibling-build NOTES.md findings are reused, not re-derived - deviate only with a documented reason in NOTES.md -3. Test harnesses pace under the rate limit - a 429 in CI is a harness bug -4. The project create/delete lifecycle never runs outside the gated target - free-tier quota is a shared resource -5. Deterministic scripts, never hand-edits to derived artifacts -6. Every regeneration is followed by `make test` before commit -7. Smoke tests restore any config they toggle and clean up everything they create +Read and follow the repository's canonical guidance in [AGENTS.md](AGENTS.md) for all work in this repository.