diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc3e3a8d2..183757d5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,16 +60,16 @@ jobs: ports: - 5432:5432 - minio_cdn: - image: minio/minio:edge-cicd + rustfs_cdn: + image: rustfs/rustfs:1.0.0-rc.5 env: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin + RUSTFS_ACCESS_KEY: minioadmin + RUSTFS_SECRET_KEY: minioadmin + RUSTFS_ADDRESS: ":9000" ports: - 9000:9000 - - 9001:9001 options: >- - --health-cmd "curl -f http://localhost:9000/minio/health/live || exit 1" + --health-cmd "curl -f http://localhost:9000/health || exit 1" --health-interval 10s --health-timeout 5s --health-retries 5 diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 422584469..f951a9e46 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -31,16 +31,16 @@ jobs: ports: - 5432:5432 - minio_cdn: - image: minio/minio:edge-cicd + rustfs_cdn: + image: rustfs/rustfs:1.0.0-rc.5 env: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin + RUSTFS_ACCESS_KEY: minioadmin + RUSTFS_SECRET_KEY: minioadmin + RUSTFS_ADDRESS: ":9000" ports: - 9000:9000 - - 9001:9001 options: >- - --health-cmd "curl -f http://localhost:9000/minio/health/live || exit 1" + --health-cmd "curl -f http://localhost:9000/health || exit 1" --health-interval 10s --health-timeout 5s --health-retries 5 diff --git a/packages/function-resolution/__tests__/capabilities.test.ts b/packages/function-resolution/__tests__/capabilities.test.ts index 31a8def69..801de19f5 100644 --- a/packages/function-resolution/__tests__/capabilities.test.ts +++ b/packages/function-resolution/__tests__/capabilities.test.ts @@ -508,7 +508,7 @@ describe('function-resolution capability resolution', () => { it('resolve_capabilities(): declarations become one resolved bundle', async () => { const [{ bundle }] = await pg.any( - `SELECT function_resolution.resolve_capabilities($1, 'database', $1, $2, 'database', $1, $3::jsonb, 'api') AS bundle`, + `SELECT function_resolution.resolve_capabilities($1, 'database', $1, $2, 'database', $3::jsonb, 'api') AS bundle`, [TENANT_DB, ids.exporter, JSON.stringify({ subject: 'monthly' })] ); @@ -530,7 +530,7 @@ describe('function-resolution capability resolution', () => { it('resolve_capabilities(): echoes required_capabilities as declared', async () => { const [{ bundle }] = await pg.any( - `SELECT function_resolution.resolve_capabilities($1, 'database', $1, $2, 'database', $1, '{}'::jsonb, 'api') AS bundle`, + `SELECT function_resolution.resolve_capabilities($1, 'database', $1, $2, 'database', '{}'::jsonb, 'api') AS bundle`, [TENANT_DB, ids.declaring] ); expect(bundle.capabilities).toEqual([ @@ -548,7 +548,7 @@ describe('function-resolution capability resolution', () => { ); const [{ bundle }] = await pg.any( - `SELECT function_resolution.resolve_capabilities($1, 'database', $1, $2, 'database', $1) AS bundle`, + `SELECT function_resolution.resolve_capabilities($1, 'database', $1, $2, 'database') AS bundle`, [TENANT_DB, ids.ambiguous] ); expect(bundle.buckets.variants.bucket_id).toBe(ids.publicVariants); @@ -563,7 +563,7 @@ describe('function-resolution capability resolution', () => { await expect( pg.any( - `SELECT function_resolution.resolve_capabilities($1, 'database', $1, $2, 'database', $1)`, + `SELECT function_resolution.resolve_capabilities($1, 'database', $1, $2, 'database')`, [TENANT_DB, ids.ambiguous] ) ).rejects.toThrow(/CAPABILITY_BINDING_UNREACHABLE/); @@ -576,7 +576,7 @@ describe('function-resolution capability resolution', () => { it('resolve_capabilities(): an undeclared channel is refused', async () => { await expect( pg.any( - `SELECT function_resolution.resolve_capabilities($1, 'database', $1, $2, 'database', $1, '{}'::jsonb, 'cron')`, + `SELECT function_resolution.resolve_capabilities($1, 'database', $1, $2, 'database', '{}'::jsonb, 'cron')`, [TENANT_DB, ids.exporter] ) ).rejects.toThrow(/CAPABILITY_CHANNEL_REFUSED/); @@ -585,7 +585,7 @@ describe('function-resolution capability resolution', () => { it('resolve_capabilities(): a missing definition raises', async () => { await expect( pg.any( - `SELECT function_resolution.resolve_capabilities($1, 'database', $1, gen_random_uuid(), 'database', $1)`, + `SELECT function_resolution.resolve_capabilities($1, 'database', $1, gen_random_uuid(), 'database')`, [TENANT_DB] ) ).rejects.toThrow(/CAPABILITY_DEFINITION_NOT_FOUND/); @@ -593,13 +593,13 @@ describe('function-resolution capability resolution', () => { it('validate_capabilities(): passes when resolvable, raises when not', async () => { await pg.query( - `SELECT function_resolution.validate_capabilities($1, 'database', $1, $2, 'database', $1)`, + `SELECT function_resolution.validate_capabilities($1, 'database', $1, $2, 'database')`, [TENANT_DB, ids.exporter] ); await expect( pg.any( - `SELECT function_resolution.validate_capabilities($1, 'database', $1, $2, 'database', $1)`, + `SELECT function_resolution.validate_capabilities($1, 'database', $1, $2, 'database')`, [TENANT_DB, ids.ambiguous] ) ).rejects.toThrow(/CAPABILITY_BUCKET_AMBIGUOUS/); diff --git a/packages/function-resolution/deploy/schemas/function_resolution/procedures/install_mantra.sql b/packages/function-resolution/deploy/schemas/function_resolution/procedures/install_mantra.sql index 139c7f840..3e17e8fd0 100644 --- a/packages/function-resolution/deploy/schemas/function_resolution/procedures/install_mantra.sql +++ b/packages/function-resolution/deploy/schemas/function_resolution/procedures/install_mantra.sql @@ -74,19 +74,29 @@ BEGIN USING ERRCODE = 'FR060'; END IF; + -- Two shapes only: a page ({path, task_identifier}, no target kind — it is + -- always a function), or a redirect ({path, target: 'redirect', to_path}) a + -- host given over to the page set uses to send its root at sign-in. A + -- service binding, or a page that names a kind, is a broken preset. IF EXISTS ( SELECT 1 FROM jsonb_array_elements(install_mantra.bindings) AS b WHERE jsonb_typeof(b) <> 'object' OR coalesce(b ->> 'path', '') = '' - OR coalesce(b ->> 'task_identifier', '') = '' - OR b ? 'target' + OR CASE b ->> 'target' + WHEN 'redirect' THEN coalesce(b ->> 'to_path', '') = '' OR b ? 'task_identifier' + ELSE coalesce(b ->> 'task_identifier', '') = '' OR b ? 'target' + END ) THEN - RAISE EXCEPTION 'MANTRA_BINDINGS_INVALID: every binding must carry a non-empty path and task_identifier, and no target kind' + RAISE EXCEPTION 'MANTRA_BINDINGS_INVALID: every binding must carry a non-empty path and either a task_identifier with no target kind, or target "redirect" with a to_path' USING ERRCODE = 'FR060'; END IF; - SELECT jsonb_agg(b || jsonb_build_object('target', 'function')) + SELECT jsonb_agg( + CASE WHEN b ->> 'target' = 'redirect' THEN b + ELSE b || jsonb_build_object('target', 'function') + END + ) INTO function_bindings FROM jsonb_array_elements(install_mantra.bindings) AS b; @@ -102,6 +112,6 @@ END; $$ LANGUAGE plpgsql VOLATILE; COMMENT ON FUNCTION function_resolution.install_mantra(uuid, regclass, uuid, jsonb, uuid) IS -'Install the Mantra page set (a JSON array of {path, task_identifier}, which the generated verb reads from the content_presets catalog at kind ''route_bindings'') onto one site as ordinary function-target routes. The sites plane arrives by reference as a regclass, so a generated caller never spells a schema name in a bare string literal the platform export''s AST rename cannot follow. A thin wrapper holding the Mantra document contract — every entry names a task, none names a target kind — over function_resolution.install_route_bindings, which owns the one-scope install: scope and ownership key read from the sites plane''s own registration, the routes plane from app_scope.routing_tables, resolution at that same (scope, entity), idempotent per (domain_id, path).'; +'Install the Mantra page set (a JSON array of {path, task_identifier}, which the generated verb reads from the content_presets catalog at kind ''route_bindings'') onto one site as ordinary function-target routes. The sites plane arrives by reference as a regclass, so a generated caller never spells a schema name in a bare string literal the platform export''s AST rename cannot follow. A thin wrapper holding the Mantra document contract — every entry names a task and no target kind, or is a {target: "redirect", to_path} entry sending a path (a dedicated host''s root) at one of the pages — over function_resolution.install_route_bindings, which owns the one-scope install: scope and ownership key read from the sites plane''s own registration, the routes plane from app_scope.routing_tables, resolution at that same (scope, entity), idempotent per (domain_id, path).'; COMMIT; diff --git a/packages/function-resolution/deploy/schemas/function_resolution/procedures/install_route_bindings.sql b/packages/function-resolution/deploy/schemas/function_resolution/procedures/install_route_bindings.sql index 6b3217be5..2b6bebb48 100644 --- a/packages/function-resolution/deploy/schemas/function_resolution/procedures/install_route_bindings.sql +++ b/packages/function-resolution/deploy/schemas/function_resolution/procedures/install_route_bindings.sql @@ -24,6 +24,22 @@ BEGIN; -- -- {"path": "/login", "target": "function", "task_identifier": "mantra:signin", "anonymous": true} -- {"path": "/app", "target": "service", "service_id": ""} +-- {"path": "/", "target": "redirect", "to_path": "/login", "anonymous": true} +-- +-- A redirect binding is a row in the scope's redirects table (the routing +-- plane's own, named by its route_module registration) that the route's +-- target_redirect_id points at: the edge answers status_code + Location and no +-- backend is involved. It redirects onto the same hostname unless the entry +-- names a `to_host`, keeps the query string, and never appends the incoming +-- path (`preserve_path` false — a path-to-path redirect). status_code defaults +-- to 302 and must be one of 301/302/307/308. +-- +-- The one place a redirect binding may REPOINT an existing route: the root +-- route guard auto-creates '/' targeting the site itself the moment a hostname +-- is routed, so on a site provisioned only to carry a page set that row serves +-- nothing. A redirect entry whose path already exists and still targets THE +-- SITE BEING INSTALLED ONTO takes that placeholder over; a root a tenant has +-- since pointed anywhere else is left alone like every other existing path. -- -- An entry may declare `anonymous`, which is the route's half of the anonymous -- contract: the URL answers callers carrying no identity. It opens nothing on @@ -116,6 +132,16 @@ DECLARE resources_schema text; resources_table text; resources_key text; + -- The routes plane's own redirects table, from the same route_module + -- registration that names the routes table; it shares the plane's + -- ownership key. NULL when the plane records none, which makes a redirect + -- binding a hard error rather than an unchecked insert. + redirects_schema text; + redirects_table text; + -- The hostname the site is routed on: a redirect binding's default to_host. + domains_schema text; + domains_table text; + domain_hostname text; -- The one ownership key value the whole install is keyed by (NULL at the -- global tier), and the entity the resolver starts its frame walk at. key_value uuid; @@ -130,12 +156,18 @@ DECLARE entry_task text; entry_service uuid; entry_anonymous boolean; + entry_to_host text; + entry_to_path text; + entry_status int; + entry_redirect_name text; target_column text; target_id uuid; service_found boolean; inserted int; + repointed_now int; installed jsonb := '[]'::jsonb; skipped jsonb := '[]'::jsonb; + repointed jsonb := '[]'::jsonb; query text; BEGIN IF install_route_bindings.database_id IS NULL THEN @@ -177,9 +209,9 @@ BEGIN IF EXISTS ( SELECT 1 FROM jsonb_array_elements(install_route_bindings.bindings) AS b - WHERE (b ->> 'target') NOT IN ('function', 'service') + WHERE (b ->> 'target') NOT IN ('function', 'service', 'redirect') ) THEN - RAISE EXCEPTION 'ROUTE_BINDINGS_TARGET_UNKNOWN: target must be "function" or "service"' + RAISE EXCEPTION 'ROUTE_BINDINGS_TARGET_UNKNOWN: target must be "function", "service" or "redirect"' USING ERRCODE = 'FR060'; END IF; @@ -190,8 +222,9 @@ BEGIN SELECT 1 FROM jsonb_array_elements(install_route_bindings.bindings) AS b WHERE (b ? 'task_identifier') AND (b ? 'service_id') - OR (b ->> 'target') = 'function' AND (b ? 'service_id') - OR (b ->> 'target') = 'service' AND (b ? 'task_identifier') + OR (b ->> 'target') = 'function' AND (b ? 'service_id' OR b ? 'to_path') + OR (b ->> 'target') = 'service' AND (b ? 'task_identifier' OR b ? 'to_path') + OR (b ->> 'target') = 'redirect' AND (b ? 'task_identifier' OR b ? 'service_id') ) THEN RAISE EXCEPTION 'ROUTE_BINDINGS_TARGET_AMBIGUOUS: a binding must carry only the key of the target kind it declares' USING ERRCODE = 'FR060'; @@ -202,8 +235,27 @@ BEGIN FROM jsonb_array_elements(install_route_bindings.bindings) AS b WHERE (b ->> 'target') = 'function' AND coalesce(b ->> 'task_identifier', '') = '' OR (b ->> 'target') = 'service' AND coalesce(b ->> 'service_id', '') = '' + OR (b ->> 'target') = 'redirect' AND coalesce(b ->> 'to_path', '') = '' ) THEN - RAISE EXCEPTION 'ROUTE_BINDINGS_INVALID: a function binding needs a task_identifier and a service binding needs a service_id' + RAISE EXCEPTION 'ROUTE_BINDINGS_INVALID: a function binding needs a task_identifier, a service binding needs a service_id, and a redirect binding needs a to_path' + USING ERRCODE = 'FR060'; + END IF; + + -- A redirect's destination is a path on a host, so it must read as one, and + -- its status must be one the edge can honour (the same set the redirects + -- table's own check constraint accepts — rejected here by name rather than + -- as a constraint violation naming no binding). + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_route_bindings.bindings) AS b + WHERE (b ->> 'target') = 'redirect' + AND ( + (b ->> 'to_path') !~ '^/' + OR (b ? 'to_host' AND coalesce(b ->> 'to_host', '') = '') + OR (b ? 'status_code' AND NOT (b -> 'status_code' <@ '[301, 302, 307, 308]'::jsonb)) + ) + ) THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_REDIRECT_INVALID: a redirect binding''s to_path must start with "/", its to_host (when given) must be non-empty, and its status_code (when given) must be 301, 302, 307 or 308' USING ERRCODE = 'FR060'; END IF; @@ -269,8 +321,8 @@ BEGIN -- 2. The routes plane serving THAT scope, resolved the one way every -- scope-aware consumer resolves it. - SELECT r.routes_schema, r.routes_table - INTO routes_schema, routes_table + SELECT r.routes_schema, r.routes_table, r.domains_schema, r.domains_table + INTO routes_schema, routes_table, domains_schema, domains_table FROM app_scope.routing_tables(install_route_bindings.database_id, plane_scope) AS r; IF routes_schema IS NULL OR routes_table IS NULL THEN @@ -279,13 +331,18 @@ BEGIN USING ERRCODE = 'FR051'; END IF; - -- The routes plane's own registration carries its ownership key and, when - -- the plane has one, the column naming the site a route renders as. - SELECT rm.entity_field, rm.serving_site_field - INTO routes_key, routes_serving_site_key + -- The routes plane's own registration carries its ownership key, the + -- column naming the site a route renders as (when the plane has one), and + -- the redirects table a redirect binding writes (when it records one). + SELECT rm.entity_field, rm.serving_site_field, rs.schema_name, rt.name + INTO routes_key, routes_serving_site_key, redirects_schema, redirects_table FROM metaschema_modules_public.route_module AS rm JOIN metaschema_public."table" AS t ON t.id = rm.routes_table_id JOIN metaschema_public.schema AS s ON s.id = t.schema_id + LEFT JOIN metaschema_public."table" AS rt + ON rt.id = rm.redirects_table_id + AND rm.redirects_table_id <> uuid_nil() + LEFT JOIN metaschema_public.schema AS rs ON rs.id = rt.schema_id WHERE s.schema_name = routes_schema AND t.name = routes_table; @@ -354,12 +411,18 @@ BEGIN -- guard auto-creates '/' carrying the target of a hostname's FIRST route, -- so installing onto a bare hostname would make '/' the first binding's -- target. A site that is not routed yet is a hard error, not a silently - -- empty install. + -- empty install. A route this install stamped as serving the site counts + -- too: once a redirect has taken the root over, the pages (serving-site + -- stamped, function-targeted) are what still tie the host to the site. -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the routes plane is named by app_scope.routing_tables query := format( - 'SELECT r.domain_id FROM %I.%I AS r WHERE r.target_site_id = $1%s ORDER BY r.path LIMIT 1', + 'SELECT r.domain_id FROM %I.%I AS r WHERE (r.target_site_id = $1%s)%s ORDER BY r.path LIMIT 1', routes_schema, routes_table, + CASE WHEN routes_serving_site_key IS NULL + THEN '' + ELSE format(' OR r.%I = $1', routes_serving_site_key) + END, CASE WHEN routes_key IS NULL THEN ' AND $2 IS NULL' ELSE format(' AND r.%I = $2', routes_key) @@ -374,6 +437,15 @@ BEGIN USING ERRCODE = 'FR054'; END IF; + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the domains plane is named by app_scope.routing_tables + query := format( + 'SELECT d.hostname FROM %I.%I AS d WHERE d.id = $1', + domains_schema, + domains_table + ); + + EXECUTE query INTO domain_hostname USING domain_id; + -- 6. Per binding: resolve the declared target at THIS (scope, entity) and -- install the route if it is not already there. FOR entry IN SELECT * FROM jsonb_array_elements(install_route_bindings.bindings) @@ -398,6 +470,52 @@ BEGIN ) AS fr; target_column := 'target_function_id'; + ELSIF entry_target = 'redirect' THEN + IF redirects_schema IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_REDIRECT_PLANE_NOT_FOUND: routes plane %.% records no redirects table, so there is nowhere a redirect target could live', + routes_schema, routes_table + USING ERRCODE = 'FR055'; + END IF; + + entry_to_host := coalesce(entry ->> 'to_host', domain_hostname); + entry_to_path := entry ->> 'to_path'; + entry_status := coalesce((entry ->> 'status_code')::int, 302); + -- Names are owner-local, so the hostname keeps two sites' roots apart. + entry_redirect_name := coalesce(entry ->> 'name', format('%s %s', domain_hostname, entry_path)); + + -- The redirect row, found by name or created: same insert-if-missing + -- contract as the routes, so a re-run never rewrites a redirect a + -- tenant has since retuned. + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the redirects plane is named by the routes plane's own route_module registration + query := format( + 'SELECT rd.id FROM %I.%I AS rd WHERE rd.name = $1%s', + redirects_schema, + redirects_table, + CASE WHEN routes_key IS NULL + THEN ' AND $2 IS NULL' + ELSE format(' AND rd.%I = $2', routes_key) + END + ); + + EXECUTE query INTO target_id USING entry_redirect_name, key_value; + + IF target_id IS NULL THEN + -- pgsql-lint-disable-next-line no-dynamic-sql -- write-only: insert into the redirects plane named by the route_module registration; every value is a bound parameter + query := format( + 'INSERT INTO %I.%I (%sname, to_host, to_path, status_code, preserve_path, preserve_query) + VALUES (%s$1, $2, $3, $4, false, true) + RETURNING id', + redirects_schema, + redirects_table, + CASE WHEN routes_key IS NULL THEN '' ELSE format('%I, ', routes_key) END, + CASE WHEN routes_key IS NULL THEN '' ELSE '$5, ' END + ); + + EXECUTE query INTO target_id + USING entry_redirect_name, entry_to_host, entry_to_path, entry_status, key_value; + END IF; + + target_column := 'target_redirect_id'; ELSE entry_service := (entry ->> 'service_id')::uuid; @@ -457,8 +575,34 @@ BEGIN install_route_bindings.site_id, entry_anonymous; GET DIAGNOSTICS inserted = ROW_COUNT; + repointed_now := 0; + IF inserted = 0 AND entry_target = 'redirect' THEN + -- The root guard's placeholder — the path exists and still targets + -- the site this install is for — is the one row a redirect binding + -- takes over. Anything a tenant has pointed elsewhere stays. + -- pgsql-lint-disable-next-line no-dynamic-sql -- write-only: update the routes plane named by app_scope.routing_tables; every value is a bound parameter + query := format( + 'UPDATE %I.%I AS r + SET target_site_id = NULL, target_redirect_id = $3, anonymous = $5 + WHERE r.domain_id = $1 AND r.path = $2 + AND r.target_site_id = $4%s', + routes_schema, + routes_table, + CASE WHEN routes_key IS NULL + THEN ' AND $6 IS NULL' + ELSE format(' AND r.%I = $6', routes_key) + END + ); + + EXECUTE query USING domain_id, entry_path, target_id, + install_route_bindings.site_id, entry_anonymous, key_value; + GET DIAGNOSTICS repointed_now = ROW_COUNT; + END IF; + IF inserted > 0 THEN installed := installed || jsonb_build_array(entry_path); + ELSIF repointed_now > 0 THEN + repointed := repointed || jsonb_build_array(entry_path); ELSE skipped := skipped || jsonb_build_array(entry_path); END IF; @@ -500,6 +644,7 @@ BEGIN 'routes_schema', routes_schema, 'routes_table', routes_table, 'installed', installed, + 'repointed', repointed, 'skipped', skipped, 'serving_site_field', routes_serving_site_key, 'serving_site_backfilled', stamped @@ -508,6 +653,6 @@ END; $$ LANGUAGE plpgsql VOLATILE; COMMENT ON FUNCTION function_resolution.install_route_bindings(uuid, text, text, uuid, jsonb, uuid) IS -'Install a set of route bindings — a JSON array of {path, target, …} entries, each NAMING its target kind ("function" with a task_identifier, or "service" with a service_id) — onto one site as ordinary route rows, at ONE scope for ONE entity. The scope and ownership key are read from the named sites plane''s own site_surface_module registration, located along the caller''s frames (nearest first) so a shared serving plane hosted by an outer frame''s database resolves for the tenant consuming it — never a caller-supplied or generated literal — and that one (scope, key) then names the routes plane (app_scope.routing_tables), pins the site, route and service reads, starts function_resolution.resolve''s frame walk, and stamps every inserted row; a service target is proved to exist in the same-scope resources plane the routes plane''s registration records, which is the plane target_service_id FKs. Idempotent per (domain_id, path); raises on a malformed document, an unknown or ambiguous target kind, an unregistered plane, a scope with no routes plane, a missing entity key, an unknown site, an unrouted site, a scope with no resources plane, an unresolvable service, or an unpublished task.'; +'Install a set of route bindings — a JSON array of {path, target, …} entries, each NAMING its target kind ("function" with a task_identifier, "service" with a service_id, or "redirect" with a to_path) — onto one site as ordinary route rows, at ONE scope for ONE entity. The scope and ownership key are read from the named sites plane''s own site_surface_module registration, located along the caller''s frames (nearest first) so a shared serving plane hosted by an outer frame''s database resolves for the tenant consuming it — never a caller-supplied or generated literal — and that one (scope, key) then names the routes plane (app_scope.routing_tables), pins the site, route and service reads, starts function_resolution.resolve''s frame walk, and stamps every inserted row; a service target is proved to exist in the same-scope resources plane the routes plane''s registration records, which is the plane target_service_id FKs. A redirect binding writes a row in the routes plane''s own redirects table (same-host unless to_host is given, 302 unless status_code is given) and is the one kind allowed to repoint an existing route: the root guard''s placeholder still targeting the site being installed onto. Idempotent per (domain_id, path); raises on a malformed document, an unknown or ambiguous target kind, an unregistered plane, a scope with no routes plane, a missing entity key, an unknown site, an unrouted site, a scope with no resources plane, an unresolvable service, or an unpublished task.'; COMMIT; diff --git a/packages/function-resolution/deploy/schemas/function_resolution/procedures/resolve_capabilities.sql b/packages/function-resolution/deploy/schemas/function_resolution/procedures/resolve_capabilities.sql index 8f3dd4a99..91c36c879 100644 --- a/packages/function-resolution/deploy/schemas/function_resolution/procedures/resolve_capabilities.sql +++ b/packages/function-resolution/deploy/schemas/function_resolution/procedures/resolve_capabilities.sql @@ -8,6 +8,7 @@ -- requires: schemas/function_resolution/procedures/bound_bucket_id -- requires: schemas/function_resolution/procedures/resolve_api -- requires: schemas/function_resolution/procedures/resolve_payload_refs +-- requires: pgpm-app-scope:schemas/app_scope/procedures/frames BEGIN; @@ -29,12 +30,23 @@ BEGIN; -- definition is invoked inside a tenant's (or an org's, or a department's) -- frame chain and RLS world, which is why the execution triple -- (database_id, scope, entity_id) is taken apart from the definition's --- (definition_scope, definition_database_id). Every requirement resolves +-- (function_definition_id, definition_scope). Every requirement resolves -- against the *execution's* frames, so one image serves every scope. -- Every capability therefore has exactly one answer here, or the invocation -- fails loudly before any code runs — a function never receives a half-built -- context, and never selects a resource itself. -- +-- Where the definition's row physically lives is decided by app_scope.frames, +-- never by the caller. The execution's frames at definition_scope are walked +-- in order and each frame's lookup database is asked for its function module +-- (definitions_location); the row is read from the first surface that holds +-- it, keyed by that frame's key_value in the module's recorded entity_field. +-- That is what lets a hosted tenant — one with no function module of its own — +-- run a `database`-scope definition that lives on the platform database's +-- shared surface: the table is the platform's, the row is the tenant's +-- (database_id = the tenant), and a row keyed to any other tenant is not found +-- on that table. Global frames (app/platform) carry no key and no key column. +-- -- Buckets resolve in three tiers, and only the third one reaches this -- declaration path: -- 1. record-associated resources are stamped into the payload when the @@ -53,55 +65,81 @@ CREATE FUNCTION function_resolution.resolve_capabilities( entity_id uuid, function_definition_id uuid, definition_scope text, - definition_database_id uuid DEFAULT NULL, payload jsonb DEFAULT '{}'::jsonb, channel text DEFAULT NULL ) RETURNS jsonb AS $$ DECLARE - v_defn_database_id uuid; - v_defs_schema text; - v_defs_table text; - v_query text; - v_definition jsonb; - v_access_channels text[]; - v_key text; - v_bound_bucket_id uuid; + -- The frame whose function surface holds the definition: where the row + -- physically is (lookup database, schema, table) and whose it is (key). + frame record; + surface_found boolean := false; + definition_database_id uuid; + definition_query text; + definition jsonb; + access_channels text[]; + unreachable_key text; + unreachable_bucket_id uuid; -- The keys a tenant fulfilled with an explicit binding, paired positionally -- with the bucket each binding names: the two resolution routes are disjoint -- sets of keys, resolved by one query each rather than key by key. - v_bound_keys text[]; - v_bound_ids uuid[]; - v_buckets jsonb := '{}'::jsonb; - v_apis jsonb := '{}'::jsonb; + bound_keys text[]; + bound_ids uuid[]; + buckets jsonb := '{}'::jsonb; + apis jsonb := '{}'::jsonb; BEGIN - v_defn_database_id := coalesce( - resolve_capabilities.definition_database_id, - resolve_capabilities.database_id - ); + FOR frame IN + SELECT f.lookup_database_id, f.key_value, l.schema_name, l.table_name, l.entity_field + FROM app_scope.frames( + resolve_capabilities.database_id, + resolve_capabilities.scope, + resolve_capabilities.entity_id + ) WITH ORDINALITY AS f(scope, lookup_database_id, key_value, ord) + CROSS JOIN LATERAL function_resolution.definitions_location(f.lookup_database_id, f.scope) l + WHERE f.scope = resolve_capabilities.definition_scope + ORDER BY f.ord + LOOP + surface_found := true; - SELECT l.schema_name, l.table_name - INTO v_defs_schema, v_defs_table - FROM function_resolution.definitions_location(v_defn_database_id, resolve_capabilities.definition_scope) l; + -- to_jsonb of the row rather than a column list: the declaration set + -- grows, and a resolver that names columns fails on a database whose + -- function module predates the newest one. The row must carry the + -- frame's key in the module's recorded scope-key column; a global frame + -- has neither, and its key is asserted NULL. + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the definitions table is located per frame + definition_query := format( + 'SELECT to_jsonb(d) FROM %I.%I d WHERE d.id = $1 AND %s', + frame.schema_name, + frame.table_name, + CASE + WHEN frame.entity_field IS NULL THEN '$2::uuid IS NULL' + ELSE format('d.%I = $2', frame.entity_field) + END + ); - IF v_defs_schema IS NULL THEN - RAISE EXCEPTION 'CAPABILITY_DEFINITION_SCOPE_UNPROVISIONED: database % has no function module at scope "%"', - v_defn_database_id, resolve_capabilities.definition_scope - USING ERRCODE = 'FR040'; - END IF; + EXECUTE definition_query + INTO definition + USING resolve_capabilities.function_definition_id, frame.key_value; - -- to_jsonb of the row rather than a column list: the declaration set grows, - -- and a resolver that names columns fails on a database whose function - -- module predates the newest one. - -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the definitions table is located per scope - v_query := format('SELECT to_jsonb(d) FROM %I.%I d WHERE d.id = $1', v_defs_schema, v_defs_table); + IF definition IS NOT NULL THEN + definition_database_id := frame.lookup_database_id; + EXIT; + END IF; + END LOOP; - EXECUTE v_query INTO v_definition USING resolve_capabilities.function_definition_id; + IF NOT surface_found THEN + RAISE EXCEPTION 'CAPABILITY_DEFINITION_SCOPE_UNPROVISIONED: no frame of database % (scope "%") has a function module at scope "%"', + resolve_capabilities.database_id, + resolve_capabilities.scope, + resolve_capabilities.definition_scope + USING ERRCODE = 'FR040'; + END IF; - IF v_definition IS NULL THEN - RAISE EXCEPTION 'CAPABILITY_DEFINITION_NOT_FOUND: no function definition % at scope "%" in database %', + IF definition IS NULL THEN + RAISE EXCEPTION 'CAPABILITY_DEFINITION_NOT_FOUND: no function definition % at scope "%" reachable from database % (scope "%")', resolve_capabilities.function_definition_id, resolve_capabilities.definition_scope, - v_defn_database_id + resolve_capabilities.database_id, + resolve_capabilities.scope USING ERRCODE = 'FR040'; END IF; @@ -110,14 +148,14 @@ BEGIN -- refused here rather than at the image, which cannot know. IF resolve_capabilities.channel IS NOT NULL THEN SELECT array_agg(c.channel) - INTO v_access_channels - FROM jsonb_array_elements_text(coalesce(v_definition->'access_channels', '[]'::jsonb)) AS c(channel); + INTO access_channels + FROM jsonb_array_elements_text(coalesce(definition->'access_channels', '[]'::jsonb)) AS c(channel); - IF NOT coalesce(v_access_channels, ARRAY[]::text[]) @> ARRAY[resolve_capabilities.channel] THEN + IF NOT coalesce(access_channels, ARRAY[]::text[]) @> ARRAY[resolve_capabilities.channel] THEN RAISE EXCEPTION 'CAPABILITY_CHANNEL_REFUSED: function % does not declare the "%" access channel (declares: %)', resolve_capabilities.function_definition_id, resolve_capabilities.channel, - coalesce(array_to_string(v_access_channels, ', '), '') + coalesce(array_to_string(access_channels, ', '), '') USING ERRCODE = 'FR041'; END IF; END IF; @@ -135,7 +173,7 @@ BEGIN -- function is not evaluated. -- ========================================================================= SELECT array_agg(b.key ORDER BY b.ord), array_agg(b.bucket_id ORDER BY b.ord) - INTO v_bound_keys, v_bound_ids + INTO bound_keys, bound_ids FROM ( SELECT k.key, k.ord, @@ -147,7 +185,7 @@ BEGIN k.key ) AS bucket_id FROM jsonb_array_elements_text( - coalesce(v_definition->'required_buckets', '[]'::jsonb) + coalesce(definition->'required_buckets', '[]'::jsonb) ) WITH ORDINALITY AS k(key, ord) ) b WHERE b.bucket_id IS NOT NULL; @@ -157,10 +195,10 @@ BEGIN -- invocation. The generated binding guard cannot check this — compute's -- published modules may not reference storage — so it is checked here, where -- a function would otherwise be handed the bucket. - IF v_bound_keys IS NOT NULL THEN + IF bound_keys IS NOT NULL THEN SELECT b.key, b.bucket_id - INTO v_key, v_bound_bucket_id - FROM unnest(v_bound_keys, v_bound_ids) AS b(key, bucket_id) + INTO unreachable_key, unreachable_bucket_id + FROM unnest(bound_keys, bound_ids) AS b(key, bucket_id) WHERE NOT EXISTS ( SELECT 1 FROM function_resolution.bucket_catalog_row( @@ -175,9 +213,9 @@ BEGIN IF FOUND THEN RAISE EXCEPTION 'CAPABILITY_BINDING_UNREACHABLE: capability "%" of function % is bound to bucket %, which database % may not reach', - v_key, + unreachable_key, resolve_capabilities.function_definition_id, - v_bound_bucket_id, + unreachable_bucket_id, resolve_capabilities.database_id USING ERRCODE = 'FR013'; END IF; @@ -190,8 +228,8 @@ BEGIN 'database_id', c.owner_database_id, 'source', 'binding' )) - INTO v_buckets - FROM unnest(v_bound_keys, v_bound_ids) AS b(key, bucket_id) + INTO buckets + FROM unnest(bound_keys, bound_ids) AS b(key, bucket_id) CROSS JOIN LATERAL function_resolution.bucket_catalog_row( resolve_capabilities.database_id, resolve_capabilities.scope, @@ -207,11 +245,11 @@ BEGIN WITH unbound AS MATERIALIZED ( SELECT k.key FROM jsonb_array_elements_text( - coalesce(v_definition->'required_buckets', '[]'::jsonb) + coalesce(definition->'required_buckets', '[]'::jsonb) ) AS k(key) - WHERE NOT k.key = ANY(coalesce(v_bound_keys, ARRAY[]::text[])) + WHERE NOT k.key = ANY(coalesce(bound_keys, ARRAY[]::text[])) ) - SELECT coalesce(v_buckets, '{}'::jsonb) || coalesce(jsonb_object_agg(k.key, jsonb_build_object( + SELECT coalesce(buckets, '{}'::jsonb) || coalesce(jsonb_object_agg(k.key, jsonb_build_object( 'bucket_id', r.bucket_id, 'key', r.bucket_key, 'type', r.bucket_type, @@ -219,7 +257,7 @@ BEGIN 'database_id', r.owner_database_id, 'source', 'tags' )), '{}'::jsonb) - INTO v_buckets + INTO buckets FROM unbound k CROSS JOIN LATERAL function_resolution.resolve_bucket( resolve_capabilities.database_id, @@ -240,9 +278,9 @@ BEGIN 'name', a.api_name, 'database_id', a.owner_database_id )), '{}'::jsonb) - INTO v_apis + INTO apis FROM jsonb_array_elements_text( - coalesce(v_definition->'required_modules', '[]'::jsonb) + coalesce(definition->'required_modules', '[]'::jsonb) ) AS s(selector) CROSS JOIN LATERAL function_resolution.resolve_api( resolve_capabilities.database_id, @@ -254,20 +292,20 @@ BEGIN RETURN jsonb_build_object( 'function_definition_id', resolve_capabilities.function_definition_id, 'definition_scope', resolve_capabilities.definition_scope, - 'definition_database_id', v_defn_database_id, + 'definition_database_id', definition_database_id, 'database_id', resolve_capabilities.database_id, 'scope', resolve_capabilities.scope, 'entity_id', resolve_capabilities.entity_id, - 'buckets', v_buckets, - 'apis', v_apis, - 'models', coalesce(v_definition->'required_models', '[]'::jsonb), - 'secrets', coalesce(v_definition->'required_secrets', '[]'::jsonb), - 'configs', coalesce(v_definition->'required_configs', '[]'::jsonb), - 'integrations', coalesce(v_definition->'integrations', '[]'::jsonb), - 'access_channels', coalesce(v_definition->'access_channels', '[]'::jsonb), + 'buckets', buckets, + 'apis', apis, + 'models', coalesce(definition->'required_models', '[]'::jsonb), + 'secrets', coalesce(definition->'required_secrets', '[]'::jsonb), + 'configs', coalesce(definition->'required_configs', '[]'::jsonb), + 'integrations', coalesce(definition->'integrations', '[]'::jsonb), + 'access_channels', coalesce(definition->'access_channels', '[]'::jsonb), -- Not coalesced: NULL means the handler declared nothing and gets the -- full platform set, an empty array means it declared none. - 'capabilities', v_definition->'required_capabilities', + 'capabilities', definition->'required_capabilities', 'payload', function_resolution.resolve_payload_refs( resolve_capabilities.database_id, resolve_capabilities.scope, diff --git a/packages/function-resolution/deploy/schemas/function_resolution/procedures/validate_capabilities.sql b/packages/function-resolution/deploy/schemas/function_resolution/procedures/validate_capabilities.sql index b6b97c96a..a3d7f8f47 100644 --- a/packages/function-resolution/deploy/schemas/function_resolution/procedures/validate_capabilities.sql +++ b/packages/function-resolution/deploy/schemas/function_resolution/procedures/validate_capabilities.sql @@ -26,7 +26,6 @@ CREATE FUNCTION function_resolution.validate_capabilities( entity_id uuid, function_definition_id uuid, definition_scope text, - definition_database_id uuid DEFAULT NULL, payload jsonb DEFAULT '{}'::jsonb, channel text DEFAULT NULL ) RETURNS void AS $$ @@ -37,7 +36,6 @@ BEGIN validate_capabilities.entity_id, validate_capabilities.function_definition_id, validate_capabilities.definition_scope, - validate_capabilities.definition_database_id, validate_capabilities.payload, validate_capabilities.channel ); diff --git a/packages/function-resolution/revert/schemas/function_resolution/procedures/resolve_capabilities.sql b/packages/function-resolution/revert/schemas/function_resolution/procedures/resolve_capabilities.sql index 07be079b9..64c2d17a1 100644 --- a/packages/function-resolution/revert/schemas/function_resolution/procedures/resolve_capabilities.sql +++ b/packages/function-resolution/revert/schemas/function_resolution/procedures/resolve_capabilities.sql @@ -2,6 +2,6 @@ BEGIN; -DROP FUNCTION function_resolution.resolve_capabilities(uuid, text, uuid, uuid, text, uuid, jsonb, text); +DROP FUNCTION function_resolution.resolve_capabilities(uuid, text, uuid, uuid, text, jsonb, text); COMMIT; diff --git a/packages/function-resolution/revert/schemas/function_resolution/procedures/validate_capabilities.sql b/packages/function-resolution/revert/schemas/function_resolution/procedures/validate_capabilities.sql index 524ec8826..a04da3f97 100644 --- a/packages/function-resolution/revert/schemas/function_resolution/procedures/validate_capabilities.sql +++ b/packages/function-resolution/revert/schemas/function_resolution/procedures/validate_capabilities.sql @@ -2,6 +2,6 @@ BEGIN; -DROP FUNCTION function_resolution.validate_capabilities(uuid, text, uuid, uuid, text, uuid, jsonb, text); +DROP FUNCTION function_resolution.validate_capabilities(uuid, text, uuid, uuid, text, jsonb, text); COMMIT; diff --git a/packages/function-resolution/sql/pgpm-function-resolution--0.45.0.bundle.tar.gz b/packages/function-resolution/sql/pgpm-function-resolution--0.45.0.bundle.tar.gz index 38c70f29c..b4780db8d 100644 Binary files a/packages/function-resolution/sql/pgpm-function-resolution--0.45.0.bundle.tar.gz and b/packages/function-resolution/sql/pgpm-function-resolution--0.45.0.bundle.tar.gz differ diff --git a/packages/function-resolution/sql/pgpm-function-resolution--0.45.0.sql b/packages/function-resolution/sql/pgpm-function-resolution--0.45.0.sql index eb05ab9a7..1f6200ee0 100644 --- a/packages/function-resolution/sql/pgpm-function-resolution--0.45.0.sql +++ b/packages/function-resolution/sql/pgpm-function-resolution--0.45.0.sql @@ -1354,55 +1354,81 @@ CREATE FUNCTION function_resolution.resolve_capabilities( entity_id uuid, function_definition_id uuid, definition_scope text, - definition_database_id uuid DEFAULT NULL, payload jsonb DEFAULT '{}'::jsonb, channel text DEFAULT NULL ) RETURNS jsonb AS $EOFCODE$ DECLARE - v_defn_database_id uuid; - v_defs_schema text; - v_defs_table text; - v_query text; - v_definition jsonb; - v_access_channels text[]; - v_key text; - v_bound_bucket_id uuid; + -- The frame whose function surface holds the definition: where the row + -- physically is (lookup database, schema, table) and whose it is (key). + frame record; + surface_found boolean := false; + definition_database_id uuid; + definition_query text; + definition jsonb; + access_channels text[]; + unreachable_key text; + unreachable_bucket_id uuid; -- The keys a tenant fulfilled with an explicit binding, paired positionally -- with the bucket each binding names: the two resolution routes are disjoint -- sets of keys, resolved by one query each rather than key by key. - v_bound_keys text[]; - v_bound_ids uuid[]; - v_buckets jsonb := '{}'::jsonb; - v_apis jsonb := '{}'::jsonb; + bound_keys text[]; + bound_ids uuid[]; + buckets jsonb := '{}'::jsonb; + apis jsonb := '{}'::jsonb; BEGIN - v_defn_database_id := coalesce( - resolve_capabilities.definition_database_id, - resolve_capabilities.database_id - ); + FOR frame IN + SELECT f.lookup_database_id, f.key_value, l.schema_name, l.table_name, l.entity_field + FROM app_scope.frames( + resolve_capabilities.database_id, + resolve_capabilities.scope, + resolve_capabilities.entity_id + ) WITH ORDINALITY AS f(scope, lookup_database_id, key_value, ord) + CROSS JOIN LATERAL function_resolution.definitions_location(f.lookup_database_id, f.scope) l + WHERE f.scope = resolve_capabilities.definition_scope + ORDER BY f.ord + LOOP + surface_found := true; + + -- to_jsonb of the row rather than a column list: the declaration set + -- grows, and a resolver that names columns fails on a database whose + -- function module predates the newest one. The row must carry the + -- frame's key in the module's recorded scope-key column; a global frame + -- has neither, and its key is asserted NULL. + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the definitions table is located per frame + definition_query := format( + 'SELECT to_jsonb(d) FROM %I.%I d WHERE d.id = $1 AND %s', + frame.schema_name, + frame.table_name, + CASE + WHEN frame.entity_field IS NULL THEN '$2::uuid IS NULL' + ELSE format('d.%I = $2', frame.entity_field) + END + ); - SELECT l.schema_name, l.table_name - INTO v_defs_schema, v_defs_table - FROM function_resolution.definitions_location(v_defn_database_id, resolve_capabilities.definition_scope) l; + EXECUTE definition_query + INTO definition + USING resolve_capabilities.function_definition_id, frame.key_value; + + IF definition IS NOT NULL THEN + definition_database_id := frame.lookup_database_id; + EXIT; + END IF; + END LOOP; - IF v_defs_schema IS NULL THEN - RAISE EXCEPTION 'CAPABILITY_DEFINITION_SCOPE_UNPROVISIONED: database % has no function module at scope "%"', - v_defn_database_id, resolve_capabilities.definition_scope + IF NOT surface_found THEN + RAISE EXCEPTION 'CAPABILITY_DEFINITION_SCOPE_UNPROVISIONED: no frame of database % (scope "%") has a function module at scope "%"', + resolve_capabilities.database_id, + resolve_capabilities.scope, + resolve_capabilities.definition_scope USING ERRCODE = 'FR040'; END IF; - -- to_jsonb of the row rather than a column list: the declaration set grows, - -- and a resolver that names columns fails on a database whose function - -- module predates the newest one. - -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the definitions table is located per scope - v_query := format('SELECT to_jsonb(d) FROM %I.%I d WHERE d.id = $1', v_defs_schema, v_defs_table); - - EXECUTE v_query INTO v_definition USING resolve_capabilities.function_definition_id; - - IF v_definition IS NULL THEN - RAISE EXCEPTION 'CAPABILITY_DEFINITION_NOT_FOUND: no function definition % at scope "%" in database %', + IF definition IS NULL THEN + RAISE EXCEPTION 'CAPABILITY_DEFINITION_NOT_FOUND: no function definition % at scope "%" reachable from database % (scope "%")', resolve_capabilities.function_definition_id, resolve_capabilities.definition_scope, - v_defn_database_id + resolve_capabilities.database_id, + resolve_capabilities.scope USING ERRCODE = 'FR040'; END IF; @@ -1411,14 +1437,14 @@ BEGIN -- refused here rather than at the image, which cannot know. IF resolve_capabilities.channel IS NOT NULL THEN SELECT array_agg(c.channel) - INTO v_access_channels - FROM jsonb_array_elements_text(coalesce(v_definition->'access_channels', '[]'::jsonb)) AS c(channel); + INTO access_channels + FROM jsonb_array_elements_text(coalesce(definition->'access_channels', '[]'::jsonb)) AS c(channel); - IF NOT coalesce(v_access_channels, ARRAY[]::text[]) @> ARRAY[resolve_capabilities.channel] THEN + IF NOT coalesce(access_channels, ARRAY[]::text[]) @> ARRAY[resolve_capabilities.channel] THEN RAISE EXCEPTION 'CAPABILITY_CHANNEL_REFUSED: function % does not declare the "%" access channel (declares: %)', resolve_capabilities.function_definition_id, resolve_capabilities.channel, - coalesce(array_to_string(v_access_channels, ', '), '') + coalesce(array_to_string(access_channels, ', '), '') USING ERRCODE = 'FR041'; END IF; END IF; @@ -1436,7 +1462,7 @@ BEGIN -- function is not evaluated. -- ========================================================================= SELECT array_agg(b.key ORDER BY b.ord), array_agg(b.bucket_id ORDER BY b.ord) - INTO v_bound_keys, v_bound_ids + INTO bound_keys, bound_ids FROM ( SELECT k.key, k.ord, @@ -1448,7 +1474,7 @@ BEGIN k.key ) AS bucket_id FROM jsonb_array_elements_text( - coalesce(v_definition->'required_buckets', '[]'::jsonb) + coalesce(definition->'required_buckets', '[]'::jsonb) ) WITH ORDINALITY AS k(key, ord) ) b WHERE b.bucket_id IS NOT NULL; @@ -1458,10 +1484,10 @@ BEGIN -- invocation. The generated binding guard cannot check this — compute's -- published modules may not reference storage — so it is checked here, where -- a function would otherwise be handed the bucket. - IF v_bound_keys IS NOT NULL THEN + IF bound_keys IS NOT NULL THEN SELECT b.key, b.bucket_id - INTO v_key, v_bound_bucket_id - FROM unnest(v_bound_keys, v_bound_ids) AS b(key, bucket_id) + INTO unreachable_key, unreachable_bucket_id + FROM unnest(bound_keys, bound_ids) AS b(key, bucket_id) WHERE NOT EXISTS ( SELECT 1 FROM function_resolution.bucket_catalog_row( @@ -1476,9 +1502,9 @@ BEGIN IF FOUND THEN RAISE EXCEPTION 'CAPABILITY_BINDING_UNREACHABLE: capability "%" of function % is bound to bucket %, which database % may not reach', - v_key, + unreachable_key, resolve_capabilities.function_definition_id, - v_bound_bucket_id, + unreachable_bucket_id, resolve_capabilities.database_id USING ERRCODE = 'FR013'; END IF; @@ -1491,8 +1517,8 @@ BEGIN 'database_id', c.owner_database_id, 'source', 'binding' )) - INTO v_buckets - FROM unnest(v_bound_keys, v_bound_ids) AS b(key, bucket_id) + INTO buckets + FROM unnest(bound_keys, bound_ids) AS b(key, bucket_id) CROSS JOIN LATERAL function_resolution.bucket_catalog_row( resolve_capabilities.database_id, resolve_capabilities.scope, @@ -1508,11 +1534,11 @@ BEGIN WITH unbound AS MATERIALIZED ( SELECT k.key FROM jsonb_array_elements_text( - coalesce(v_definition->'required_buckets', '[]'::jsonb) + coalesce(definition->'required_buckets', '[]'::jsonb) ) AS k(key) - WHERE NOT k.key = ANY(coalesce(v_bound_keys, ARRAY[]::text[])) + WHERE NOT k.key = ANY(coalesce(bound_keys, ARRAY[]::text[])) ) - SELECT coalesce(v_buckets, '{}'::jsonb) || coalesce(jsonb_object_agg(k.key, jsonb_build_object( + SELECT coalesce(buckets, '{}'::jsonb) || coalesce(jsonb_object_agg(k.key, jsonb_build_object( 'bucket_id', r.bucket_id, 'key', r.bucket_key, 'type', r.bucket_type, @@ -1520,7 +1546,7 @@ BEGIN 'database_id', r.owner_database_id, 'source', 'tags' )), '{}'::jsonb) - INTO v_buckets + INTO buckets FROM unbound k CROSS JOIN LATERAL function_resolution.resolve_bucket( resolve_capabilities.database_id, @@ -1541,9 +1567,9 @@ BEGIN 'name', a.api_name, 'database_id', a.owner_database_id )), '{}'::jsonb) - INTO v_apis + INTO apis FROM jsonb_array_elements_text( - coalesce(v_definition->'required_modules', '[]'::jsonb) + coalesce(definition->'required_modules', '[]'::jsonb) ) AS s(selector) CROSS JOIN LATERAL function_resolution.resolve_api( resolve_capabilities.database_id, @@ -1555,20 +1581,20 @@ BEGIN RETURN jsonb_build_object( 'function_definition_id', resolve_capabilities.function_definition_id, 'definition_scope', resolve_capabilities.definition_scope, - 'definition_database_id', v_defn_database_id, + 'definition_database_id', definition_database_id, 'database_id', resolve_capabilities.database_id, 'scope', resolve_capabilities.scope, 'entity_id', resolve_capabilities.entity_id, - 'buckets', v_buckets, - 'apis', v_apis, - 'models', coalesce(v_definition->'required_models', '[]'::jsonb), - 'secrets', coalesce(v_definition->'required_secrets', '[]'::jsonb), - 'configs', coalesce(v_definition->'required_configs', '[]'::jsonb), - 'integrations', coalesce(v_definition->'integrations', '[]'::jsonb), - 'access_channels', coalesce(v_definition->'access_channels', '[]'::jsonb), + 'buckets', buckets, + 'apis', apis, + 'models', coalesce(definition->'required_models', '[]'::jsonb), + 'secrets', coalesce(definition->'required_secrets', '[]'::jsonb), + 'configs', coalesce(definition->'required_configs', '[]'::jsonb), + 'integrations', coalesce(definition->'integrations', '[]'::jsonb), + 'access_channels', coalesce(definition->'access_channels', '[]'::jsonb), -- Not coalesced: NULL means the handler declared nothing and gets the -- full platform set, an empty array means it declared none. - 'capabilities', v_definition->'required_capabilities', + 'capabilities', definition->'required_capabilities', 'payload', function_resolution.resolve_payload_refs( resolve_capabilities.database_id, resolve_capabilities.scope, @@ -1585,7 +1611,6 @@ CREATE FUNCTION function_resolution.validate_capabilities( entity_id uuid, function_definition_id uuid, definition_scope text, - definition_database_id uuid DEFAULT NULL, payload jsonb DEFAULT '{}'::jsonb, channel text DEFAULT NULL ) RETURNS void AS $EOFCODE$ @@ -1596,7 +1621,6 @@ BEGIN validate_capabilities.entity_id, validate_capabilities.function_definition_id, validate_capabilities.definition_scope, - validate_capabilities.definition_database_id, validate_capabilities.payload, validate_capabilities.channel ); @@ -1685,6 +1709,16 @@ DECLARE resources_schema text; resources_table text; resources_key text; + -- The routes plane's own redirects table, from the same route_module + -- registration that names the routes table; it shares the plane's + -- ownership key. NULL when the plane records none, which makes a redirect + -- binding a hard error rather than an unchecked insert. + redirects_schema text; + redirects_table text; + -- The hostname the site is routed on: a redirect binding's default to_host. + domains_schema text; + domains_table text; + domain_hostname text; -- The one ownership key value the whole install is keyed by (NULL at the -- global tier), and the entity the resolver starts its frame walk at. key_value uuid; @@ -1699,12 +1733,18 @@ DECLARE entry_task text; entry_service uuid; entry_anonymous boolean; + entry_to_host text; + entry_to_path text; + entry_status int; + entry_redirect_name text; target_column text; target_id uuid; service_found boolean; inserted int; + repointed_now int; installed jsonb := '[]'::jsonb; skipped jsonb := '[]'::jsonb; + repointed jsonb := '[]'::jsonb; query text; BEGIN IF install_route_bindings.database_id IS NULL THEN @@ -1746,9 +1786,9 @@ BEGIN IF EXISTS ( SELECT 1 FROM jsonb_array_elements(install_route_bindings.bindings) AS b - WHERE (b ->> 'target') NOT IN ('function', 'service') + WHERE (b ->> 'target') NOT IN ('function', 'service', 'redirect') ) THEN - RAISE EXCEPTION 'ROUTE_BINDINGS_TARGET_UNKNOWN: target must be "function" or "service"' + RAISE EXCEPTION 'ROUTE_BINDINGS_TARGET_UNKNOWN: target must be "function", "service" or "redirect"' USING ERRCODE = 'FR060'; END IF; @@ -1759,8 +1799,9 @@ BEGIN SELECT 1 FROM jsonb_array_elements(install_route_bindings.bindings) AS b WHERE (b ? 'task_identifier') AND (b ? 'service_id') - OR (b ->> 'target') = 'function' AND (b ? 'service_id') - OR (b ->> 'target') = 'service' AND (b ? 'task_identifier') + OR (b ->> 'target') = 'function' AND (b ? 'service_id' OR b ? 'to_path') + OR (b ->> 'target') = 'service' AND (b ? 'task_identifier' OR b ? 'to_path') + OR (b ->> 'target') = 'redirect' AND (b ? 'task_identifier' OR b ? 'service_id') ) THEN RAISE EXCEPTION 'ROUTE_BINDINGS_TARGET_AMBIGUOUS: a binding must carry only the key of the target kind it declares' USING ERRCODE = 'FR060'; @@ -1771,8 +1812,27 @@ BEGIN FROM jsonb_array_elements(install_route_bindings.bindings) AS b WHERE (b ->> 'target') = 'function' AND coalesce(b ->> 'task_identifier', '') = '' OR (b ->> 'target') = 'service' AND coalesce(b ->> 'service_id', '') = '' + OR (b ->> 'target') = 'redirect' AND coalesce(b ->> 'to_path', '') = '' ) THEN - RAISE EXCEPTION 'ROUTE_BINDINGS_INVALID: a function binding needs a task_identifier and a service binding needs a service_id' + RAISE EXCEPTION 'ROUTE_BINDINGS_INVALID: a function binding needs a task_identifier, a service binding needs a service_id, and a redirect binding needs a to_path' + USING ERRCODE = 'FR060'; + END IF; + + -- A redirect's destination is a path on a host, so it must read as one, and + -- its status must be one the edge can honour (the same set the redirects + -- table's own check constraint accepts — rejected here by name rather than + -- as a constraint violation naming no binding). + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(install_route_bindings.bindings) AS b + WHERE (b ->> 'target') = 'redirect' + AND ( + (b ->> 'to_path') !~ '^/' + OR (b ? 'to_host' AND coalesce(b ->> 'to_host', '') = '') + OR (b ? 'status_code' AND NOT (b -> 'status_code' <@ '[301, 302, 307, 308]'::jsonb)) + ) + ) THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_REDIRECT_INVALID: a redirect binding''s to_path must start with "/", its to_host (when given) must be non-empty, and its status_code (when given) must be 301, 302, 307 or 308' USING ERRCODE = 'FR060'; END IF; @@ -1838,8 +1898,8 @@ BEGIN -- 2. The routes plane serving THAT scope, resolved the one way every -- scope-aware consumer resolves it. - SELECT r.routes_schema, r.routes_table - INTO routes_schema, routes_table + SELECT r.routes_schema, r.routes_table, r.domains_schema, r.domains_table + INTO routes_schema, routes_table, domains_schema, domains_table FROM app_scope.routing_tables(install_route_bindings.database_id, plane_scope) AS r; IF routes_schema IS NULL OR routes_table IS NULL THEN @@ -1848,13 +1908,18 @@ BEGIN USING ERRCODE = 'FR051'; END IF; - -- The routes plane's own registration carries its ownership key and, when - -- the plane has one, the column naming the site a route renders as. - SELECT rm.entity_field, rm.serving_site_field - INTO routes_key, routes_serving_site_key + -- The routes plane's own registration carries its ownership key, the + -- column naming the site a route renders as (when the plane has one), and + -- the redirects table a redirect binding writes (when it records one). + SELECT rm.entity_field, rm.serving_site_field, rs.schema_name, rt.name + INTO routes_key, routes_serving_site_key, redirects_schema, redirects_table FROM metaschema_modules_public.route_module AS rm JOIN metaschema_public."table" AS t ON t.id = rm.routes_table_id JOIN metaschema_public.schema AS s ON s.id = t.schema_id + LEFT JOIN metaschema_public."table" AS rt + ON rt.id = rm.redirects_table_id + AND rm.redirects_table_id <> uuid_nil() + LEFT JOIN metaschema_public.schema AS rs ON rs.id = rt.schema_id WHERE s.schema_name = routes_schema AND t.name = routes_table; @@ -1923,12 +1988,18 @@ BEGIN -- guard auto-creates '/' carrying the target of a hostname's FIRST route, -- so installing onto a bare hostname would make '/' the first binding's -- target. A site that is not routed yet is a hard error, not a silently - -- empty install. + -- empty install. A route this install stamped as serving the site counts + -- too: once a redirect has taken the root over, the pages (serving-site + -- stamped, function-targeted) are what still tie the host to the site. -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the routes plane is named by app_scope.routing_tables query := format( - 'SELECT r.domain_id FROM %I.%I AS r WHERE r.target_site_id = $1%s ORDER BY r.path LIMIT 1', + 'SELECT r.domain_id FROM %I.%I AS r WHERE (r.target_site_id = $1%s)%s ORDER BY r.path LIMIT 1', routes_schema, routes_table, + CASE WHEN routes_serving_site_key IS NULL + THEN '' + ELSE format(' OR r.%I = $1', routes_serving_site_key) + END, CASE WHEN routes_key IS NULL THEN ' AND $2 IS NULL' ELSE format(' AND r.%I = $2', routes_key) @@ -1943,6 +2014,15 @@ BEGIN USING ERRCODE = 'FR054'; END IF; + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the domains plane is named by app_scope.routing_tables + query := format( + 'SELECT d.hostname FROM %I.%I AS d WHERE d.id = $1', + domains_schema, + domains_table + ); + + EXECUTE query INTO domain_hostname USING domain_id; + -- 6. Per binding: resolve the declared target at THIS (scope, entity) and -- install the route if it is not already there. FOR entry IN SELECT * FROM jsonb_array_elements(install_route_bindings.bindings) @@ -1967,6 +2047,52 @@ BEGIN ) AS fr; target_column := 'target_function_id'; + ELSIF entry_target = 'redirect' THEN + IF redirects_schema IS NULL THEN + RAISE EXCEPTION 'ROUTE_BINDINGS_REDIRECT_PLANE_NOT_FOUND: routes plane %.% records no redirects table, so there is nowhere a redirect target could live', + routes_schema, routes_table + USING ERRCODE = 'FR055'; + END IF; + + entry_to_host := coalesce(entry ->> 'to_host', domain_hostname); + entry_to_path := entry ->> 'to_path'; + entry_status := coalesce((entry ->> 'status_code')::int, 302); + -- Names are owner-local, so the hostname keeps two sites' roots apart. + entry_redirect_name := coalesce(entry ->> 'name', format('%s %s', domain_hostname, entry_path)); + + -- The redirect row, found by name or created: same insert-if-missing + -- contract as the routes, so a re-run never rewrites a redirect a + -- tenant has since retuned. + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the redirects plane is named by the routes plane's own route_module registration + query := format( + 'SELECT rd.id FROM %I.%I AS rd WHERE rd.name = $1%s', + redirects_schema, + redirects_table, + CASE WHEN routes_key IS NULL + THEN ' AND $2 IS NULL' + ELSE format(' AND rd.%I = $2', routes_key) + END + ); + + EXECUTE query INTO target_id USING entry_redirect_name, key_value; + + IF target_id IS NULL THEN + -- pgsql-lint-disable-next-line no-dynamic-sql -- write-only: insert into the redirects plane named by the route_module registration; every value is a bound parameter + query := format( + 'INSERT INTO %I.%I (%sname, to_host, to_path, status_code, preserve_path, preserve_query) + VALUES (%s$1, $2, $3, $4, false, true) + RETURNING id', + redirects_schema, + redirects_table, + CASE WHEN routes_key IS NULL THEN '' ELSE format('%I, ', routes_key) END, + CASE WHEN routes_key IS NULL THEN '' ELSE '$5, ' END + ); + + EXECUTE query INTO target_id + USING entry_redirect_name, entry_to_host, entry_to_path, entry_status, key_value; + END IF; + + target_column := 'target_redirect_id'; ELSE entry_service := (entry ->> 'service_id')::uuid; @@ -2026,8 +2152,34 @@ BEGIN install_route_bindings.site_id, entry_anonymous; GET DIAGNOSTICS inserted = ROW_COUNT; + repointed_now := 0; + IF inserted = 0 AND entry_target = 'redirect' THEN + -- The root guard's placeholder — the path exists and still targets + -- the site this install is for — is the one row a redirect binding + -- takes over. Anything a tenant has pointed elsewhere stays. + -- pgsql-lint-disable-next-line no-dynamic-sql -- write-only: update the routes plane named by app_scope.routing_tables; every value is a bound parameter + query := format( + 'UPDATE %I.%I AS r + SET target_site_id = NULL, target_redirect_id = $3, anonymous = $5 + WHERE r.domain_id = $1 AND r.path = $2 + AND r.target_site_id = $4%s', + routes_schema, + routes_table, + CASE WHEN routes_key IS NULL + THEN ' AND $6 IS NULL' + ELSE format(' AND r.%I = $6', routes_key) + END + ); + + EXECUTE query USING domain_id, entry_path, target_id, + install_route_bindings.site_id, entry_anonymous, key_value; + GET DIAGNOSTICS repointed_now = ROW_COUNT; + END IF; + IF inserted > 0 THEN installed := installed || jsonb_build_array(entry_path); + ELSIF repointed_now > 0 THEN + repointed := repointed || jsonb_build_array(entry_path); ELSE skipped := skipped || jsonb_build_array(entry_path); END IF; @@ -2069,6 +2221,7 @@ BEGIN 'routes_schema', routes_schema, 'routes_table', routes_table, 'installed', installed, + 'repointed', repointed, 'skipped', skipped, 'serving_site_field', routes_serving_site_key, 'serving_site_backfilled', stamped @@ -2076,7 +2229,7 @@ BEGIN END; $EOFCODE$ LANGUAGE plpgsql VOLATILE; -COMMENT ON FUNCTION function_resolution.install_route_bindings(uuid, text, text, uuid, jsonb, uuid) IS 'Install a set of route bindings — a JSON array of {path, target, …} entries, each NAMING its target kind ("function" with a task_identifier, or "service" with a service_id) — onto one site as ordinary route rows, at ONE scope for ONE entity. The scope and ownership key are read from the named sites plane''s own site_surface_module registration, located along the caller''s frames (nearest first) so a shared serving plane hosted by an outer frame''s database resolves for the tenant consuming it — never a caller-supplied or generated literal — and that one (scope, key) then names the routes plane (app_scope.routing_tables), pins the site, route and service reads, starts function_resolution.resolve''s frame walk, and stamps every inserted row; a service target is proved to exist in the same-scope resources plane the routes plane''s registration records, which is the plane target_service_id FKs. Idempotent per (domain_id, path); raises on a malformed document, an unknown or ambiguous target kind, an unregistered plane, a scope with no routes plane, a missing entity key, an unknown site, an unrouted site, a scope with no resources plane, an unresolvable service, or an unpublished task.'; +COMMENT ON FUNCTION function_resolution.install_route_bindings(uuid, text, text, uuid, jsonb, uuid) IS 'Install a set of route bindings — a JSON array of {path, target, …} entries, each NAMING its target kind ("function" with a task_identifier, "service" with a service_id, or "redirect" with a to_path) — onto one site as ordinary route rows, at ONE scope for ONE entity. The scope and ownership key are read from the named sites plane''s own site_surface_module registration, located along the caller''s frames (nearest first) so a shared serving plane hosted by an outer frame''s database resolves for the tenant consuming it — never a caller-supplied or generated literal — and that one (scope, key) then names the routes plane (app_scope.routing_tables), pins the site, route and service reads, starts function_resolution.resolve''s frame walk, and stamps every inserted row; a service target is proved to exist in the same-scope resources plane the routes plane''s registration records, which is the plane target_service_id FKs. A redirect binding writes a row in the routes plane''s own redirects table (same-host unless to_host is given, 302 unless status_code is given) and is the one kind allowed to repoint an existing route: the root guard''s placeholder still targeting the site being installed onto. Idempotent per (domain_id, path); raises on a malformed document, an unknown or ambiguous target kind, an unregistered plane, a scope with no routes plane, a missing entity key, an unknown site, an unrouted site, a scope with no resources plane, an unresolvable service, or an unpublished task.'; CREATE FUNCTION function_resolution.install_mantra( database_id uuid, @@ -2115,19 +2268,29 @@ BEGIN USING ERRCODE = 'FR060'; END IF; + -- Two shapes only: a page ({path, task_identifier}, no target kind — it is + -- always a function), or a redirect ({path, target: 'redirect', to_path}) a + -- host given over to the page set uses to send its root at sign-in. A + -- service binding, or a page that names a kind, is a broken preset. IF EXISTS ( SELECT 1 FROM jsonb_array_elements(install_mantra.bindings) AS b WHERE jsonb_typeof(b) <> 'object' OR coalesce(b ->> 'path', '') = '' - OR coalesce(b ->> 'task_identifier', '') = '' - OR b ? 'target' + OR CASE b ->> 'target' + WHEN 'redirect' THEN coalesce(b ->> 'to_path', '') = '' OR b ? 'task_identifier' + ELSE coalesce(b ->> 'task_identifier', '') = '' OR b ? 'target' + END ) THEN - RAISE EXCEPTION 'MANTRA_BINDINGS_INVALID: every binding must carry a non-empty path and task_identifier, and no target kind' + RAISE EXCEPTION 'MANTRA_BINDINGS_INVALID: every binding must carry a non-empty path and either a task_identifier with no target kind, or target "redirect" with a to_path' USING ERRCODE = 'FR060'; END IF; - SELECT jsonb_agg(b || jsonb_build_object('target', 'function')) + SELECT jsonb_agg( + CASE WHEN b ->> 'target' = 'redirect' THEN b + ELSE b || jsonb_build_object('target', 'function') + END + ) INTO function_bindings FROM jsonb_array_elements(install_mantra.bindings) AS b; @@ -2142,4 +2305,4 @@ BEGIN END; $EOFCODE$ LANGUAGE plpgsql VOLATILE; -COMMENT ON FUNCTION function_resolution.install_mantra(uuid, regclass, uuid, jsonb, uuid) IS 'Install the Mantra page set (a JSON array of {path, task_identifier}, which the generated verb reads from the content_presets catalog at kind ''route_bindings'') onto one site as ordinary function-target routes. The sites plane arrives by reference as a regclass, so a generated caller never spells a schema name in a bare string literal the platform export''s AST rename cannot follow. A thin wrapper holding the Mantra document contract — every entry names a task, none names a target kind — over function_resolution.install_route_bindings, which owns the one-scope install: scope and ownership key read from the sites plane''s own registration, the routes plane from app_scope.routing_tables, resolution at that same (scope, entity), idempotent per (domain_id, path).'; \ No newline at end of file +COMMENT ON FUNCTION function_resolution.install_mantra(uuid, regclass, uuid, jsonb, uuid) IS 'Install the Mantra page set (a JSON array of {path, task_identifier}, which the generated verb reads from the content_presets catalog at kind ''route_bindings'') onto one site as ordinary function-target routes. The sites plane arrives by reference as a regclass, so a generated caller never spells a schema name in a bare string literal the platform export''s AST rename cannot follow. A thin wrapper holding the Mantra document contract — every entry names a task and no target kind, or is a {target: "redirect", to_path} entry sending a path (a dedicated host''s root) at one of the pages — over function_resolution.install_route_bindings, which owns the one-scope install: scope and ownership key read from the sites plane''s own registration, the routes plane from app_scope.routing_tables, resolution at that same (scope, entity), idempotent per (domain_id, path).'; \ No newline at end of file diff --git a/packages/function-resolution/verify/schemas/function_resolution/procedures/resolve_capabilities.sql b/packages/function-resolution/verify/schemas/function_resolution/procedures/resolve_capabilities.sql index 0e6fe50be..54ab98319 100644 --- a/packages/function-resolution/verify/schemas/function_resolution/procedures/resolve_capabilities.sql +++ b/packages/function-resolution/verify/schemas/function_resolution/procedures/resolve_capabilities.sql @@ -2,6 +2,6 @@ BEGIN; -SELECT assert_function('function_resolution.resolve_capabilities(uuid, text, uuid, uuid, text, uuid, jsonb, text)'::regprocedure); +SELECT assert_function('function_resolution.resolve_capabilities(uuid, text, uuid, uuid, text, jsonb, text)'::regprocedure); ROLLBACK; diff --git a/packages/function-resolution/verify/schemas/function_resolution/procedures/validate_capabilities.sql b/packages/function-resolution/verify/schemas/function_resolution/procedures/validate_capabilities.sql index 772cad731..636cb3d46 100644 --- a/packages/function-resolution/verify/schemas/function_resolution/procedures/validate_capabilities.sql +++ b/packages/function-resolution/verify/schemas/function_resolution/procedures/validate_capabilities.sql @@ -2,6 +2,6 @@ BEGIN; -SELECT assert_function('function_resolution.validate_capabilities(uuid, text, uuid, uuid, text, uuid, jsonb, text)'::regprocedure); +SELECT assert_function('function_resolution.validate_capabilities(uuid, text, uuid, uuid, text, jsonb, text)'::regprocedure); ROLLBACK; diff --git a/packages/metaschema-modules/__tests__/__snapshots__/modules.test.ts.snap b/packages/metaschema-modules/__tests__/__snapshots__/modules.test.ts.snap index ef18643f1..9b996556e 100644 --- a/packages/metaschema-modules/__tests__/__snapshots__/modules.test.ts.snap +++ b/packages/metaschema-modules/__tests__/__snapshots__/modules.test.ts.snap @@ -159,7 +159,7 @@ exports[`db_meta_modules should verify module table structures have database_id exports[`db_meta_modules should verify module tables have proper foreign key relationships 1`] = ` { - "constraintCount": 566, + "constraintCount": 567, "foreignTables": [ "catalog_module", "database", diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/billing_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/billing_module/table.sql index e42fae3a9..2522f1f90 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/billing_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/billing_module/table.sql @@ -18,6 +18,8 @@ CREATE TABLE metaschema_modules_public.billing_module ( -- Meters table: defines what you track (quota, boolean, credit_pool) meters_table_id uuid NOT NULL DEFAULT uuid_nil(), meters_table_name text NOT NULL DEFAULT '', + credit_packs_table_id uuid NOT NULL DEFAULT uuid_nil(), + credit_packs_table_name text NOT NULL DEFAULT '', -- Plan subscriptions table: assigns plans to entities with lifecycle plan_subscriptions_table_id uuid NOT NULL DEFAULT uuid_nil(), @@ -45,6 +47,7 @@ CREATE TABLE metaschema_modules_public.billing_module ( -- Generated functions record_usage_function text NOT NULL DEFAULT '', + grant_meter_credits_function text NOT NULL DEFAULT '', sweep_expired_subscriptions_function text NOT NULL DEFAULT '', rollup_usage_summary_function text NOT NULL DEFAULT '', @@ -68,6 +71,7 @@ CREATE TABLE metaschema_modules_public.billing_module ( CONSTRAINT schema_fkey FOREIGN KEY (schema_id) REFERENCES metaschema_public.schema (id) ON DELETE CASCADE, CONSTRAINT private_schema_fkey FOREIGN KEY (private_schema_id) REFERENCES metaschema_public.schema (id) ON DELETE CASCADE, CONSTRAINT meters_table_fkey FOREIGN KEY (meters_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT credit_packs_table_fkey FOREIGN KEY (credit_packs_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT plan_subscriptions_table_fkey FOREIGN KEY (plan_subscriptions_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT ledger_table_fkey FOREIGN KEY (ledger_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT balances_table_fkey FOREIGN KEY (balances_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, @@ -83,6 +87,7 @@ CREATE INDEX billing_module_meter_credits_table_id_idx ON metaschema_modules_pub CREATE INDEX billing_module_meter_defaults_table_id_idx ON metaschema_modules_public.billing_module ( meter_defaults_table_id ); CREATE INDEX billing_module_meter_sources_table_id_idx ON metaschema_modules_public.billing_module ( meter_sources_table_id ); CREATE INDEX billing_module_meters_table_id_idx ON metaschema_modules_public.billing_module ( meters_table_id ); +CREATE INDEX billing_module_credit_packs_table_id_idx ON metaschema_modules_public.billing_module ( credit_packs_table_id ); CREATE INDEX billing_module_plan_subscriptions_table_id_idx ON metaschema_modules_public.billing_module ( plan_subscriptions_table_id ); CREATE INDEX billing_module_private_schema_id_idx ON metaschema_modules_public.billing_module ( private_schema_id ); CREATE INDEX billing_module_schema_id_idx ON metaschema_modules_public.billing_module ( schema_id ); @@ -97,6 +102,7 @@ COMMENT ON COLUMN metaschema_modules_public.billing_module.meter_credits_table_i COMMENT ON COLUMN metaschema_modules_public.billing_module.meter_defaults_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.billing_module.meter_sources_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.billing_module.meters_table_id IS '@module_table'; +COMMENT ON COLUMN metaschema_modules_public.billing_module.credit_packs_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.billing_module.plan_subscriptions_table_id IS '@module_table'; COMMIT; diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/billing_provider_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/billing_provider_module/table.sql index 1caad4c3d..0b24ed29d 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/billing_provider_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/billing_provider_module/table.sql @@ -75,6 +75,13 @@ CREATE TABLE metaschema_modules_public.billing_provider_module ( -- The webhook side of a completed purchase: the system-only seam that moves -- an entity onto the plan a verified provider event names. activate_plan_subscription_function text NOT NULL DEFAULT '', + -- Its read half: the system-only seam that names the active + -- plan_subscriptions row a provider's subscription object is recorded + -- against, for jobs that hold no entity membership. + get_active_plan_subscription_function text NOT NULL DEFAULT '', + get_billing_subscription_by_entity_function text NOT NULL DEFAULT '', + get_billing_subscription_by_external_id_function text NOT NULL DEFAULT '', + get_plan_pricing_by_external_price_function text NOT NULL DEFAULT '', prefix text NULL, diff --git a/packages/metaschema-modules/sql/metaschema-modules--0.45.0.bundle.tar.gz b/packages/metaschema-modules/sql/metaschema-modules--0.45.0.bundle.tar.gz index 782d9ba1f..bd532950a 100644 Binary files a/packages/metaschema-modules/sql/metaschema-modules--0.45.0.bundle.tar.gz and b/packages/metaschema-modules/sql/metaschema-modules--0.45.0.bundle.tar.gz differ diff --git a/packages/metaschema-modules/sql/metaschema-modules--0.45.0.sql b/packages/metaschema-modules/sql/metaschema-modules--0.45.0.sql index 2ce397242..4d92410fa 100644 --- a/packages/metaschema-modules/sql/metaschema-modules--0.45.0.sql +++ b/packages/metaschema-modules/sql/metaschema-modules--0.45.0.sql @@ -2753,6 +2753,8 @@ CREATE TABLE metaschema_modules_public.billing_module ( private_schema_name text, meters_table_id uuid NOT NULL DEFAULT uuid_nil(), meters_table_name text NOT NULL DEFAULT '', + credit_packs_table_id uuid NOT NULL DEFAULT uuid_nil(), + credit_packs_table_name text NOT NULL DEFAULT '', plan_subscriptions_table_id uuid NOT NULL DEFAULT uuid_nil(), plan_subscriptions_table_name text NOT NULL DEFAULT '', ledger_table_id uuid NOT NULL DEFAULT uuid_nil(), @@ -2766,6 +2768,7 @@ CREATE TABLE metaschema_modules_public.billing_module ( meter_defaults_table_id uuid NOT NULL DEFAULT uuid_nil(), meter_defaults_table_name text NOT NULL DEFAULT '', record_usage_function text NOT NULL DEFAULT '', + grant_meter_credits_function text NOT NULL DEFAULT '', sweep_expired_subscriptions_function text NOT NULL DEFAULT '', rollup_usage_summary_function text NOT NULL DEFAULT '', prefix text NULL, @@ -2789,6 +2792,10 @@ CREATE TABLE metaschema_modules_public.billing_module ( FOREIGN KEY(meters_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT credit_packs_table_fkey + FOREIGN KEY(credit_packs_table_id) + REFERENCES metaschema_public.table (id) + ON DELETE CASCADE, CONSTRAINT plan_subscriptions_table_fkey FOREIGN KEY(plan_subscriptions_table_id) REFERENCES metaschema_public.table (id) @@ -2829,6 +2836,8 @@ CREATE INDEX billing_module_meter_sources_table_id_idx ON metaschema_modules_pub CREATE INDEX billing_module_meters_table_id_idx ON metaschema_modules_public.billing_module (meters_table_id); +CREATE INDEX billing_module_credit_packs_table_id_idx ON metaschema_modules_public.billing_module (credit_packs_table_id); + CREATE INDEX billing_module_plan_subscriptions_table_id_idx ON metaschema_modules_public.billing_module (plan_subscriptions_table_id); CREATE INDEX billing_module_private_schema_id_idx ON metaschema_modules_public.billing_module (private_schema_id); @@ -2847,6 +2856,8 @@ COMMENT ON COLUMN metaschema_modules_public.billing_module.meter_sources_table_i COMMENT ON COLUMN metaschema_modules_public.billing_module.meters_table_id IS '@module_table'; +COMMENT ON COLUMN metaschema_modules_public.billing_module.credit_packs_table_id IS '@module_table'; + COMMENT ON COLUMN metaschema_modules_public.billing_module.plan_subscriptions_table_id IS '@module_table'; CREATE TABLE metaschema_modules_public.billing_provider_module ( @@ -2892,6 +2903,10 @@ CREATE TABLE metaschema_modules_public.billing_provider_module ( get_fallback_free_plan_function text NOT NULL DEFAULT '', record_dispute_function text NOT NULL DEFAULT '', activate_plan_subscription_function text NOT NULL DEFAULT '', + get_active_plan_subscription_function text NOT NULL DEFAULT '', + get_billing_subscription_by_entity_function text NOT NULL DEFAULT '', + get_billing_subscription_by_external_id_function text NOT NULL DEFAULT '', + get_plan_pricing_by_external_price_function text NOT NULL DEFAULT '', prefix text NULL, api_name text DEFAULT NULL, private_api_name text DEFAULT NULL, diff --git a/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/billing_module/table.sql b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/billing_module/table.sql index 7b6ad146d..f087f6bbe 100644 --- a/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/billing_module/table.sql +++ b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/billing_module/table.sql @@ -9,6 +9,8 @@ SELECT private_schema_id, meters_table_id, meters_table_name, + credit_packs_table_id, + credit_packs_table_name, plan_subscriptions_table_id, plan_subscriptions_table_name, ledger_table_id, @@ -17,8 +19,9 @@ SELECT balances_table_name, meter_sources_table_id, meter_sources_table_name, - record_usage_function, - sweep_expired_subscriptions_function, + record_usage_function, + grant_meter_credits_function, + sweep_expired_subscriptions_function, rollup_usage_summary_function, prefix FROM metaschema_modules_public.billing_module diff --git a/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/billing_provider_module/table.sql b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/billing_provider_module/table.sql index 1e87f0bb0..9bc234351 100644 --- a/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/billing_provider_module/table.sql +++ b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/billing_provider_module/table.sql @@ -22,6 +22,9 @@ SELECT billing_webhook_events_table_id, billing_webhook_events_table_name, process_billing_event_function, + get_billing_subscription_by_entity_function, + get_billing_subscription_by_external_id_function, + get_plan_pricing_by_external_price_function, prefix FROM metaschema_modules_public.billing_provider_module WHERE FALSE; diff --git a/packages/metaschema-schema/deploy/schemas/metaschema_private/procedures/is_valid_step_up.sql b/packages/metaschema-schema/deploy/schemas/metaschema_private/procedures/is_valid_step_up.sql index b298ef71e..53ecca25f 100644 --- a/packages/metaschema-schema/deploy/schemas/metaschema_private/procedures/is_valid_step_up.sql +++ b/packages/metaschema-schema/deploy/schemas/metaschema_private/procedures/is_valid_step_up.sql @@ -24,9 +24,14 @@ BEGIN; -- min_age_anchor (optional): measure min_age from a related row's -- timestamp instead of the guarded row's created_at, -- for a configuration row that is replaced rather --- than edited. Object with exactly {table_id (uuid), --- fk_field (text), timestamp_field (text)}. Requires --- min_age, excludes min_age_lookup. UPDATE/DELETE only. +-- than edited. Object with {table_id (uuid), +-- timestamp_field (text)} and exactly one of +-- fk_field (text: the anchor row is the one the +-- guarded row points at) or conditions (tree over +-- the anchor table: the anchor is its earliest +-- matching row, for a singleton whose subject is +-- the database itself). Requires min_age, excludes +-- min_age_lookup. UPDATE/DELETE only. -- min_age_unless (optional): conditions tree (same grammar as -- conditions) that forfeits the min_age grace: a row -- younger than min_age is still guarded when it @@ -35,6 +40,11 @@ BEGIN; -- allow_system (optional): boolean; when true the system role -- (jwt.claims.role_type = 'system') skips the guard so -- provisioning paths without a session can write. +-- human_only (optional): boolean; when true the trigger body refuses a +-- principal caller (jwt.claims.principal_id set and +-- distinct from the human's user_id) before it calls +-- require_step_up, which a bypass_step_up principal +-- would otherwise satisfy on its claims alone. -- conditions (optional): declarative WHEN-clause tree gating the guard -- (compiled by metaschema_generators.build_condition_expr -- and validated through the ast_validate framework at @@ -268,6 +278,9 @@ DECLARE -- allow_system validation (system-role exemption) v_allow_system jsonb; + -- human_only validation (principal-caller refusal) + v_human_only jsonb; + -- conditions validation (declarative WHEN-clause tree) v_conditions jsonb; @@ -340,7 +353,7 @@ BEGIN END IF; FOR v_obj_key IN SELECT key FROM jsonb_each(v_value) LOOP - IF v_obj_key NOT IN ('type', 'min_age', 'min_age_lookup', 'min_age_anchor', 'min_age_unless', 'allow_system', 'conditions', 'related_conditions') THEN + IF v_obj_key NOT IN ('type', 'min_age', 'min_age_lookup', 'min_age_anchor', 'min_age_unless', 'allow_system', 'human_only', 'conditions', 'related_conditions') THEN RETURN false; END IF; END LOOP; @@ -424,17 +437,31 @@ BEGIN END IF; FOR v_anchor_key IN SELECT key FROM jsonb_each(v_min_age_anchor) LOOP - IF v_anchor_key NOT IN ('table_id', 'fk_field', 'timestamp_field') THEN + IF v_anchor_key NOT IN ('table_id', 'fk_field', 'conditions', 'timestamp_field') THEN RETURN false; END IF; END LOOP; IF jsonb_typeof(v_min_age_anchor -> 'table_id') IS DISTINCT FROM 'string' - OR jsonb_typeof(v_min_age_anchor -> 'fk_field') IS DISTINCT FROM 'string' OR jsonb_typeof(v_min_age_anchor -> 'timestamp_field') IS DISTINCT FROM 'string' THEN RETURN false; END IF; + -- the anchor row is found one way: by key or by conditions + IF (v_min_age_anchor ? 'fk_field') = (v_min_age_anchor ? 'conditions') THEN + RETURN false; + END IF; + + IF v_min_age_anchor ? 'fk_field' + AND jsonb_typeof(v_min_age_anchor -> 'fk_field') IS DISTINCT FROM 'string' THEN + RETURN false; + END IF; + + IF v_min_age_anchor ? 'conditions' + AND NOT metaschema_private.is_valid_step_up_conditions(v_min_age_anchor -> 'conditions') THEN + RETURN false; + END IF; + BEGIN v_anchor_table_id := (v_min_age_anchor ->> 'table_id')::uuid; EXCEPTION WHEN OTHERS THEN @@ -463,6 +490,13 @@ BEGIN END IF; END IF; + v_human_only := v_value -> 'human_only'; + IF v_human_only IS NOT NULL THEN + IF jsonb_typeof(v_human_only) != 'boolean' THEN + RETURN false; + END IF; + END IF; + v_conditions := v_value -> 'conditions'; IF v_conditions IS NOT NULL THEN IF NOT metaschema_private.is_valid_step_up_conditions(v_conditions) THEN diff --git a/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/embedding_chunks/table.sql b/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/embedding_chunks/table.sql index 751d381fa..e52eb1e12 100644 --- a/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/embedding_chunks/table.sql +++ b/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/embedding_chunks/table.sql @@ -19,6 +19,12 @@ CREATE TABLE metaschema_public.embedding_chunks ( -- content field configuration content_field_name text NOT NULL DEFAULT 'content', + -- parent columns the chunks are cut from (json array of field names); + -- the enqueue trigger fires only once one of them is populated, and + -- passes them to the worker so it chunks exactly that text. + -- NULL means every content column the worker can read, on every write. + source_fields jsonb, + -- embedding configuration dimensions int NOT NULL DEFAULT 768, metric text NOT NULL DEFAULT 'cosine', diff --git a/packages/metaschema-schema/sql/metaschema-schema--0.45.0.bundle.tar.gz b/packages/metaschema-schema/sql/metaschema-schema--0.45.0.bundle.tar.gz index dc3b1f1a1..fcffec419 100644 Binary files a/packages/metaschema-schema/sql/metaschema-schema--0.45.0.bundle.tar.gz and b/packages/metaschema-schema/sql/metaschema-schema--0.45.0.bundle.tar.gz differ diff --git a/packages/metaschema-schema/sql/metaschema-schema--0.45.0.sql b/packages/metaschema-schema/sql/metaschema-schema--0.45.0.sql index 2f3f6ff51..4b0bcf1fd 100644 --- a/packages/metaschema-schema/sql/metaschema-schema--0.45.0.sql +++ b/packages/metaschema-schema/sql/metaschema-schema--0.45.0.sql @@ -221,6 +221,9 @@ DECLARE -- allow_system validation (system-role exemption) v_allow_system jsonb; + -- human_only validation (principal-caller refusal) + v_human_only jsonb; + -- conditions validation (declarative WHEN-clause tree) v_conditions jsonb; @@ -293,7 +296,7 @@ BEGIN END IF; FOR v_obj_key IN SELECT key FROM jsonb_each(v_value) LOOP - IF v_obj_key NOT IN ('type', 'min_age', 'min_age_lookup', 'min_age_anchor', 'min_age_unless', 'allow_system', 'conditions', 'related_conditions') THEN + IF v_obj_key NOT IN ('type', 'min_age', 'min_age_lookup', 'min_age_anchor', 'min_age_unless', 'allow_system', 'human_only', 'conditions', 'related_conditions') THEN RETURN false; END IF; END LOOP; @@ -377,17 +380,31 @@ BEGIN END IF; FOR v_anchor_key IN SELECT key FROM jsonb_each(v_min_age_anchor) LOOP - IF v_anchor_key NOT IN ('table_id', 'fk_field', 'timestamp_field') THEN + IF v_anchor_key NOT IN ('table_id', 'fk_field', 'conditions', 'timestamp_field') THEN RETURN false; END IF; END LOOP; IF jsonb_typeof(v_min_age_anchor -> 'table_id') IS DISTINCT FROM 'string' - OR jsonb_typeof(v_min_age_anchor -> 'fk_field') IS DISTINCT FROM 'string' OR jsonb_typeof(v_min_age_anchor -> 'timestamp_field') IS DISTINCT FROM 'string' THEN RETURN false; END IF; + -- the anchor row is found one way: by key or by conditions + IF (v_min_age_anchor ? 'fk_field') = (v_min_age_anchor ? 'conditions') THEN + RETURN false; + END IF; + + IF v_min_age_anchor ? 'fk_field' + AND jsonb_typeof(v_min_age_anchor -> 'fk_field') IS DISTINCT FROM 'string' THEN + RETURN false; + END IF; + + IF v_min_age_anchor ? 'conditions' + AND NOT metaschema_private.is_valid_step_up_conditions(v_min_age_anchor -> 'conditions') THEN + RETURN false; + END IF; + BEGIN v_anchor_table_id := (v_min_age_anchor ->> 'table_id')::uuid; EXCEPTION WHEN OTHERS THEN @@ -416,6 +433,13 @@ BEGIN END IF; END IF; + v_human_only := v_value -> 'human_only'; + IF v_human_only IS NOT NULL THEN + IF jsonb_typeof(v_human_only) != 'boolean' THEN + RETURN false; + END IF; + END IF; + v_conditions := v_value -> 'conditions'; IF v_conditions IS NOT NULL THEN IF NOT metaschema_private.is_valid_step_up_conditions(v_conditions) THEN @@ -1153,6 +1177,7 @@ CREATE TABLE metaschema_public.embedding_chunks ( chunks_table_id uuid, chunks_table_name text, content_field_name text NOT NULL DEFAULT 'content', + source_fields jsonb, dimensions int NOT NULL DEFAULT 768, metric text NOT NULL DEFAULT 'cosine', chunk_size int NOT NULL DEFAULT 1000,