@fayz-ai/db 0.9.0-next.1 → 0.10.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.
- package/README.md +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/helpers.d.ts.map +1 -0
- package/dist/schema/spine.d.ts +1 -1
- package/migrations/024_analytics_engine.sql +317 -0
- package/package.json +1 -3
- package/dist/helpers.d.ts.map +0 -1
- package/src/helpers.ts +0 -23
- package/src/index.ts +0 -12
- package/src/schema/spine.ts +0 -35
- /package/dist/{helpers.d.ts → lib/helpers.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -39,4 +39,4 @@ export const notes = pgTable('notes', {
|
|
|
39
39
|
The data spine beneath every plugin schema; apps compose it in their own `drizzle.config`.
|
|
40
40
|
|
|
41
41
|
## Roadmap & contributing
|
|
42
|
-
Built and evolving in the open. See the [Fayz SDK roadmap](../../docs/ROADMAP.md#db) for current gaps, missing features, and good first issues.
|
|
42
|
+
Built and evolving in the open. See the [Fayz SDK roadmap](../../docs/platform/ROADMAP.md#db) for current gaps, missing features, and good first issues.
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/schema/spine.ts","../src/helpers.ts"],"names":["pgTable","uuid","timestamp"],"mappings":";;;;;AAYO,IAAM,OAAA,GAAUA,eAAQ,SAAA,EAAW;AAAA,EACxC,EAAA,EAAIC,WAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,MAAA,GAASD,eAAQ,QAAA,EAAU;AAAA,EACtC,EAAA,EAAIC,WAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,MAAA,GAASD,eAAQ,QAAA,EAAU;AAAA,EACtC,EAAA,EAAIC,WAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,YAAA,GAAeD,eAAQ,cAAA,EAAgB;AAAA,EAClD,EAAA,EAAIC,WAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,QAAA,GAAWD,eAAQ,UAAA,EAAY;AAAA,EAC1C,EAAA,EAAIC,WAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,UAAA,GAAaD,eAAQ,aAAA,EAAe;AAAA,EAC/C,EAAA,EAAIC,WAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AC1BM,IAAM,QAAA,GAAW,MACtBA,WAAAA,CAAK,WAAW,EACb,OAAA,EAAQ,CACR,UAAA,CAAW,MAAM,OAAA,CAAQ,EAAA,EAAI,EAAE,QAAA,EAAU,WAAW;AAGlD,IAAM,UAAA,GAAa;AAAA,EACxB,SAAA,EAAWC,gBAAA,CAAU,YAAA,EAAc,EAAE,YAAA,EAAc,MAAM,CAAA,CAAE,OAAA,EAAQ,CAAE,UAAA,EAAW;AAAA,EAChF,SAAA,EAAWA,gBAAA,CAAU,YAAA,EAAc,EAAE,YAAA,EAAc,MAAM,CAAA,CAAE,OAAA,EAAQ,CAAE,UAAA;AACvE;AAGO,IAAM,SAAA,GAAY;AAAA,EACvB,SAAA,EAAWA,gBAAA,CAAU,YAAA,EAAc,EAAE,YAAA,EAAc,MAAM,CAAA,CAAE,OAAA,EAAQ,CAAE,UAAA;AACvE","file":"index.cjs","sourcesContent":["import { pgTable, uuid } from 'drizzle-orm/pg-core'\n\n/**\n * Ring 0 — the core spine, declared as Drizzle *references* only.\n *\n * These tables are owned by the platform (@fayz-ai/
|
|
1
|
+
{"version":3,"sources":["../src/schema/spine.ts","../src/lib/helpers.ts"],"names":["pgTable","uuid","timestamp"],"mappings":";;;;;AAYO,IAAM,OAAA,GAAUA,eAAQ,SAAA,EAAW;AAAA,EACxC,EAAA,EAAIC,WAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,MAAA,GAASD,eAAQ,QAAA,EAAU;AAAA,EACtC,EAAA,EAAIC,WAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,MAAA,GAASD,eAAQ,QAAA,EAAU;AAAA,EACtC,EAAA,EAAIC,WAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,YAAA,GAAeD,eAAQ,cAAA,EAAgB;AAAA,EAClD,EAAA,EAAIC,WAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,QAAA,GAAWD,eAAQ,UAAA,EAAY;AAAA,EAC1C,EAAA,EAAIC,WAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,UAAA,GAAaD,eAAQ,aAAA,EAAe;AAAA,EAC/C,EAAA,EAAIC,WAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AC1BM,IAAM,QAAA,GAAW,MACtBA,WAAAA,CAAK,WAAW,EACb,OAAA,EAAQ,CACR,UAAA,CAAW,MAAM,OAAA,CAAQ,EAAA,EAAI,EAAE,QAAA,EAAU,WAAW;AAGlD,IAAM,UAAA,GAAa;AAAA,EACxB,SAAA,EAAWC,gBAAA,CAAU,YAAA,EAAc,EAAE,YAAA,EAAc,MAAM,CAAA,CAAE,OAAA,EAAQ,CAAE,UAAA,EAAW;AAAA,EAChF,SAAA,EAAWA,gBAAA,CAAU,YAAA,EAAc,EAAE,YAAA,EAAc,MAAM,CAAA,CAAE,OAAA,EAAQ,CAAE,UAAA;AACvE;AAGO,IAAM,SAAA,GAAY;AAAA,EACvB,SAAA,EAAWA,gBAAA,CAAU,YAAA,EAAc,EAAE,YAAA,EAAc,MAAM,CAAA,CAAE,OAAA,EAAQ,CAAE,UAAA;AACvE","file":"index.cjs","sourcesContent":["import { pgTable, uuid } from 'drizzle-orm/pg-core'\n\n/**\n * Ring 0 — the core spine, declared as Drizzle *references* only.\n *\n * These tables are owned by the platform (@fayz-ai/admin core) and already exist\n * in every provisioned pool, directly in the `public` schema (industry-pool\n * model — no saas_core schema). We declare a minimal shape here purely so plugin\n * tables can express real foreign keys in TypeScript. They land in the Drizzle\n * *baseline* snapshot (never re-created), so only the `id` FK target is needed —\n * the live columns are authoritative.\n */\nexport const tenants = pgTable('tenants', {\n id: uuid('id').primaryKey(),\n})\n\nexport const people = pgTable('people', {\n id: uuid('id').primaryKey(),\n})\n\nexport const orders = pgTable('orders', {\n id: uuid('id').primaryKey(),\n})\n\nexport const appointments = pgTable('appointments', {\n id: uuid('id').primaryKey(),\n})\n\nexport const products = pgTable('products', {\n id: uuid('id').primaryKey(),\n})\n\nexport const orderItems = pgTable('order_items', {\n id: uuid('id').primaryKey(),\n})\n","import { uuid, timestamp } from 'drizzle-orm/pg-core'\nimport { tenants } from '../schema/spine'\n\n/**\n * Canonical tenant-scoping column: `tenant_id uuid NOT NULL REFERENCES\n * public.tenants(id) ON DELETE CASCADE`. Every Ring-1 plugin table uses this\n * so tenancy is identical everywhere (and RLS can assume the column exists).\n */\nexport const tenantId = () =>\n uuid('tenant_id')\n .notNull()\n .references(() => tenants.id, { onDelete: 'cascade' })\n\n/** Standard `created_at` / `updated_at` timestamptz pair with `now()` defaults. */\nexport const timestamps = {\n createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),\n updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),\n}\n\n/** Just `created_at` (for append-only / event-style tables). */\nexport const createdAt = {\n createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),\n}\n"]}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export * from 'drizzle-orm/pg-core';
|
|
2
2
|
export { tenants, people, orders, appointments, products, orderItems } from './schema/spine';
|
|
3
|
-
export { tenantId, timestamps, createdAt } from './helpers';
|
|
3
|
+
export { tenantId, timestamps, createdAt } from './lib/helpers';
|
|
4
4
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAQA,cAAc,qBAAqB,CAAA;AAEnC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAC5F,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAQA,cAAc,qBAAqB,CAAA;AAEnC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAC5F,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA"}
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/schema/spine.ts","../src/helpers.ts"],"names":["uuid"],"mappings":";;;;AAYO,IAAM,OAAA,GAAU,QAAQ,SAAA,EAAW;AAAA,EACxC,EAAA,EAAI,IAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,MAAA,GAAS,QAAQ,QAAA,EAAU;AAAA,EACtC,EAAA,EAAI,IAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,MAAA,GAAS,QAAQ,QAAA,EAAU;AAAA,EACtC,EAAA,EAAI,IAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,YAAA,GAAe,QAAQ,cAAA,EAAgB;AAAA,EAClD,EAAA,EAAI,IAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,QAAA,GAAW,QAAQ,UAAA,EAAY;AAAA,EAC1C,EAAA,EAAI,IAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,UAAA,GAAa,QAAQ,aAAA,EAAe;AAAA,EAC/C,EAAA,EAAI,IAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AC1BM,IAAM,QAAA,GAAW,MACtBA,IAAAA,CAAK,WAAW,EACb,OAAA,EAAQ,CACR,UAAA,CAAW,MAAM,OAAA,CAAQ,EAAA,EAAI,EAAE,QAAA,EAAU,WAAW;AAGlD,IAAM,UAAA,GAAa;AAAA,EACxB,SAAA,EAAW,SAAA,CAAU,YAAA,EAAc,EAAE,YAAA,EAAc,MAAM,CAAA,CAAE,OAAA,EAAQ,CAAE,UAAA,EAAW;AAAA,EAChF,SAAA,EAAW,SAAA,CAAU,YAAA,EAAc,EAAE,YAAA,EAAc,MAAM,CAAA,CAAE,OAAA,EAAQ,CAAE,UAAA;AACvE;AAGO,IAAM,SAAA,GAAY;AAAA,EACvB,SAAA,EAAW,SAAA,CAAU,YAAA,EAAc,EAAE,YAAA,EAAc,MAAM,CAAA,CAAE,OAAA,EAAQ,CAAE,UAAA;AACvE","file":"index.js","sourcesContent":["import { pgTable, uuid } from 'drizzle-orm/pg-core'\n\n/**\n * Ring 0 — the core spine, declared as Drizzle *references* only.\n *\n * These tables are owned by the platform (@fayz-ai/
|
|
1
|
+
{"version":3,"sources":["../src/schema/spine.ts","../src/lib/helpers.ts"],"names":["uuid"],"mappings":";;;;AAYO,IAAM,OAAA,GAAU,QAAQ,SAAA,EAAW;AAAA,EACxC,EAAA,EAAI,IAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,MAAA,GAAS,QAAQ,QAAA,EAAU;AAAA,EACtC,EAAA,EAAI,IAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,MAAA,GAAS,QAAQ,QAAA,EAAU;AAAA,EACtC,EAAA,EAAI,IAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,YAAA,GAAe,QAAQ,cAAA,EAAgB;AAAA,EAClD,EAAA,EAAI,IAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,QAAA,GAAW,QAAQ,UAAA,EAAY;AAAA,EAC1C,EAAA,EAAI,IAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AAEM,IAAM,UAAA,GAAa,QAAQ,aAAA,EAAe;AAAA,EAC/C,EAAA,EAAI,IAAA,CAAK,IAAI,CAAA,CAAE,UAAA;AACjB,CAAC;AC1BM,IAAM,QAAA,GAAW,MACtBA,IAAAA,CAAK,WAAW,EACb,OAAA,EAAQ,CACR,UAAA,CAAW,MAAM,OAAA,CAAQ,EAAA,EAAI,EAAE,QAAA,EAAU,WAAW;AAGlD,IAAM,UAAA,GAAa;AAAA,EACxB,SAAA,EAAW,SAAA,CAAU,YAAA,EAAc,EAAE,YAAA,EAAc,MAAM,CAAA,CAAE,OAAA,EAAQ,CAAE,UAAA,EAAW;AAAA,EAChF,SAAA,EAAW,SAAA,CAAU,YAAA,EAAc,EAAE,YAAA,EAAc,MAAM,CAAA,CAAE,OAAA,EAAQ,CAAE,UAAA;AACvE;AAGO,IAAM,SAAA,GAAY;AAAA,EACvB,SAAA,EAAW,SAAA,CAAU,YAAA,EAAc,EAAE,YAAA,EAAc,MAAM,CAAA,CAAE,OAAA,EAAQ,CAAE,UAAA;AACvE","file":"index.js","sourcesContent":["import { pgTable, uuid } from 'drizzle-orm/pg-core'\n\n/**\n * Ring 0 — the core spine, declared as Drizzle *references* only.\n *\n * These tables are owned by the platform (@fayz-ai/admin core) and already exist\n * in every provisioned pool, directly in the `public` schema (industry-pool\n * model — no saas_core schema). We declare a minimal shape here purely so plugin\n * tables can express real foreign keys in TypeScript. They land in the Drizzle\n * *baseline* snapshot (never re-created), so only the `id` FK target is needed —\n * the live columns are authoritative.\n */\nexport const tenants = pgTable('tenants', {\n id: uuid('id').primaryKey(),\n})\n\nexport const people = pgTable('people', {\n id: uuid('id').primaryKey(),\n})\n\nexport const orders = pgTable('orders', {\n id: uuid('id').primaryKey(),\n})\n\nexport const appointments = pgTable('appointments', {\n id: uuid('id').primaryKey(),\n})\n\nexport const products = pgTable('products', {\n id: uuid('id').primaryKey(),\n})\n\nexport const orderItems = pgTable('order_items', {\n id: uuid('id').primaryKey(),\n})\n","import { uuid, timestamp } from 'drizzle-orm/pg-core'\nimport { tenants } from '../schema/spine'\n\n/**\n * Canonical tenant-scoping column: `tenant_id uuid NOT NULL REFERENCES\n * public.tenants(id) ON DELETE CASCADE`. Every Ring-1 plugin table uses this\n * so tenancy is identical everywhere (and RLS can assume the column exists).\n */\nexport const tenantId = () =>\n uuid('tenant_id')\n .notNull()\n .references(() => tenants.id, { onDelete: 'cascade' })\n\n/** Standard `created_at` / `updated_at` timestamptz pair with `now()` defaults. */\nexport const timestamps = {\n createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),\n updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),\n}\n\n/** Just `created_at` (for append-only / event-style tables). */\nexport const createdAt = {\n createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../src/lib/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/dist/schema/spine.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ring 0 — the core spine, declared as Drizzle *references* only.
|
|
3
3
|
*
|
|
4
|
-
* These tables are owned by the platform (@fayz-ai/
|
|
4
|
+
* These tables are owned by the platform (@fayz-ai/admin core) and already exist
|
|
5
5
|
* in every provisioned pool, directly in the `public` schema (industry-pool
|
|
6
6
|
* model — no saas_core schema). We declare a minimal shape here purely so plugin
|
|
7
7
|
* tables can express real foreign keys in TypeScript. They land in the Drizzle
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- The analytics engine — one aggregate function for every domain
|
|
3
|
+
-- ----------------------------------------------------------------------------
|
|
4
|
+
-- Until now every number in the product was computed twice and differently: a
|
|
5
|
+
-- dashboard KPI pulled `listOrders({limit: 200})` and summed in JavaScript,
|
|
6
|
+
-- while the report engine ran a flat SELECT over a `rep_*` view. Two engines,
|
|
7
|
+
-- two answers, and the KPI silently wrong the moment a tenant passes 200 rows.
|
|
8
|
+
--
|
|
9
|
+
-- This is the half that belongs to NO domain: an allowlist of queryable read
|
|
10
|
+
-- models, an identifier guard, and analytics_run() — which groups, buckets and
|
|
11
|
+
-- aggregates any registered read model from parameters. "Orders over time" and
|
|
12
|
+
-- "sales by product" are the same call with different dimensions, so adding a
|
|
13
|
+
-- report stops being a database migration.
|
|
14
|
+
--
|
|
15
|
+
-- Each domain ships its own read models and registers them (see
|
|
16
|
+
-- packages/shop/migrations/0039_analytics_read_models.sql). A salon, a school
|
|
17
|
+
-- and a store all reuse this function over completely different views.
|
|
18
|
+
--
|
|
19
|
+
-- Because a KPI and the report behind it are literally the same query with and
|
|
20
|
+
-- without the GROUP BY, they cannot disagree.
|
|
21
|
+
--
|
|
22
|
+
-- SECURITY. This function builds dynamic SQL from caller-supplied strings, so:
|
|
23
|
+
-- * the source must be registered in plg_analytics_read_models — an allowlist,
|
|
24
|
+
-- not a prefix convention a future view could accidentally satisfy;
|
|
25
|
+
-- * every column name is verified to exist on that source via
|
|
26
|
+
-- information_schema before it reaches the statement;
|
|
27
|
+
-- * aggregates and time grains are matched against fixed enums;
|
|
28
|
+
-- * identifiers go through %I and literals through %L, never concatenation;
|
|
29
|
+
-- * the function is SECURITY INVOKER and read models are security_invoker=true,
|
|
30
|
+
-- so the underlying RLS still decides which rows exist. The explicit tenant
|
|
31
|
+
-- filter is defence in depth, not the only lock.
|
|
32
|
+
--
|
|
33
|
+
-- Idempotent.
|
|
34
|
+
-- ============================================================================
|
|
35
|
+
|
|
36
|
+
-- ----------------------------------------------------------------------------
|
|
37
|
+
-- 1. The allowlist. A read model that is not in here cannot be queried, full
|
|
38
|
+
-- stop. Registering is a migration-time act, never a runtime one.
|
|
39
|
+
-- ----------------------------------------------------------------------------
|
|
40
|
+
CREATE TABLE IF NOT EXISTS public.plg_analytics_read_models (
|
|
41
|
+
name text PRIMARY KEY,
|
|
42
|
+
-- The time spine. Taken from HERE and not from the client: the read model is
|
|
43
|
+
-- the authority on which of its columns is "when this happened".
|
|
44
|
+
date_column text NOT NULL DEFAULT 'created_at',
|
|
45
|
+
tenant_column text NOT NULL DEFAULT 'tenant_id',
|
|
46
|
+
description text
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
ALTER TABLE public.plg_analytics_read_models ENABLE ROW LEVEL SECURITY;
|
|
50
|
+
|
|
51
|
+
DROP POLICY IF EXISTS plg_analytics_read_models_read ON public.plg_analytics_read_models;
|
|
52
|
+
CREATE POLICY plg_analytics_read_models_read ON public.plg_analytics_read_models
|
|
53
|
+
FOR SELECT TO authenticated USING (true);
|
|
54
|
+
|
|
55
|
+
GRANT SELECT ON public.plg_analytics_read_models TO authenticated;
|
|
56
|
+
GRANT ALL ON public.plg_analytics_read_models TO service_role;
|
|
57
|
+
|
|
58
|
+
-- ----------------------------------------------------------------------------
|
|
59
|
+
-- 7. Identifier guard. Every column name from the client passes through here
|
|
60
|
+
-- before it can reach a statement. Raises rather than returning false so a
|
|
61
|
+
-- typo in a card definition fails loudly at the call instead of silently
|
|
62
|
+
-- dropping a dimension and returning a wrong-shaped answer.
|
|
63
|
+
-- ----------------------------------------------------------------------------
|
|
64
|
+
CREATE OR REPLACE FUNCTION public.analytics_assert_column(p_source text, p_column text)
|
|
65
|
+
RETURNS text
|
|
66
|
+
LANGUAGE plpgsql
|
|
67
|
+
STABLE
|
|
68
|
+
AS $$
|
|
69
|
+
BEGIN
|
|
70
|
+
IF p_column IS NULL OR p_column !~ '^[a-z_][a-z0-9_]*$' THEN
|
|
71
|
+
RAISE EXCEPTION 'analytics: malformed column name %', p_column USING ERRCODE = '22023';
|
|
72
|
+
END IF;
|
|
73
|
+
|
|
74
|
+
IF NOT EXISTS (
|
|
75
|
+
SELECT 1 FROM information_schema.columns
|
|
76
|
+
WHERE table_schema = 'public' AND table_name = p_source AND column_name = p_column
|
|
77
|
+
) THEN
|
|
78
|
+
RAISE EXCEPTION 'analytics: column % does not exist on %', p_column, p_source USING ERRCODE = '42703';
|
|
79
|
+
END IF;
|
|
80
|
+
|
|
81
|
+
RETURN p_column;
|
|
82
|
+
END;
|
|
83
|
+
$$;
|
|
84
|
+
|
|
85
|
+
-- ----------------------------------------------------------------------------
|
|
86
|
+
-- 8. analytics_run — the whole engine.
|
|
87
|
+
--
|
|
88
|
+
-- p_dimensions [{"key":"created_at","grain":"day"}, {"key":"product_name"}]
|
|
89
|
+
-- p_measures [{"key":"net_sales","agg":"sum","column":"net_sales"}]
|
|
90
|
+
-- p_filters {"financial_status":"paid","category_id":["uuid","uuid"]}
|
|
91
|
+
--
|
|
92
|
+
-- Returns {"rows":[...], "total":n, "summary":{...}} where `summary` is the
|
|
93
|
+
-- same measures rolled up over the WHOLE range with no group-by. The summary
|
|
94
|
+
-- is what a KPI card reads and what a report's total row shows — computed
|
|
95
|
+
-- once, from the same filters, so they cannot disagree.
|
|
96
|
+
--
|
|
97
|
+
-- SECURITY INVOKER (the default, stated for the reader): RLS on the base
|
|
98
|
+
-- tables decides visibility; the tenant predicate below is belt and braces.
|
|
99
|
+
-- ----------------------------------------------------------------------------
|
|
100
|
+
CREATE OR REPLACE FUNCTION public.analytics_run(
|
|
101
|
+
p_source text,
|
|
102
|
+
p_tenant_id uuid DEFAULT NULL,
|
|
103
|
+
p_from timestamptz DEFAULT NULL,
|
|
104
|
+
p_to timestamptz DEFAULT NULL,
|
|
105
|
+
p_dimensions jsonb DEFAULT '[]'::jsonb,
|
|
106
|
+
p_measures jsonb DEFAULT '[]'::jsonb,
|
|
107
|
+
p_filters jsonb DEFAULT '{}'::jsonb,
|
|
108
|
+
p_search text DEFAULT NULL,
|
|
109
|
+
p_search_columns text[] DEFAULT NULL,
|
|
110
|
+
p_sort text DEFAULT NULL,
|
|
111
|
+
p_dir text DEFAULT 'desc',
|
|
112
|
+
p_limit int DEFAULT 500,
|
|
113
|
+
p_offset int DEFAULT 0
|
|
114
|
+
)
|
|
115
|
+
RETURNS jsonb
|
|
116
|
+
LANGUAGE plpgsql
|
|
117
|
+
STABLE
|
|
118
|
+
AS $$
|
|
119
|
+
DECLARE
|
|
120
|
+
v_model public.plg_analytics_read_models%ROWTYPE;
|
|
121
|
+
v_where text[] := ARRAY[]::text[];
|
|
122
|
+
v_where_sql text;
|
|
123
|
+
v_select text[] := ARRAY[]::text[];
|
|
124
|
+
v_group text[] := ARRAY[]::text[];
|
|
125
|
+
v_agg_only text[] := ARRAY[]::text[];
|
|
126
|
+
v_dim jsonb;
|
|
127
|
+
v_meas jsonb;
|
|
128
|
+
v_key text;
|
|
129
|
+
v_col text;
|
|
130
|
+
v_grain text;
|
|
131
|
+
v_agg text;
|
|
132
|
+
v_expr text;
|
|
133
|
+
v_filter_key text;
|
|
134
|
+
v_filter_val jsonb;
|
|
135
|
+
v_search_ors text[] := ARRAY[]::text[];
|
|
136
|
+
v_sort_sql text := '';
|
|
137
|
+
v_rows jsonb;
|
|
138
|
+
v_summary jsonb;
|
|
139
|
+
v_total bigint;
|
|
140
|
+
v_limit int := LEAST(GREATEST(COALESCE(p_limit, 500), 1), 5000);
|
|
141
|
+
v_offset int := GREATEST(COALESCE(p_offset, 0), 0);
|
|
142
|
+
BEGIN
|
|
143
|
+
-- 8.1 The source must be registered. Not "look like a read model" — registered.
|
|
144
|
+
SELECT * INTO v_model FROM public.plg_analytics_read_models WHERE name = p_source;
|
|
145
|
+
IF NOT FOUND THEN
|
|
146
|
+
RAISE EXCEPTION 'analytics: % is not a registered read model', p_source USING ERRCODE = '42P01';
|
|
147
|
+
END IF;
|
|
148
|
+
|
|
149
|
+
-- 8.2 Tenant + date window. The date column comes from the registry, so a
|
|
150
|
+
-- caller cannot redirect the range onto some other timestamp.
|
|
151
|
+
IF p_tenant_id IS NOT NULL THEN
|
|
152
|
+
v_where := v_where || format('%I = %L', v_model.tenant_column, p_tenant_id);
|
|
153
|
+
END IF;
|
|
154
|
+
IF p_from IS NOT NULL THEN
|
|
155
|
+
v_where := v_where || format('%I >= %L', v_model.date_column, p_from);
|
|
156
|
+
END IF;
|
|
157
|
+
IF p_to IS NOT NULL THEN
|
|
158
|
+
v_where := v_where || format('%I <= %L', v_model.date_column, p_to);
|
|
159
|
+
END IF;
|
|
160
|
+
|
|
161
|
+
-- 8.3 Equality / IN filters.
|
|
162
|
+
FOR v_filter_key, v_filter_val IN SELECT key, value FROM jsonb_each(COALESCE(p_filters, '{}'::jsonb))
|
|
163
|
+
LOOP
|
|
164
|
+
CONTINUE WHEN v_filter_val IS NULL OR jsonb_typeof(v_filter_val) = 'null';
|
|
165
|
+
v_col := public.analytics_assert_column(p_source, v_filter_key);
|
|
166
|
+
|
|
167
|
+
IF jsonb_typeof(v_filter_val) = 'array' THEN
|
|
168
|
+
CONTINUE WHEN jsonb_array_length(v_filter_val) = 0;
|
|
169
|
+
v_where := v_where || format(
|
|
170
|
+
'%I::text = ANY (SELECT jsonb_array_elements_text(%L::jsonb))', v_col, v_filter_val);
|
|
171
|
+
ELSE
|
|
172
|
+
v_where := v_where || format('%I::text = %L', v_col, v_filter_val #>> '{}');
|
|
173
|
+
END IF;
|
|
174
|
+
END LOOP;
|
|
175
|
+
|
|
176
|
+
-- 8.4 Free-text search across the caller's declared text columns.
|
|
177
|
+
IF p_search IS NOT NULL AND length(btrim(p_search)) > 0 AND p_search_columns IS NOT NULL THEN
|
|
178
|
+
FOREACH v_col IN ARRAY p_search_columns LOOP
|
|
179
|
+
v_col := public.analytics_assert_column(p_source, v_col);
|
|
180
|
+
v_search_ors := v_search_ors || format('%I::text ILIKE %L', v_col, '%' || btrim(p_search) || '%');
|
|
181
|
+
END LOOP;
|
|
182
|
+
IF array_length(v_search_ors, 1) > 0 THEN
|
|
183
|
+
v_where := v_where || ('(' || array_to_string(v_search_ors, ' OR ') || ')');
|
|
184
|
+
END IF;
|
|
185
|
+
END IF;
|
|
186
|
+
|
|
187
|
+
v_where_sql := CASE WHEN array_length(v_where, 1) > 0
|
|
188
|
+
THEN ' WHERE ' || array_to_string(v_where, ' AND ')
|
|
189
|
+
ELSE '' END;
|
|
190
|
+
|
|
191
|
+
-- 8.5 Dimensions. A grain turns a timestamp into a bucket; without one the
|
|
192
|
+
-- raw column value is the group.
|
|
193
|
+
FOR v_dim IN SELECT * FROM jsonb_array_elements(COALESCE(p_dimensions, '[]'::jsonb))
|
|
194
|
+
LOOP
|
|
195
|
+
v_key := v_dim ->> 'key';
|
|
196
|
+
v_col := public.analytics_assert_column(p_source, v_key);
|
|
197
|
+
v_grain := v_dim ->> 'grain';
|
|
198
|
+
|
|
199
|
+
IF v_grain IS NULL THEN
|
|
200
|
+
v_expr := format('%I', v_col);
|
|
201
|
+
ELSIF v_grain IN ('hour','day','week','month','quarter','year') THEN
|
|
202
|
+
v_expr := format('date_trunc(%L, %I)', v_grain, v_col);
|
|
203
|
+
ELSE
|
|
204
|
+
RAISE EXCEPTION 'analytics: unsupported time grain %', v_grain USING ERRCODE = '22023';
|
|
205
|
+
END IF;
|
|
206
|
+
|
|
207
|
+
v_select := v_select || format('%s AS %I', v_expr, v_key);
|
|
208
|
+
v_group := v_group || v_expr;
|
|
209
|
+
END LOOP;
|
|
210
|
+
|
|
211
|
+
-- 8.6 Measures. Fixed aggregate vocabulary — anything else is rejected rather
|
|
212
|
+
-- than passed through, which is the difference between a parameter and an
|
|
213
|
+
-- injection point. 'ratio' is deliberately absent: it is derived on the
|
|
214
|
+
-- client from two real measures so ratio-of-sums never degrades into
|
|
215
|
+
-- avg-of-ratios.
|
|
216
|
+
FOR v_meas IN SELECT * FROM jsonb_array_elements(COALESCE(p_measures, '[]'::jsonb))
|
|
217
|
+
LOOP
|
|
218
|
+
v_key := v_meas ->> 'key';
|
|
219
|
+
IF v_key IS NULL OR v_key !~ '^[a-z_][a-z0-9_]*$' THEN
|
|
220
|
+
RAISE EXCEPTION 'analytics: malformed measure key %', v_key USING ERRCODE = '22023';
|
|
221
|
+
END IF;
|
|
222
|
+
v_agg := lower(COALESCE(v_meas ->> 'agg', 'sum'));
|
|
223
|
+
|
|
224
|
+
IF v_agg = 'count' THEN
|
|
225
|
+
v_expr := 'count(*)';
|
|
226
|
+
ELSE
|
|
227
|
+
v_col := public.analytics_assert_column(p_source, COALESCE(v_meas ->> 'column', v_key));
|
|
228
|
+
v_expr := CASE v_agg
|
|
229
|
+
WHEN 'sum' THEN format('sum(%I)', v_col)
|
|
230
|
+
WHEN 'avg' THEN format('avg(%I)', v_col)
|
|
231
|
+
WHEN 'min' THEN format('min(%I)', v_col)
|
|
232
|
+
WHEN 'max' THEN format('max(%I)', v_col)
|
|
233
|
+
WHEN 'count_distinct' THEN format('count(DISTINCT %I)', v_col)
|
|
234
|
+
ELSE NULL
|
|
235
|
+
END;
|
|
236
|
+
IF v_expr IS NULL THEN
|
|
237
|
+
RAISE EXCEPTION 'analytics: unsupported aggregate %', v_agg USING ERRCODE = '22023';
|
|
238
|
+
END IF;
|
|
239
|
+
END IF;
|
|
240
|
+
|
|
241
|
+
v_select := v_select || format('%s AS %I', v_expr, v_key);
|
|
242
|
+
v_agg_only := v_agg_only || format('%s AS %I', v_expr, v_key);
|
|
243
|
+
END LOOP;
|
|
244
|
+
|
|
245
|
+
-- 8.7 No measures ⇒ a raw detail listing (the classic tabular report).
|
|
246
|
+
IF array_length(v_agg_only, 1) IS NULL AND array_length(v_group, 1) IS NULL THEN
|
|
247
|
+
v_select := ARRAY['*'];
|
|
248
|
+
END IF;
|
|
249
|
+
|
|
250
|
+
-- 8.8 Sort. Must be one of the things actually selected — an alias, not an
|
|
251
|
+
-- arbitrary expression.
|
|
252
|
+
IF p_sort IS NOT NULL AND p_sort ~ '^[a-z_][a-z0-9_]*$' THEN
|
|
253
|
+
IF EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(p_measures,'[]'::jsonb)) m WHERE m->>'key' = p_sort)
|
|
254
|
+
OR EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(p_dimensions,'[]'::jsonb)) d WHERE d->>'key' = p_sort)
|
|
255
|
+
OR (array_length(v_agg_only,1) IS NULL AND array_length(v_group,1) IS NULL)
|
|
256
|
+
THEN
|
|
257
|
+
IF array_length(v_agg_only,1) IS NULL AND array_length(v_group,1) IS NULL THEN
|
|
258
|
+
PERFORM public.analytics_assert_column(p_source, p_sort);
|
|
259
|
+
END IF;
|
|
260
|
+
v_sort_sql := format(' ORDER BY %I %s NULLS LAST',
|
|
261
|
+
p_sort,
|
|
262
|
+
CASE WHEN lower(COALESCE(p_dir,'desc')) = 'asc' THEN 'ASC' ELSE 'DESC' END);
|
|
263
|
+
END IF;
|
|
264
|
+
END IF;
|
|
265
|
+
|
|
266
|
+
-- 8.9 Page of rows.
|
|
267
|
+
EXECUTE format(
|
|
268
|
+
'SELECT COALESCE(jsonb_agg(t), ''[]''::jsonb) FROM (SELECT %s FROM public.%I%s%s%s LIMIT %s OFFSET %s) t',
|
|
269
|
+
array_to_string(v_select, ', '),
|
|
270
|
+
p_source,
|
|
271
|
+
v_where_sql,
|
|
272
|
+
CASE WHEN array_length(v_group, 1) > 0
|
|
273
|
+
THEN ' GROUP BY ' || array_to_string(v_group, ', ') ELSE '' END,
|
|
274
|
+
v_sort_sql,
|
|
275
|
+
v_limit,
|
|
276
|
+
v_offset
|
|
277
|
+
) INTO v_rows;
|
|
278
|
+
|
|
279
|
+
-- 8.10 Row count for pagination. This must count what the MAIN query returns,
|
|
280
|
+
-- not what it reads: grouping ⇒ number of groups; aggregating with no
|
|
281
|
+
-- dimensions ⇒ exactly one row, however many rows fed it; otherwise the
|
|
282
|
+
-- underlying rows. Counting reads here made a 1-row KPI claim two pages.
|
|
283
|
+
IF array_length(v_group, 1) > 0 THEN
|
|
284
|
+
EXECUTE format(
|
|
285
|
+
'SELECT count(*) FROM (SELECT %s FROM public.%I%s GROUP BY %s) c',
|
|
286
|
+
array_to_string(v_group, ', '), p_source, v_where_sql, array_to_string(v_group, ', ')
|
|
287
|
+
) INTO v_total;
|
|
288
|
+
ELSIF array_length(v_agg_only, 1) > 0 THEN
|
|
289
|
+
v_total := 1;
|
|
290
|
+
ELSE
|
|
291
|
+
EXECUTE format('SELECT count(*) FROM public.%I%s', p_source, v_where_sql) INTO v_total;
|
|
292
|
+
END IF;
|
|
293
|
+
|
|
294
|
+
-- 8.11 The summary: identical filters, identical measures, no group-by. This
|
|
295
|
+
-- is the KPI headline AND the report's total row — one number, one place.
|
|
296
|
+
IF array_length(v_agg_only, 1) > 0 THEN
|
|
297
|
+
EXECUTE format(
|
|
298
|
+
'SELECT to_jsonb(s) FROM (SELECT %s FROM public.%I%s) s',
|
|
299
|
+
array_to_string(v_agg_only, ', '),
|
|
300
|
+
p_source,
|
|
301
|
+
v_where_sql
|
|
302
|
+
) INTO v_summary;
|
|
303
|
+
END IF;
|
|
304
|
+
|
|
305
|
+
RETURN jsonb_build_object(
|
|
306
|
+
'rows', COALESCE(v_rows, '[]'::jsonb),
|
|
307
|
+
'total', COALESCE(v_total, 0),
|
|
308
|
+
'summary', COALESCE(v_summary, '{}'::jsonb)
|
|
309
|
+
);
|
|
310
|
+
END;
|
|
311
|
+
$$;
|
|
312
|
+
|
|
313
|
+
REVOKE ALL ON FUNCTION public.analytics_run(text, uuid, timestamptz, timestamptz, jsonb, jsonb, jsonb, text, text[], text, text, int, int) FROM public;
|
|
314
|
+
GRANT EXECUTE ON FUNCTION public.analytics_run(text, uuid, timestamptz, timestamptz, jsonb, jsonb, jsonb, text, text[], text, text, int, int) TO authenticated, service_role;
|
|
315
|
+
|
|
316
|
+
REVOKE ALL ON FUNCTION public.analytics_assert_column(text, text) FROM public;
|
|
317
|
+
GRANT EXECUTE ON FUNCTION public.analytics_assert_column(text, text) TO authenticated, service_role;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"fayz": {
|
|
4
4
|
"status": "beta"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.10.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": {
|
package/dist/helpers.d.ts.map
DELETED
|
@@ -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'
|
package/src/schema/spine.ts
DELETED
|
@@ -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
|