@fayz-ai/db 0.9.0 → 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 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.
@@ -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/saas 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"]}
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
@@ -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,WAAW,CAAA"}
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/saas 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"]}
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"}
@@ -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/saas core) and already exist
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.9.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": {
@@ -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