@fayz-ai/db 0.9.0 → 0.11.0

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.
@@ -0,0 +1,55 @@
1
+ -- ============================================================================
2
+ -- 034 — custom_fields: the tenant's own fields, on every core record.
3
+ --
4
+ -- The rule this exists to make true: customization lives in DATA, never in
5
+ -- schema. Every tenant in a pool shares one identical schema — no per-tenant
6
+ -- DDL, no schema branch. A merchant asking for "tipo de pele on the client
7
+ -- record" gets an INSERT into plg_entity_fields (035), not a migration.
8
+ --
9
+ -- Why a column and not `metadata`: metadata is the system's bag (a block's
10
+ -- title, an order's direction, Google's etag). custom_fields is the merchant's
11
+ -- content, and only it is governed by the registry. Keeping them apart is what
12
+ -- lets a future connector ship "this tenant's fields" as one predictable
13
+ -- payload instead of guessing which keys are internal.
14
+ --
15
+ -- All eleven archetypes at once. A partial rollout produces "works on the
16
+ -- entities somebody remembered", which is the failure mode 026 refused when it
17
+ -- declined to put an audit trigger on one table at a time.
18
+ --
19
+ -- Idempotent.
20
+ -- ============================================================================
21
+
22
+ ALTER TABLE public.people ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
23
+ ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
24
+ ALTER TABLE public.products ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
25
+ ALTER TABLE public.services ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
26
+ ALTER TABLE public.orders ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
27
+ ALTER TABLE public.order_items ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
28
+ ALTER TABLE public.transactions ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
29
+ ALTER TABLE public.appointments ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
30
+ ALTER TABLE public.appointment_items ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
31
+ ALTER TABLE public.schedules ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
32
+ ALTER TABLE public.locations ADD COLUMN IF NOT EXISTS custom_fields jsonb NOT NULL DEFAULT '{}'::jsonb;
33
+
34
+ COMMENT ON COLUMN public.people.custom_fields IS
35
+ 'Fields this tenant declared for its own records. Keys are governed by '
36
+ 'plg_entity_fields (035) and validated on write; never free-form. Distinct '
37
+ 'from metadata, which is the system''s own bag.';
38
+
39
+ -- ---------------------------------------------------------------------------
40
+ -- Indexes: only where there is use, and only by platform decision.
41
+ --
42
+ -- The reason `metadata` became unfilterable is not jsonb — it is that no
43
+ -- metadata column in this tree has ever had an index (128 references, zero).
44
+ -- jsonb_path_ops because the filter path compiles to containment (@>), the same
45
+ -- choice and same reason as plg_entity_records_data_gin.
46
+ --
47
+ -- The other eight archetypes carry the column and no index until something
48
+ -- actually queries them. Promoting one is a migration, deliberately.
49
+ -- ---------------------------------------------------------------------------
50
+ CREATE INDEX IF NOT EXISTS people_custom_fields_gin
51
+ ON public.people USING gin (custom_fields jsonb_path_ops);
52
+ CREATE INDEX IF NOT EXISTS appointments_custom_fields_gin
53
+ ON public.appointments USING gin (custom_fields jsonb_path_ops);
54
+ CREATE INDEX IF NOT EXISTS products_custom_fields_gin
55
+ ON public.products USING gin (custom_fields jsonb_path_ops);
@@ -0,0 +1,148 @@
1
+ -- ============================================================================
2
+ -- 035 — plg_entity_fields: which fields exist, for whom, and what they accept.
3
+ --
4
+ -- The half of 034 that makes custom_fields governed instead of free-form. A key
5
+ -- that is not declared here is refused on write; UI, validation and reports are
6
+ -- generated from these rows, not hardcoded.
7
+ --
8
+ -- tenant_id NULL is the vertical's own vocabulary (the salon anamnesis a pool
9
+ -- ships with); non-null is what ONE merchant defined. Same convention, and the
10
+ -- same reason, as plg_shop_rule_attributes: widening the vocabulary is an
11
+ -- INSERT. Two partial unique indexes keep the halves from colliding.
12
+ --
13
+ -- This is also the AI builder's whole surface for extra fields: a row here, no
14
+ -- DDL, no migration. The column it fills already exists everywhere (034), which
15
+ -- is what makes that boundary enforceable rather than merely stated.
16
+ --
17
+ -- Idempotent.
18
+ -- ============================================================================
19
+
20
+ CREATE TABLE IF NOT EXISTS public.plg_entity_fields (
21
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
22
+
23
+ -- NULL = the vertical's vocabulary, readable by every tenant in the pool and
24
+ -- writable by none of them. Non-null = this tenant's own field.
25
+ tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE,
26
+
27
+ -- The record kind this field lives on: 'person', 'appointment', 'product'…
28
+ -- No CHECK — the next archetype is a value, not a migration. The closed set
29
+ -- lives in TS, where it can throw a useful error naming the valid ones.
30
+ reference text NOT NULL CHECK (reference ~ '^[a-z][a-z0-9_]*$'),
31
+
32
+ -- The key inside custom_fields. IMMUTABLE once written — see the trigger.
33
+ key text NOT NULL CHECK (key ~ '^[a-z][a-zA-Z0-9_]*$'),
34
+
35
+ -- A closed vocabulary on purpose: a type is a MODEL change (this CHECK, the
36
+ -- TS union, and every renderer), not an insert. Mirrors ENTITY_FIELD_TYPES.
37
+ type text NOT NULL CHECK (type IN (
38
+ 'text', 'richtext', 'number', 'boolean', 'json',
39
+ 'image', 'url', 'ref', 'point', 'date', 'datetime', 'select'
40
+ )),
41
+
42
+ label text,
43
+ help text,
44
+ required boolean NOT NULL DEFAULT false,
45
+ default_value jsonb,
46
+ -- min/max/pattern/enum — read by validateEntityRecordData, not by Postgres.
47
+ validation jsonb NOT NULL DEFAULT '{}'::jsonb,
48
+ -- The choices, when type = 'select'.
49
+ options jsonb,
50
+ position integer NOT NULL DEFAULT 0,
51
+
52
+ -- 'declared' came from code and is re-asserted on every deploy; 'merchant'
53
+ -- came from the admin with no deploy. A deploy never deletes a merchant row —
54
+ -- a declared key ADOPTS a colliding merchant key instead.
55
+ origin text NOT NULL DEFAULT 'merchant' CHECK (origin IN ('declared', 'merchant')),
56
+ owner text,
57
+
58
+ -- Soft: a value already written to custom_fields must stay readable after the
59
+ -- field leaves the form.
60
+ archived_at timestamptz,
61
+
62
+ created_at timestamptz NOT NULL DEFAULT now(),
63
+ updated_at timestamptz NOT NULL DEFAULT now()
64
+ );
65
+
66
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_entity_fields_core_key
67
+ ON public.plg_entity_fields (reference, key) WHERE tenant_id IS NULL;
68
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_entity_fields_tenant_key
69
+ ON public.plg_entity_fields (tenant_id, reference, key) WHERE tenant_id IS NOT NULL;
70
+ CREATE INDEX IF NOT EXISTS plg_entity_fields_lookup
71
+ ON public.plg_entity_fields (tenant_id, reference, position) WHERE archived_at IS NULL;
72
+
73
+ -- ---------------------------------------------------------------------------
74
+ -- The machine key does not move.
75
+ --
76
+ -- The label is the merchant's to rename whenever they like. The key is the name
77
+ -- of data already written into custom_fields on every record, and the thing an
78
+ -- integration maps to. Renaming it silently orphans every value ever stored.
79
+ -- ---------------------------------------------------------------------------
80
+ CREATE OR REPLACE FUNCTION public.plg_entity_fields_key_is_immutable()
81
+ RETURNS trigger
82
+ LANGUAGE plpgsql
83
+ SET search_path = public
84
+ AS $fn$
85
+ BEGIN
86
+ IF NEW.key IS DISTINCT FROM OLD.key OR NEW.reference IS DISTINCT FROM OLD.reference THEN
87
+ RAISE EXCEPTION
88
+ 'A chave de máquina de um campo não muda (%.% → %.%). O rótulo é livre; a '
89
+ 'chave é o nome do dado já gravado em custom_fields e o que uma integração '
90
+ 'mapeia. Para trocar, arquive este campo e crie outro.',
91
+ OLD.reference, OLD.key, NEW.reference, NEW.key;
92
+ END IF;
93
+ RETURN NEW;
94
+ END;
95
+ $fn$;
96
+
97
+ DROP TRIGGER IF EXISTS plg_entity_fields_immutable_key ON public.plg_entity_fields;
98
+ CREATE TRIGGER plg_entity_fields_immutable_key
99
+ BEFORE UPDATE ON public.plg_entity_fields
100
+ FOR EACH ROW EXECUTE FUNCTION public.plg_entity_fields_key_is_immutable();
101
+
102
+ DROP TRIGGER IF EXISTS plg_entity_fields_updated_at ON public.plg_entity_fields;
103
+ CREATE TRIGGER plg_entity_fields_updated_at
104
+ BEFORE UPDATE ON public.plg_entity_fields
105
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
106
+
107
+ -- ---------------------------------------------------------------------------
108
+ -- RLS and the default-privilege trap.
109
+ --
110
+ -- 011 closed `anon` on every table and on future ones. It did NOT close
111
+ -- `authenticated`, which is still born with the full set on any new public
112
+ -- table — here that would be every tenant editing every other tenant's field
113
+ -- definitions. Stripped first, granted back only what is needed (029's block).
114
+ -- ---------------------------------------------------------------------------
115
+ REVOKE ALL ON public.plg_entity_fields FROM anon, authenticated;
116
+
117
+ ALTER TABLE public.plg_entity_fields ENABLE ROW LEVEL SECURITY;
118
+
119
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_entity_fields TO authenticated;
120
+
121
+ -- Reads the vertical's vocabulary AND its own. The NULL half is what a pool
122
+ -- ships with, and a tenant has to see it to render a form from it.
123
+ DROP POLICY IF EXISTS plg_entity_fields_member_read ON public.plg_entity_fields;
124
+ CREATE POLICY plg_entity_fields_member_read ON public.plg_entity_fields
125
+ FOR SELECT TO authenticated
126
+ USING (tenant_id IS NULL OR tenant_id IN (SELECT public.user_tenant_ids()));
127
+
128
+ -- Writes only its own. `tenant_id IS NULL` fails these three by construction —
129
+ -- NULL IN (…) is never true — so no merchant can edit the pool's vocabulary.
130
+ DROP POLICY IF EXISTS plg_entity_fields_member_insert ON public.plg_entity_fields;
131
+ CREATE POLICY plg_entity_fields_member_insert ON public.plg_entity_fields
132
+ FOR INSERT TO authenticated
133
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
134
+
135
+ DROP POLICY IF EXISTS plg_entity_fields_member_update ON public.plg_entity_fields;
136
+ CREATE POLICY plg_entity_fields_member_update ON public.plg_entity_fields
137
+ FOR UPDATE TO authenticated
138
+ USING (tenant_id IN (SELECT public.user_tenant_ids()))
139
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
140
+
141
+ DROP POLICY IF EXISTS plg_entity_fields_member_delete ON public.plg_entity_fields;
142
+ CREATE POLICY plg_entity_fields_member_delete ON public.plg_entity_fields
143
+ FOR DELETE TO authenticated
144
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
145
+
146
+ COMMENT ON TABLE public.plg_entity_fields IS
147
+ 'Per-tenant field registry for custom_fields (034). tenant_id NULL is the '
148
+ 'vertical vocabulary — readable by every tenant, writable by none.';
@@ -0,0 +1,84 @@
1
+ -- ============================================================================
2
+ -- analytics_run_batch — one round trip for a whole dashboard
3
+ -- ----------------------------------------------------------------------------
4
+ -- A card fetches itself. That is the right ownership (a tile added to a pack
5
+ -- must not require editing a page-level query), and it is why opening
6
+ -- /analytics fired FORTY-ONE requests: twenty-two cards, most of them also
7
+ -- asking for the comparison window, each its own POST. Change the date range
8
+ -- and all forty-one go again.
9
+ --
10
+ -- Nothing about the ownership needs to change — only the transport. This takes
11
+ -- an ARRAY of the exact same arguments analytics_run already accepts and
12
+ -- returns the answers in order, so a dashboard is one call and a card is still
13
+ -- the thing that declares its own query.
14
+ --
15
+ -- The engine itself is untouched: every element goes through analytics_run,
16
+ -- which means the allowlist, the identifier guard, the fixed aggregate
17
+ -- vocabulary and SECURITY INVOKER still decide what any of this can read. A
18
+ -- batch cannot reach a row a single call could not.
19
+ --
20
+ -- Per-element failure is contained. One card naming a column that does not
21
+ -- exist must not blank the other twenty-one, so each iteration runs in its own
22
+ -- subtransaction and a failure is RETURNED as {"error", "code"} at that index
23
+ -- rather than raised — which is also what lets the client render one broken
24
+ -- tile beside twenty-one live ones.
25
+ --
26
+ -- Requires 024_analytics_engine.sql. Idempotent.
27
+ -- ============================================================================
28
+
29
+ CREATE OR REPLACE FUNCTION public.analytics_run_batch(p_requests jsonb)
30
+ RETURNS jsonb
31
+ LANGUAGE plpgsql
32
+ STABLE
33
+ AS $$
34
+ DECLARE
35
+ v_req jsonb;
36
+ v_result jsonb;
37
+ v_out jsonb := '[]'::jsonb;
38
+ BEGIN
39
+ IF p_requests IS NULL OR jsonb_typeof(p_requests) <> 'array' THEN
40
+ RAISE EXCEPTION 'analytics: batch expects an array of requests' USING ERRCODE = '22023';
41
+ END IF;
42
+
43
+ -- A cap, not a convenience: this is a caller-supplied loop over a function
44
+ -- that reads tables, and an unbounded array is a way to ask one request to do
45
+ -- unbounded work. The largest pack in the product is ~30 cards.
46
+ IF jsonb_array_length(p_requests) > 100 THEN
47
+ RAISE EXCEPTION 'analytics: batch of % exceeds the limit of 100',
48
+ jsonb_array_length(p_requests) USING ERRCODE = '22023';
49
+ END IF;
50
+
51
+ FOR v_req IN SELECT * FROM jsonb_array_elements(p_requests)
52
+ LOOP
53
+ BEGIN
54
+ v_result := public.analytics_run(
55
+ p_source => v_req ->> 'source',
56
+ p_tenant_id => NULLIF(v_req ->> 'tenant_id', '')::uuid,
57
+ p_from => NULLIF(v_req ->> 'from', '')::timestamptz,
58
+ p_to => NULLIF(v_req ->> 'to', '')::timestamptz,
59
+ p_dimensions => COALESCE(v_req -> 'dimensions', '[]'::jsonb),
60
+ p_measures => COALESCE(v_req -> 'measures', '[]'::jsonb),
61
+ p_filters => COALESCE(v_req -> 'filters', '{}'::jsonb),
62
+ p_search => NULLIF(v_req ->> 'search', ''),
63
+ p_search_columns => CASE
64
+ WHEN jsonb_typeof(v_req -> 'search_columns') = 'array'
65
+ THEN ARRAY(SELECT jsonb_array_elements_text(v_req -> 'search_columns'))
66
+ END,
67
+ p_sort => NULLIF(v_req ->> 'sort', ''),
68
+ p_dir => COALESCE(NULLIF(v_req ->> 'dir', ''), 'desc'),
69
+ p_limit => COALESCE((v_req ->> 'limit')::int, 500),
70
+ p_offset => COALESCE((v_req ->> 'offset')::int, 0)
71
+ );
72
+ EXCEPTION WHEN OTHERS THEN
73
+ v_result := jsonb_build_object('error', SQLERRM, 'code', SQLSTATE);
74
+ END;
75
+
76
+ v_out := v_out || jsonb_build_array(v_result);
77
+ END LOOP;
78
+
79
+ RETURN v_out;
80
+ END;
81
+ $$;
82
+
83
+ REVOKE ALL ON FUNCTION public.analytics_run_batch(jsonb) FROM public;
84
+ GRANT EXECUTE ON FUNCTION public.analytics_run_batch(jsonb) TO authenticated, service_role;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.9.0",
6
+ "version": "0.11.0",
7
7
  "description": "Fayz SDK database layer — Drizzle schema primitives, spine references, and migration helpers shared across plugins.",
8
8
  "type": "module",
9
9
  "sideEffects": false,
@@ -12,7 +12,6 @@
12
12
  "types": "./dist/index.d.ts",
13
13
  "exports": {
14
14
  ".": {
15
- "source": "./src/index.ts",
16
15
  "types": "./dist/index.d.ts",
17
16
  "import": "./dist/index.js",
18
17
  "require": "./dist/index.cjs"
@@ -20,7 +19,6 @@
20
19
  },
21
20
  "files": [
22
21
  "dist",
23
- "src",
24
22
  "migrations"
25
23
  ],
26
24
  "dependencies": {
@@ -43,6 +41,9 @@
43
41
  "build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap --noEmit false",
44
42
  "dev": "tsup --watch",
45
43
  "typecheck": "tsc --noEmit",
44
+ "test": "bash test/run-migrations.sh",
45
+ "test:migrations": "bash test/run-migrations.sh",
46
+ "test:negative-control": "bash test/negative-control.sh",
46
47
  "clean": "rm -rf dist"
47
48
  }
48
49
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../src/helpers.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,eAAO,MAAM,QAAQ,sGAGqC,CAAA;AAE1D,mFAAmF;AACnF,eAAO,MAAM,UAAU;;;CAGtB,CAAA;AAED,gEAAgE;AAChE,eAAO,MAAM,SAAS;;CAErB,CAAA"}
package/src/helpers.ts DELETED
@@ -1,23 +0,0 @@
1
- import { uuid, timestamp } from 'drizzle-orm/pg-core'
2
- import { tenants } from './schema/spine'
3
-
4
- /**
5
- * Canonical tenant-scoping column: `tenant_id uuid NOT NULL REFERENCES
6
- * public.tenants(id) ON DELETE CASCADE`. Every Ring-1 plugin table uses this
7
- * so tenancy is identical everywhere (and RLS can assume the column exists).
8
- */
9
- export const tenantId = () =>
10
- uuid('tenant_id')
11
- .notNull()
12
- .references(() => tenants.id, { onDelete: 'cascade' })
13
-
14
- /** Standard `created_at` / `updated_at` timestamptz pair with `now()` defaults. */
15
- export const timestamps = {
16
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
17
- updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
18
- }
19
-
20
- /** Just `created_at` (for append-only / event-style tables). */
21
- export const createdAt = {
22
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
23
- }
package/src/index.ts DELETED
@@ -1,12 +0,0 @@
1
- // @fayz-ai/db — shared Drizzle schema layer for the Fayz SDK.
2
- //
3
- // Re-exports the spine references (Ring 0) and the column helpers that plugin
4
- // schemas compose with. Plugins import from here; apps compose plugin schemas
5
- // in their own drizzle.config.
6
- // Re-export the Drizzle pg-core builders so apps import them from @fayz-ai/db
7
- // (one drizzle-orm instance everywhere — avoids dual-copy PgColumn type clashes
8
- // when an app composes its own tables with @fayz-ai/db spine refs + plugin schema).
9
- export * from 'drizzle-orm/pg-core'
10
-
11
- export { tenants, people, orders, appointments, products, orderItems } from './schema/spine'
12
- export { tenantId, timestamps, createdAt } from './helpers'
@@ -1,35 +0,0 @@
1
- import { pgTable, uuid } from 'drizzle-orm/pg-core'
2
-
3
- /**
4
- * Ring 0 — the core spine, declared as Drizzle *references* only.
5
- *
6
- * These tables are owned by the platform (@fayz-ai/saas core) and already exist
7
- * in every provisioned pool, directly in the `public` schema (industry-pool
8
- * model — no saas_core schema). We declare a minimal shape here purely so plugin
9
- * tables can express real foreign keys in TypeScript. They land in the Drizzle
10
- * *baseline* snapshot (never re-created), so only the `id` FK target is needed —
11
- * the live columns are authoritative.
12
- */
13
- export const tenants = pgTable('tenants', {
14
- id: uuid('id').primaryKey(),
15
- })
16
-
17
- export const people = pgTable('people', {
18
- id: uuid('id').primaryKey(),
19
- })
20
-
21
- export const orders = pgTable('orders', {
22
- id: uuid('id').primaryKey(),
23
- })
24
-
25
- export const appointments = pgTable('appointments', {
26
- id: uuid('id').primaryKey(),
27
- })
28
-
29
- export const products = pgTable('products', {
30
- id: uuid('id').primaryKey(),
31
- })
32
-
33
- export const orderItems = pgTable('order_items', {
34
- id: uuid('id').primaryKey(),
35
- })
File without changes