pilotswarm-sdk 0.5.44 → 0.5.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/api/src/api-client.js +6 -1
  2. package/api/src/http-api-transport.js +47 -0
  3. package/api/src/protocol.js +14 -0
  4. package/dist/agent-manager-tools.d.ts +2 -0
  5. package/dist/agent-manager-tools.d.ts.map +1 -1
  6. package/dist/agent-manager-tools.js +4 -1
  7. package/dist/agent-manager-tools.js.map +1 -1
  8. package/dist/agent-package-format.d.ts +6 -0
  9. package/dist/agent-package-format.d.ts.map +1 -1
  10. package/dist/agent-package-format.js +33 -0
  11. package/dist/agent-package-format.js.map +1 -1
  12. package/dist/agent-package-service.d.ts.map +1 -1
  13. package/dist/agent-package-service.js +7 -2
  14. package/dist/agent-package-service.js.map +1 -1
  15. package/dist/artifact-tools.js +1 -1
  16. package/dist/artifact-tools.js.map +1 -1
  17. package/dist/canvas-app-catalog.d.ts +61 -0
  18. package/dist/canvas-app-catalog.d.ts.map +1 -0
  19. package/dist/canvas-app-catalog.js +146 -0
  20. package/dist/canvas-app-catalog.js.map +1 -0
  21. package/dist/canvas-app-manifest.d.ts +94 -2
  22. package/dist/canvas-app-manifest.d.ts.map +1 -1
  23. package/dist/canvas-app-manifest.js +140 -1
  24. package/dist/canvas-app-manifest.js.map +1 -1
  25. package/dist/canvas-kv.d.ts +176 -0
  26. package/dist/canvas-kv.d.ts.map +1 -0
  27. package/dist/canvas-kv.js +281 -0
  28. package/dist/canvas-kv.js.map +1 -0
  29. package/dist/canvas-support.d.ts +40 -0
  30. package/dist/canvas-support.d.ts.map +1 -0
  31. package/dist/canvas-support.js +79 -0
  32. package/dist/canvas-support.js.map +1 -0
  33. package/dist/cms-migrations.d.ts.map +1 -1
  34. package/dist/cms-migrations.js +822 -0
  35. package/dist/cms-migrations.js.map +1 -1
  36. package/dist/cms.d.ts +101 -0
  37. package/dist/cms.d.ts.map +1 -1
  38. package/dist/cms.js +80 -1
  39. package/dist/cms.js.map +1 -1
  40. package/dist/facts-migrations.d.ts.map +1 -1
  41. package/dist/facts-migrations.js +30 -0
  42. package/dist/facts-migrations.js.map +1 -1
  43. package/dist/index.d.ts +10 -2
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +5 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/inspect-tools.d.ts +6 -0
  48. package/dist/inspect-tools.d.ts.map +1 -1
  49. package/dist/inspect-tools.js +1 -0
  50. package/dist/inspect-tools.js.map +1 -1
  51. package/dist/managed-session.d.ts +7 -0
  52. package/dist/managed-session.d.ts.map +1 -1
  53. package/dist/managed-session.js +193 -64
  54. package/dist/managed-session.js.map +1 -1
  55. package/dist/management-client.d.ts +37 -1
  56. package/dist/management-client.d.ts.map +1 -1
  57. package/dist/management-client.js +80 -3
  58. package/dist/management-client.js.map +1 -1
  59. package/dist/mcp-loader.d.ts +68 -0
  60. package/dist/mcp-loader.d.ts.map +1 -1
  61. package/dist/mcp-loader.js +79 -0
  62. package/dist/mcp-loader.js.map +1 -1
  63. package/dist/session-manager.d.ts +20 -0
  64. package/dist/session-manager.d.ts.map +1 -1
  65. package/dist/session-manager.js +13 -1
  66. package/dist/session-manager.js.map +1 -1
  67. package/dist/session-proxy.d.ts +1 -9
  68. package/dist/session-proxy.d.ts.map +1 -1
  69. package/dist/session-proxy.js +95 -77
  70. package/dist/session-proxy.js.map +1 -1
  71. package/dist/web/generated-op-methods.d.ts +53 -0
  72. package/dist/web/generated-op-methods.d.ts.map +1 -1
  73. package/dist/web/generated-op-methods.js +12 -0
  74. package/dist/web/generated-op-methods.js.map +1 -1
  75. package/dist/web/web-management-client.d.ts +18 -0
  76. package/dist/web/web-management-client.d.ts.map +1 -1
  77. package/dist/web/web-management-client.js +27 -0
  78. package/dist/web/web-management-client.js.map +1 -1
  79. package/dist/worker.d.ts +27 -0
  80. package/dist/worker.d.ts.map +1 -1
  81. package/dist/worker.js +145 -13
  82. package/dist/worker.js.map +1 -1
  83. package/package.json +3 -3
  84. package/plugins/system/agents/default.agent.md +30 -126
  85. package/plugins/system/skills/canvas-apps/SKILL.md +224 -111
  86. package/plugins/system/skills/html-visuals/SKILL.md +2 -2
@@ -322,6 +322,16 @@ export function CMS_MIGRATIONS(schema) {
322
322
  name: "provider_ledger_base",
323
323
  sql: migration_0062_provider_ledger_base(schema),
324
324
  },
325
+ {
326
+ version: "0063",
327
+ name: "agent_package_editors",
328
+ sql: migration_0063_agent_package_editors(schema),
329
+ },
330
+ {
331
+ version: "0064",
332
+ name: "canvas_kv",
333
+ sql: migration_0064_canvas_kv(schema),
334
+ },
325
335
  ];
326
336
  }
327
337
  // ─── Migration 0044: ms-space keyset for session paging ──────────
@@ -12747,4 +12757,816 @@ END;
12747
12757
  $$ LANGUAGE plpgsql VOLATILE;
12748
12758
  `;
12749
12759
  }
12760
+ // ─── Migration 0063: package editors, and everyone in the picker ─────
12761
+ //
12762
+ // Two things, one migration, because they ship together.
12763
+ //
12764
+ // EDITORS. A shared package's owner can grant named users WRITE access:
12765
+ // publish a new version, republish into it, pin, enable/disable. Never
12766
+ // scope changes, delete, or the editor list itself — those stay with the
12767
+ // owner (or an admin). The grant lives on the SHARED row only, and it is
12768
+ // deleted when that row is demoted to user scope: unsharing revokes. A
12769
+ // personal copy is a different row and never inherits a grant.
12770
+ //
12771
+ // The table keys on users.user_id like session_shares does, and the grant
12772
+ // path creates the grantee row create-only (0033's rule): a person who has
12773
+ // never signed in can be granted, and the 0032 email adoption folds them
12774
+ // into the real principal on first login.
12775
+ //
12776
+ // THE PICKER. cms_list_users hid every row whose display_name was NULL —
12777
+ // which is every Entra sign-in whose access token carries no `name` claim.
12778
+ // The user exists (0042 registers them at login) and is invisible to every
12779
+ // share dialog. Widen to "has a name OR an email"; a row with neither is
12780
+ // still the raw-id-grant placeholder the original filter meant to hide.
12781
+ //
12782
+ // Every function below that already existed is CREATE OR REPLACE'd here in
12783
+ // full: editing an applied migration in place is a silent no-op.
12784
+ function migration_0063_agent_package_editors(schema) {
12785
+ const s = `"${schema}"`;
12786
+ return `
12787
+ CREATE TABLE IF NOT EXISTS ${s}.agent_package_editors (
12788
+ package_id TEXT NOT NULL REFERENCES ${s}.agent_packages(package_id) ON DELETE CASCADE,
12789
+ user_id BIGINT NOT NULL REFERENCES ${s}.users(user_id),
12790
+ granted_by BIGINT REFERENCES ${s}.users(user_id),
12791
+ granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
12792
+ PRIMARY KEY (package_id, user_id)
12793
+ );
12794
+ CREATE INDEX IF NOT EXISTS agent_package_editors_user_idx ON ${s}.agent_package_editors (user_id);
12795
+
12796
+ -- Is this principal an editor of this package row?
12797
+ CREATE OR REPLACE FUNCTION ${s}.cms_agent_package_is_editor(
12798
+ p_package_id TEXT, p_provider TEXT, p_subject TEXT
12799
+ ) RETURNS BOOLEAN AS $$
12800
+ SELECT EXISTS (
12801
+ SELECT 1
12802
+ FROM ${s}.agent_package_editors e
12803
+ JOIN ${s}.users u ON u.user_id = e.user_id
12804
+ WHERE e.package_id = p_package_id
12805
+ AND u.provider = NULLIF(BTRIM(p_provider), '')
12806
+ AND u.subject = NULLIF(BTRIM(p_subject), '')
12807
+ );
12808
+ $$ LANGUAGE sql STABLE;
12809
+
12810
+ -- ── Authz gains an editor mode ───────────────────────────────────
12811
+ --
12812
+ -- One new trailing argument with a default, so the existing seven-argument
12813
+ -- callers (delete, and anything else that must stay owner-only) keep
12814
+ -- resolving to this function unchanged. The seven-argument overload is
12815
+ -- dropped first: leaving it would make every seven-argument call ambiguous.
12816
+ DROP FUNCTION IF EXISTS ${s}.cms_agent_package_authz(TEXT, TEXT, TEXT, BOOLEAN, TEXT, TEXT, TEXT);
12817
+
12818
+ CREATE OR REPLACE FUNCTION ${s}.cms_agent_package_authz(
12819
+ p_name TEXT, p_actor_provider TEXT, p_actor_subject TEXT, p_is_admin BOOLEAN,
12820
+ p_sel_scope TEXT, p_sel_owner_provider TEXT, p_sel_owner_subject TEXT,
12821
+ p_allow_editor BOOLEAN DEFAULT FALSE
12822
+ ) RETURNS ${s}.agent_packages AS $$
12823
+ DECLARE
12824
+ v_pkg ${s}.agent_packages;
12825
+ v_id TEXT;
12826
+ v_count INT;
12827
+ v_is_owner BOOLEAN;
12828
+ BEGIN
12829
+ v_id := ${s}.cms_resolve_agent_package_id(
12830
+ p_name, p_actor_provider, p_actor_subject,
12831
+ p_sel_scope, p_sel_owner_provider, p_sel_owner_subject, FALSE);
12832
+
12833
+ -- Editors do not own a copy, so "my copy, then shared" finds nothing for
12834
+ -- them when they omit the selector. Fall through to the shared copy,
12835
+ -- which is the only row an editor grant can exist on.
12836
+ IF v_id IS NULL AND p_allow_editor AND p_sel_scope IS NULL THEN
12837
+ SELECT p.package_id INTO v_id FROM ${s}.agent_packages p
12838
+ WHERE p.name = p_name AND p.scope = 'shared';
12839
+ END IF;
12840
+
12841
+ -- Admin fallback (0043): NULL-owner rows and other users' private rows
12842
+ -- have no owner triple to select by; refuse ambiguity rather than guess.
12843
+ IF v_id IS NULL AND p_is_admin AND p_sel_scope IS NULL THEN
12844
+ SELECT count(*) INTO v_count FROM ${s}.agent_packages p WHERE p.name = p_name;
12845
+ IF v_count > 1 THEN
12846
+ RAISE EXCEPTION 'AGENT_PACKAGE_AMBIGUOUS: % copies of "%" exist; name an owner or scope to pick one', v_count, p_name;
12847
+ END IF;
12848
+ SELECT p.package_id INTO v_id FROM ${s}.agent_packages p WHERE p.name = p_name;
12849
+ END IF;
12850
+
12851
+ IF v_id IS NULL THEN
12852
+ RAISE EXCEPTION 'AGENT_PACKAGE_NOT_FOUND: package "%" does not exist', p_name;
12853
+ END IF;
12854
+
12855
+ SELECT * INTO v_pkg FROM ${s}.agent_packages WHERE package_id = v_id FOR UPDATE;
12856
+ IF NOT FOUND THEN
12857
+ RAISE EXCEPTION 'AGENT_PACKAGE_NOT_FOUND: package "%" does not exist', p_name;
12858
+ END IF;
12859
+
12860
+ v_is_owner := v_pkg.owner_provider IS NOT NULL
12861
+ AND v_pkg.owner_provider IS NOT DISTINCT FROM NULLIF(BTRIM(p_actor_provider), '')
12862
+ AND v_pkg.owner_subject IS NOT DISTINCT FROM NULLIF(BTRIM(p_actor_subject), '');
12863
+
12864
+ IF p_is_admin OR v_is_owner THEN
12865
+ RETURN v_pkg;
12866
+ END IF;
12867
+ IF p_allow_editor AND ${s}.cms_agent_package_is_editor(v_pkg.package_id, p_actor_provider, p_actor_subject) THEN
12868
+ RETURN v_pkg;
12869
+ END IF;
12870
+ IF p_allow_editor THEN
12871
+ RAISE EXCEPTION 'AGENT_PACKAGE_FORBIDDEN: only the package creator, an editor, or an admin can modify "%"', p_name;
12872
+ END IF;
12873
+ RAISE EXCEPTION 'AGENT_PACKAGE_FORBIDDEN: only the package creator or an admin can modify "%"', p_name;
12874
+ END;
12875
+ $$ LANGUAGE plpgsql;
12876
+
12877
+ -- ── Editor-level mutations: enable/disable and pin ───────────────
12878
+ CREATE OR REPLACE FUNCTION ${s}.cms_set_agent_package_enabled(
12879
+ p_name TEXT, p_enabled BOOLEAN, p_actor_provider TEXT, p_actor_subject TEXT, p_is_admin BOOLEAN,
12880
+ p_sel_scope TEXT, p_sel_owner_provider TEXT, p_sel_owner_subject TEXT
12881
+ ) RETURNS VOID AS $$
12882
+ DECLARE
12883
+ v_pkg ${s}.agent_packages;
12884
+ BEGIN
12885
+ v_pkg := ${s}.cms_agent_package_authz(
12886
+ p_name, p_actor_provider, p_actor_subject, p_is_admin,
12887
+ p_sel_scope, p_sel_owner_provider, p_sel_owner_subject, TRUE);
12888
+ UPDATE ${s}.agent_packages SET enabled = p_enabled WHERE package_id = v_pkg.package_id;
12889
+ PERFORM ${s}.cms_agent_registry_bump();
12890
+ END;
12891
+ $$ LANGUAGE plpgsql;
12892
+
12893
+ CREATE OR REPLACE FUNCTION ${s}.cms_pin_agent_package_version(
12894
+ p_name TEXT, p_semver TEXT, p_actor_provider TEXT, p_actor_subject TEXT, p_is_admin BOOLEAN,
12895
+ p_sel_scope TEXT, p_sel_owner_provider TEXT, p_sel_owner_subject TEXT
12896
+ ) RETURNS VOID AS $$
12897
+ DECLARE
12898
+ v_pkg ${s}.agent_packages;
12899
+ v_version_id TEXT;
12900
+ BEGIN
12901
+ v_pkg := ${s}.cms_agent_package_authz(
12902
+ p_name, p_actor_provider, p_actor_subject, p_is_admin,
12903
+ p_sel_scope, p_sel_owner_provider, p_sel_owner_subject, TRUE);
12904
+ SELECT version_id INTO v_version_id FROM ${s}.agent_package_versions
12905
+ WHERE package_id = v_pkg.package_id AND semver = p_semver;
12906
+ IF NOT FOUND THEN
12907
+ RAISE EXCEPTION 'AGENT_PACKAGE_VERSION_NOT_FOUND: %@% is not a published version', p_name, p_semver;
12908
+ END IF;
12909
+ UPDATE ${s}.agent_packages SET active_version_id = v_version_id WHERE package_id = v_pkg.package_id;
12910
+ PERFORM ${s}.cms_agent_registry_bump();
12911
+ END;
12912
+ $$ LANGUAGE plpgsql;
12913
+
12914
+ -- ── Scope change stays owner-only; demotion revokes every editor ─
12915
+ CREATE OR REPLACE FUNCTION ${s}.cms_set_agent_package_scope(
12916
+ p_name TEXT, p_scope TEXT, p_actor_provider TEXT, p_actor_subject TEXT, p_is_admin BOOLEAN,
12917
+ p_sel_scope TEXT, p_sel_owner_provider TEXT, p_sel_owner_subject TEXT
12918
+ ) RETURNS VOID AS $$
12919
+ DECLARE
12920
+ v_pkg ${s}.agent_packages;
12921
+ v_clash TEXT;
12922
+ BEGIN
12923
+ IF p_scope NOT IN ('shared', 'user') THEN
12924
+ RAISE EXCEPTION 'AGENT_PACKAGE_BAD_SCOPE: scope must be shared or user, got "%"', p_scope;
12925
+ END IF;
12926
+ v_pkg := ${s}.cms_agent_package_authz(
12927
+ p_name, p_actor_provider, p_actor_subject, p_is_admin,
12928
+ p_sel_scope, p_sel_owner_provider, p_sel_owner_subject);
12929
+
12930
+ IF p_scope = 'shared' AND v_pkg.scope <> 'shared' THEN
12931
+ SELECT p.package_id INTO v_clash FROM ${s}.agent_packages p
12932
+ WHERE p.name = p_name AND p.scope = 'shared';
12933
+ IF v_clash IS NOT NULL THEN
12934
+ RAISE EXCEPTION 'AGENT_PACKAGE_NAME_TAKEN: a shared package named "%" already exists; rename before promoting', p_name;
12935
+ END IF;
12936
+ END IF;
12937
+
12938
+ IF p_scope = 'user' AND v_pkg.scope <> 'user' THEN
12939
+ IF v_pkg.owner_provider IS NULL OR v_pkg.owner_subject IS NULL THEN
12940
+ RAISE EXCEPTION 'AGENT_PACKAGE_NO_OWNER: "%" has no owner identity to demote to', p_name;
12941
+ END IF;
12942
+ SELECT p.package_id INTO v_clash FROM ${s}.agent_packages p
12943
+ WHERE p.name = p_name AND p.scope = 'user'
12944
+ AND p.owner_provider = v_pkg.owner_provider
12945
+ AND p.owner_subject = v_pkg.owner_subject;
12946
+ IF v_clash IS NOT NULL THEN
12947
+ RAISE EXCEPTION 'AGENT_PACKAGE_NAME_TAKEN: that owner already has a user-scope package named "%"', p_name;
12948
+ END IF;
12949
+ -- Unsharing revokes. Same statement, same transaction: a failed
12950
+ -- demotion above leaves the grants exactly as they were.
12951
+ DELETE FROM ${s}.agent_package_editors WHERE package_id = v_pkg.package_id;
12952
+ END IF;
12953
+
12954
+ UPDATE ${s}.agent_packages SET scope = p_scope WHERE package_id = v_pkg.package_id;
12955
+ PERFORM ${s}.cms_agent_registry_bump();
12956
+ END;
12957
+ $$ LANGUAGE plpgsql;
12958
+
12959
+ -- ── Publish: an editor may add versions to the shared package ────
12960
+ --
12961
+ -- Body is 0043's, with one change at the existing-package gate. The row
12962
+ -- keeps its owner: an editor's publish never reassigns the package.
12963
+ CREATE OR REPLACE FUNCTION ${s}.cms_publish_agent_package(
12964
+ p_package_id TEXT, p_version_id TEXT, p_name TEXT, p_scope TEXT,
12965
+ p_owner_provider TEXT, p_owner_subject TEXT, p_source_id TEXT,
12966
+ p_semver TEXT, p_sha256 TEXT, p_size_bytes BIGINT, p_artifact_filename TEXT,
12967
+ p_commit_sha TEXT, p_manifest JSONB, p_created_by TEXT, p_is_admin BOOLEAN
12968
+ ) RETURNS TABLE(status TEXT, package_id TEXT, version_id TEXT) AS $$
12969
+ DECLARE
12970
+ v_pkg RECORD;
12971
+ v_ver RECORD;
12972
+ v_owner_provider TEXT := NULLIF(BTRIM(p_owner_provider), '');
12973
+ v_owner_subject TEXT := NULLIF(BTRIM(p_owner_subject), '');
12974
+ v_is_owner BOOLEAN;
12975
+ BEGIN
12976
+ IF p_scope NOT IN ('shared', 'user') THEN
12977
+ RAISE EXCEPTION 'AGENT_PACKAGE_BAD_SCOPE: scope must be shared or user, got "%"', p_scope;
12978
+ END IF;
12979
+
12980
+ IF LOWER(BTRIM(p_name)) LIKE '\\_\\_%' THEN
12981
+ RAISE EXCEPTION 'AGENT_PACKAGE_RESERVED_NAME: "%" uses the reserved "__" prefix', p_name;
12982
+ END IF;
12983
+
12984
+ IF NOT p_is_admin AND (v_owner_provider IS NULL OR v_owner_subject IS NULL) THEN
12985
+ RAISE EXCEPTION 'AGENT_PACKAGE_FORBIDDEN: publishing without an owner identity requires the admin role';
12986
+ END IF;
12987
+
12988
+ <<retry>>
12989
+ LOOP
12990
+ SELECT * INTO v_pkg FROM ${s}.agent_packages p
12991
+ WHERE p.name = p_name
12992
+ AND p.scope = p_scope
12993
+ AND (p_scope = 'shared'
12994
+ OR (p.owner_provider IS NOT DISTINCT FROM v_owner_provider
12995
+ AND p.owner_subject IS NOT DISTINCT FROM v_owner_subject))
12996
+ FOR UPDATE;
12997
+ IF NOT FOUND THEN
12998
+ BEGIN
12999
+ INSERT INTO ${s}.agent_packages
13000
+ (package_id, source_id, name, scope, owner_provider, owner_subject, created_by)
13001
+ VALUES (p_package_id, p_source_id, p_name, p_scope,
13002
+ v_owner_provider, v_owner_subject, p_created_by);
13003
+ EXCEPTION WHEN unique_violation THEN
13004
+ CONTINUE retry;
13005
+ END;
13006
+ INSERT INTO ${s}.agent_package_versions
13007
+ (version_id, package_id, semver, sha256, size_bytes, artifact_filename, commit_sha, manifest, created_by)
13008
+ VALUES (p_version_id, p_package_id, p_semver, p_sha256, p_size_bytes,
13009
+ p_artifact_filename, p_commit_sha, p_manifest, p_created_by);
13010
+ UPDATE ${s}.agent_packages SET active_version_id = p_version_id WHERE ${s}.agent_packages.package_id = p_package_id;
13011
+ PERFORM ${s}.cms_agent_registry_bump();
13012
+ RETURN QUERY SELECT 'published'::TEXT, p_package_id, p_version_id;
13013
+ RETURN;
13014
+ END IF;
13015
+ EXIT retry;
13016
+ END LOOP;
13017
+
13018
+ v_is_owner := v_pkg.owner_provider IS NOT NULL
13019
+ AND v_pkg.owner_provider IS NOT DISTINCT FROM v_owner_provider
13020
+ AND v_pkg.owner_subject IS NOT DISTINCT FROM v_owner_subject;
13021
+ IF NOT p_is_admin AND NOT v_is_owner
13022
+ AND NOT ${s}.cms_agent_package_is_editor(v_pkg.package_id, v_owner_provider, v_owner_subject) THEN
13023
+ RAISE EXCEPTION 'AGENT_PACKAGE_FORBIDDEN: only the package creator, an editor, or an admin can publish new versions of "%"', p_name;
13024
+ END IF;
13025
+
13026
+ SELECT * INTO v_ver FROM ${s}.agent_package_versions v
13027
+ WHERE v.package_id = v_pkg.package_id AND v.semver = p_semver;
13028
+ IF FOUND THEN
13029
+ IF v_ver.sha256 = p_sha256 THEN
13030
+ RETURN QUERY SELECT 'noop'::TEXT, v_pkg.package_id, v_ver.version_id;
13031
+ RETURN;
13032
+ END IF;
13033
+ RAISE EXCEPTION 'AGENT_PACKAGE_SEMVER_CONFLICT: %@% is already published with different content — published versions are immutable, bump the version', p_name, p_semver;
13034
+ END IF;
13035
+
13036
+ INSERT INTO ${s}.agent_package_versions
13037
+ (version_id, package_id, semver, sha256, size_bytes, artifact_filename, commit_sha, manifest, created_by)
13038
+ VALUES (p_version_id, v_pkg.package_id, p_semver, p_sha256, p_size_bytes,
13039
+ p_artifact_filename, p_commit_sha, p_manifest, p_created_by);
13040
+ UPDATE ${s}.agent_packages
13041
+ SET active_version_id = p_version_id,
13042
+ source_id = COALESCE(p_source_id, ${s}.agent_packages.source_id)
13043
+ WHERE ${s}.agent_packages.package_id = v_pkg.package_id;
13044
+ PERFORM ${s}.cms_agent_registry_bump();
13045
+ RETURN QUERY SELECT 'published'::TEXT, v_pkg.package_id, p_version_id;
13046
+ END;
13047
+ $$ LANGUAGE plpgsql;
13048
+
13049
+ -- ── Grant / revoke / list ────────────────────────────────────────
13050
+ --
13051
+ -- All three pin the SHARED copy: editors only exist there. Grant and revoke
13052
+ -- are owner-or-admin (authz without editor mode). No registry epoch bump:
13053
+ -- who may edit changes nothing a worker installs.
13054
+ -- The shared copy of a name, or a legible refusal. Resolving with the
13055
+ -- selector pinned to 'shared' returns nothing when only a private copy
13056
+ -- exists, and "does not exist" is the wrong answer to give the person who
13057
+ -- owns that copy. So: no shared copy AND the actor can see a copy → NOT_SHARED;
13058
+ -- no copy the actor can see → NOT_FOUND.
13059
+ CREATE OR REPLACE FUNCTION ${s}.cms_agent_package_require_shared(
13060
+ p_name TEXT, p_actor_provider TEXT, p_actor_subject TEXT, p_is_admin BOOLEAN
13061
+ ) RETURNS VOID AS $$
13062
+ BEGIN
13063
+ IF EXISTS (SELECT 1 FROM ${s}.agent_packages p WHERE p.name = p_name AND p.scope = 'shared') THEN
13064
+ RETURN;
13065
+ END IF;
13066
+ IF EXISTS (
13067
+ SELECT 1 FROM ${s}.agent_packages p
13068
+ WHERE p.name = p_name
13069
+ AND (p_is_admin
13070
+ OR (p.owner_provider = NULLIF(BTRIM(p_actor_provider), '')
13071
+ AND p.owner_subject = NULLIF(BTRIM(p_actor_subject), '')))
13072
+ ) THEN
13073
+ RAISE EXCEPTION 'AGENT_PACKAGE_NOT_SHARED: "%" is not shared; editors exist only on a shared package — promote it first', p_name;
13074
+ END IF;
13075
+ RAISE EXCEPTION 'AGENT_PACKAGE_NOT_FOUND: package "%" does not exist', p_name;
13076
+ END;
13077
+ $$ LANGUAGE plpgsql;
13078
+
13079
+ CREATE OR REPLACE FUNCTION ${s}.cms_grant_agent_package_editor(
13080
+ p_name TEXT, p_provider TEXT, p_subject TEXT,
13081
+ p_actor_provider TEXT, p_actor_subject TEXT, p_is_admin BOOLEAN
13082
+ ) RETURNS VOID AS $$
13083
+ DECLARE
13084
+ v_pkg ${s}.agent_packages;
13085
+ v_provider TEXT := NULLIF(BTRIM(p_provider), '');
13086
+ v_subject TEXT := NULLIF(BTRIM(p_subject), '');
13087
+ v_user_id BIGINT;
13088
+ v_granted_by BIGINT;
13089
+ BEGIN
13090
+ IF v_provider IS NULL OR v_subject IS NULL THEN
13091
+ RAISE EXCEPTION 'Grantee provider and subject are required';
13092
+ END IF;
13093
+ PERFORM ${s}.cms_agent_package_require_shared(p_name, p_actor_provider, p_actor_subject, p_is_admin);
13094
+ v_pkg := ${s}.cms_agent_package_authz(
13095
+ p_name, p_actor_provider, p_actor_subject, p_is_admin, 'shared', NULL, NULL);
13096
+ IF v_pkg.owner_provider = v_provider AND v_pkg.owner_subject = v_subject THEN
13097
+ RAISE EXCEPTION 'AGENT_PACKAGE_EDITOR_IS_OWNER: the owner of "%" already has full access', p_name;
13098
+ END IF;
13099
+
13100
+ -- Create-only (0033): a grant never rewrites a sighted user's identity.
13101
+ INSERT INTO ${s}.users (provider, subject)
13102
+ VALUES (v_provider, v_subject)
13103
+ ON CONFLICT (provider, subject) DO NOTHING;
13104
+ SELECT u.user_id INTO v_user_id FROM ${s}.users u
13105
+ WHERE u.provider = v_provider AND u.subject = v_subject;
13106
+
13107
+ SELECT u.user_id INTO v_granted_by FROM ${s}.users u
13108
+ WHERE u.provider = NULLIF(BTRIM(p_actor_provider), '')
13109
+ AND u.subject = NULLIF(BTRIM(p_actor_subject), '');
13110
+
13111
+ INSERT INTO ${s}.agent_package_editors (package_id, user_id, granted_by)
13112
+ VALUES (v_pkg.package_id, v_user_id, v_granted_by)
13113
+ ON CONFLICT (package_id, user_id) DO NOTHING;
13114
+ END;
13115
+ $$ LANGUAGE plpgsql;
13116
+
13117
+ CREATE OR REPLACE FUNCTION ${s}.cms_revoke_agent_package_editor(
13118
+ p_name TEXT, p_provider TEXT, p_subject TEXT,
13119
+ p_actor_provider TEXT, p_actor_subject TEXT, p_is_admin BOOLEAN
13120
+ ) RETURNS VOID AS $$
13121
+ DECLARE
13122
+ v_pkg ${s}.agent_packages;
13123
+ BEGIN
13124
+ PERFORM ${s}.cms_agent_package_require_shared(p_name, p_actor_provider, p_actor_subject, p_is_admin);
13125
+ v_pkg := ${s}.cms_agent_package_authz(
13126
+ p_name, p_actor_provider, p_actor_subject, p_is_admin, 'shared', NULL, NULL);
13127
+ DELETE FROM ${s}.agent_package_editors e
13128
+ USING ${s}.users u
13129
+ WHERE e.package_id = v_pkg.package_id
13130
+ AND u.user_id = e.user_id
13131
+ AND u.provider = NULLIF(BTRIM(p_provider), '')
13132
+ AND u.subject = NULLIF(BTRIM(p_subject), '');
13133
+ END;
13134
+ $$ LANGUAGE plpgsql;
13135
+
13136
+ -- Anyone who can see the shared package can see its editors: this is a
13137
+ -- trusted system, and who may edit a shared agent is not a secret.
13138
+ CREATE OR REPLACE FUNCTION ${s}.cms_list_agent_package_editors(
13139
+ p_name TEXT
13140
+ ) RETURNS TABLE(
13141
+ provider TEXT, subject TEXT, email TEXT, display_name TEXT,
13142
+ granted_at TIMESTAMPTZ, granted_by_display TEXT
13143
+ ) AS $$
13144
+ BEGIN
13145
+ -- "No editors" and "no such shared package" must not look the same.
13146
+ IF NOT EXISTS (SELECT 1 FROM ${s}.agent_packages p WHERE p.name = p_name AND p.scope = 'shared') THEN
13147
+ RAISE EXCEPTION 'AGENT_PACKAGE_NOT_FOUND: there is no shared package named "%"', p_name;
13148
+ END IF;
13149
+ RETURN QUERY
13150
+ SELECT u.provider, u.subject, u.email, u.display_name,
13151
+ e.granted_at,
13152
+ COALESCE(g.display_name, g.email, g.subject) AS granted_by_display
13153
+ FROM ${s}.agent_packages p
13154
+ JOIN ${s}.agent_package_editors e ON e.package_id = p.package_id
13155
+ JOIN ${s}.users u ON u.user_id = e.user_id
13156
+ LEFT JOIN ${s}.users g ON g.user_id = e.granted_by
13157
+ WHERE p.name = p_name AND p.scope = 'shared'
13158
+ ORDER BY e.granted_at, u.user_id;
13159
+ END;
13160
+ $$ LANGUAGE plpgsql STABLE;
13161
+
13162
+ -- ── Reads carry can_edit ─────────────────────────────────────────
13163
+ DROP FUNCTION IF EXISTS ${s}.cms_list_agent_packages(TEXT, TEXT, BOOLEAN);
13164
+ DROP FUNCTION IF EXISTS ${s}.cms_get_agent_package(TEXT, TEXT, TEXT, BOOLEAN, TEXT, TEXT, TEXT);
13165
+
13166
+ CREATE OR REPLACE FUNCTION ${s}.cms_list_agent_packages(
13167
+ p_viewer_provider TEXT, p_viewer_subject TEXT, p_is_admin BOOLEAN
13168
+ ) RETURNS TABLE(
13169
+ package_id TEXT, source_id TEXT, name TEXT, scope TEXT,
13170
+ owner_provider TEXT, owner_subject TEXT,
13171
+ owner_email TEXT, owner_display_name TEXT,
13172
+ enabled BOOLEAN, created_by TEXT, created_at TIMESTAMPTZ,
13173
+ active_version_id TEXT, semver TEXT, sha256 TEXT, size_bytes BIGINT,
13174
+ artifact_filename TEXT, commit_sha TEXT, manifest JSONB,
13175
+ version_created_at TIMESTAMPTZ, version_created_by TEXT,
13176
+ shadowed BOOLEAN, can_edit BOOLEAN
13177
+ ) AS $$
13178
+ SELECT p.package_id, p.source_id, p.name, p.scope,
13179
+ p.owner_provider, p.owner_subject,
13180
+ u.email, u.display_name,
13181
+ p.enabled, p.created_by, p.created_at,
13182
+ v.version_id, v.semver, v.sha256, v.size_bytes,
13183
+ v.artifact_filename, v.commit_sha, v.manifest, v.created_at, v.created_by,
13184
+ (p.scope = 'shared' AND EXISTS (
13185
+ SELECT 1 FROM ${s}.agent_packages o
13186
+ WHERE o.name = p.name AND o.scope = 'user' AND o.enabled
13187
+ AND o.owner_provider = BTRIM(p_viewer_provider)
13188
+ AND o.owner_subject = BTRIM(p_viewer_subject)
13189
+ )) AS shadowed,
13190
+ (p_is_admin
13191
+ OR (p.owner_provider = BTRIM(p_viewer_provider) AND p.owner_subject = BTRIM(p_viewer_subject))
13192
+ OR ${s}.cms_agent_package_is_editor(p.package_id, p_viewer_provider, p_viewer_subject)
13193
+ ) AS can_edit
13194
+ FROM ${s}.agent_packages p
13195
+ LEFT JOIN ${s}.agent_package_versions v ON v.version_id = p.active_version_id
13196
+ LEFT JOIN ${s}.users u
13197
+ ON u.provider = p.owner_provider AND u.subject = p.owner_subject
13198
+ WHERE p.scope = 'shared'
13199
+ OR p_is_admin
13200
+ OR (p.owner_provider = BTRIM(p_viewer_provider) AND p.owner_subject = BTRIM(p_viewer_subject))
13201
+ ORDER BY p.name, p.scope, p.owner_provider, p.owner_subject;
13202
+ $$ LANGUAGE sql;
13203
+
13204
+ CREATE OR REPLACE FUNCTION ${s}.cms_get_agent_package(
13205
+ p_name TEXT, p_viewer_provider TEXT, p_viewer_subject TEXT, p_is_admin BOOLEAN,
13206
+ p_sel_scope TEXT, p_sel_owner_provider TEXT, p_sel_owner_subject TEXT
13207
+ ) RETURNS TABLE(
13208
+ package_id TEXT, source_id TEXT, name TEXT, scope TEXT,
13209
+ owner_provider TEXT, owner_subject TEXT,
13210
+ owner_email TEXT, owner_display_name TEXT,
13211
+ enabled BOOLEAN, created_by TEXT, created_at TIMESTAMPTZ,
13212
+ active_version_id TEXT, version_id TEXT, semver TEXT, sha256 TEXT,
13213
+ size_bytes BIGINT, artifact_filename TEXT, commit_sha TEXT, manifest JSONB,
13214
+ version_created_at TIMESTAMPTZ, version_created_by TEXT,
13215
+ can_edit BOOLEAN
13216
+ ) AS $$
13217
+ DECLARE
13218
+ v_id TEXT;
13219
+ v_count INT;
13220
+ BEGIN
13221
+ v_id := ${s}.cms_resolve_agent_package_id(
13222
+ p_name, p_viewer_provider, p_viewer_subject,
13223
+ p_sel_scope, p_sel_owner_provider, p_sel_owner_subject, TRUE);
13224
+ IF v_id IS NULL THEN
13225
+ v_id := ${s}.cms_resolve_agent_package_id(
13226
+ p_name, p_viewer_provider, p_viewer_subject,
13227
+ p_sel_scope, p_sel_owner_provider, p_sel_owner_subject, FALSE);
13228
+ END IF;
13229
+
13230
+ IF v_id IS NULL AND p_is_admin AND p_sel_scope IS NULL THEN
13231
+ SELECT count(*) INTO v_count FROM ${s}.agent_packages p WHERE p.name = p_name;
13232
+ IF v_count = 1 THEN
13233
+ SELECT p.package_id INTO v_id FROM ${s}.agent_packages p WHERE p.name = p_name;
13234
+ END IF;
13235
+ END IF;
13236
+
13237
+ IF v_id IS NULL THEN
13238
+ RETURN;
13239
+ END IF;
13240
+
13241
+ RETURN QUERY
13242
+ SELECT p.package_id, p.source_id, p.name, p.scope,
13243
+ p.owner_provider, p.owner_subject,
13244
+ u.email, u.display_name,
13245
+ p.enabled, p.created_by, p.created_at, p.active_version_id,
13246
+ v.version_id, v.semver, v.sha256, v.size_bytes,
13247
+ v.artifact_filename, v.commit_sha, v.manifest, v.created_at, v.created_by,
13248
+ (p_is_admin
13249
+ OR (p.owner_provider = BTRIM(p_viewer_provider) AND p.owner_subject = BTRIM(p_viewer_subject))
13250
+ OR ${s}.cms_agent_package_is_editor(p.package_id, p_viewer_provider, p_viewer_subject)
13251
+ ) AS can_edit
13252
+ FROM ${s}.agent_packages p
13253
+ LEFT JOIN ${s}.agent_package_versions v ON v.package_id = p.package_id
13254
+ LEFT JOIN ${s}.users u
13255
+ ON u.provider = p.owner_provider AND u.subject = p.owner_subject
13256
+ WHERE p.package_id = v_id
13257
+ AND (p.scope = 'shared' OR p_is_admin
13258
+ OR (p.owner_provider = BTRIM(p_viewer_provider) AND p.owner_subject = BTRIM(p_viewer_subject)))
13259
+ ORDER BY v.created_at DESC;
13260
+ END;
13261
+ $$ LANGUAGE plpgsql;
13262
+
13263
+ -- ── The picker: a name OR an email makes a user findable ─────────
13264
+ CREATE OR REPLACE FUNCTION ${s}.cms_list_users(
13265
+ p_limit INT
13266
+ ) RETURNS TABLE (
13267
+ provider TEXT,
13268
+ subject TEXT,
13269
+ email TEXT,
13270
+ display_name TEXT
13271
+ ) AS $$
13272
+ DECLARE
13273
+ v_limit INT := GREATEST(1, LEAST(COALESCE(p_limit, 500), 2000));
13274
+ BEGIN
13275
+ RETURN QUERY
13276
+ SELECT u.provider, u.subject, u.email, u.display_name
13277
+ FROM ${s}.users u
13278
+ WHERE NOT (u.provider = 'system' AND u.subject = 'system')
13279
+ AND NOT (u.provider = 'local' AND u.subject = 'default')
13280
+ -- The no-auth provider's anonymous principal is not a person either.
13281
+ AND NOT (u.provider = 'none')
13282
+ -- Sighted members carry a name or an email (0042 writes both from the
13283
+ -- token when present). A row with neither is a raw-id grant
13284
+ -- placeholder and stays hidden until that person signs in.
13285
+ AND (u.display_name IS NOT NULL OR u.email IS NOT NULL)
13286
+ ORDER BY u.updated_at DESC, u.user_id DESC
13287
+ LIMIT v_limit;
13288
+ END;
13289
+ $$ LANGUAGE plpgsql;
13290
+
13291
+ -- ── Ghost adoption must carry editor grants across ───────────────
13292
+ --
13293
+ -- 0032's cms_register_user adopts email-keyed placeholder rows on a real
13294
+ -- user's first sighting: shares move to the real user and the placeholder
13295
+ -- is deleted. agent_package_editors also references users(user_id), so a
13296
+ -- placeholder that was granted an editor row blocked that DELETE with an FK
13297
+ -- violation — and the whole registration rolled back, which meant the
13298
+ -- person could not sign in. Same body as 0032 plus the editor re-pointing.
13299
+ CREATE OR REPLACE FUNCTION ${s}.cms_register_user(
13300
+ p_provider TEXT,
13301
+ p_subject TEXT,
13302
+ p_email TEXT,
13303
+ p_display_name TEXT
13304
+ ) RETURNS BIGINT AS $$
13305
+ DECLARE
13306
+ v_provider TEXT := NULLIF(BTRIM(p_provider), '');
13307
+ v_subject TEXT := NULLIF(BTRIM(p_subject), '');
13308
+ v_email TEXT := NULLIF(BTRIM(p_email), '');
13309
+ v_display TEXT := NULLIF(BTRIM(p_display_name), '');
13310
+ v_user_id BIGINT;
13311
+ v_ghost BIGINT;
13312
+ BEGIN
13313
+ IF v_provider IS NULL OR v_subject IS NULL THEN
13314
+ RAISE EXCEPTION 'User provider and subject are required';
13315
+ END IF;
13316
+
13317
+ INSERT INTO ${s}.users (provider, subject, email, display_name)
13318
+ VALUES (v_provider, v_subject, v_email, v_display)
13319
+ ON CONFLICT (provider, subject) DO UPDATE
13320
+ SET email = COALESCE(EXCLUDED.email, ${s}.users.email),
13321
+ display_name = COALESCE(EXCLUDED.display_name, ${s}.users.display_name),
13322
+ updated_at = now()
13323
+ WHERE COALESCE(EXCLUDED.email, ${s}.users.email) IS DISTINCT FROM ${s}.users.email
13324
+ OR COALESCE(EXCLUDED.display_name, ${s}.users.display_name) IS DISTINCT FROM ${s}.users.display_name;
13325
+
13326
+ SELECT user_id INTO v_user_id
13327
+ FROM ${s}.users
13328
+ WHERE provider = v_provider AND subject = v_subject;
13329
+
13330
+ IF v_email IS NOT NULL THEN
13331
+ FOR v_ghost IN
13332
+ SELECT u.user_id FROM ${s}.users u
13333
+ WHERE u.provider = v_provider
13334
+ AND LOWER(u.subject) = LOWER(v_email)
13335
+ AND u.user_id <> v_user_id
13336
+ LOOP
13337
+ UPDATE ${s}.session_shares ss SET user_id = v_user_id
13338
+ WHERE ss.user_id = v_ghost
13339
+ AND NOT EXISTS (
13340
+ SELECT 1 FROM ${s}.session_shares e
13341
+ WHERE e.session_id = ss.session_id AND e.user_id = v_user_id
13342
+ );
13343
+ UPDATE ${s}.session_shares e SET access = 'write'
13344
+ FROM ${s}.session_shares g
13345
+ WHERE g.user_id = v_ghost AND g.session_id = e.session_id
13346
+ AND e.user_id = v_user_id AND g.access = 'write' AND e.access <> 'write';
13347
+ DELETE FROM ${s}.session_shares WHERE user_id = v_ghost;
13348
+ UPDATE ${s}.session_shares SET granted_by = v_user_id WHERE granted_by = v_ghost;
13349
+ -- Editor grants: move the ones the real user does not already
13350
+ -- hold, drop the duplicates, re-point granted_by.
13351
+ UPDATE ${s}.agent_package_editors ge SET user_id = v_user_id
13352
+ WHERE ge.user_id = v_ghost
13353
+ AND NOT EXISTS (
13354
+ SELECT 1 FROM ${s}.agent_package_editors e
13355
+ WHERE e.package_id = ge.package_id AND e.user_id = v_user_id
13356
+ );
13357
+ DELETE FROM ${s}.agent_package_editors WHERE user_id = v_ghost;
13358
+ UPDATE ${s}.agent_package_editors SET granted_by = v_user_id WHERE granted_by = v_ghost;
13359
+ UPDATE ${s}.session_owners SET user_id = v_user_id WHERE user_id = v_ghost;
13360
+ UPDATE ${s}.session_group_owners SET user_id = v_user_id WHERE user_id = v_ghost;
13361
+ DELETE FROM ${s}.users WHERE user_id = v_ghost;
13362
+ END LOOP;
13363
+ END IF;
13364
+
13365
+ RETURN v_user_id;
13366
+ END;
13367
+ $$ LANGUAGE plpgsql;
13368
+ `;
13369
+ }
13370
+ // ─── Migration 0064: the canvas KV store ─────────────────────────────
13371
+ //
13372
+ // Shared, durable, per-key state for interactive canvas apps
13373
+ // (docs/proposals/interactive-canvas-apps.md Part C). One row per
13374
+ // (session, slot, key); the value is the envelope {v, by, at}; `rev` is the
13375
+ // compare-and-swap token; deletes are tombstones (rev still advances, so a
13376
+ // live viewer drops the key and a late joiner never sees it).
13377
+ //
13378
+ // The rows live HERE, in the CMS, not in the facts table the proposal first
13379
+ // named: the facts store can be a different database (HorizonDB), and the
13380
+ // write must be atomic with its NOTIFY on the CMS connection. One statement
13381
+ // does the CAS check, the quota check, the upsert and the notify, so two
13382
+ // writers on one key serialize on the row and two viewers never see a
13383
+ // write without its ping.
13384
+ //
13385
+ // session_canvases gains the per-canvas access policy (Part D: who may
13386
+ // write) and the manifest's `kv` block cached at draw time (the app's half
13387
+ // of the switch), so a write never has to re-read the document.
13388
+ function migration_0064_canvas_kv(schema) {
13389
+ const s = `"${schema}"`;
13390
+ return `
13391
+ ALTER TABLE ${s}.session_canvases
13392
+ ADD COLUMN IF NOT EXISTS kv_access TEXT NOT NULL DEFAULT 'owner'
13393
+ CHECK (kv_access IN ('owner', 'readers', 'link'));
13394
+ ALTER TABLE ${s}.session_canvases
13395
+ ADD COLUMN IF NOT EXISTS kv_manifest JSONB;
13396
+
13397
+ CREATE TABLE IF NOT EXISTS ${s}.canvas_kv (
13398
+ session_id TEXT NOT NULL REFERENCES ${s}.sessions(session_id) ON DELETE CASCADE,
13399
+ slot SMALLINT NOT NULL CHECK (slot BETWEEN 1 AND 5),
13400
+ key TEXT NOT NULL,
13401
+ value JSONB NOT NULL,
13402
+ rev BIGINT NOT NULL DEFAULT 1,
13403
+ deleted_at TIMESTAMPTZ,
13404
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
13405
+ PRIMARY KEY (session_id, slot, key)
13406
+ );
13407
+
13408
+ -- Policy + manifest switch, upserting the 0045 row when the canvas has
13409
+ -- never been drawn (an owner may set the policy before the first draw).
13410
+ CREATE OR REPLACE FUNCTION ${s}.cms_set_canvas_kv_access(
13411
+ p_session_id TEXT, p_slot INT, p_access TEXT
13412
+ ) RETURNS VOID AS $$
13413
+ BEGIN
13414
+ IF p_access NOT IN ('owner', 'readers', 'link') THEN
13415
+ RAISE EXCEPTION 'CANVAS_KV_BAD_ACCESS: kv-access must be owner, readers or link, got "%"', p_access;
13416
+ END IF;
13417
+ INSERT INTO ${s}.session_canvases (session_id, slot, name, latest_rev, size_bytes, kv_access, updated_at)
13418
+ VALUES (p_session_id, p_slot, '', 0, NULL, p_access, now())
13419
+ ON CONFLICT (session_id, slot) DO UPDATE SET kv_access = EXCLUDED.kv_access, updated_at = now();
13420
+ END;
13421
+ $$ LANGUAGE plpgsql;
13422
+
13423
+ CREATE OR REPLACE FUNCTION ${s}.cms_set_canvas_kv_manifest(
13424
+ p_session_id TEXT, p_slot INT, p_manifest JSONB
13425
+ ) RETURNS VOID AS $$
13426
+ BEGIN
13427
+ INSERT INTO ${s}.session_canvases (session_id, slot, name, latest_rev, size_bytes, kv_manifest, updated_at)
13428
+ VALUES (p_session_id, p_slot, '', 0, NULL, p_manifest, now())
13429
+ ON CONFLICT (session_id, slot) DO UPDATE SET kv_manifest = EXCLUDED.kv_manifest, updated_at = now();
13430
+ END;
13431
+ $$ LANGUAGE plpgsql;
13432
+
13433
+ CREATE OR REPLACE FUNCTION ${s}.cms_get_canvas_kv_settings(
13434
+ p_session_id TEXT, p_slot INT
13435
+ ) RETURNS TABLE(kv_access TEXT, kv_manifest JSONB, latest_rev INTEGER) AS $$
13436
+ SELECT c.kv_access, c.kv_manifest, c.latest_rev
13437
+ FROM ${s}.session_canvases c
13438
+ WHERE c.session_id = p_session_id AND c.slot = p_slot;
13439
+ $$ LANGUAGE sql STABLE;
13440
+
13441
+ -- The write. p_value NULL = delete. p_if_match NULL = no CAS; 0 = the key
13442
+ -- must not be live (claim); N = the live rev must be N. Statuses:
13443
+ -- written | deleted | not_found | conflict | too_large | quota_keys | quota_bytes
13444
+ CREATE OR REPLACE FUNCTION ${s}.cms_canvas_kv_write(
13445
+ p_session_id TEXT, p_slot INT, p_key TEXT, p_value JSONB, p_if_match BIGINT,
13446
+ p_max_keys INT, p_max_bytes BIGINT, p_max_value_bytes INT, p_schema TEXT
13447
+ ) RETURNS TABLE(status TEXT, rev BIGINT, size_bytes INT) AS $$
13448
+ DECLARE
13449
+ v_rev BIGINT;
13450
+ v_deleted TIMESTAMPTZ;
13451
+ v_found BOOLEAN;
13452
+ v_live BOOLEAN;
13453
+ v_size INT;
13454
+ v_keys BIGINT;
13455
+ v_bytes BIGINT;
13456
+ v_new_rev BIGINT;
13457
+ BEGIN
13458
+ SELECT k.rev, k.deleted_at INTO v_rev, v_deleted
13459
+ FROM ${s}.canvas_kv k
13460
+ WHERE k.session_id = p_session_id AND k.slot = p_slot AND k.key = p_key
13461
+ FOR UPDATE;
13462
+ v_found := FOUND;
13463
+ v_live := v_found AND v_deleted IS NULL;
13464
+
13465
+ IF p_if_match IS NOT NULL THEN
13466
+ IF (p_if_match = 0 AND v_live) OR (p_if_match <> 0 AND (NOT v_live OR v_rev <> p_if_match)) THEN
13467
+ RETURN QUERY SELECT 'conflict'::TEXT, COALESCE(v_rev, 0)::BIGINT, NULL::INT;
13468
+ RETURN;
13469
+ END IF;
13470
+ END IF;
13471
+
13472
+ IF p_value IS NULL THEN
13473
+ IF NOT v_live THEN
13474
+ RETURN QUERY SELECT 'not_found'::TEXT, COALESCE(v_rev, 0)::BIGINT, NULL::INT;
13475
+ RETURN;
13476
+ END IF;
13477
+ UPDATE ${s}.canvas_kv k SET deleted_at = now(), rev = k.rev + 1, updated_at = now()
13478
+ WHERE k.session_id = p_session_id AND k.slot = p_slot AND k.key = p_key
13479
+ RETURNING k.rev INTO v_new_rev;
13480
+ PERFORM pg_notify('pilotswarm_canvas_live', json_build_object(
13481
+ 'schema', p_schema, 'sessionId', p_session_id, 'slot', p_slot,
13482
+ 'kind', 'kv', 'key', p_key, 'rev', v_new_rev, 'op', 'delete')::text);
13483
+ RETURN QUERY SELECT 'deleted'::TEXT, v_new_rev, 0;
13484
+ RETURN;
13485
+ END IF;
13486
+
13487
+ v_size := octet_length(p_value::text);
13488
+ IF v_size > p_max_value_bytes THEN
13489
+ RETURN QUERY SELECT 'too_large'::TEXT, COALESCE(v_rev, 0)::BIGINT, v_size;
13490
+ RETURN;
13491
+ END IF;
13492
+
13493
+ -- Budget check against every OTHER live key of this canvas.
13494
+ SELECT count(*), COALESCE(sum(octet_length(k.value::text)), 0) INTO v_keys, v_bytes
13495
+ FROM ${s}.canvas_kv k
13496
+ WHERE k.session_id = p_session_id AND k.slot = p_slot AND k.deleted_at IS NULL AND k.key <> p_key;
13497
+ IF NOT v_live AND v_keys + 1 > p_max_keys THEN
13498
+ RETURN QUERY SELECT 'quota_keys'::TEXT, COALESCE(v_rev, 0)::BIGINT, v_size;
13499
+ RETURN;
13500
+ END IF;
13501
+ IF v_bytes + v_size > p_max_bytes THEN
13502
+ RETURN QUERY SELECT 'quota_bytes'::TEXT, COALESCE(v_rev, 0)::BIGINT, v_size;
13503
+ RETURN;
13504
+ END IF;
13505
+
13506
+ -- A claim (if_match = 0) must not become an update under a concurrent
13507
+ -- first insert: the upsert only resurrects a tombstone, never overwrites
13508
+ -- a live row, and a missing RETURNING row is reported as a conflict.
13509
+ IF p_if_match = 0 THEN
13510
+ INSERT INTO ${s}.canvas_kv (session_id, slot, key, value, rev, deleted_at, updated_at)
13511
+ VALUES (p_session_id, p_slot, p_key, p_value, 1, NULL, now())
13512
+ ON CONFLICT (session_id, slot, key) DO UPDATE SET
13513
+ value = EXCLUDED.value, rev = ${s}.canvas_kv.rev + 1, deleted_at = NULL, updated_at = now()
13514
+ WHERE ${s}.canvas_kv.deleted_at IS NOT NULL
13515
+ RETURNING ${s}.canvas_kv.rev INTO v_new_rev;
13516
+ IF v_new_rev IS NULL THEN
13517
+ RETURN QUERY SELECT 'conflict'::TEXT, COALESCE(v_rev, 0)::BIGINT, v_size;
13518
+ RETURN;
13519
+ END IF;
13520
+ ELSE
13521
+ INSERT INTO ${s}.canvas_kv (session_id, slot, key, value, rev, deleted_at, updated_at)
13522
+ VALUES (p_session_id, p_slot, p_key, p_value, 1, NULL, now())
13523
+ ON CONFLICT (session_id, slot, key) DO UPDATE SET
13524
+ value = EXCLUDED.value, rev = ${s}.canvas_kv.rev + 1, deleted_at = NULL, updated_at = now()
13525
+ RETURNING ${s}.canvas_kv.rev INTO v_new_rev;
13526
+ END IF;
13527
+
13528
+ -- The value rides the ping when the envelope is small; a bigger value
13529
+ -- ships a pointer and the viewer fetches that one key. pg_notify hard
13530
+ -- errors past 8000 bytes and that would roll back the write.
13531
+ PERFORM pg_notify('pilotswarm_canvas_live', (
13532
+ SELECT CASE WHEN octet_length(m.with_value) <= 7000 THEN m.with_value ELSE m.pointer END
13533
+ FROM (SELECT
13534
+ json_build_object('schema', p_schema, 'sessionId', p_session_id, 'slot', p_slot,
13535
+ 'kind', 'kv', 'key', p_key, 'rev', v_new_rev, 'op', 'put', 'value', p_value)::text AS with_value,
13536
+ json_build_object('schema', p_schema, 'sessionId', p_session_id, 'slot', p_slot,
13537
+ 'kind', 'kv', 'key', p_key, 'rev', v_new_rev, 'op', 'put')::text AS pointer) m));
13538
+ RETURN QUERY SELECT 'written'::TEXT, v_new_rev, v_size;
13539
+ END;
13540
+ $$ LANGUAGE plpgsql;
13541
+
13542
+ CREATE OR REPLACE FUNCTION ${s}.cms_canvas_kv_get(
13543
+ p_session_id TEXT, p_slot INT, p_key TEXT
13544
+ ) RETURNS TABLE(key TEXT, value JSONB, rev BIGINT, updated_at TIMESTAMPTZ) AS $$
13545
+ SELECT k.key, k.value, k.rev, k.updated_at
13546
+ FROM ${s}.canvas_kv k
13547
+ WHERE k.session_id = p_session_id AND k.slot = p_slot AND k.key = p_key AND k.deleted_at IS NULL;
13548
+ $$ LANGUAGE sql STABLE;
13549
+
13550
+ -- Prefix listing, key-ordered, cursor = the last key of the previous page.
13551
+ CREATE OR REPLACE FUNCTION ${s}.cms_canvas_kv_list(
13552
+ p_session_id TEXT, p_slot INT, p_prefix TEXT, p_limit INT, p_after_key TEXT
13553
+ ) RETURNS TABLE(key TEXT, value JSONB, rev BIGINT, updated_at TIMESTAMPTZ) AS $$
13554
+ SELECT k.key, k.value, k.rev, k.updated_at
13555
+ FROM ${s}.canvas_kv k
13556
+ WHERE k.session_id = p_session_id AND k.slot = p_slot AND k.deleted_at IS NULL
13557
+ AND (p_prefix IS NULL OR p_prefix = '' OR left(k.key, length(p_prefix)) = p_prefix)
13558
+ AND (p_after_key IS NULL OR k.key > p_after_key)
13559
+ ORDER BY k.key
13560
+ LIMIT GREATEST(1, LEAST(COALESCE(p_limit, 200), 200));
13561
+ $$ LANGUAGE sql STABLE;
13562
+
13563
+ CREATE OR REPLACE FUNCTION ${s}.cms_canvas_kv_stats(
13564
+ p_session_id TEXT, p_slot INT
13565
+ ) RETURNS TABLE(keys BIGINT, bytes BIGINT) AS $$
13566
+ SELECT count(*)::BIGINT, COALESCE(sum(octet_length(k.value::text)), 0)::BIGINT
13567
+ FROM ${s}.canvas_kv k
13568
+ WHERE k.session_id = p_session_id AND k.slot = p_slot AND k.deleted_at IS NULL;
13569
+ $$ LANGUAGE sql STABLE;
13570
+ `;
13571
+ }
12750
13572
  //# sourceMappingURL=cms-migrations.js.map