diff --git a/packages/app-scope/__tests__/actor-entity-type.test.ts b/packages/app-scope/__tests__/actor-entity-type.test.ts index e35556495..526070efd 100644 --- a/packages/app-scope/__tests__/actor-entity-type.test.ts +++ b/packages/app-scope/__tests__/actor-entity-type.test.ts @@ -178,13 +178,14 @@ describe('app_scope.actor_entity', () => { ( database_id, schema_id, + private_schema_id, principals_table_id, principal_entities_table_id, users_table_id, sessions_table_id, session_credentials_table_id ) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, + VALUES ($1, $2, $2, $3, $4, $5, $6, $7)`, [ PRINCIPAL_DATABASE_ID, principalSchema.id, diff --git a/packages/database-jobs/__tests__/jobs.test.ts b/packages/database-jobs/__tests__/jobs.test.ts index 799c49dad..7c8da1323 100644 --- a/packages/database-jobs/__tests__/jobs.test.ts +++ b/packages/database-jobs/__tests__/jobs.test.ts @@ -457,6 +457,39 @@ describe('scheduled jobs', () => { }); }); + it('run_scheduled_job re-runs a keyed job that is out of attempts instead of wedging on it', async () => { + // constructive-planning#2013: a per-minute schedule with max_attempts = 1 + // whose first tick failed (fail_job clears locked_at, so the dead job read + // as "never been run") raised ALREADY_SCHEDULED on every later tick. + const scheduled = await pg.one( + `INSERT INTO app_jobs.scheduled_jobs ( + database_id, task_identifier, schedule_info, key, max_attempts + ) VALUES ($1, $2, $3, $4, 1) + RETURNING id, key`, + [database_id, 'dead_keyed_job', { rule: '0 * * * * *' }, 'dead_keyed_job'] + ); + const [first] = await pg.any(`SELECT * FROM app_jobs.run_scheduled_job($1)`, [scheduled.id]); + + // A tick while the job is still waiting to run is covered by it. + await expect( + pg.any(`SELECT * FROM app_jobs.run_scheduled_job($1)`, [scheduled.id]) + ).rejects.toThrow('ALREADY_SCHEDULED'); + + // The worker claims it, fails it, and the attempt budget is gone. + await pg.any(`SELECT * FROM app_jobs.get_job('worker-1', ARRAY[$1::text])`, ['dead_keyed_job']); + await pg.any(`SELECT * FROM app_jobs.fail_job('worker-1', $1, $2)`, [ + first.id, + 'Function "dead_keyed_job" is not registered in function_definitions' + ]); + const dead = await pg.one(`SELECT attempts, max_attempts, locked_at FROM app_jobs.jobs WHERE id = $1`, [first.id]); + expect(dead).toEqual({ attempts: 1, max_attempts: 1, locked_at: null }); + + // The next tick replaces it with a fresh attempt on the same row. + const [again] = await pg.any(`SELECT * FROM app_jobs.run_scheduled_job($1)`, [scheduled.id]); + expect(again.id).toBe(first.id); + expect({ attempts: again.attempts, last_error: again.last_error }).toEqual({ attempts: 0, last_error: null }); + }); + it('run_scheduled_job rejects a malformed entity pair under strict attribution', async () => { const task_identifier = 'malformed_scheduled_job'; await pg.any(`BEGIN`); diff --git a/packages/database-jobs/deploy/schemas/app_jobs/procedures/run_scheduled_job.sql b/packages/database-jobs/deploy/schemas/app_jobs/procedures/run_scheduled_job.sql index 92a3244a6..d3036427f 100644 --- a/packages/database-jobs/deploy/schemas/app_jobs/procedures/run_scheduled_job.sql +++ b/packages/database-jobs/deploy/schemas/app_jobs/procedures/run_scheduled_job.sql @@ -35,6 +35,11 @@ BEGIN app_jobs.jobs js WHERE js.id = sched.last_scheduled_id + -- a job out of attempts never runs again (get_job skips it), so it + -- covers nothing: fail_job cleared its locked_at, which otherwise reads + -- as "never been run" and wedges the schedule on a permanently failed + -- job. The keyed upsert below replaces it with a fresh attempt instead. + AND js.attempts < js.max_attempts AND (js.locked_at IS NULL -- never been run OR js.locked_at >= (NOW() - job_expiry) -- still running within a safe interval diff --git a/packages/database-jobs/sql/pgpm-database-jobs--0.44.0.bundle.tar.gz b/packages/database-jobs/sql/pgpm-database-jobs--0.44.0.bundle.tar.gz index 15fac8774..60a097258 100644 Binary files a/packages/database-jobs/sql/pgpm-database-jobs--0.44.0.bundle.tar.gz and b/packages/database-jobs/sql/pgpm-database-jobs--0.44.0.bundle.tar.gz differ diff --git a/packages/database-jobs/sql/pgpm-database-jobs--0.44.0.sql b/packages/database-jobs/sql/pgpm-database-jobs--0.44.0.sql index 38f111c72..e6bd4c5b1 100644 --- a/packages/database-jobs/sql/pgpm-database-jobs--0.44.0.sql +++ b/packages/database-jobs/sql/pgpm-database-jobs--0.44.0.sql @@ -433,6 +433,11 @@ BEGIN app_jobs.jobs js WHERE js.id = sched.last_scheduled_id + -- a job out of attempts never runs again (get_job skips it), so it + -- covers nothing: fail_job cleared its locked_at, which otherwise reads + -- as "never been run" and wedges the schedule on a permanently failed + -- job. The keyed upsert below replaces it with a fresh attempt instead. + AND js.attempts < js.max_attempts AND (js.locked_at IS NULL -- never been run OR js.locked_at >= (NOW() - job_expiry) -- still running within a safe interval diff --git a/packages/function-resolution/__tests__/capabilities.test.ts b/packages/function-resolution/__tests__/capabilities.test.ts index 759b063d0..31a8def69 100644 --- a/packages/function-resolution/__tests__/capabilities.test.ts +++ b/packages/function-resolution/__tests__/capabilities.test.ts @@ -39,7 +39,8 @@ describe('function-resolution capability resolution', () => { required_buckets text[] NOT NULL DEFAULT '{}', required_modules text[] NOT NULL DEFAULT '{}', required_models text[] NOT NULL DEFAULT '{}', - integrations text[] NOT NULL DEFAULT '{}' + integrations text[] NOT NULL DEFAULT '{}', + required_capabilities jsonb )` ); await pg.query( @@ -176,9 +177,10 @@ describe('function-resolution capability resolution', () => { resource_installations_table_id, apps_table_id, buckets_table_id, sites_web_config_table_id, sites_error_pages_table_id, sites_app_links_table_id, sites_deep_links_table_id, + app_store_identities_table_id, images_table_id, redirects_table_id, - app_store_identities_table_id, bindings_table_id, scope) - VALUES ($1, $2, $3, $3, $3, $3, $4, $3, $3, $3, $3, $3, $3, $5, $3, $3, $3, $3, $3, $3, $6, 'database')`, + bindings_table_id, scope) + VALUES ($1, $2, $3, $3, $3, $4, $3, $3, $3, $3, $3, $3, $5, $3, $3, $3, $3, $3, $3, $3, $6, 'database')`, [ TENANT_DB, catFunctions.schemaId, @@ -272,8 +274,17 @@ describe('function-resolution capability resolution', () => { [TENANT_DB] ); ids.ambiguous = ambiguous.id; + const declaring = await pg.one( + `INSERT INTO cap_defs.function_definitions + (database_id, task_identifier, access_channels, required_buckets, required_capabilities) + VALUES ($1, 'signup:welcome', ARRAY['api'], ARRAY['exports'], + '[{"name":"email","version":"1.0.0"},{"name":"events","version":"1.0.0","declaration":{"stream":"signups"}}]'::jsonb) + RETURNING id`, + [TENANT_DB] + ); + ids.declaring = declaring.id; - for (const id of [ids.exporter, ids.ambiguous]) { + for (const id of [ids.exporter, ids.ambiguous, ids.declaring]) { await pg.query( `INSERT INTO catalog_private.functions (id, owner_scope, owner_key, is_visible, database_id, task_identifier) SELECT d.id, 'database', d.database_id, false, d.database_id, d.task_identifier @@ -512,6 +523,20 @@ describe('function-resolution capability resolution', () => { expect(bundle.apis['notifications_module'].api_id).toBe(ids.adminApi); expect(bundle.models).toEqual(['gpt-4o']); expect(bundle.payload).toEqual({ subject: 'monthly' }); + // A definition that declared no capabilities echoes null, not [] — the + // runtime mounts the full platform set for it. + expect(bundle.capabilities).toBeNull(); + }); + + 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`, + [TENANT_DB, ids.declaring] + ); + expect(bundle.capabilities).toEqual([ + { name: 'email', version: '1.0.0' }, + { name: 'events', version: '1.0.0', declaration: { stream: 'signups' } }, + ]); }); it('resolve_capabilities(): an explicit binding overrides tag discovery', async () => { diff --git a/packages/function-resolution/__tests__/published-capability-catalog.test.ts b/packages/function-resolution/__tests__/published-capability-catalog.test.ts index fabdce162..a85437e37 100644 --- a/packages/function-resolution/__tests__/published-capability-catalog.test.ts +++ b/packages/function-resolution/__tests__/published-capability-catalog.test.ts @@ -245,7 +245,7 @@ describe('capability resolution against the published catalog planes', () => { sites_error_pages_table_id, sites_app_links_table_id, sites_deep_links_table_id, images_table_id, redirects_table_id, scope) - VALUES ($1, $2, $3, $3, $3, $3, $4, $3, $3, $3, $3, $3, $3, $5, $6, $3, $3, $3, $3, $3, $3, 'database')`, + VALUES ($1, $2, $3, $3, $3, $4, $3, $3, $3, $3, $3, $3, $5, $3, $6, $3, $3, $3, $3, $3, $3, 'database')`, [dbId, schemaId, bucketsTableId, apisTableId, bucketsTableId, bindingsTableId] ); // Label kept for readability of the fixture rows above. 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 0a7489182..6b3217be5 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 @@ -22,9 +22,14 @@ BEGIN; -- -- The bindings document is a JSON array whose every entry NAMES ITS TARGET KIND: -- --- {"path": "/login", "target": "function", "task_identifier": "mantra:signin"} +-- {"path": "/login", "target": "function", "task_identifier": "mantra:signin", "anonymous": true} -- {"path": "/app", "target": "service", "service_id": ""} -- +-- 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 +-- its own — the definition behind it must declare anonymous_callable too — and +-- defaults to false, so a document that says nothing installs closed routes. +-- -- The kind is never inferred from which key happens to be present, and an entry -- carrying keys for two kinds is a malformed document rather than a precedence -- question: guessing is how a deployment silently binds /app to the wrong plane. @@ -124,6 +129,7 @@ DECLARE entry_target text; entry_task text; entry_service uuid; + entry_anonymous boolean; target_column text; target_id uuid; service_found boolean; @@ -374,6 +380,7 @@ BEGIN LOOP entry_path := entry ->> 'path'; entry_target := entry ->> 'target'; + entry_anonymous := coalesce((entry ->> 'anonymous')::boolean, false); IF entry_target = 'function' THEN entry_task := entry ->> 'task_identifier'; @@ -426,8 +433,8 @@ BEGIN -- pgsql-lint-disable-next-line no-dynamic-sql -- write-only: insert into the routes plane named by app_scope.routing_tables; every value is a bound parameter query := format( - 'INSERT INTO %I.%I (%s%sdomain_id, path, %I) - SELECT %s%s$1, $2, $3 + 'INSERT INTO %I.%I (%s%sdomain_id, path, anonymous, %I) + SELECT %s%s$1, $2, $6, $3 WHERE NOT EXISTS ( SELECT 1 FROM %I.%I AS x WHERE x.domain_id = $1 AND x.path = $2%s)', @@ -447,7 +454,7 @@ BEGIN ); EXECUTE query USING domain_id, entry_path, target_id, key_value, - install_route_bindings.site_id; + install_route_bindings.site_id, entry_anonymous; GET DIAGNOSTICS inserted = ROW_COUNT; IF inserted > 0 THEN 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 813dc9112..8f3dd4a99 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 @@ -265,6 +265,9 @@ BEGIN 'configs', coalesce(v_definition->'required_configs', '[]'::jsonb), 'integrations', coalesce(v_definition->'integrations', '[]'::jsonb), 'access_channels', coalesce(v_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', 'payload', function_resolution.resolve_payload_refs( resolve_capabilities.database_id, resolve_capabilities.scope, diff --git a/packages/function-resolution/sql/pgpm-function-resolution--0.44.2.bundle.tar.gz b/packages/function-resolution/sql/pgpm-function-resolution--0.44.2.bundle.tar.gz index 1e4903b70..5d27590bb 100644 Binary files a/packages/function-resolution/sql/pgpm-function-resolution--0.44.2.bundle.tar.gz and b/packages/function-resolution/sql/pgpm-function-resolution--0.44.2.bundle.tar.gz differ diff --git a/packages/function-resolution/sql/pgpm-function-resolution--0.44.2.sql b/packages/function-resolution/sql/pgpm-function-resolution--0.44.2.sql index 3dc9bd258..eb05ab9a7 100644 --- a/packages/function-resolution/sql/pgpm-function-resolution--0.44.2.sql +++ b/packages/function-resolution/sql/pgpm-function-resolution--0.44.2.sql @@ -1566,6 +1566,9 @@ BEGIN 'configs', coalesce(v_definition->'required_configs', '[]'::jsonb), 'integrations', coalesce(v_definition->'integrations', '[]'::jsonb), 'access_channels', coalesce(v_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', 'payload', function_resolution.resolve_payload_refs( resolve_capabilities.database_id, resolve_capabilities.scope, @@ -1695,6 +1698,7 @@ DECLARE entry_target text; entry_task text; entry_service uuid; + entry_anonymous boolean; target_column text; target_id uuid; service_found boolean; @@ -1945,6 +1949,7 @@ BEGIN LOOP entry_path := entry ->> 'path'; entry_target := entry ->> 'target'; + entry_anonymous := coalesce((entry ->> 'anonymous')::boolean, false); IF entry_target = 'function' THEN entry_task := entry ->> 'task_identifier'; @@ -1997,8 +2002,8 @@ BEGIN -- pgsql-lint-disable-next-line no-dynamic-sql -- write-only: insert into the routes plane named by app_scope.routing_tables; every value is a bound parameter query := format( - 'INSERT INTO %I.%I (%s%sdomain_id, path, %I) - SELECT %s%s$1, $2, $3 + 'INSERT INTO %I.%I (%s%sdomain_id, path, anonymous, %I) + SELECT %s%s$1, $2, $6, $3 WHERE NOT EXISTS ( SELECT 1 FROM %I.%I AS x WHERE x.domain_id = $1 AND x.path = $2%s)', @@ -2018,7 +2023,7 @@ BEGIN ); EXECUTE query USING domain_id, entry_path, target_id, key_value, - install_route_bindings.site_id; + install_route_bindings.site_id, entry_anonymous; GET DIAGNOSTICS inserted = ROW_COUNT; IF inserted > 0 THEN diff --git a/packages/jobs/__tests__/run-scheduled-job-race.test.ts b/packages/jobs/__tests__/run-scheduled-job-race.test.ts new file mode 100644 index 000000000..242fc4feb --- /dev/null +++ b/packages/jobs/__tests__/run-scheduled-job-race.test.ts @@ -0,0 +1,155 @@ +import { getConnections, PgTestClient } from 'pgsql-test'; + +let pg: PgTestClient; +let teardown: () => Promise; + +describe('run_scheduled_job concurrency safety', () => { + beforeAll(async () => { + ({ pg, teardown } = await getConnections()); + }); + + beforeEach(async () => { + await pg.beforeEach(); + }); + + afterEach(async () => { + await pg.afterEach(); + }); + + afterAll(async () => { + await teardown(); + }); + + const addScheduledJob = async (key: string) => { + const [scheduled] = await pg.any( + `SELECT * FROM app_jobs.add_scheduled_job( + identifier := $1::text, + payload := $2::json, + schedule_info := $3::json, + job_key := $4::text + )`, + ['my_job', { just: 'run it' }, { rule: '*/1 * * * *' }, key] + ); + return scheduled; + }; + + it('stale-read second invocation upserts instead of dying on jobs_key_key', async () => { + const scheduled = await addScheduledJob('race_key_1'); + + const [first] = await pg.any( + `SELECT * FROM app_jobs.run_scheduled_job($1)`, + [scheduled.id] + ); + expect(first.id).toBeTruthy(); + expect(first.key).toBe('race_key_1'); + + // simulate the racing runner's stale read: it never saw the first + // invocation's last_scheduled_id, so the already-scheduled probe passes + await pg.any( + `UPDATE app_jobs.scheduled_jobs SET last_scheduled_id = NULL WHERE id = $1`, + [scheduled.id] + ); + + const [second] = await pg.any( + `SELECT * FROM app_jobs.run_scheduled_job($1)`, + [scheduled.id] + ); + + // the insert conflicts on jobs_key_key and refreshes the pending job + expect(second.id).toBe(first.id); + + const jobs = await pg.any( + `SELECT * FROM app_jobs.jobs WHERE key = $1`, + ['race_key_1'] + ); + expect(jobs.length).toBe(1); + expect(jobs[0].attempts).toBe(0); + expect(jobs[0].last_error).toBeNull(); + + const [sched] = await pg.any( + `SELECT last_scheduled_id FROM app_jobs.scheduled_jobs WHERE id = $1`, + [scheduled.id] + ); + expect(sched.last_scheduled_id).toBe(first.id); + }); + + it('returns a null record when a BEFORE INSERT trigger suppresses the transport row', async () => { + // mirrors the function_module cron fire trigger, which converts the keyed + // transport row into a pending invocation and RETURN NULLs + await pg.any(` + CREATE FUNCTION pg_temp.suppress_job () + RETURNS TRIGGER AS $$ + BEGIN + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER suppress_job_tg + BEFORE INSERT ON app_jobs.jobs + FOR EACH ROW + EXECUTE PROCEDURE pg_temp.suppress_job (); + `); + + const scheduled = await addScheduledJob('race_key_4'); + const [result] = await pg.any( + `SELECT * FROM app_jobs.run_scheduled_job($1)`, + [scheduled.id] + ); + expect(result.id).toBeNull(); + + const jobs = await pg.any(`SELECT * FROM app_jobs.jobs WHERE key = $1`, [ + 'race_key_4', + ]); + expect(jobs.length).toBe(0); + + await pg.any(`DROP TRIGGER suppress_job_tg ON app_jobs.jobs`); + }); + + it('raises ALREADY_SCHEDULED when the keyed job is locked (in flight)', async () => { + const scheduled = await addScheduledJob('race_key_2'); + + const [first] = await pg.any( + `SELECT * FROM app_jobs.run_scheduled_job($1)`, + [scheduled.id] + ); + + await pg.any( + `UPDATE app_jobs.jobs SET locked_at = NOW(), locked_by = 'worker-1' WHERE id = $1`, + [first.id] + ); + await pg.any( + `UPDATE app_jobs.scheduled_jobs SET last_scheduled_id = NULL WHERE id = $1`, + [scheduled.id] + ); + + const jobs = await pg.any( + `SELECT * FROM app_jobs.jobs WHERE key = $1`, + ['race_key_2'] + ); + expect(jobs.length).toBe(1); + + // raising aborts the surrounding test transaction, so this stays last + await expect( + pg.any(`SELECT * FROM app_jobs.run_scheduled_job($1)`, [scheduled.id]) + ).rejects.toThrow('ALREADY_SCHEDULED'); + }); + + it('raises ALREADY_SCHEDULED when last_scheduled_id points at a pending job', async () => { + const scheduled = await addScheduledJob('race_key_3'); + + await pg.any(`SELECT * FROM app_jobs.run_scheduled_job($1)`, [ + scheduled.id, + ]); + + await expect( + pg.any(`SELECT * FROM app_jobs.run_scheduled_job($1)`, [scheduled.id]) + ).rejects.toThrow('ALREADY_SCHEDULED'); + }); + + it('returns a null record when the scheduled job does not exist', async () => { + const [result] = await pg.any( + `SELECT * FROM app_jobs.run_scheduled_job($1)`, + [999999999] + ); + expect(result.id).toBeNull(); + }); +}); diff --git a/packages/metaschema-modules/__tests__/__snapshots__/modules.test.ts.snap b/packages/metaschema-modules/__tests__/__snapshots__/modules.test.ts.snap index 8feb32b7b..ef18643f1 100644 --- a/packages/metaschema-modules/__tests__/__snapshots__/modules.test.ts.snap +++ b/packages/metaschema-modules/__tests__/__snapshots__/modules.test.ts.snap @@ -58,6 +58,7 @@ exports[`db_meta_modules should have all expected module tables 1`] = ` "rate_limit_meters_module", "rate_limits_module", "realtime_module", + "refusal_log_module", "repository_module", "resource_module", "rls_module", @@ -84,8 +85,8 @@ exports[`db_meta_modules should have all expected module tables 1`] = ` exports[`db_meta_modules should verify all module tables exist in metaschema_modules_public schema 1`] = ` { - "moduleTablesCount": 75, - "totalTables": 84, + "moduleTablesCount": 76, + "totalTables": 85, } `; @@ -152,13 +153,13 @@ exports[`db_meta_modules should verify emails_module table structure 1`] = ` exports[`db_meta_modules should verify module table structures have database_id foreign keys 1`] = ` { - "constraintCount": 75, + "constraintCount": 76, } `; exports[`db_meta_modules should verify module tables have proper foreign key relationships 1`] = ` { - "constraintCount": 558, + "constraintCount": 566, "foreignTables": [ "catalog_module", "database", diff --git a/packages/metaschema-modules/__tests__/modules.test.ts b/packages/metaschema-modules/__tests__/modules.test.ts index 733773e60..e6d580382 100644 --- a/packages/metaschema-modules/__tests__/modules.test.ts +++ b/packages/metaschema-modules/__tests__/modules.test.ts @@ -31,7 +31,6 @@ describe('db_meta_modules', () => { 'infra_config_module', 'internal_secrets_module', 'internal_config_module', - 'cluster_module', 'invites_module', 'events_module', 'limits_module', diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/agent_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/agent_module/table.sql index c4326dd5a..6140ceb96 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/agent_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/agent_module/table.sql @@ -49,6 +49,13 @@ CREATE TABLE metaschema_modules_public.agent_module ( event_table_name text NOT NULL DEFAULT 'agent_event', workspace_table_name text NOT NULL DEFAULT 'agent_run_workspace', + -- Name of the AST-generated run cost settlement function in the private + -- schema (agent_run.total_cost / token_usage from priced inference_log + -- rows). Prefixed like the tables so two scopes share one private schema; + -- stays '' when the module has no runs or the scope has no inference log, + -- and the 'agent_run:settle_cost' maintenance task then skips this row. + settle_run_cost_function_name text NOT NULL DEFAULT '', + -- Feature flags has_plans boolean NOT NULL DEFAULT false, has_resources boolean NOT NULL DEFAULT false, diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/inference_log_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/inference_log_module/table.sql index a0df776ad..1474dc4dd 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/inference_log_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/inference_log_module/table.sql @@ -28,6 +28,12 @@ CREATE TABLE metaschema_modules_public.inference_log_module ( usage_summary_table_id uuid NOT NULL DEFAULT uuid_nil(), usage_summary_table_name text NOT NULL DEFAULT '', + -- Provider/model price catalog: internal per-million-token rates a BEFORE + -- INSERT trigger uses to stamp inference_log.cost (COGS, never a customer + -- meter). + inference_price_table_id uuid NOT NULL DEFAULT uuid_nil(), + inference_price_table_name text NOT NULL DEFAULT '', + -- Partition lifecycle configuration "interval" text NOT NULL DEFAULT '1 month', retention text NOT NULL DEFAULT '12 months', @@ -56,11 +62,13 @@ CREATE TABLE metaschema_modules_public.inference_log_module ( CONSTRAINT private_schema_fkey FOREIGN KEY (private_schema_id) REFERENCES metaschema_public.schema (id) ON DELETE CASCADE, CONSTRAINT inference_log_table_fkey FOREIGN KEY (inference_log_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT usage_summary_table_fkey FOREIGN KEY (usage_summary_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT inference_price_table_fkey FOREIGN KEY (inference_price_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT inference_log_module_database_id_prefix_unique UNIQUE NULLS NOT DISTINCT (database_id, prefix) ); CREATE INDEX inference_log_module_inference_log_table_id_idx ON metaschema_modules_public.inference_log_module ( inference_log_table_id ); CREATE INDEX inference_log_module_usage_summary_table_id_idx ON metaschema_modules_public.inference_log_module ( usage_summary_table_id ); +CREATE INDEX inference_log_module_inference_price_table_id_idx ON metaschema_modules_public.inference_log_module ( inference_price_table_id ); CREATE INDEX inference_log_module_private_schema_id_idx ON metaschema_modules_public.inference_log_module ( private_schema_id ); CREATE INDEX inference_log_module_schema_id_idx ON metaschema_modules_public.inference_log_module ( schema_id ); @@ -70,5 +78,6 @@ CREATE INDEX inference_log_module_schema_id_idx ON metaschema_modules_public.inf -- install, keyed by the role name in the column. COMMENT ON COLUMN metaschema_modules_public.inference_log_module.inference_log_table_id IS '@module_table'; COMMENT ON COLUMN metaschema_modules_public.inference_log_module.usage_summary_table_id IS '@module_table'; +COMMENT ON COLUMN metaschema_modules_public.inference_log_module.inference_price_table_id IS '@module_table'; COMMIT; diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/principal_auth_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/principal_auth_module/table.sql index e81ef8c8b..09b3ebbae 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/principal_auth_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/principal_auth_module/table.sql @@ -9,6 +9,8 @@ CREATE TABLE metaschema_modules_public.principal_auth_module ( database_id uuid NOT NULL, schema_id uuid NOT NULL DEFAULT uuid_nil(), + -- auth_private: server-only procedures (sweep_expired_principals) + private_schema_id uuid NOT NULL DEFAULT uuid_nil(), principals_table_id uuid NOT NULL DEFAULT uuid_nil(), principal_entities_table_id uuid NOT NULL DEFAULT uuid_nil(), principal_scope_overrides_table_id uuid NOT NULL DEFAULT uuid_nil(), @@ -29,10 +31,14 @@ CREATE TABLE metaschema_modules_public.principal_auth_module ( create_org_api_key_function text NOT NULL DEFAULT 'create_org_api_key', revoke_org_api_key_function text NOT NULL DEFAULT 'revoke_org_api_key', + -- principals:expire_sweep maintenance function the worker resolves through this row + sweep_expired_principals_function text NOT NULL DEFAULT 'sweep_expired_principals', + api_name text DEFAULT 'auth', CONSTRAINT db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, 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 principals_table_fkey FOREIGN KEY (principals_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT principal_entities_table_fkey FOREIGN KEY (principal_entities_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT users_table_fkey FOREIGN KEY (users_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, @@ -47,6 +53,7 @@ CREATE INDEX principal_auth_module_session_credentials_table_id_idx ON metaschem CREATE INDEX principal_auth_module_sessions_table_id_idx ON metaschema_modules_public.principal_auth_module ( sessions_table_id ); CREATE INDEX principal_auth_module_users_table_id_idx ON metaschema_modules_public.principal_auth_module ( users_table_id ); CREATE INDEX principal_auth_module_schema_id_idx ON metaschema_modules_public.principal_auth_module ( schema_id ); +CREATE INDEX principal_auth_module_private_schema_id_idx ON metaschema_modules_public.principal_auth_module ( private_schema_id ); COMMENT ON CONSTRAINT principals_table_fkey ON metaschema_modules_public.principal_auth_module IS E'@behavior -*'; diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/refusal_log_module/constraints/one_platform_scope.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/refusal_log_module/constraints/one_platform_scope.sql new file mode 100644 index 000000000..b5e44e7b1 --- /dev/null +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/refusal_log_module/constraints/one_platform_scope.sql @@ -0,0 +1,12 @@ +-- Deploy schemas/metaschema_modules_public/tables/refusal_log_module/constraints/one_platform_scope to pg + +-- requires: schemas/metaschema_modules_public/tables/refusal_log_module/table + +BEGIN; + +-- At most one platform-scope refusal_log_module per database. +CREATE UNIQUE INDEX refusal_log_module_one_platform_scope + ON metaschema_modules_public.refusal_log_module (database_id) + WHERE scope = 'platform'; + +COMMIT; diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/refusal_log_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/refusal_log_module/table.sql new file mode 100644 index 000000000..1bd4a580a --- /dev/null +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/refusal_log_module/table.sql @@ -0,0 +1,74 @@ +-- Deploy schemas/metaschema_modules_public/tables/refusal_log_module/table to pg + +-- requires: schemas/metaschema_modules_public/schema + +BEGIN; + +-- Platform-only refusal observability: per-minute refusal_log ledger and its +-- daily refusal_usage_summary rollup. Installed once at platform scope; the +-- insert trigger rejects any other scope, so nothing lands in tenant databases. +CREATE TABLE metaschema_modules_public.refusal_log_module ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + database_id uuid NOT NULL, + + -- Scope-key column name on the generated table(s), recorded by the insert + -- trigger via metaschema_generators.scope_key_column(scope): platform -> NULL. + entity_field text, + schema_id uuid NOT NULL DEFAULT uuid_nil(), + private_schema_id uuid NOT NULL DEFAULT uuid_nil(), + + -- Schema name overrides: when set, the trigger uses these instead of hardcoded defaults. + public_schema_name text, + private_schema_name text, + + -- Refusal log (partitioned by minute_bucket — one row per minute/database/lane/reason/route/source) + log_table_id uuid NOT NULL DEFAULT uuid_nil(), + log_table_name text NOT NULL DEFAULT '', + + -- Refusal usage summary rollup (partitioned by date) + summary_table_id uuid NOT NULL DEFAULT uuid_nil(), + summary_table_name text NOT NULL DEFAULT '', + + -- Generated functions + record_refusals_function text NOT NULL DEFAULT '', + rollup_refusal_usage_summary_function text NOT NULL DEFAULT '', + + -- Partition lifecycle configuration: raw tier is short-lived forensics, + -- summary tier carries the trend. + log_interval text NOT NULL DEFAULT '1 day', + log_retention text NOT NULL DEFAULT '7 days', + log_premake int NOT NULL DEFAULT 2, + summary_interval text NOT NULL DEFAULT '1 month', + summary_retention text NOT NULL DEFAULT '3 months', + summary_premake int NOT NULL DEFAULT 2, + + -- Scope configuration: only 'platform' is accepted. + scope text NOT NULL, + + -- Table name prefix. Auto-derived from scope by the trigger when empty. + prefix text NOT NULL DEFAULT '', + + -- API routing (configurable per-module) + api_name text DEFAULT 'usage', + private_api_name text DEFAULT NULL, + + CONSTRAINT db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, + 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 log_table_fkey FOREIGN KEY (log_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT summary_table_fkey FOREIGN KEY (summary_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT refusal_log_module_database_id_scope_unique UNIQUE (database_id, scope) +); + +CREATE INDEX refusal_log_module_log_table_id_idx ON metaschema_modules_public.refusal_log_module ( log_table_id ); +CREATE INDEX refusal_log_module_summary_table_id_idx ON metaschema_modules_public.refusal_log_module ( summary_table_id ); +CREATE INDEX refusal_log_module_private_schema_id_idx ON metaschema_modules_public.refusal_log_module ( private_schema_id ); +CREATE INDEX refusal_log_module_schema_id_idx ON metaschema_modules_public.refusal_log_module ( schema_id ); + +-- Tables this module generates: the @module_table marker is what +-- metaschema_modules_private.tg_module_install_provenance attributes to this +-- install, keyed by the role name in the column. +COMMENT ON COLUMN metaschema_modules_public.refusal_log_module.log_table_id IS '@module_table'; +COMMENT ON COLUMN metaschema_modules_public.refusal_log_module.summary_table_id IS '@module_table'; + +COMMIT; diff --git a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/user_auth_module/table.sql b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/user_auth_module/table.sql index 972dfab7f..8dfff5e90 100644 --- a/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/user_auth_module/table.sql +++ b/packages/metaschema-modules/deploy/schemas/metaschema_modules_public/tables/user_auth_module/table.sql @@ -9,6 +9,8 @@ CREATE TABLE metaschema_modules_public.user_auth_module ( database_id uuid NOT NULL, schema_id uuid NOT NULL DEFAULT uuid_nil(), + -- auth_private: server-only procedures (revoke_session_tree, sweep_expired_sessions) + private_schema_id uuid NOT NULL DEFAULT uuid_nil(), emails_table_id uuid NOT NULL DEFAULT uuid_nil(), users_table_id uuid NOT NULL DEFAULT uuid_nil(), secrets_table_id uuid NOT NULL DEFAULT uuid_nil(), @@ -41,6 +43,11 @@ CREATE TABLE metaschema_modules_public.user_auth_module ( request_cross_origin_token_function text NOT NULL DEFAULT 'request_cross_origin_token', extend_token_expires text NOT NULL DEFAULT 'extend_token_expires', + -- Session tree: private cascade helper and the sessions:expire_sweep + -- maintenance function the worker resolves through this row. + revoke_session_tree_function text NOT NULL DEFAULT 'revoke_session_tree', + sweep_expired_sessions_function text NOT NULL DEFAULT 'sweep_expired_sessions', + -- UNIQUE(api_id), -- API routing (configurable per-module) @@ -49,6 +56,7 @@ CREATE TABLE metaschema_modules_public.user_auth_module ( CONSTRAINT db_fkey FOREIGN KEY (database_id) REFERENCES metaschema_public.database (id) ON DELETE CASCADE, 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 email_table_fkey FOREIGN KEY (emails_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT users_table_fkey FOREIGN KEY (users_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, CONSTRAINT secrets_table_fkey FOREIGN KEY (secrets_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, @@ -67,6 +75,7 @@ CREATE INDEX user_auth_module_session_credentials_table_id_idx ON metaschema_mod CREATE INDEX user_auth_module_sessions_table_id_idx ON metaschema_modules_public.user_auth_module ( sessions_table_id ); CREATE INDEX user_auth_module_users_table_id_idx ON metaschema_modules_public.user_auth_module ( users_table_id ); CREATE INDEX user_auth_module_schema_id_idx ON metaschema_modules_public.user_auth_module ( schema_id ); +CREATE INDEX user_auth_module_private_schema_id_idx ON metaschema_modules_public.user_auth_module ( private_schema_id ); COMMENT ON CONSTRAINT email_table_fkey ON metaschema_modules_public.user_auth_module IS E'@behavior -*'; diff --git a/packages/metaschema-modules/pgpm.plan b/packages/metaschema-modules/pgpm.plan index 99562e226..b33c4b714 100644 --- a/packages/metaschema-modules/pgpm.plan +++ b/packages/metaschema-modules/pgpm.plan @@ -98,3 +98,5 @@ schemas/metaschema_modules_public/tables/image_module/table [schemas/metaschema_ schemas/metaschema_modules_public/tables/repository_module/table [schemas/metaschema_modules_public/schema] 2026-08-15T01:00:00Z devin # add repository_module config table (repositories, events, workflows, builds, local change requests) schemas/metaschema_modules_public/tables/machine_module/table [schemas/metaschema_modules_public/schema] 2026-08-15T02:00:00Z devin # add machine_module config table (enrolled machines, sessions, command ledger) schemas/metaschema_modules_public/tables/cluster_module/table [schemas/metaschema_modules_public/schema] 2026-08-17T00:00:00Z devin # add cluster_module config table (fleet catalog: clusters, database servers, physical databases, placements) +schemas/metaschema_modules_public/tables/refusal_log_module/table [schemas/metaschema_modules_public/schema] 2026-09-03T00:30:00Z devin # add refusal_log_module config table (platform-only per-minute refusal ledger + daily rollup) +schemas/metaschema_modules_public/tables/refusal_log_module/constraints/one_platform_scope [schemas/metaschema_modules_public/tables/refusal_log_module/table] 2026-09-03T00:30:01Z devin # enforce at most one platform-scope refusal_log_module per database diff --git a/packages/metaschema-modules/revert/schemas/metaschema_modules_public/tables/refusal_log_module/constraints/one_platform_scope.sql b/packages/metaschema-modules/revert/schemas/metaschema_modules_public/tables/refusal_log_module/constraints/one_platform_scope.sql new file mode 100644 index 000000000..d447cf381 --- /dev/null +++ b/packages/metaschema-modules/revert/schemas/metaschema_modules_public/tables/refusal_log_module/constraints/one_platform_scope.sql @@ -0,0 +1,7 @@ +-- Revert schemas/metaschema_modules_public/tables/refusal_log_module/constraints/one_platform_scope from pg + +BEGIN; + +DROP INDEX metaschema_modules_public.refusal_log_module_one_platform_scope; + +COMMIT; diff --git a/packages/metaschema-modules/revert/schemas/metaschema_modules_public/tables/refusal_log_module/table.sql b/packages/metaschema-modules/revert/schemas/metaschema_modules_public/tables/refusal_log_module/table.sql new file mode 100644 index 000000000..da841fabf --- /dev/null +++ b/packages/metaschema-modules/revert/schemas/metaschema_modules_public/tables/refusal_log_module/table.sql @@ -0,0 +1,3 @@ +-- Revert schemas/metaschema_modules_public/tables/refusal_log_module/table from pg + +DROP TABLE IF EXISTS metaschema_modules_public.refusal_log_module; diff --git a/packages/metaschema-modules/sql/metaschema-modules--0.44.2.bundle.tar.gz b/packages/metaschema-modules/sql/metaschema-modules--0.44.2.bundle.tar.gz index b73ed7670..520046ce4 100644 Binary files a/packages/metaschema-modules/sql/metaschema-modules--0.44.2.bundle.tar.gz and b/packages/metaschema-modules/sql/metaschema-modules--0.44.2.bundle.tar.gz differ diff --git a/packages/metaschema-modules/sql/metaschema-modules--0.44.2.sql b/packages/metaschema-modules/sql/metaschema-modules--0.44.2.sql index 8b88c3c8e..2ce397242 100644 --- a/packages/metaschema-modules/sql/metaschema-modules--0.44.2.sql +++ b/packages/metaschema-modules/sql/metaschema-modules--0.44.2.sql @@ -1220,6 +1220,7 @@ CREATE TABLE metaschema_modules_public.user_auth_module ( id uuid PRIMARY KEY DEFAULT uuidv7(), database_id uuid NOT NULL, schema_id uuid NOT NULL DEFAULT uuid_nil(), + private_schema_id uuid NOT NULL DEFAULT uuid_nil(), emails_table_id uuid NOT NULL DEFAULT uuid_nil(), users_table_id uuid NOT NULL DEFAULT uuid_nil(), secrets_table_id uuid NOT NULL DEFAULT uuid_nil(), @@ -1243,6 +1244,8 @@ CREATE TABLE metaschema_modules_public.user_auth_module ( sign_in_cross_origin_function text NOT NULL DEFAULT 'sign_in_cross_origin', request_cross_origin_token_function text NOT NULL DEFAULT 'request_cross_origin_token', extend_token_expires text NOT NULL DEFAULT 'extend_token_expires', + revoke_session_tree_function text NOT NULL DEFAULT 'revoke_session_tree', + sweep_expired_sessions_function text NOT NULL DEFAULT 'sweep_expired_sessions', api_name text DEFAULT 'auth', private_api_name text DEFAULT NULL, CONSTRAINT db_fkey @@ -1253,6 +1256,10 @@ CREATE TABLE metaschema_modules_public.user_auth_module ( 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 email_table_fkey FOREIGN KEY(emails_table_id) REFERENCES metaschema_public.table (id) @@ -1295,6 +1302,8 @@ CREATE INDEX user_auth_module_users_table_id_idx ON metaschema_modules_public.us CREATE INDEX user_auth_module_schema_id_idx ON metaschema_modules_public.user_auth_module (schema_id); +CREATE INDEX user_auth_module_private_schema_id_idx ON metaschema_modules_public.user_auth_module (private_schema_id); + COMMENT ON CONSTRAINT email_table_fkey ON metaschema_modules_public.user_auth_module IS '@behavior -*'; COMMENT ON CONSTRAINT users_table_fkey ON metaschema_modules_public.user_auth_module IS '@behavior -*'; @@ -3133,6 +3142,8 @@ CREATE TABLE metaschema_modules_public.inference_log_module ( inference_log_table_name text NOT NULL DEFAULT '', usage_summary_table_id uuid NOT NULL DEFAULT uuid_nil(), usage_summary_table_name text NOT NULL DEFAULT '', + inference_price_table_id uuid NOT NULL DEFAULT uuid_nil(), + inference_price_table_name text NOT NULL DEFAULT '', "interval" text NOT NULL DEFAULT '1 month', retention text NOT NULL DEFAULT '12 months', premake int NOT NULL DEFAULT 2, @@ -3161,6 +3172,10 @@ CREATE TABLE metaschema_modules_public.inference_log_module ( FOREIGN KEY(usage_summary_table_id) REFERENCES metaschema_public.table (id) ON DELETE CASCADE, + CONSTRAINT inference_price_table_fkey + FOREIGN KEY(inference_price_table_id) + REFERENCES metaschema_public.table (id) + ON DELETE CASCADE, CONSTRAINT inference_log_module_database_id_prefix_unique UNIQUE NULLS NOT DISTINCT (database_id, prefix) ); @@ -3169,6 +3184,8 @@ CREATE INDEX inference_log_module_inference_log_table_id_idx ON metaschema_modul CREATE INDEX inference_log_module_usage_summary_table_id_idx ON metaschema_modules_public.inference_log_module (usage_summary_table_id); +CREATE INDEX inference_log_module_inference_price_table_id_idx ON metaschema_modules_public.inference_log_module (inference_price_table_id); + CREATE INDEX inference_log_module_private_schema_id_idx ON metaschema_modules_public.inference_log_module (private_schema_id); CREATE INDEX inference_log_module_schema_id_idx ON metaschema_modules_public.inference_log_module (schema_id); @@ -3177,6 +3194,8 @@ COMMENT ON COLUMN metaschema_modules_public.inference_log_module.inference_log_t COMMENT ON COLUMN metaschema_modules_public.inference_log_module.usage_summary_table_id IS '@module_table'; +COMMENT ON COLUMN metaschema_modules_public.inference_log_module.inference_price_table_id IS '@module_table'; + CREATE TABLE metaschema_modules_public.compute_log_module ( id uuid PRIMARY KEY DEFAULT uuidv7(), database_id uuid NOT NULL, @@ -3457,6 +3476,7 @@ CREATE TABLE metaschema_modules_public.agent_module ( run_table_name text NOT NULL DEFAULT 'agent_run', event_table_name text NOT NULL DEFAULT 'agent_event', workspace_table_name text NOT NULL DEFAULT 'agent_run_workspace', + settle_run_cost_function_name text NOT NULL DEFAULT '', has_plans boolean NOT NULL DEFAULT false, has_resources boolean NOT NULL DEFAULT false, has_agents boolean NOT NULL DEFAULT false, @@ -4500,6 +4520,7 @@ CREATE TABLE metaschema_modules_public.principal_auth_module ( id uuid PRIMARY KEY DEFAULT uuidv7(), database_id uuid NOT NULL, schema_id uuid NOT NULL DEFAULT uuid_nil(), + private_schema_id uuid NOT NULL DEFAULT uuid_nil(), principals_table_id uuid NOT NULL DEFAULT uuid_nil(), principal_entities_table_id uuid NOT NULL DEFAULT uuid_nil(), principal_scope_overrides_table_id uuid NOT NULL DEFAULT uuid_nil(), @@ -4514,6 +4535,7 @@ CREATE TABLE metaschema_modules_public.principal_auth_module ( delete_org_principal_function text NOT NULL DEFAULT 'delete_org_principal', create_org_api_key_function text NOT NULL DEFAULT 'create_org_api_key', revoke_org_api_key_function text NOT NULL DEFAULT 'revoke_org_api_key', + sweep_expired_principals_function text NOT NULL DEFAULT 'sweep_expired_principals', api_name text DEFAULT 'auth', CONSTRAINT db_fkey FOREIGN KEY(database_id) @@ -4523,6 +4545,10 @@ CREATE TABLE metaschema_modules_public.principal_auth_module ( 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 principals_table_fkey FOREIGN KEY(principals_table_id) REFERENCES metaschema_public.table (id) @@ -4559,6 +4585,8 @@ CREATE INDEX principal_auth_module_users_table_id_idx ON metaschema_modules_publ CREATE INDEX principal_auth_module_schema_id_idx ON metaschema_modules_public.principal_auth_module (schema_id); +CREATE INDEX principal_auth_module_private_schema_id_idx ON metaschema_modules_public.principal_auth_module (private_schema_id); + COMMENT ON CONSTRAINT principals_table_fkey ON metaschema_modules_public.principal_auth_module IS '@behavior -*'; COMMENT ON CONSTRAINT users_table_fkey ON metaschema_modules_public.principal_auth_module IS '@behavior -*'; @@ -6558,4 +6586,66 @@ COMMENT ON COLUMN metaschema_modules_public.cluster_module.database_servers_tabl COMMENT ON COLUMN metaschema_modules_public.cluster_module.physical_databases_table_id IS '@module_table'; -COMMENT ON COLUMN metaschema_modules_public.cluster_module.database_placements_table_id IS '@module_table'; \ No newline at end of file +COMMENT ON COLUMN metaschema_modules_public.cluster_module.database_placements_table_id IS '@module_table'; + +CREATE TABLE metaschema_modules_public.refusal_log_module ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + database_id uuid NOT NULL, + entity_field text, + schema_id uuid NOT NULL DEFAULT uuid_nil(), + private_schema_id uuid NOT NULL DEFAULT uuid_nil(), + public_schema_name text, + private_schema_name text, + log_table_id uuid NOT NULL DEFAULT uuid_nil(), + log_table_name text NOT NULL DEFAULT '', + summary_table_id uuid NOT NULL DEFAULT uuid_nil(), + summary_table_name text NOT NULL DEFAULT '', + record_refusals_function text NOT NULL DEFAULT '', + rollup_refusal_usage_summary_function text NOT NULL DEFAULT '', + log_interval text NOT NULL DEFAULT '1 day', + log_retention text NOT NULL DEFAULT '7 days', + log_premake int NOT NULL DEFAULT 2, + summary_interval text NOT NULL DEFAULT '1 month', + summary_retention text NOT NULL DEFAULT '3 months', + summary_premake int NOT NULL DEFAULT 2, + scope text NOT NULL, + prefix text NOT NULL DEFAULT '', + api_name text DEFAULT 'usage', + private_api_name text DEFAULT NULL, + CONSTRAINT db_fkey + FOREIGN KEY(database_id) + REFERENCES metaschema_public.database (id) + ON DELETE CASCADE, + 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 log_table_fkey + FOREIGN KEY(log_table_id) + REFERENCES metaschema_public.table (id) + ON DELETE CASCADE, + CONSTRAINT summary_table_fkey + FOREIGN KEY(summary_table_id) + REFERENCES metaschema_public.table (id) + ON DELETE CASCADE, + CONSTRAINT refusal_log_module_database_id_scope_unique + UNIQUE (database_id, scope) +); + +CREATE INDEX refusal_log_module_log_table_id_idx ON metaschema_modules_public.refusal_log_module (log_table_id); + +CREATE INDEX refusal_log_module_summary_table_id_idx ON metaschema_modules_public.refusal_log_module (summary_table_id); + +CREATE INDEX refusal_log_module_private_schema_id_idx ON metaschema_modules_public.refusal_log_module (private_schema_id); + +CREATE INDEX refusal_log_module_schema_id_idx ON metaschema_modules_public.refusal_log_module (schema_id); + +COMMENT ON COLUMN metaschema_modules_public.refusal_log_module.log_table_id IS '@module_table'; + +COMMENT ON COLUMN metaschema_modules_public.refusal_log_module.summary_table_id IS '@module_table'; + +CREATE UNIQUE INDEX refusal_log_module_one_platform_scope ON metaschema_modules_public.refusal_log_module (database_id) WHERE scope = 'platform'; \ No newline at end of file diff --git a/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/inference_log_module/table.sql b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/inference_log_module/table.sql index e95632294..cd1cf0be3 100644 --- a/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/inference_log_module/table.sql +++ b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/inference_log_module/table.sql @@ -11,6 +11,8 @@ SELECT inference_log_table_name, usage_summary_table_id, usage_summary_table_name, + inference_price_table_id, + inference_price_table_name, "interval", retention, premake, diff --git a/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/refusal_log_module/constraints/one_platform_scope.sql b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/refusal_log_module/constraints/one_platform_scope.sql new file mode 100644 index 000000000..b737fcd53 --- /dev/null +++ b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/refusal_log_module/constraints/one_platform_scope.sql @@ -0,0 +1,10 @@ +-- Verify schemas/metaschema_modules_public/tables/refusal_log_module/constraints/one_platform_scope on pg + +BEGIN; + +SELECT 1/count(*) +FROM pg_indexes +WHERE schemaname = 'metaschema_modules_public' + AND indexname = 'refusal_log_module_one_platform_scope'; + +ROLLBACK; diff --git a/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/refusal_log_module/table.sql b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/refusal_log_module/table.sql new file mode 100644 index 000000000..31c42f108 --- /dev/null +++ b/packages/metaschema-modules/verify/schemas/metaschema_modules_public/tables/refusal_log_module/table.sql @@ -0,0 +1,9 @@ +-- Verify schemas/metaschema_modules_public/tables/refusal_log_module/table on pg + +SELECT id, database_id, schema_id, private_schema_id, + log_table_id, summary_table_id, + log_retention, summary_retention, + record_refusals_function, rollup_refusal_usage_summary_function, + prefix +FROM metaschema_modules_public.refusal_log_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 dc5cfdd18..b298ef71e 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 @@ -21,11 +21,37 @@ BEGIN; -- table. Object with exactly {table_id (uuid), -- fk_field (text), min_age_field (text)}. Requires -- min_age as the fallback default. UPDATE/DELETE only. +-- 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. +-- 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 +-- matches. Requires min_age; excludes min_age_lookup +-- and min_age_anchor. UPDATE/DELETE only. +-- 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. -- conditions (optional): declarative WHEN-clause tree gating the guard -- (compiled by metaschema_generators.build_condition_expr -- and validated through the ast_validate framework at -- apply time). Shape-validated here via -- is_valid_step_up_conditions. +-- related_conditions (optional): the guard arms on the row the written +-- row points at rather than on the written row. +-- Object with exactly {table_id (uuid), fk_field +-- (text), conditions (tree over the related table)}. +-- Tested in the trigger body, since a WHEN clause +-- may not hold a subquery. Excludes min_age_lookup +-- and min_age_anchor. +-- name (optional): identifies one of several guards on a verb. +-- - a non-empty array of such objects when one verb needs guards of +-- differing posture. Names are unique lowercase snake_case; at most one +-- element is unnamed (the verb's default guard, keeping its historical +-- trigger name). -- Shape validator for the conditions tree accepted by the declarative -- step_up field. Mirrors the grammar of @@ -231,8 +257,28 @@ DECLARE v_lookup_key text; v_lookup_table_id uuid; + -- min_age_anchor validation (window measured from a related row) + v_min_age_anchor jsonb; + v_anchor_key text; + v_anchor_table_id uuid; + + -- min_age_unless validation (grace forfeited when the tree matches) + v_min_age_unless jsonb; + + -- allow_system validation (system-role exemption) + v_allow_system jsonb; + -- conditions validation (declarative WHEN-clause tree) v_conditions jsonb; + + -- related_conditions validation (guard arms on a related row) + v_related_conditions jsonb; + v_related_key text; + v_related_table_id uuid; + + -- named-guard array validation + v_guard jsonb; + v_guard_names text[]; BEGIN IF step_up IS NULL THEN RETURN false; @@ -251,6 +297,35 @@ BEGIN RETURN false; END IF; + -- A list of guards: every element is an object that would be valid + -- on its own, and carries a name no sibling shares (one may go + -- unnamed: the verb's default guard). + IF jsonb_typeof(v_value) = 'array' THEN + IF jsonb_array_length(v_value) = 0 THEN + RETURN false; + END IF; + v_guard_names := ARRAY[]::text[]; + FOR v_guard IN SELECT elem FROM jsonb_array_elements(v_value) AS g(elem) LOOP + IF jsonb_typeof(v_guard) != 'object' THEN + RETURN false; + END IF; + IF v_guard ? 'name' THEN + IF jsonb_typeof(v_guard -> 'name') != 'string' + OR (v_guard ->> 'name') !~ '^[a-z][a-z0-9_]*$' THEN + RETURN false; + END IF; + END IF; + IF COALESCE(v_guard ->> 'name', '') = ANY(v_guard_names) THEN + RETURN false; + END IF; + v_guard_names := v_guard_names || COALESCE(v_guard ->> 'name', ''); + IF NOT metaschema_private.is_valid_step_up(jsonb_build_object(v_key, v_guard - 'name')) THEN + RETURN false; + END IF; + END LOOP; + CONTINUE; + END IF; + IF jsonb_typeof(v_value) = 'boolean' THEN IF v_value = 'false'::jsonb THEN RETURN false; @@ -265,7 +340,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', 'conditions') THEN + IF v_obj_key NOT IN ('type', 'min_age', 'min_age_lookup', 'min_age_anchor', 'min_age_unless', 'allow_system', 'conditions', 'related_conditions') THEN RETURN false; END IF; END LOOP; @@ -331,12 +406,103 @@ BEGIN END; END IF; + v_min_age_anchor := v_value -> 'min_age_anchor'; + IF v_min_age_anchor IS NOT NULL THEN + -- an anchor says where the window is measured from, so it + -- needs a window, and INSERT has no window at all + IF v_key = 'INSERT' OR v_min_age IS NULL THEN + RETURN false; + END IF; + + -- the two are alternative sources for the same window + IF v_min_age_lookup IS NOT NULL THEN + RETURN false; + END IF; + + IF jsonb_typeof(v_min_age_anchor) != 'object' THEN + RETURN false; + 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 + 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; + + BEGIN + v_anchor_table_id := (v_min_age_anchor ->> 'table_id')::uuid; + EXCEPTION WHEN OTHERS THEN + RETURN false; + END; + END IF; + + v_min_age_unless := v_value -> 'min_age_unless'; + IF v_min_age_unless IS NOT NULL THEN + -- forfeiting a grace needs a grace to forfeit, and only a + -- static window can be forfeited + IF v_key = 'INSERT' OR v_min_age IS NULL + OR v_min_age_lookup IS NOT NULL OR v_min_age_anchor IS NOT NULL THEN + RETURN false; + END IF; + + IF NOT metaschema_private.is_valid_step_up_conditions(v_min_age_unless) THEN + RETURN false; + END IF; + END IF; + + v_allow_system := v_value -> 'allow_system'; + IF v_allow_system IS NOT NULL THEN + IF jsonb_typeof(v_allow_system) != '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 RETURN false; END IF; END IF; + + v_related_conditions := v_value -> 'related_conditions'; + IF v_related_conditions IS NOT NULL THEN + -- the body belongs to one mechanism: a lookup or anchored + -- window already occupies it + IF v_min_age_lookup IS NOT NULL OR v_min_age_anchor IS NOT NULL THEN + RETURN false; + END IF; + + IF jsonb_typeof(v_related_conditions) != 'object' THEN + RETURN false; + END IF; + + FOR v_related_key IN SELECT key FROM jsonb_each(v_related_conditions) LOOP + IF v_related_key NOT IN ('table_id', 'fk_field', 'conditions') THEN + RETURN false; + END IF; + END LOOP; + + IF jsonb_typeof(v_related_conditions -> 'table_id') IS DISTINCT FROM 'string' + OR jsonb_typeof(v_related_conditions -> 'fk_field') IS DISTINCT FROM 'string' THEN + RETURN false; + END IF; + + BEGIN + v_related_table_id := (v_related_conditions ->> 'table_id')::uuid; + EXCEPTION WHEN OTHERS THEN + RETURN false; + END; + + IF NOT metaschema_private.is_valid_step_up_conditions(v_related_conditions -> 'conditions') THEN + RETURN false; + END IF; + END IF; ELSE RETURN false; END IF; diff --git a/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/index/table.sql b/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/index/table.sql index 671bf9567..37f31cb78 100644 --- a/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/index/table.sql +++ b/packages/metaschema-schema/deploy/schemas/metaschema_public/tables/index/table.sql @@ -21,6 +21,9 @@ CREATE TABLE metaschema_public.index ( index_params jsonb, where_clause jsonb, is_unique boolean NOT NULL default false, + -- UNIQUE ... NULLS NOT DISTINCT: NULL key values collide, so a nullable + -- attribution column can be part of an upsert's conflict target. + nulls_not_distinct boolean NOT NULL default false, options jsonb, op_classes text[], diff --git a/packages/metaschema-schema/sql/metaschema-schema--0.44.2.bundle.tar.gz b/packages/metaschema-schema/sql/metaschema-schema--0.44.2.bundle.tar.gz index 5b41b4b1d..f5029d4e3 100644 Binary files a/packages/metaschema-schema/sql/metaschema-schema--0.44.2.bundle.tar.gz and b/packages/metaschema-schema/sql/metaschema-schema--0.44.2.bundle.tar.gz differ diff --git a/packages/metaschema-schema/sql/metaschema-schema--0.44.2.sql b/packages/metaschema-schema/sql/metaschema-schema--0.44.2.sql index 1a00ddfa2..2f3f6ff51 100644 --- a/packages/metaschema-schema/sql/metaschema-schema--0.44.2.sql +++ b/packages/metaschema-schema/sql/metaschema-schema--0.44.2.sql @@ -210,8 +210,28 @@ DECLARE v_lookup_key text; v_lookup_table_id uuid; + -- min_age_anchor validation (window measured from a related row) + v_min_age_anchor jsonb; + v_anchor_key text; + v_anchor_table_id uuid; + + -- min_age_unless validation (grace forfeited when the tree matches) + v_min_age_unless jsonb; + + -- allow_system validation (system-role exemption) + v_allow_system jsonb; + -- conditions validation (declarative WHEN-clause tree) v_conditions jsonb; + + -- related_conditions validation (guard arms on a related row) + v_related_conditions jsonb; + v_related_key text; + v_related_table_id uuid; + + -- named-guard array validation + v_guard jsonb; + v_guard_names text[]; BEGIN IF step_up IS NULL THEN RETURN false; @@ -230,6 +250,35 @@ BEGIN RETURN false; END IF; + -- A list of guards: every element is an object that would be valid + -- on its own, and carries a name no sibling shares (one may go + -- unnamed: the verb's default guard). + IF jsonb_typeof(v_value) = 'array' THEN + IF jsonb_array_length(v_value) = 0 THEN + RETURN false; + END IF; + v_guard_names := ARRAY[]::text[]; + FOR v_guard IN SELECT elem FROM jsonb_array_elements(v_value) AS g(elem) LOOP + IF jsonb_typeof(v_guard) != 'object' THEN + RETURN false; + END IF; + IF v_guard ? 'name' THEN + IF jsonb_typeof(v_guard -> 'name') != 'string' + OR (v_guard ->> 'name') !~ '^[a-z][a-z0-9_]*$' THEN + RETURN false; + END IF; + END IF; + IF COALESCE(v_guard ->> 'name', '') = ANY(v_guard_names) THEN + RETURN false; + END IF; + v_guard_names := v_guard_names || COALESCE(v_guard ->> 'name', ''); + IF NOT metaschema_private.is_valid_step_up(jsonb_build_object(v_key, v_guard - 'name')) THEN + RETURN false; + END IF; + END LOOP; + CONTINUE; + END IF; + IF jsonb_typeof(v_value) = 'boolean' THEN IF v_value = 'false'::jsonb THEN RETURN false; @@ -244,7 +293,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', 'conditions') THEN + IF v_obj_key NOT IN ('type', 'min_age', 'min_age_lookup', 'min_age_anchor', 'min_age_unless', 'allow_system', 'conditions', 'related_conditions') THEN RETURN false; END IF; END LOOP; @@ -310,12 +359,103 @@ BEGIN END; END IF; + v_min_age_anchor := v_value -> 'min_age_anchor'; + IF v_min_age_anchor IS NOT NULL THEN + -- an anchor says where the window is measured from, so it + -- needs a window, and INSERT has no window at all + IF v_key = 'INSERT' OR v_min_age IS NULL THEN + RETURN false; + END IF; + + -- the two are alternative sources for the same window + IF v_min_age_lookup IS NOT NULL THEN + RETURN false; + END IF; + + IF jsonb_typeof(v_min_age_anchor) != 'object' THEN + RETURN false; + 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 + 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; + + BEGIN + v_anchor_table_id := (v_min_age_anchor ->> 'table_id')::uuid; + EXCEPTION WHEN OTHERS THEN + RETURN false; + END; + END IF; + + v_min_age_unless := v_value -> 'min_age_unless'; + IF v_min_age_unless IS NOT NULL THEN + -- forfeiting a grace needs a grace to forfeit, and only a + -- static window can be forfeited + IF v_key = 'INSERT' OR v_min_age IS NULL + OR v_min_age_lookup IS NOT NULL OR v_min_age_anchor IS NOT NULL THEN + RETURN false; + END IF; + + IF NOT metaschema_private.is_valid_step_up_conditions(v_min_age_unless) THEN + RETURN false; + END IF; + END IF; + + v_allow_system := v_value -> 'allow_system'; + IF v_allow_system IS NOT NULL THEN + IF jsonb_typeof(v_allow_system) != '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 RETURN false; END IF; END IF; + + v_related_conditions := v_value -> 'related_conditions'; + IF v_related_conditions IS NOT NULL THEN + -- the body belongs to one mechanism: a lookup or anchored + -- window already occupies it + IF v_min_age_lookup IS NOT NULL OR v_min_age_anchor IS NOT NULL THEN + RETURN false; + END IF; + + IF jsonb_typeof(v_related_conditions) != 'object' THEN + RETURN false; + END IF; + + FOR v_related_key IN SELECT key FROM jsonb_each(v_related_conditions) LOOP + IF v_related_key NOT IN ('table_id', 'fk_field', 'conditions') THEN + RETURN false; + END IF; + END LOOP; + + IF jsonb_typeof(v_related_conditions -> 'table_id') IS DISTINCT FROM 'string' + OR jsonb_typeof(v_related_conditions -> 'fk_field') IS DISTINCT FROM 'string' THEN + RETURN false; + END IF; + + BEGIN + v_related_table_id := (v_related_conditions ->> 'table_id')::uuid; + EXCEPTION WHEN OTHERS THEN + RETURN false; + END; + + IF NOT metaschema_private.is_valid_step_up_conditions(v_related_conditions -> 'conditions') THEN + RETURN false; + END IF; + END IF; ELSE RETURN false; END IF; @@ -637,6 +777,7 @@ CREATE TABLE metaschema_public.index ( index_params jsonb, where_clause jsonb, is_unique boolean NOT NULL DEFAULT false, + nulls_not_distinct boolean NOT NULL DEFAULT false, options jsonb, op_classes text[], smart_tags jsonb, diff --git a/packages/object-store/__tests__/objects/immutable-objects.test.ts b/packages/object-store/__tests__/objects/immutable-objects.test.ts new file mode 100644 index 000000000..fca706a5c --- /dev/null +++ b/packages/object-store/__tests__/objects/immutable-objects.test.ts @@ -0,0 +1,72 @@ +jest.setTimeout(30000); + +import { getConnections, PgTestClient } from 'pgsql-test'; + +let pg: PgTestClient; +let teardown: () => Promise; + +const scope_id = 'd0f7ab73-356f-4aac-b9cb-d1a4274906d6'; + +const insertObject = async (): Promise => { + const [row] = await pg.any( + `INSERT INTO object_store_public.object (scope_id, data) + VALUES ($1, $2) + RETURNING id`, + [scope_id, { name: 'deletable' }] + ); + return row.id; +}; + +const countObject = async (id: string): Promise => { + const [row] = await pg.any( + `SELECT count(*)::int AS n FROM object_store_public.object o WHERE o.id = $1 AND o.scope_id = $2`, + [id, scope_id] + ); + return row.n; +}; + +describe('immutable objects', () => { + beforeAll(async () => { + ({ pg, teardown } = await getConnections()); + }); + + afterAll(async () => { + await teardown(); + }); + + beforeEach(async () => { + await pg.beforeEach(); + }); + + afterEach(async () => { + await pg.afterEach(); + }); + + it('deletes an unfrozen object', async () => { + const id = await insertObject(); + + await pg.any(`DELETE FROM object_store_public.object o WHERE o.id = $1 AND o.scope_id = $2`, [id, scope_id]); + + // The BEFORE DELETE trigger has to return OLD: returning NEW (NULL for a + // delete) cancelled the delete silently and left the row in place. + expect(await countObject(id)).toBe(0); + }); + + it('refuses to delete a frozen object', async () => { + const id = await insertObject(); + await pg.any(`UPDATE object_store_public.object o SET frzn = true WHERE o.id = $1 AND o.scope_id = $2`, [ + id, + scope_id + ]); + + // The raise aborts the surrounding test transaction, so the delete runs + // inside a savepoint to keep the row count assertion runnable. + await pg.any('SAVEPOINT frozen_delete'); + await expect( + pg.any(`DELETE FROM object_store_public.object o WHERE o.id = $1 AND o.scope_id = $2`, [id, scope_id]) + ).rejects.toThrow(/immutable record/); + await pg.any('ROLLBACK TO SAVEPOINT frozen_delete'); + + expect(await countObject(id)).toBe(1); + }); +}); diff --git a/packages/utils/deploy/schemas/utils/procedures/default_self_reference.sql b/packages/utils/deploy/schemas/utils/procedures/default_self_reference.sql new file mode 100644 index 000000000..83af9592b --- /dev/null +++ b/packages/utils/deploy/schemas/utils/procedures/default_self_reference.sql @@ -0,0 +1,27 @@ +-- Deploy schemas/utils/procedures/default_self_reference to pg + +-- requires: schemas/utils/schema + +BEGIN; + +-- BEFORE INSERT trigger function that defaults a self-referencing column to +-- the row's own id when the insert leaves it NULL. Lets a tree table keep the +-- column NOT NULL while callers only supply it for non-root rows. +-- +-- TG_ARGV[0] : name of the column to default to NEW.id +CREATE FUNCTION utils.default_self_reference() + RETURNS TRIGGER +AS $$ +DECLARE + column_name text := TG_ARGV[0]; +BEGIN + IF (to_jsonb(NEW) ->> column_name) IS NULL THEN + NEW := jsonb_populate_record(NEW, jsonb_build_object(column_name, NEW.id)); + END IF; + + RETURN NEW; +END; +$$ +LANGUAGE 'plpgsql'; + +COMMIT; diff --git a/packages/utils/pgpm.plan b/packages/utils/pgpm.plan index 5ff949755..4eeda54f6 100644 --- a/packages/utils/pgpm.plan +++ b/packages/utils/pgpm.plan @@ -7,3 +7,4 @@ schemas/utils/procedures/mask_pad [schemas/utils/schema] 2017-08-11T08:11:51Z sk schemas/utils/procedures/throw [schemas/utils/schema] 2017-08-11T08:11:51Z skitch # add schemas/utils/procedures/throw schemas/utils/procedures/ensure_singleton [schemas/utils/schema] 2021-04-25T11:50:15Z Dan Lynch # add schemas/utils/procedures/ensure_singleton schemas/utils/procedures/enforce_identity_providers_quota [schemas/utils/schema] 2026-04-19T00:00:00Z devin # add schemas/utils/procedures/enforce_identity_providers_quota +schemas/utils/procedures/default_self_reference [schemas/utils/schema] 2026-09-04T07:24:31Z devin # add schemas/utils/procedures/default_self_reference diff --git a/packages/utils/revert/schemas/utils/procedures/default_self_reference.sql b/packages/utils/revert/schemas/utils/procedures/default_self_reference.sql new file mode 100644 index 000000000..ed29b4314 --- /dev/null +++ b/packages/utils/revert/schemas/utils/procedures/default_self_reference.sql @@ -0,0 +1,7 @@ +-- Revert schemas/utils/procedures/default_self_reference from pg + +BEGIN; + +DROP FUNCTION utils.default_self_reference(); + +COMMIT; diff --git a/packages/utils/sql/pgpm-utils--0.44.0.bundle.tar.gz b/packages/utils/sql/pgpm-utils--0.44.0.bundle.tar.gz index 5403bed97..fcf9ae96b 100644 Binary files a/packages/utils/sql/pgpm-utils--0.44.0.bundle.tar.gz and b/packages/utils/sql/pgpm-utils--0.44.0.bundle.tar.gz differ diff --git a/packages/utils/sql/pgpm-utils--0.44.0.sql b/packages/utils/sql/pgpm-utils--0.44.0.sql index 33b71f231..6b2a5b246 100644 --- a/packages/utils/sql/pgpm-utils--0.44.0.sql +++ b/packages/utils/sql/pgpm-utils--0.44.0.sql @@ -102,4 +102,16 @@ BEGIN RETURN NEW; END; +$EOFCODE$ LANGUAGE plpgsql; + +CREATE FUNCTION utils.default_self_reference() RETURNS trigger AS $EOFCODE$ +DECLARE + column_name text := TG_ARGV[0]; +BEGIN + IF (to_jsonb(NEW) ->> column_name) IS NULL THEN + NEW := jsonb_populate_record(NEW, jsonb_build_object(column_name, NEW.id)); + END IF; + + RETURN NEW; +END; $EOFCODE$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/packages/utils/verify/schemas/utils/procedures/default_self_reference.sql b/packages/utils/verify/schemas/utils/procedures/default_self_reference.sql new file mode 100644 index 000000000..84dfc241b --- /dev/null +++ b/packages/utils/verify/schemas/utils/procedures/default_self_reference.sql @@ -0,0 +1,7 @@ +-- Verify schemas/utils/procedures/default_self_reference on pg + +BEGIN; + +SELECT assert_function('utils.default_self_reference()'::regprocedure); + +ROLLBACK;