@fayz-ai/db 0.8.0 → 0.8.3

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.
@@ -31,8 +31,9 @@ DECLARE
31
31
  -- 004 archetypes (self-governed via 004/006 policies)
32
32
  'people', 'categories', 'products', 'services', 'orders', 'order_items',
33
33
  'transactions', 'appointments', 'appointment_items', 'schedules',
34
- -- core, non-API helper tables
35
- 'sequences', 'documents', 'fayz_migration_ledger'
34
+ -- core, non-API helper tables (both ledger names kept for pre/post-rename compat;
35
+ -- `_migrations` is also covered by the NOT LIKE '\_%' filter below)
36
+ 'sequences', 'documents', 'fayz_migration_ledger', '_migrations'
36
37
  ];
37
38
  BEGIN
38
39
  FOR t IN
@@ -8,9 +8,15 @@
8
8
  -- service_role only: no anon/authenticated grants, RLS enabled with NO
9
9
  -- policies, so PostgREST (anon/authenticated) is deny-all. Only the service
10
10
  -- role (which bypasses RLS) — i.e. the runner — can read/write it.
11
+ --
12
+ -- Naming: the ledger is an internal, non-API table, so it uses the `_`-prefixed
13
+ -- `public._migrations` name (excluded from the project_rls auto-policy pass by
14
+ -- the `NOT LIKE '\_%'` filter). Legacy pools provisioned as `fayz_migration_ledger`
15
+ -- are migrated by 012_rename_migration_ledger.sql (and by the runner's
16
+ -- ensureLedger guard); fresh installs are born as `_migrations` here.
11
17
  -- ============================================================================
12
18
 
13
- CREATE TABLE IF NOT EXISTS public.fayz_migration_ledger (
19
+ CREATE TABLE IF NOT EXISTS public._migrations (
14
20
  id bigserial PRIMARY KEY,
15
21
  plugin_id text NOT NULL,
16
22
  file_name text NOT NULL,
@@ -21,10 +27,10 @@ CREATE TABLE IF NOT EXISTS public.fayz_migration_ledger (
21
27
  UNIQUE(plugin_id, file_name)
22
28
  );
23
29
 
24
- ALTER TABLE public.fayz_migration_ledger ENABLE ROW LEVEL SECURITY;
30
+ ALTER TABLE public._migrations ENABLE ROW LEVEL SECURITY;
25
31
 
26
32
  -- Deny-all through PostgREST: revoke any inherited grants, grant to nobody but
27
33
  -- service_role (which bypasses RLS anyway). No policies = no anon/authenticated access.
28
- REVOKE ALL ON public.fayz_migration_ledger FROM anon, authenticated;
29
- GRANT ALL ON public.fayz_migration_ledger TO service_role;
30
- GRANT USAGE, SELECT ON SEQUENCE public.fayz_migration_ledger_id_seq TO service_role;
34
+ REVOKE ALL ON public._migrations FROM anon, authenticated;
35
+ GRANT ALL ON public._migrations TO service_role;
36
+ GRANT USAGE, SELECT ON SEQUENCE public._migrations_id_seq TO service_role;
@@ -0,0 +1,61 @@
1
+ -- ============================================================================
2
+ -- 012_rename_migration_ledger.sql — rename the migration ledger onto the
3
+ -- internal `_`-prefixed baseline name:
4
+ --
5
+ -- public.fayz_migration_ledger → public._migrations
6
+ --
7
+ -- Legacy-pool remediation ONLY: 010_migration_ledger.sql now creates `_migrations`
8
+ -- directly, so fresh installs never reach a rename branch. The runner's
9
+ -- ensureLedger (cli/src/lib/ledger.ts) performs the same guarded rename at the
10
+ -- START of every apply, so on a real pool the ledger is already `_migrations`
11
+ -- by the time this migration runs — this file is the declarative backstop.
12
+ --
13
+ -- Fully idempotent + guarded: each RENAME fires only when the old object exists
14
+ -- and the new one does not. A table RENAME preserves the rows (the applied-file
15
+ -- history MUST survive), plus RLS state, grants and constraints; re-assert the
16
+ -- deny-all grants afterwards to keep the baseline explicit.
17
+ -- ============================================================================
18
+
19
+ DO $$
20
+ BEGIN
21
+ -- Table (carries the applied-migration history — never drop + recreate).
22
+ IF to_regclass('public.fayz_migration_ledger') IS NOT NULL
23
+ AND to_regclass('public._migrations') IS NULL THEN
24
+ ALTER TABLE public.fayz_migration_ledger RENAME TO _migrations;
25
+ END IF;
26
+
27
+ -- bigserial owns fayz_migration_ledger_id_seq — a table rename does not move it.
28
+ IF to_regclass('public.fayz_migration_ledger_id_seq') IS NOT NULL
29
+ AND to_regclass('public._migrations_id_seq') IS NULL THEN
30
+ ALTER SEQUENCE public.fayz_migration_ledger_id_seq RENAME TO _migrations_id_seq;
31
+ END IF;
32
+
33
+ -- Named constraints are auto-named after the old table; rename for parity with
34
+ -- a fresh install. Guard on the new table existing so this is safe pre-rename.
35
+ IF to_regclass('public._migrations') IS NOT NULL THEN
36
+ IF EXISTS (SELECT 1 FROM pg_constraint
37
+ WHERE conname = 'fayz_migration_ledger_pkey'
38
+ AND conrelid = 'public._migrations'::regclass) THEN
39
+ ALTER TABLE public._migrations RENAME CONSTRAINT fayz_migration_ledger_pkey TO _migrations_pkey;
40
+ END IF;
41
+ IF EXISTS (SELECT 1 FROM pg_constraint
42
+ WHERE conname = 'fayz_migration_ledger_plugin_id_file_name_key'
43
+ AND conrelid = 'public._migrations'::regclass) THEN
44
+ ALTER TABLE public._migrations
45
+ RENAME CONSTRAINT fayz_migration_ledger_plugin_id_file_name_key
46
+ TO _migrations_plugin_id_file_name_key;
47
+ END IF;
48
+ END IF;
49
+ END $$;
50
+
51
+ -- Re-assert the deny-all posture (idempotent; RLS/grants survive a rename but the
52
+ -- baseline keeps this explicit so a manually-created ledger converges too).
53
+ DO $$
54
+ BEGIN
55
+ IF to_regclass('public._migrations') IS NOT NULL THEN
56
+ EXECUTE 'ALTER TABLE public._migrations ENABLE ROW LEVEL SECURITY';
57
+ EXECUTE 'REVOKE ALL ON public._migrations FROM anon, authenticated';
58
+ EXECUTE 'GRANT ALL ON public._migrations TO service_role';
59
+ EXECUTE 'GRANT USAGE, SELECT ON SEQUENCE public._migrations_id_seq TO service_role';
60
+ END IF;
61
+ END $$;
@@ -0,0 +1,22 @@
1
+ -- ============================================================================
2
+ -- 013_sequences_rls.sql — close the RLS drift on public.sequences.
3
+ --
4
+ -- public.sequences (the REC-/PAG- numbering counter, created by the financial
5
+ -- plugin's 004_order_to_cash.sql) shipped WITHOUT row-level security on a set of
6
+ -- pools. Baseline = RLS ON, deny-all (no policies), exactly like the salon pool:
7
+ -- the table is written ONLY through the SECURITY DEFINER function
8
+ -- public.next_sequence(), which runs as the table owner and so bypasses RLS.
9
+ -- With RLS on and no policy, the PostgREST anon/authenticated surface is deny-all,
10
+ -- so the counter can never be read or tampered with directly by a tenant.
11
+ --
12
+ -- Legacy-pool remediation: guarded so it is a no-op on pools that never enabled
13
+ -- Payments (sequences absent) and idempotent on pools already at RLS ON. Fresh
14
+ -- installs are born with RLS via the financial plugin's own 004_order_to_cash.sql.
15
+ --
16
+ -- NOTE: next_sequence() MUST be SECURITY DEFINER for the deny-all baseline to
17
+ -- keep working — it is called directly via PostgREST RPC by authenticated users
18
+ -- (plugins/plugin-financial/src/data/supabase.ts → createInvoice). That fix ships
19
+ -- in the financial plugin migration alongside this one.
20
+ -- ============================================================================
21
+
22
+ ALTER TABLE IF EXISTS public.sequences ENABLE ROW LEVEL SECURITY;
@@ -0,0 +1,28 @@
1
+ -- ============================================================================
2
+ -- 014_plan_entitlements.sql — plans carry machine-readable entitlements.
3
+ --
4
+ -- public.plans shipped with `features jsonb` as DISPLAY BULLETS only — nothing
5
+ -- in the pool encodes what a plan actually gates. Server-side enforcement (the
6
+ -- agent RPCs' role→plan→limit guard, and eventually RLS defense-in-depth)
7
+ -- needs the SAME `{features: {id: bool}, limits: {key: number}}` structure the
8
+ -- client resolves from `config.billing` (@fayz-ai/core PlanEntitlements; -1 =
9
+ -- unlimited, absent feature = allowed).
10
+ --
11
+ -- Writer: the app's plan catalog is code (billing.ts) → synced into this table
12
+ -- (transitionally by the app seed, then by the Fayz platform on
13
+ -- `fayz manifest sync` — the manifest carries billing.plans verbatim).
14
+ --
15
+ -- `hidden` retires the "qa-free-test plans visible on SubscriptionPage" debt:
16
+ -- QA/internal plans stay selectable by id but are never rendered.
17
+ --
18
+ -- Guarded + idempotent: no-op when the pool predates public.plans (legacy
19
+ -- saas_core pools run their app-local mirror of this ALTER instead).
20
+ -- ============================================================================
21
+
22
+ DO $$
23
+ BEGIN
24
+ IF to_regclass('public.plans') IS NOT NULL THEN
25
+ ALTER TABLE public.plans ADD COLUMN IF NOT EXISTS entitlements jsonb NOT NULL DEFAULT '{}'::jsonb;
26
+ ALTER TABLE public.plans ADD COLUMN IF NOT EXISTS hidden boolean NOT NULL DEFAULT false;
27
+ END IF;
28
+ END $$;
@@ -0,0 +1,108 @@
1
+ -- ============================================================================
2
+ -- 015_agent_guard.sql — the role→plan→limit gate for agent_* RPCs.
3
+ --
4
+ -- Every server-plane agent write RPC (agent_<domain>_<verb>) calls this FIRST.
5
+ -- It is the SQL mirror of the shared TS engine (@fayz-ai/core/access
6
+ -- resolveAccess/resolveLimit): role first (owner bypasses role, NOT plan;
7
+ -- `manage` satisfies any action; tenant_role_overrides win over role grants),
8
+ -- then plan feature gate (only explicit false denies), then the plan cap for
9
+ -- p_limit_key (absent / -1 = unlimited). The RPC counts its own domain rows
10
+ -- and passes p_used — the guard never runs dynamic SQL.
11
+ --
12
+ -- Returns NULL when allowed, or the AgentDenial jsonb mirror when denied:
13
+ -- {"allowed":false,"reason":"role"|"plan"|"limit",
14
+ -- "limit":{"key":..,"max":..,"used":..},"upgradeUrl":"/settings/subscription"}
15
+ -- A parity test on the app side runs the same scenarios through the TS engine
16
+ -- and through this function — divergence is a bug.
17
+ --
18
+ -- Permission ids use the `category.action` catalog form; grants live in
19
+ -- role_permissions (+ per-tenant tenant_role_overrides with granted boolean).
20
+ -- ============================================================================
21
+
22
+ CREATE OR REPLACE FUNCTION public.agent_guard(
23
+ p_tenant_id uuid,
24
+ p_actor_user_id uuid,
25
+ p_feature text,
26
+ p_action text,
27
+ p_limit_key text DEFAULT NULL,
28
+ p_used integer DEFAULT NULL,
29
+ p_n integer DEFAULT 1
30
+ ) RETURNS jsonb
31
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
32
+ SET search_path = public
33
+ AS $$
34
+ DECLARE
35
+ v_role text;
36
+ v_has boolean;
37
+ v_override boolean;
38
+ v_plan_id text;
39
+ v_ent jsonb;
40
+ v_feature_flag jsonb;
41
+ v_cap numeric;
42
+ BEGIN
43
+ -- ── membership + role ────────────────────────────────────────────────────
44
+ SELECT tm.role INTO v_role
45
+ FROM tenant_members tm
46
+ WHERE tm.tenant_id = p_tenant_id AND tm.user_id = p_actor_user_id;
47
+ IF v_role IS NULL THEN
48
+ RETURN jsonb_build_object('allowed', false, 'reason', 'role',
49
+ 'upgradeUrl', '/settings/subscription');
50
+ END IF;
51
+
52
+ IF v_role <> 'owner' THEN
53
+ -- per-tenant override wins in BOTH directions (grant or deny)
54
+ SELECT tro.granted INTO v_override
55
+ FROM tenant_role_overrides tro
56
+ WHERE tro.tenant_id = p_tenant_id AND tro.role = v_role
57
+ AND tro.permission_id IN (p_feature || '.' || p_action, p_feature || '.manage')
58
+ ORDER BY (tro.permission_id = p_feature || '.' || p_action) DESC
59
+ LIMIT 1;
60
+
61
+ IF v_override IS NOT NULL THEN
62
+ v_has := v_override;
63
+ ELSE
64
+ SELECT EXISTS (
65
+ SELECT 1 FROM role_permissions rp
66
+ WHERE rp.role = v_role
67
+ AND rp.permission_id IN (p_feature || '.' || p_action, p_feature || '.manage')
68
+ ) INTO v_has;
69
+ END IF;
70
+
71
+ IF NOT v_has THEN
72
+ RETURN jsonb_build_object('allowed', false, 'reason', 'role',
73
+ 'upgradeUrl', '/settings/subscription');
74
+ END IF;
75
+ END IF;
76
+
77
+ -- ── plan feature gate (owner does NOT bypass) ────────────────────────────
78
+ SELECT t.plan INTO v_plan_id FROM tenants t WHERE t.id = p_tenant_id;
79
+ SELECT p.entitlements INTO v_ent FROM plans p WHERE p.id = v_plan_id;
80
+
81
+ IF v_ent IS NOT NULL THEN
82
+ v_feature_flag := v_ent->'features'->p_feature;
83
+ -- only an EXPLICIT false denies (additive plans — mirror isEntitledByPlan)
84
+ IF v_feature_flag IS NOT NULL AND v_feature_flag = to_jsonb(false) THEN
85
+ RETURN jsonb_build_object('allowed', false, 'reason', 'plan',
86
+ 'upgradeUrl', '/settings/subscription');
87
+ END IF;
88
+
89
+ -- ── plan cap (mirror resolveLimit: absent / -1 = unlimited) ────────────
90
+ IF p_limit_key IS NOT NULL AND p_used IS NOT NULL THEN
91
+ v_cap := (v_ent->'limits'->>p_limit_key)::numeric;
92
+ IF v_cap IS NOT NULL AND v_cap <> -1 AND (p_used + COALESCE(p_n, 1)) > v_cap THEN
93
+ RETURN jsonb_build_object('allowed', false, 'reason', 'limit',
94
+ 'limit', jsonb_build_object('key', p_limit_key,
95
+ 'max', v_cap,
96
+ 'used', p_used),
97
+ 'upgradeUrl', '/settings/subscription');
98
+ END IF;
99
+ END IF;
100
+ END IF;
101
+
102
+ RETURN NULL; -- allowed
103
+ END;
104
+ $$;
105
+
106
+ REVOKE ALL ON FUNCTION public.agent_guard(uuid, uuid, text, text, text, integer, integer) FROM public;
107
+ GRANT EXECUTE ON FUNCTION public.agent_guard(uuid, uuid, text, text, text, integer, integer)
108
+ TO authenticated, service_role;
@@ -0,0 +1,102 @@
1
+ -- ============================================================================
2
+ -- 016_agent_guard_actor.sql — the actor cannot be spoofed by a signed-in caller.
3
+ --
4
+ -- agent_* RPCs receive p_actor_user_id explicitly because the Fayz broker
5
+ -- (service_role, no JWT) injects the verified actor server-side. But the same
6
+ -- functions are EXECUTE-granted to `authenticated` so the surface can call
7
+ -- them client-plane before S4 — and there, p_actor comes from the client. A
8
+ -- signed-in user passing SOMEONE ELSE's id would borrow their role.
9
+ --
10
+ -- Rule added at the top of agent_guard (every write RPC's first call): when
11
+ -- the caller carries a JWT identity (auth.uid() not null), p_actor MUST be
12
+ -- that identity. Service-role/broker calls (auth.uid() null) are unaffected.
13
+ -- New file rather than editing 015 — applied migrations are never edited
14
+ -- (ledger rule); CREATE OR REPLACE supersedes the body.
15
+ -- ============================================================================
16
+
17
+ CREATE OR REPLACE FUNCTION public.agent_guard(
18
+ p_tenant_id uuid,
19
+ p_actor_user_id uuid,
20
+ p_feature text,
21
+ p_action text,
22
+ p_limit_key text DEFAULT NULL,
23
+ p_used integer DEFAULT NULL,
24
+ p_n integer DEFAULT 1
25
+ ) RETURNS jsonb
26
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
27
+ SET search_path = public
28
+ AS $$
29
+ DECLARE
30
+ v_role text;
31
+ v_has boolean;
32
+ v_override boolean;
33
+ v_plan_id text;
34
+ v_ent jsonb;
35
+ v_feature_flag jsonb;
36
+ v_cap numeric;
37
+ BEGIN
38
+ -- ── actor integrity: a JWT caller can only act as themselves ─────────────
39
+ IF auth.uid() IS NOT NULL AND auth.uid() IS DISTINCT FROM p_actor_user_id THEN
40
+ RETURN jsonb_build_object('allowed', false, 'reason', 'role',
41
+ 'upgradeUrl', '/settings/subscription');
42
+ END IF;
43
+
44
+ -- ── membership + role ────────────────────────────────────────────────────
45
+ SELECT tm.role INTO v_role
46
+ FROM tenant_members tm
47
+ WHERE tm.tenant_id = p_tenant_id AND tm.user_id = p_actor_user_id;
48
+ IF v_role IS NULL THEN
49
+ RETURN jsonb_build_object('allowed', false, 'reason', 'role',
50
+ 'upgradeUrl', '/settings/subscription');
51
+ END IF;
52
+
53
+ IF v_role <> 'owner' THEN
54
+ SELECT tro.granted INTO v_override
55
+ FROM tenant_role_overrides tro
56
+ WHERE tro.tenant_id = p_tenant_id AND tro.role = v_role
57
+ AND tro.permission_id IN (p_feature || '.' || p_action, p_feature || '.manage')
58
+ ORDER BY (tro.permission_id = p_feature || '.' || p_action) DESC
59
+ LIMIT 1;
60
+
61
+ IF v_override IS NOT NULL THEN
62
+ v_has := v_override;
63
+ ELSE
64
+ SELECT EXISTS (
65
+ SELECT 1 FROM role_permissions rp
66
+ WHERE rp.role = v_role
67
+ AND rp.permission_id IN (p_feature || '.' || p_action, p_feature || '.manage')
68
+ ) INTO v_has;
69
+ END IF;
70
+
71
+ IF NOT v_has THEN
72
+ RETURN jsonb_build_object('allowed', false, 'reason', 'role',
73
+ 'upgradeUrl', '/settings/subscription');
74
+ END IF;
75
+ END IF;
76
+
77
+ -- ── plan feature gate (owner does NOT bypass) ────────────────────────────
78
+ SELECT t.plan INTO v_plan_id FROM tenants t WHERE t.id = p_tenant_id;
79
+ SELECT p.entitlements INTO v_ent FROM plans p WHERE p.id = v_plan_id;
80
+
81
+ IF v_ent IS NOT NULL THEN
82
+ v_feature_flag := v_ent->'features'->p_feature;
83
+ IF v_feature_flag IS NOT NULL AND v_feature_flag = to_jsonb(false) THEN
84
+ RETURN jsonb_build_object('allowed', false, 'reason', 'plan',
85
+ 'upgradeUrl', '/settings/subscription');
86
+ END IF;
87
+
88
+ IF p_limit_key IS NOT NULL AND p_used IS NOT NULL THEN
89
+ v_cap := (v_ent->'limits'->>p_limit_key)::numeric;
90
+ IF v_cap IS NOT NULL AND v_cap <> -1 AND (p_used + COALESCE(p_n, 1)) > v_cap THEN
91
+ RETURN jsonb_build_object('allowed', false, 'reason', 'limit',
92
+ 'limit', jsonb_build_object('key', p_limit_key,
93
+ 'max', v_cap,
94
+ 'used', p_used),
95
+ 'upgradeUrl', '/settings/subscription');
96
+ END IF;
97
+ END IF;
98
+ END IF;
99
+
100
+ RETURN NULL;
101
+ END;
102
+ $$;
@@ -0,0 +1,115 @@
1
+ -- ============================================================================
2
+ -- 017_core_addresses.sql — public.addresses becomes part of the SPINE.
3
+ -- ----------------------------------------------------------------------------
4
+ -- The address book was already declared "core" by @fayz-ai/shop's 0012
5
+ -- ("move address/payment concepts OUT of the plugin, into core"), but the SQL
6
+ -- physically shipped inside the shop package. Consequence, live today: any app
7
+ -- that does not install @fayz-ai/shop has no addresses table, while the person
8
+ -- archetype's "Endereços" tab (packages/saas AddressesTab) queries it
9
+ -- unconditionally — beauty-saas, dentist-saas, agency-os and every other
10
+ -- non-shop vertical render "Could not find the table 'public.addresses'".
11
+ --
12
+ -- An address is not e-commerce. It is where a client is delivered to, where a
13
+ -- supplier is collected from, where a staff member lives, where a unit of the
14
+ -- business sits. It belongs beside `people` and `locations`, in the spine.
15
+ --
16
+ -- Replay safety (this file runs on pools that already have the table, via shop
17
+ -- 0012, AND on pools that have never seen it):
18
+ -- • CREATE TABLE IF NOT EXISTS + ADD COLUMN IF NOT EXISTS — additive only
19
+ -- • the owner_type CHECK is dropped and rebuilt WIDER, never narrower, so a
20
+ -- pool holding 'shop_customer' rows keeps them valid
21
+ -- • no DROP of data, no DROP of the shop's own policies
22
+ -- Ordering: the spine runs BEFORE plugin migrations (cli buildMigrationPlan
23
+ -- ① spine → ④ plugin), so on a fresh shop pool this file creates the table and
24
+ -- shop 0012's own CREATE ... IF NOT EXISTS degrades to a no-op.
25
+ --
26
+ -- `owner_type` stays text rather than a FK because the owner is polymorphic:
27
+ -- a core `people` row, a `locations` row, the tenant itself, or — until the
28
+ -- shop's customer table is folded into `people` — a plg_shop_customers row.
29
+ -- ============================================================================
30
+
31
+ -- ----------------------------------------------------------------------------
32
+ -- 1. The table
33
+ -- ----------------------------------------------------------------------------
34
+ CREATE TABLE IF NOT EXISTS public.addresses (
35
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
36
+ tenant_id uuid NOT NULL,
37
+ owner_type text NOT NULL DEFAULT 'person',
38
+ owner_id uuid,
39
+ kind text NOT NULL DEFAULT 'both',
40
+ label text, -- "Casa", "Trabalho", "Depósito"
41
+ recipient text, -- quem recebe, se não for o próprio dono
42
+ phone text,
43
+ postal_code text NOT NULL, -- CEP
44
+ street text NOT NULL,
45
+ number text,
46
+ complement text,
47
+ district text, -- bairro
48
+ city text NOT NULL,
49
+ state text NOT NULL, -- UF
50
+ country text NOT NULL DEFAULT 'BR',
51
+ is_default boolean NOT NULL DEFAULT false,
52
+ metadata jsonb NOT NULL DEFAULT '{}',
53
+ created_at timestamptz NOT NULL DEFAULT now(),
54
+ updated_at timestamptz NOT NULL DEFAULT now()
55
+ );
56
+
57
+ COMMENT ON TABLE public.addresses IS
58
+ 'Core address book. N addresses per owner (person, location, tenant), tenant-scoped. Owned by @fayz-ai/db — plugins read it, none of them own it.';
59
+
60
+ -- Pools provisioned by shop 0012 predate `metadata`/'both' defaults; add what
61
+ -- is missing rather than assuming the 0012 shape.
62
+ ALTER TABLE public.addresses
63
+ ADD COLUMN IF NOT EXISTS owner_type text NOT NULL DEFAULT 'person',
64
+ ADD COLUMN IF NOT EXISTS owner_id uuid,
65
+ ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'both',
66
+ ADD COLUMN IF NOT EXISTS label text,
67
+ ADD COLUMN IF NOT EXISTS recipient text,
68
+ ADD COLUMN IF NOT EXISTS phone text,
69
+ ADD COLUMN IF NOT EXISTS number text,
70
+ ADD COLUMN IF NOT EXISTS complement text,
71
+ ADD COLUMN IF NOT EXISTS district text,
72
+ ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}';
73
+
74
+ -- ----------------------------------------------------------------------------
75
+ -- 2. Constraints — rebuilt WIDER than shop 0012's.
76
+ -- 'staff' and 'partner' are person kinds and already covered by 'person';
77
+ -- what 0012 lacked is nothing — this only re-asserts the same set so a pool
78
+ -- that never ran 0012 gets it, and a pool that did is left unchanged.
79
+ -- ----------------------------------------------------------------------------
80
+ ALTER TABLE public.addresses DROP CONSTRAINT IF EXISTS addresses_owner_type_check;
81
+ ALTER TABLE public.addresses
82
+ ADD CONSTRAINT addresses_owner_type_check
83
+ CHECK (owner_type IN ('person', 'shop_customer', 'location', 'tenant'));
84
+
85
+ ALTER TABLE public.addresses DROP CONSTRAINT IF EXISTS addresses_kind_check;
86
+ ALTER TABLE public.addresses
87
+ ADD CONSTRAINT addresses_kind_check
88
+ CHECK (kind IN ('shipping', 'billing', 'both'));
89
+
90
+ CREATE INDEX IF NOT EXISTS addresses_tenant_idx ON public.addresses (tenant_id);
91
+ CREATE INDEX IF NOT EXISTS addresses_owner_idx ON public.addresses (owner_type, owner_id);
92
+ -- One default per owner per kind, enforced here rather than trusting every
93
+ -- caller to clear the previous one.
94
+ CREATE UNIQUE INDEX IF NOT EXISTS addresses_one_default_idx
95
+ ON public.addresses (owner_type, owner_id, kind) WHERE is_default;
96
+
97
+ DROP TRIGGER IF EXISTS addresses_updated_at ON public.addresses;
98
+ CREATE TRIGGER addresses_updated_at BEFORE UPDATE ON public.addresses
99
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
100
+
101
+ -- ----------------------------------------------------------------------------
102
+ -- 3. RLS — tenant members manage their tenant's rows. Anon gets nothing.
103
+ -- The shop's extra `addresses_self_read` policy (a signed-in storefront
104
+ -- customer reading their own rows) is left untouched: it is additive, and
105
+ -- shop 0012 runs after this file.
106
+ -- ----------------------------------------------------------------------------
107
+ ALTER TABLE public.addresses ENABLE ROW LEVEL SECURITY;
108
+
109
+ DROP POLICY IF EXISTS "addresses_member_all" ON public.addresses;
110
+ CREATE POLICY "addresses_member_all" ON public.addresses FOR ALL TO authenticated
111
+ USING (tenant_id IN (SELECT public.user_tenant_ids()))
112
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
113
+
114
+ GRANT ALL ON public.addresses TO authenticated, service_role;
115
+ REVOKE ALL ON public.addresses FROM anon;
@@ -0,0 +1,482 @@
1
+ -- ============================================================================
2
+ -- 018_global_search.sql — ONE indexed lookup for "find me anything".
3
+ -- ----------------------------------------------------------------------------
4
+ -- The command palette and the agent's findAnything both answered the same
5
+ -- question — "which record is this text?" — by asking every entity separately:
6
+ -- N round-trips of `column ILIKE '%term%'`, accent-sensitive, single-word only,
7
+ -- and unindexable (every one of them a sequential scan). It is correct at
8
+ -- fixture size and unusable at ten thousand clients.
9
+ --
10
+ -- This replaces the fan-out with a spine primitive:
11
+ --
12
+ -- public.fayz_norm(text) fold: strip accents, lowercase, collapse
13
+ -- punctuation. IMMUTABLE, so it can be
14
+ -- INDEXED. Mirrors foldText() in
15
+ -- @fayz-ai/core/src/search/text.ts — the two
16
+ -- must agree or server and client disagree
17
+ -- about what matched.
18
+ -- public.fayz_digits(text) digits only — phones, CPF/CNPJ, SKUs.
19
+ -- public.fayz_search_sources WHAT is searchable. A registry row per
20
+ -- source, the SQL mirror of the client's
21
+ -- entity registry. Plugins and apps add their
22
+ -- own rows from their own migrations; nothing
23
+ -- here is hard-coded into the function.
24
+ -- public.fayz_search_reindex() builds the GIN trigram index for every
25
+ -- registered source FROM THE REGISTRY, so the
26
+ -- index expression is the query expression by
27
+ -- construction (an index built by hand from a
28
+ -- retyped expression is an index the planner
29
+ -- silently refuses to use).
30
+ -- public.fayz_global_search(...) one call, all sources, ranked, capped.
31
+ --
32
+ -- Why trigram GIN and not tsvector full-text: people search a CRM for fragments
33
+ -- and typos ("bigodin", "jose", "1198"), not for stemmed words. `%term%` under
34
+ -- a gin_trgm_ops index is an index scan; under FTS it is not expressible at all.
35
+ --
36
+ -- SECURITY INVOKER on purpose: the search runs as the caller, so RLS decides
37
+ -- which rows exist. `p_tenant_id` narrows, it does not authorize.
38
+ --
39
+ -- Replay safety: every statement is CREATE OR REPLACE / IF NOT EXISTS /
40
+ -- ON CONFLICT DO UPDATE. Re-running is a no-op. Degrades cleanly on a pool
41
+ -- without pg_trgm — the function still answers, just without index support and
42
+ -- without typo tolerance.
43
+ -- ============================================================================
44
+
45
+ -- ----------------------------------------------------------------------------
46
+ -- 0. Extensions (best effort — a managed pool usually has them already)
47
+ -- ----------------------------------------------------------------------------
48
+ DO $$
49
+ DECLARE
50
+ v_schema text := CASE WHEN EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'extensions')
51
+ THEN 'extensions' ELSE 'public' END;
52
+ BEGIN
53
+ IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm') THEN
54
+ BEGIN
55
+ EXECUTE format('CREATE EXTENSION pg_trgm WITH SCHEMA %I', v_schema);
56
+ EXCEPTION WHEN OTHERS THEN
57
+ RAISE NOTICE 'fayz search: pg_trgm unavailable (%), falling back to unindexed LIKE', SQLERRM;
58
+ END;
59
+ END IF;
60
+ IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'btree_gin') THEN
61
+ BEGIN
62
+ EXECUTE format('CREATE EXTENSION btree_gin WITH SCHEMA %I', v_schema);
63
+ EXCEPTION WHEN OTHERS THEN
64
+ RAISE NOTICE 'fayz search: btree_gin unavailable (%), tenant filter stays a recheck', SQLERRM;
65
+ END;
66
+ END IF;
67
+ END $$;
68
+
69
+ -- ----------------------------------------------------------------------------
70
+ -- 1. Folding — the one definition of "same text"
71
+ -- ----------------------------------------------------------------------------
72
+ -- translate() rather than unaccent(): unaccent is STABLE (it depends on a
73
+ -- dictionary), so it cannot appear in an index expression without an IMMUTABLE
74
+ -- wrapper whose correctness depends on nobody ever editing the dictionary.
75
+ -- A fixed character map is IMMUTABLE by construction and covers every accent
76
+ -- the fleet's Latin-script locales actually produce.
77
+ CREATE OR REPLACE FUNCTION public.fayz_norm(p_text text)
78
+ RETURNS text
79
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE
80
+ AS $$
81
+ SELECT btrim(regexp_replace(
82
+ lower(translate(
83
+ coalesce(p_text, ''),
84
+ 'ÀÁÂÃÄÅàáâãäåÈÉÊËèéêëÌÍÎÏìíîïÒÓÔÕÖòóôõöÙÚÛÜùúûüÇçÑñÝýÿŠšŽžØøÆæŒœÐðÞþıŁł',
85
+ 'AAAAAAaaaaaaEEEEeeeeIIIIiiiiOOOOOoooooUUUUuuuuCcNnYyySsZzOoAaOoDdTtiLl'
86
+ )),
87
+ '[^a-z0-9]+', ' ', 'g'))
88
+ $$;
89
+
90
+ COMMENT ON FUNCTION public.fayz_norm(text) IS
91
+ 'Search folding: strip diacritics, lowercase, collapse non-alphanumerics to single spaces. IMMUTABLE so it can be indexed. Mirrored by foldText() in @fayz-ai/core.';
92
+
93
+ CREATE OR REPLACE FUNCTION public.fayz_digits(p_text text)
94
+ RETURNS text
95
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE
96
+ AS $$ SELECT regexp_replace(coalesce(p_text, ''), '[^0-9]+', '', 'g') $$;
97
+
98
+ COMMENT ON FUNCTION public.fayz_digits(text) IS
99
+ 'Digits only — lets "(11) 98765-4321", "11987654321" and "98765" all find the same phone.';
100
+
101
+ -- concat_ws(), concat() and array_to_string() are all STABLE (they route through
102
+ -- type output functions), which bars them from an index expression — Postgres
103
+ -- rejects the CREATE INDEX outright. A haystack has to be assembled from
104
+ -- something IMMUTABLE, and for text arguments with a fixed separator the
105
+ -- assembly genuinely is: same input, same output, forever.
106
+ CREATE OR REPLACE FUNCTION public.fayz_cat(VARIADIC p_parts text[])
107
+ RETURNS text
108
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE
109
+ AS $$ SELECT coalesce(array_to_string(p_parts, ' '), '') $$;
110
+
111
+ COMMENT ON FUNCTION public.fayz_cat(text[]) IS
112
+ 'IMMUTABLE space-join of text values, NULLs skipped. The only concatenation allowed inside a fayz_search_sources haystack_expr — concat_ws is STABLE and cannot be indexed.';
113
+
114
+ -- ----------------------------------------------------------------------------
115
+ -- 2. The source registry — what global search is allowed to look at
116
+ -- ----------------------------------------------------------------------------
117
+ CREATE TABLE IF NOT EXISTS public.fayz_search_sources (
118
+ -- Matches the client's deriveEntityKey(). Sources with a `kind_column` emit
119
+ -- `<entity_key>:<kind>` per row, which is exactly how archetype entities are
120
+ -- keyed on the client ('person' + kind 'customer' → 'person:customer').
121
+ entity_key text PRIMARY KEY,
122
+ relation text NOT NULL, -- 'public.people'
123
+ id_column text NOT NULL DEFAULT 'id',
124
+ tenant_column text NOT NULL DEFAULT 'tenant_id',
125
+ kind_column text,
126
+ title_expr text NOT NULL, -- what the row is CALLED
127
+ subtitle_expr text, -- the disambiguating line
128
+ haystack_expr text NOT NULL, -- everything matchable
129
+ digits_expr text, -- phone/document columns
130
+ filter_expr text, -- extra SQL predicate
131
+ icon text,
132
+ weight numeric NOT NULL DEFAULT 1, -- multiplies the rank
133
+ enabled boolean NOT NULL DEFAULT true,
134
+ created_at timestamptz NOT NULL DEFAULT now()
135
+ );
136
+
137
+ COMMENT ON TABLE public.fayz_search_sources IS
138
+ 'Registry of searchable relations for public.fayz_global_search. Readable by any member, writable only by the migration role — the expressions are executed as SQL.';
139
+
140
+ ALTER TABLE public.fayz_search_sources ENABLE ROW LEVEL SECURITY;
141
+
142
+ DO $$ BEGIN
143
+ IF NOT EXISTS (
144
+ SELECT 1 FROM pg_policies
145
+ WHERE schemaname = 'public' AND tablename = 'fayz_search_sources'
146
+ AND policyname = 'fayz_search_sources_read'
147
+ ) THEN
148
+ -- Not tenant-scoped: the catalog describes SHAPES, never rows. Row access
149
+ -- is decided by each source relation's own RLS when the search runs.
150
+ CREATE POLICY "fayz_search_sources_read" ON public.fayz_search_sources
151
+ FOR SELECT TO authenticated USING (true);
152
+ END IF;
153
+ END $$;
154
+
155
+ REVOKE ALL ON public.fayz_search_sources FROM anon;
156
+ GRANT SELECT ON public.fayz_search_sources TO authenticated;
157
+
158
+ -- ----------------------------------------------------------------------------
159
+ -- 3. Spine sources
160
+ -- ----------------------------------------------------------------------------
161
+ -- `weight` is the editorial call about what a business means when it types a
162
+ -- name: a person first, then what it sells, then where and when. Ledger rows
163
+ -- are found by number, so they sit lower.
164
+ INSERT INTO public.fayz_search_sources
165
+ (entity_key, relation, kind_column, title_expr, subtitle_expr, haystack_expr, digits_expr, filter_expr, icon, weight)
166
+ VALUES
167
+ ('person', 'public.people', 'kind',
168
+ 'name',
169
+ $sub$nullif(concat_ws(' · ', nullif(email,''), nullif(phone,'')), '')$sub$,
170
+ $hay$public.fayz_cat(name, email, phone, document_number, notes, public.fayz_cat(VARIADIC tags))$hay$,
171
+ $dig$concat_ws(' ', phone, document_number)$dig$,
172
+ NULL, 'User', 1.00),
173
+
174
+ ('product', 'public.products', NULL,
175
+ 'name',
176
+ $sub$nullif(concat_ws(' · ', nullif(sku,''), nullif(description,'')), '')$sub$,
177
+ $hay$public.fayz_cat(name, sku, description, public.fayz_cat(VARIADIC tags))$hay$,
178
+ $dig$sku$dig$,
179
+ NULL, 'Box', 0.95),
180
+
181
+ ('service', 'public.services', NULL,
182
+ 'name',
183
+ $sub$nullif(description, '')$sub$,
184
+ $hay$public.fayz_cat(name, description, public.fayz_cat(VARIADIC tags))$hay$,
185
+ NULL,
186
+ NULL, 'Sparkles', 0.95),
187
+
188
+ ('location', 'public.locations', 'kind',
189
+ 'name',
190
+ $sub$nullif(concat_ws(' · ', nullif(city,''), nullif(state,'')), '')$sub$,
191
+ $hay$public.fayz_cat(name, email, phone, address, city, state, postal_code)$hay$,
192
+ $dig$concat_ws(' ', phone, postal_code)$dig$,
193
+ NULL, 'MapPin', 0.80),
194
+
195
+ ('category', 'public.categories', 'kind',
196
+ 'name',
197
+ $sub$nullif(slug, '')$sub$,
198
+ $hay$public.fayz_cat(name, slug)$hay$,
199
+ NULL,
200
+ NULL, 'Tag', 0.60),
201
+
202
+ ('address', 'public.addresses', NULL,
203
+ $tit$concat_ws(', ', nullif(street,''), nullif(number,''))$tit$,
204
+ $sub$nullif(concat_ws(' · ', nullif(district,''), nullif(city,''), nullif(postal_code,'')), '')$sub$,
205
+ $hay$public.fayz_cat(label, recipient, street, number, complement, district, city, state, postal_code)$hay$,
206
+ $dig$concat_ws(' ', postal_code, phone)$dig$,
207
+ NULL, 'MapPin', 0.55)
208
+ ON CONFLICT (entity_key) DO UPDATE SET
209
+ relation = EXCLUDED.relation,
210
+ kind_column = EXCLUDED.kind_column,
211
+ title_expr = EXCLUDED.title_expr,
212
+ subtitle_expr = EXCLUDED.subtitle_expr,
213
+ haystack_expr = EXCLUDED.haystack_expr,
214
+ digits_expr = EXCLUDED.digits_expr,
215
+ filter_expr = EXCLUDED.filter_expr,
216
+ icon = EXCLUDED.icon,
217
+ weight = EXCLUDED.weight;
218
+
219
+ -- Order/transaction sources only make sense once those tables carry a human
220
+ -- reference; they are registered by the plugins that own that shape.
221
+
222
+ -- ----------------------------------------------------------------------------
223
+ -- 4. Index builder — derived from the registry, never retyped
224
+ -- ----------------------------------------------------------------------------
225
+ CREATE OR REPLACE FUNCTION public.fayz_search_reindex()
226
+ RETURNS integer
227
+ LANGUAGE plpgsql VOLATILE
228
+ SET search_path = public, extensions, pg_temp
229
+ AS $$
230
+ DECLARE
231
+ v_src record;
232
+ v_rel text;
233
+ v_name text;
234
+ v_built integer := 0;
235
+ v_has_trgm boolean := EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm');
236
+ v_has_gin boolean := EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'btree_gin');
237
+ BEGIN
238
+ IF NOT v_has_trgm THEN
239
+ RAISE NOTICE 'fayz search: pg_trgm missing — no index built, search falls back to sequential LIKE';
240
+ RETURN 0;
241
+ END IF;
242
+
243
+ FOR v_src IN
244
+ SELECT * FROM public.fayz_search_sources WHERE enabled ORDER BY entity_key
245
+ LOOP
246
+ v_rel := to_regclass(v_src.relation)::text;
247
+ CONTINUE WHEN v_rel IS NULL;
248
+ -- A view cannot be indexed; only its base tables can.
249
+ CONTINUE WHEN (SELECT c.relkind FROM pg_class c WHERE c.oid = to_regclass(v_src.relation))
250
+ NOT IN ('r', 'p');
251
+
252
+ -- Name derived from (relation, expression): two sources over the same
253
+ -- columns share one index instead of building it twice.
254
+ v_name := 'fayz_search_' || substr(md5(v_rel || '|' || v_src.haystack_expr), 1, 20);
255
+
256
+ BEGIN
257
+ IF v_has_gin THEN
258
+ -- Composite: the tenant equality is resolved INSIDE the index, so a
259
+ -- 500-tenant pool does not trigram-scan its neighbours' rows.
260
+ EXECUTE format(
261
+ 'CREATE INDEX IF NOT EXISTS %I ON %s USING gin (%I, public.fayz_norm(%s) gin_trgm_ops)',
262
+ v_name, v_rel, v_src.tenant_column, v_src.haystack_expr);
263
+ ELSE
264
+ EXECUTE format(
265
+ 'CREATE INDEX IF NOT EXISTS %I ON %s USING gin (public.fayz_norm(%s) gin_trgm_ops)',
266
+ v_name, v_rel, v_src.haystack_expr);
267
+ END IF;
268
+ v_built := v_built + 1;
269
+ EXCEPTION WHEN OTHERS THEN
270
+ -- Most likely cause: btree_gin present but with no GIN opclass for the
271
+ -- tenant column's type. A trigram-only index still serves every query;
272
+ -- the tenant equality just becomes a recheck.
273
+ BEGIN
274
+ EXECUTE format(
275
+ 'CREATE INDEX IF NOT EXISTS %I ON %s USING gin (public.fayz_norm(%s) gin_trgm_ops)',
276
+ v_name, v_rel, v_src.haystack_expr);
277
+ v_built := v_built + 1;
278
+ EXCEPTION WHEN OTHERS THEN
279
+ RAISE NOTICE 'fayz search: could not index % (%): %', v_src.entity_key, v_rel, SQLERRM;
280
+ END;
281
+ END;
282
+ END LOOP;
283
+
284
+ RETURN v_built;
285
+ END $$;
286
+
287
+ COMMENT ON FUNCTION public.fayz_search_reindex() IS
288
+ 'Builds/refreshes the GIN trigram index for every enabled row of fayz_search_sources. Call it after inserting your own source row.';
289
+
290
+ SELECT public.fayz_search_reindex();
291
+
292
+ -- ----------------------------------------------------------------------------
293
+ -- 5. The search itself
294
+ -- ----------------------------------------------------------------------------
295
+ DO $$ BEGIN
296
+ IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'fayz_search_hit'
297
+ AND typnamespace = 'public'::regnamespace) THEN
298
+ CREATE TYPE public.fayz_search_hit AS (
299
+ entity_key text,
300
+ record_id text,
301
+ title text,
302
+ subtitle text,
303
+ score real
304
+ );
305
+ END IF;
306
+ END $$;
307
+
308
+ CREATE OR REPLACE FUNCTION public.fayz_global_search(
309
+ p_query text,
310
+ p_tenant_id uuid,
311
+ p_entity_keys text[] DEFAULT NULL,
312
+ p_limit integer DEFAULT 30,
313
+ p_per_source integer DEFAULT 8
314
+ )
315
+ RETURNS TABLE (entity_key text, record_id text, title text, subtitle text, score real)
316
+ LANGUAGE plpgsql STABLE SECURITY INVOKER
317
+ SET search_path = public, extensions, pg_temp
318
+ AS $$
319
+ DECLARE
320
+ v_q text := public.fayz_norm(p_query);
321
+ v_digits text := public.fayz_digits(p_query);
322
+ v_tokens text[];
323
+ v_token text;
324
+ v_src record;
325
+ v_acc public.fayz_search_hit[] := '{}';
326
+ v_rows public.fayz_search_hit[];
327
+ v_title text;
328
+ v_hay text;
329
+ v_match text;
330
+ v_where text;
331
+ v_key text;
332
+ v_kinds text[];
333
+ v_sql text;
334
+ v_phase integer;
335
+ v_has_trgm boolean := EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm');
336
+ BEGIN
337
+ -- One character matches every row; two is where a lookup starts to mean
338
+ -- something. The client enforces the same floor before it even calls.
339
+ IF v_q IS NULL OR length(v_q) < 2 THEN RETURN; END IF;
340
+ IF p_tenant_id IS NULL THEN RETURN; END IF;
341
+
342
+ v_tokens := array_remove(string_to_array(v_q, ' '), '');
343
+ p_limit := least(greatest(coalesce(p_limit, 30), 1), 200);
344
+ p_per_source := least(greatest(coalesce(p_per_source, 8), 1), 50);
345
+ IF length(v_digits) < 4 THEN v_digits := ''; END IF;
346
+
347
+ IF v_has_trgm THEN
348
+ BEGIN
349
+ -- Touching a pg_trgm function loads the library, which is what registers
350
+ -- its GUCs; before that they are unrecognized placeholders only a
351
+ -- superuser may set. pg_trgm ships word_similarity_threshold at 0.6,
352
+ -- which forgives a wrong letter but not the transposition people
353
+ -- actually type ("bigdoinho"). LOCAL, so nothing outside this call
354
+ -- inherits a looser notion of "close" — and best-effort, because a pool
355
+ -- that refuses the setting should still search, just less forgivingly.
356
+ PERFORM similarity('a', 'a');
357
+ PERFORM set_config('pg_trgm.word_similarity_threshold', '0.40', true);
358
+ EXCEPTION WHEN OTHERS THEN NULL;
359
+ END;
360
+ END IF;
361
+
362
+ -- Two phases. Phase 1 is literal: every token must appear, which is exactly
363
+ -- what the trigram index answers, and it is what the user meant 99 times out
364
+ -- of 100. Phase 2 only runs when phase 1 found NOTHING, and only then pays
365
+ -- for fuzzy matching. ORing the two would have made every ordinary query
366
+ -- carry the cost — and would have let near-misses dilute exact answers.
367
+ <<phases>>
368
+ FOR v_phase IN 1..2 LOOP
369
+ EXIT WHEN v_phase = 2 AND (
370
+ coalesce(array_length(v_acc, 1), 0) > 0 OR NOT v_has_trgm OR length(v_q) < 4
371
+ );
372
+
373
+ FOR v_src IN
374
+ SELECT s.* FROM public.fayz_search_sources s
375
+ WHERE s.enabled
376
+ AND to_regclass(s.relation) IS NOT NULL
377
+ AND (
378
+ p_entity_keys IS NULL
379
+ OR s.entity_key = ANY (p_entity_keys)
380
+ OR (s.kind_column IS NOT NULL AND EXISTS (
381
+ SELECT 1 FROM unnest(p_entity_keys) k WHERE k LIKE s.entity_key || ':%'))
382
+ )
383
+ ORDER BY s.weight DESC, s.entity_key
384
+ LOOP
385
+ v_title := 'public.fayz_norm(' || v_src.title_expr || ')';
386
+ v_hay := 'public.fayz_norm(' || v_src.haystack_expr || ')';
387
+
388
+ IF v_phase = 1 THEN
389
+ -- Every token must land somewhere. Emitted as separate literal LIKEs (not
390
+ -- `LIKE ALL (array)`) because that is the only form the planner can push
391
+ -- into the trigram index. The tokens came out of fayz_norm, so they are
392
+ -- already [a-z0-9 ] — quote_literal is belt and braces.
393
+ v_where := '';
394
+ FOREACH v_token IN ARRAY v_tokens LOOP
395
+ IF v_where <> '' THEN v_where := v_where || ' AND '; END IF;
396
+ v_where := v_where || v_hay || ' LIKE ' || quote_literal('%' || v_token || '%');
397
+ END LOOP;
398
+ v_match := '(' || v_where || ')';
399
+
400
+ IF v_digits <> '' AND v_src.digits_expr IS NOT NULL THEN
401
+ v_match := v_match || ' OR public.fayz_digits(' || v_src.digits_expr || ') LIKE '
402
+ || quote_literal('%' || v_digits || '%');
403
+ END IF;
404
+ ELSE
405
+ -- `<%` (word_similarity) rather than `%` (similarity): the right-hand
406
+ -- side is a whole haystack, and plain similarity of a 7-letter query
407
+ -- against a 200-character row is always below any usable threshold.
408
+ -- Measured against the INDEXED expression so phase 2 is an index scan too.
409
+ v_match := quote_literal(v_q) || ' <% ' || v_hay;
410
+ END IF;
411
+
412
+ -- Archetype sources emit one key per kind. When the caller asked for a
413
+ -- subset of kinds, push that INTO the source query: filtering after a
414
+ -- per-source LIMIT would let a populous kind starve the one being asked for.
415
+ v_key := quote_literal(v_src.entity_key);
416
+ IF v_src.kind_column IS NOT NULL THEN
417
+ v_key := v_key || ' || '':'' || ' || quote_ident(v_src.kind_column);
418
+ IF p_entity_keys IS NOT NULL AND NOT (v_src.entity_key = ANY (p_entity_keys)) THEN
419
+ SELECT array_agg(substr(k, length(v_src.entity_key) + 2))
420
+ INTO v_kinds
421
+ FROM unnest(p_entity_keys) k
422
+ WHERE k LIKE v_src.entity_key || ':%';
423
+ IF v_kinds IS NOT NULL THEN
424
+ v_match := '(' || v_match || ') AND ' || quote_ident(v_src.kind_column)
425
+ || ' = ANY (' || quote_literal(v_kinds::text) || '::text[])';
426
+ END IF;
427
+ END IF;
428
+ END IF;
429
+
430
+ v_sql :=
431
+ 'SELECT ' || v_key || '::text AS entity_key, '
432
+ || quote_ident(v_src.id_column) || '::text AS record_id, '
433
+ || '(' || v_src.title_expr || ')::text AS title, '
434
+ || '(' || coalesce(v_src.subtitle_expr, 'NULL') || ')::text AS subtitle, '
435
+ -- Rank ladder, mirrored by scoreCandidate() on the client. The client
436
+ -- re-scores what it receives; this ordering decides what survives the
437
+ -- per-source LIMIT, which is the decision the client cannot make.
438
+ || '(GREATEST('
439
+ || ' CASE WHEN ' || v_title || ' = ' || quote_literal(v_q) || ' THEN 1.00'
440
+ || ' WHEN ' || v_title || ' LIKE ' || quote_literal(v_q || '%') || ' THEN 0.94'
441
+ || ' WHEN ' || v_title || ' LIKE ' || quote_literal('% ' || v_q || '%') || ' THEN 0.88'
442
+ || ' WHEN ' || v_title || ' LIKE ' || quote_literal('%' || v_q || '%') || ' THEN 0.76'
443
+ || ' WHEN ' || v_hay || ' LIKE ' || quote_literal('%' || v_q || '%') || ' THEN 0.62'
444
+ || ' ELSE 0.40 END'
445
+ || CASE WHEN v_has_trgm
446
+ THEN ', word_similarity(' || quote_literal(v_q) || ', ' || v_title || ')::numeric * 0.60'
447
+ ELSE '' END
448
+ || ') * ' || v_src.weight || ')::real AS score'
449
+ || ' FROM ' || to_regclass(v_src.relation)::text
450
+ || ' WHERE ' || quote_ident(v_src.tenant_column) || ' = $1'
451
+ || CASE WHEN v_src.filter_expr IS NULL THEN '' ELSE ' AND (' || v_src.filter_expr || ')' END
452
+ || ' AND (' || v_match || ')'
453
+ || ' ORDER BY 5 DESC LIMIT ' || p_per_source;
454
+
455
+ BEGIN
456
+ EXECUTE 'SELECT coalesce(array_agg(x::public.fayz_search_hit), ''{}''::public.fayz_search_hit[]) FROM ('
457
+ || v_sql || ') x'
458
+ INTO v_rows USING p_tenant_id;
459
+ v_acc := v_acc || v_rows;
460
+ EXCEPTION WHEN OTHERS THEN
461
+ -- One misconfigured source must never blank the whole search box.
462
+ RAISE NOTICE 'fayz search: source % failed: %', v_src.entity_key, SQLERRM;
463
+ END;
464
+ END LOOP;
465
+ END LOOP phases;
466
+
467
+ RETURN QUERY
468
+ SELECT h.entity_key, h.record_id, h.title, h.subtitle, h.score
469
+ FROM unnest(v_acc) h
470
+ ORDER BY h.score DESC, length(coalesce(h.title, '')) ASC, h.title
471
+ LIMIT p_limit;
472
+ END $$;
473
+
474
+ COMMENT ON FUNCTION public.fayz_global_search(text, uuid, text[], integer, integer) IS
475
+ 'Global search across every registered source in one round-trip. SECURITY INVOKER — RLS decides visibility; p_tenant_id only narrows. p_entity_keys restricts to the keys the caller is allowed to see.';
476
+
477
+ REVOKE ALL ON FUNCTION public.fayz_global_search(text, uuid, text[], integer, integer) FROM PUBLIC;
478
+ REVOKE ALL ON FUNCTION public.fayz_global_search(text, uuid, text[], integer, integer) FROM anon;
479
+ GRANT EXECUTE ON FUNCTION public.fayz_global_search(text, uuid, text[], integer, integer) TO authenticated;
480
+
481
+ GRANT EXECUTE ON FUNCTION public.fayz_norm(text) TO authenticated, anon;
482
+ GRANT EXECUTE ON FUNCTION public.fayz_digits(text) TO authenticated, anon;
@@ -0,0 +1,20 @@
1
+ -- 019: Person-first team model — link a membership (login + RBAC role) to the
2
+ -- person record it represents.
3
+ --
4
+ -- A "team member" is a PERSON (public.people, kind in the app's team.personKinds).
5
+ -- public.tenant_members is the OPTIONAL access overlay: a row exists only when a
6
+ -- person is granted login + role. person_id ties the two. Nullable both ways:
7
+ -- * login-only account (e.g. the owner who signed up) -> tenant_members with person_id NULL
8
+ -- * team person without access (e.g. a teacher who never logs in) -> people row, NO tenant_members row
9
+ -- Idempotent + additive (safe to re-apply).
10
+
11
+ ALTER TABLE public.tenant_members
12
+ ADD COLUMN IF NOT EXISTS person_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
13
+
14
+ -- A person maps to at most one membership per tenant (partial: many NULLs allowed).
15
+ CREATE UNIQUE INDEX IF NOT EXISTS tenant_members_tenant_person_uidx
16
+ ON public.tenant_members (tenant_id, person_id)
17
+ WHERE person_id IS NOT NULL;
18
+
19
+ CREATE INDEX IF NOT EXISTS tenant_members_person_idx
20
+ ON public.tenant_members (person_id);
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.8.0",
6
+ "version": "0.8.3",
7
7
  "description": "Fayz SDK database layer — Drizzle schema primitives, spine references, and migration helpers shared across plugins.",
8
8
  "type": "module",
9
9
  "main": "./dist/index.cjs",