Proof of concept for the Viero platform's IDP-to-product data integration.
Status: Validated. All 6 tests passing. Seeds idempotent. Date: 2026-03-30 Related: ADR-002 (Product-IDP Data Integration)
- What This Proves
- Architecture
- File Structure
- How It Works
- Running the PoC
- Test Results
- Recipes
- Production Requirements
- Known Caveats
- FAQ
- Review Findings
Products (fuel-logistics, fleet-cards) can read IDP user data with native Prisma relations and JOINs, without:
- Direct access to IDP tables (products see PostgreSQL VIEWs, not tables)
- Migration conflicts (Prisma Migrate ignores
viewblocks) - Manual sync of field definitions (generator reads IDP schema, uses allowlist)
- Exposing sensitive fields (password, PIN, lockout state excluded by allowlist)
- Per-product placeholder replacement (all products share
idp_viewsschema)
PostgreSQL database: viero_platform
+--------------------------------------------------+
| idp schema (IDP owns, migrates) |
| +---------+ +---------+ +------+ |
| | User | | Tenant | | Role | ... more |
| | (TABLE) | | (TABLE) | |(TABLE)| |
| +---------+ +---------+ +------+ |
| (has password, pin, lockout, tokens, etc.) |
+--------------------------------------------------+
| | |
| PostgreSQL VIEWs (safe fields only)
v v v
+--------------------------------------------------+
| idp_views schema (shared, read-only) |
| +---------+ +-----------+ +--------+ |
| | IdpUser | | IdpTenant | | IdpRole| |
| | (VIEW) | | (VIEW) | | (VIEW) | |
| +---------+ +-----------+ +--------+ |
+--------------------------------------------------+
^
| Prisma @relation
+--------------------------------------------------+
| fuel_logistics schema (Product owns, migrates) |
| +---------+ +--------+ |
| | Driver | | Order | |
| | (TABLE) | | (TABLE)| |
| +---------+ +--------+ |
+--------------------------------------------------+
Three schemas, one database:
| Schema | Owner | Contains | Prisma keyword |
|---|---|---|---|
idp |
IDP backend | Real tables (User, Tenant, Role, etc.) | model |
idp_views |
Generated (shared) | Read-only VIEWs (IdpUser, IdpTenant, IdpRole) | view |
fuel_logistics |
Product backend | Product tables (Driver, Order, etc.) | model |
poc-multischema/
|
+-- idp/ THE IDP BACKEND
| |
| +-- prisma/schema/
| | +-- _config.prisma Prisma config: schemas = ["idp"]
| | +-- models.prisma SOURCE OF TRUTH: model User, Tenant, Role
| |
| +-- prisma.config.ts Prisma 7 datasource URL config
| |
| +-- scripts/
| | +-- generate-shared-views.ts THE GENERATOR: reads models, outputs views
| | Uses ALLOWLIST (only listed fields exposed)
| | Run: pnpm generate:shared
| |
| +-- packages/idp-views/ GENERATED OUTPUT (becomes git submodule)
| | +-- idp-views.prisma Prisma views with @@schema("idp_views")
| | +-- create-idp-views.sql SQL to create PostgreSQL VIEWs
| | +-- add-relations.sh Products run this to inject relations
| |
| +-- src/seed.ts Seeds 2 tenants, 4 users, 1 role
| +-- .env DATABASE_URL
| +-- package.json
|
+-- product/ A PRODUCT BACKEND (e.g., fuel-logistics)
| |
| +-- idp-views/ -> submodule Points to idp/packages/idp-views
| | Products NEVER edit files in here
| |
| +-- idp-relations.conf Product's relations config
| | Format: ViewName:relation_definition
| | Example: IdpUser:drivers Driver[]
| |
| +-- prisma/schema/
| | +-- _config.prisma schemas = ["fuel_logistics", "idp_views"]
| | +-- models.prisma Product tables: model Driver, Order
| | +-- idp-views.prisma GENERATED by add-relations.sh
| | Submodule source + product relations
| |
| +-- prisma.config.ts Prisma 7 datasource URL config
| +-- src/seed.ts Seeds 3 drivers, 9 orders
| +-- src/test-queries.ts 6 cross-schema query tests
| +-- .env DATABASE_URL
| +-- package.json
|
+-- README.md
idp/prisma/schema/models.prisma is the source of truth:
model User {
id String @id @default(uuid(7))
email String?
firstName String?
password String // NEVER exposed
pin String? // NEVER exposed
userTypes UserType[]
isActive Boolean @default(true)
tenantId String?
// ...
@@schema("idp")
}pnpm generate:shared in the IDP runs generate-shared-views.ts:
- Recursively finds all
.prismafiles in the schema directory - Parses models using a brace-counting parser
- For each model in
EXPOSED_MODELS, includes ONLY the explicitly listed fields - New fields added to the IDP model are hidden by default
- Outputs
idp-views.prisma+create-idp-views.sqlwith@@schema("idp_views") - Validates output with
prisma format
Allowlist config:
const EXPOSED_MODELS = {
User: {
viewName: 'IdpUser',
fields: ['id', 'email', 'firstName', 'lastName', 'mobileNumber',
'userTypes', 'isActive', 'tenantId', 'avatarUrl',
'createdAt', 'updatedAt'],
// NOT exposed: password, pin, refreshTokenVersion, failedLoginAttempts,
// lockedUntil, shouldChangePassword, lastPasswordChangeAt, lastLoginAt, metadata
},
// ...
};git submodule add <idp-repo>/packages/idp-views idp-viewsThe submodule contains the generated .prisma file, SQL file, and the add-relations.sh script.
./idp-views/add-relations.shThis copies idp-views/idp-views.prisma into prisma/schema/idp-views.prisma, injecting
relations from idp-relations.conf between the PRODUCT_RELATIONS_START/END markers.
cat idp-views/create-idp-views.sql | psql -U <user> -d <db>Creates VIEWs in the idp_views schema:
CREATE SCHEMA IF NOT EXISTS "idp_views";
CREATE VIEW "idp_views"."IdpUser" AS
SELECT id, email, "firstName", "lastName", "mobileNumber",
"userTypes", "isActive", "tenantId", "avatarUrl",
"createdAt", "updatedAt"
FROM "idp"."User";// Cross-schema JOIN via include
const drivers = await prisma.driver.findMany({
include: { idpUser: true },
});
// Filter by IDP field
const results = await prisma.driver.findMany({
where: { idpUser: { firstName: { contains: 'Mohammed', mode: 'insensitive' } } },
include: { idpUser: true },
});
// Pagination with cross-schema data
const page = await prisma.driver.findMany({
skip: 0, take: 20,
include: { idpUser: { select: { firstName: true, lastName: true } } },
});- Docker, Node.js 20+, pnpm
docker run -d --name poc-multischema-pg \
-e POSTGRES_USER=viero -e POSTGRES_PASSWORD=viero \
-e POSTGRES_DB=viero_platform -p 5434:5432 postgres:16
sleep 3
docker exec poc-multischema-pg psql -U viero -d viero_platform \
-c "CREATE SCHEMA IF NOT EXISTS idp; CREATE SCHEMA IF NOT EXISTS fuel_logistics; CREATE SCHEMA IF NOT EXISTS idp_views;"cd idp
pnpm install
pnpm prisma migrate dev --name init
pnpm prisma generate
pnpm seed
pnpm generate:sharedcd product
ln -s ../idp/packages/idp-views idp-views # git submodule in production
./idp-views/add-relations.sh
cat idp-views/create-idp-views.sql | docker exec -i poc-multischema-pg psql -U viero -d viero_platform
pnpm install
pnpm prisma db push
pnpm prisma generate
pnpm seed
pnpm testdocker stop poc-multischema-pg && docker rm poc-multischema-pg| # | Test | What It Validates |
|---|---|---|
| 1 | List drivers + user info | include: { idpUser: true } JOINs across schemas |
| 2 | Search by name "Mohammed" | where: { idpUser: { firstName: { contains } } } filters through VIEWs |
| 3 | Filter by tenant | Multi-tenant data correctly scoped |
| 4 | 3-way nested include | Driver + IdpUser + Orders in one query |
| 5 | Read-only check | Prisma view behavior verification |
| 6 | Pagination | skip/take with cross-schema include + count |
1. IDP: edit prisma/schema/models.prisma -> add the field
2. IDP: edit scripts/generate-shared-views.ts -> add field name to EXPOSED_MODELS.User.fields
3. IDP: pnpm prisma migrate dev --name <name> -> migrate the table
4. IDP: pnpm generate:shared -> regenerate views
5. IDP: commit everything (migration + packages/idp-views/)
6. Product: pull submodule (cd idp-views && git pull)
7. Product: ./idp-views/add-relations.sh -> regenerate local views
8. Product: re-run the SQL (create-idp-views.sql) -> update PostgreSQL VIEWs
9. Product: pnpm prisma generate -> update Prisma client types
1. IDP: edit prisma/schema/models.prisma -> add the field
2. IDP: pnpm prisma migrate dev --name <name>
3. IDP: pnpm generate:shared -> field NOT in allowlist, NOT in output
4. Products: nothing to do. VIEWs unchanged.
1. Product: edit prisma/schema/models.prisma -> add model with @@schema("fuel_logistics")
2. Product: pnpm prisma migrate dev --name <name>
IDP not involved.
1. Product: edit prisma/schema/models.prisma
-> add idpUserId String and idpUser IdpUser @relation(...)
2. Product: edit idp-relations.conf
-> add line: IdpUser:vehicles Vehicle[]
3. Product: ./idp-views/add-relations.sh
4. Product: pnpm prisma migrate dev --name <name>
5. Product: pnpm prisma generate
1. Create new repo
2. git submodule add <idp-repo>/packages/idp-views idp-views
3. Create prisma/schema/_config.prisma:
schemas = ["<your_schema>", "idp_views"], previewFeatures = ["views"]
4. Create idp-relations.conf with your relations
5. ./idp-views/add-relations.sh
6. Run SQL: psql -f idp-views/create-idp-views.sql (VIEWs shared, already exist if another product set them up)
7. Create product schema: CREATE SCHEMA IF NOT EXISTS "<your_schema>"
8. pnpm prisma migrate dev --name init
9. pnpm prisma generate
1. IDP: model already exists in models.prisma
2. IDP: edit scripts/generate-shared-views.ts -> add to EXPOSED_MODELS with viewName + fields
3. IDP: pnpm generate:shared
4. Products: pull submodule, run add-relations.sh, run SQL, regenerate
1. IDP: edit prisma/schema/models.prisma -> add value to enum
2. IDP: pnpm prisma migrate dev
3. IDP: pnpm generate:shared
4. Products: pull submodule, run add-relations.sh, pnpm prisma generate
IMPORTANT: Deploy IDP first. If a user has the new type before products know about it,
Prisma may fail to parse the value.
These are NOT in the PoC but required before production.
Without this, products can bypass VIEWs and query idp."User" directly (including passwords).
CREATE ROLE fuel_app WITH LOGIN PASSWORD '...';
-- Product can access its own schema + shared views
GRANT USAGE ON SCHEMA fuel_logistics TO fuel_app;
GRANT ALL ON ALL TABLES IN SCHEMA fuel_logistics TO fuel_app;
GRANT USAGE ON SCHEMA idp_views TO fuel_app;
GRANT SELECT ON ALL TABLES IN SCHEMA idp_views TO fuel_app;
-- Product CANNOT access idp schema
REVOKE ALL ON SCHEMA idp FROM fuel_app;Multiple services sharing one PostgreSQL need a connection pooler. Without it, 10 services x 3 replicas x 15 connections = 450 connections (PostgreSQL default max: 100).
VIEWs expose all tenants. Application-level filtering works but is not a security boundary. For defense in depth, add RLS policies or tenant-scoped VIEWs.
1. IDP migrates first (creates/alters tables in idp schema)
2. VIEWs updated (re-run create-idp-views.sql if columns changed)
3. Products deploy (Prisma client expects VIEW columns to match)
Rule: IDP treats VIEW-exposed columns as a public API. Never remove columns; only add.
# IDP CI: regenerate shared after schema changes
- run: pnpm generate:shared
- run: git diff --exit-code packages/idp-views/
# Product CI: sync before generate
- run: git submodule update --init --recursive
- run: ./idp-views/add-relations.sh
- run: pnpm prisma generateRequires previewFeatures = ["views"]. Preview features can change between versions.
Pin your Prisma version across IDP and all products.
@relation from a model to a view works in practice (proven by this PoC) but is not
explicitly documented by Prisma. No FK constraint is created at the database level.
Referential integrity depends on application logic.
Prisma 7's view keyword generates create(), update(), delete() methods on the
client at runtime. They fail at the database level (PostgreSQL rejects writes to views).
Database role isolation is defense in depth.
The UserType enum lives in the idp schema at the PostgreSQL level. The VIEW passes
through the column type. If IDP adds a new enum value before products update their Prisma
schema, Prisma may fail to parse the value. Deploy IDP first, then products.
All services share one PostgreSQL instance. Heavy queries from one product can impact
others. Monitor with pg_stat_statements. Use PgBouncer and consider read replicas
at scale.
Q: Does the product ever need IDP database credentials?
No. The product connects with its own role. VIEWs in idp_views execute as the VIEW owner.
Q: What if I add a field to the IDP and forget to add it to the allowlist? It stays hidden. The allowlist is safe-by-default: new fields are not exposed unless explicitly added.
Q: What if I accidentally add password to the allowlist?
Code review should catch this. For extra safety, add a CI check:
grep -q 'password\|pin' packages/idp-views/idp-views.prisma && exit 1
Q: Can I add indexes on VIEWs?
No. VIEWs use the underlying table's indexes. Ensure the IDP maintains indexes on
User(id), User(tenantId), etc.
Q: What about materialized views for performance? Simple VIEWs are query rewrites; PostgreSQL sees through them and uses indexes directly. At 100K users this is performant. Switch to materialized views only if measured performance degrades.
Q: Why idp_views schema instead of putting VIEWs in each product's schema?
Shared schema means one set of VIEWs for all products. No per-product SQL generation.
The submodule file has @@schema("idp_views") hardcoded, no placeholder needed.
Q: Why not just hand-maintain the views file? The real IDP has 15+ models. Hand-maintaining views across multiple products leads to drift and risks leaking sensitive fields. The generator with an allowlist is safer.
Q: What's the migration path if the shared DB becomes a bottleneck?
- Add read replicas (VIEWs can point at replicas)
- Use PostgreSQL logical replication to separate databases
- Replace VIEWs with event-driven sync (product code stays the same; only the backing data changes)
Q: Why does the product need add-relations.sh instead of using the submodule directly?
Each product needs different relations on the views (drivers Driver[] vs cards Card[]).
Prisma requires both sides of a relation in the same view block. The script copies the
submodule file and injects product-specific relations.
This PoC was reviewed by 8 specialized agents across 3 rounds. Summary of final findings:
- Sound pattern for shared-DB multi-product platform
idp_viewsshared schema eliminates per-product template replacement- Allowlist approach is security-critical and correctly implemented
- Scales to 50K+ users with proper indexes; PgBouncer needed at 5+ services
- All sensitive fields (password, pin, lockout state, tokens) correctly excluded
- Allowlist is safe-by-default (new fields hidden)
- VIEWs are application-level boundary; DB role isolation needed for production
- No tenant filtering in VIEWs (application-level; RLS recommended for production)
- Generator: brace-counting parser works for Prisma schemas (edge case:
}in string literals) - Sync script: portable bash, validates schema name, handles empty conf files
- Seeds: idempotent (upsert pattern)
- Tests: demo scripts proving the concept (not assertion-based test suite)
view+@unique(not@id) is correctmodel-to-viewrelations work in practice (not officially documented)previewFeatures = ["views"]still required in Prisma 7multiSchemais GA (no preview flag needed)schemas = ["fuel_logistics", "idp_views"]is correct