Skip to content

[Feature Request] ZenStack for API integration #563

Open
@ymc9

Description

@ymc9

NOTE: This is quite ambitious item, and chance is low it can be contained in V2, but including it here just for people who may find it very useful and want to give it a push

The proposal here is based on previous discussions with people on Discord. Moving to GitHub for better visibility.


Goal

Prisma’s schema offers a pleasant DX to model and program against a database. However, the pattern - DSL schema + TS code generation, has more potential than working with databases. Since ZenStack has already replicated the entire Prisma schema language with many extensions, we can try to achieve more above that.

Web development has been gradually shifting from hosting everything in-house to leveraging external services. That means databases, as important as they still are, only represent a (shrinking) part of the entire application’s model. Databases often store “opaque references” to models of foreign systems, and developers have to deal with disparate APIs and orchestrate them with database operations.

Hopefully, we can make the schema a gateway toward unifying and simplifying the DX:

  • Unified CRUD API style with natural joining and nesting
  • Consistently strongly-typed everywhere
  • Unified access control

ZenStack should provide a more powerful plugin mechanism, allowing plugin developers to contribute models and runtime behavior to interface with 3rd-party API systems.

Use Cases

1. Auth

When using auth services like Clerk, the main user repository is on the auth provider’s side. If you don’t choose to synchronize it to your own database, you can reference it as a foreign model:

plugin clerk {
    provider = 'zenstack-plugin-clerk'
    secretKey = env('CLERK_SECRET_KEY')
}

// ClerkUser model is imported from the "clerk" plugin and extended with a one-to-one "user" relationship
// Syntax here is subject to discussion
model ClerkUser imports clerk::User {

    // Imported models can only declare relation fields

    // a "virtual" relation to the `User` model
    // `onDelete` marks it to be deleted when `User` is deleted
    user User? @relation(onDelete: Cascade)

    @@allow('create', false)
    @@allow('all', user != null && auth() == user)
}

model User {
    id String @id

    // a one-to-one relationship with the `ClerkUser` foreign model,
    // internally mapped to a field storing clerk's user id
    clerkUser ClerkUser

    @@allow('create', true)
    @@allow('all', auth() == this)
}

model Post {
    id String @id
    author User @relation(...)
    authorId String
  
    @@allow('read', true)
    @@allow('all', auth() == author)
}

And the enhanced PrismaClient allows you to do things like:

// user will be typed as `User & { clerkUser: ClerkUser }`
// type `ClerkUser` is provided by the `zenstack-plugin-clerk` plugin
const user = await prisma.user.findMany({
    where: { clerkUser: { email } },
    include: { clerkUser: true }
});

// update user attribute on clerk side
await prisma.user.update({
    where: { id },
    data: {
        clerkUser: { update: { attributes: { level: newLevel } } }
    }
});

// fetch posts with user email
await prisma.post.findMany({
    where: { authorId: userId },
    include: { clerkUser: { select: { email: true } } }
});

2. Blob Data

Using blob services like S3 to store files and save a reference in database table is a very common pattern.

plugin blob {
    provider = 'zenstack-plugin-s3'
    region = env('S3-REGION')
    bucket = env('S3-BUCKET')
    ...
}

model UserImageBlob imports blob::Blob {
    user User? @relation(onDelete: Cascade)

    @@allow('read', true)
    @@allow('all', user != null && auth() == user)
}

model User {
    id String @id
    profileImage UserImageBlob?
}
await prisma.user.update({
    where: { id },
    data: { profileImage: { update: { data: buffer } } }
});

const user = await prisma.user.findUnique({
    where: { id }, include: { profileImage: true }
});

const imageData = await readStream(user.profileImage.data);

3. Subscription

Interfacing with payment systems like Stripe:

plugin stripe {
    provider = 'zenstack-plugin-stripe'
    apiKey = env('STRIPE-API-KEY')
}

model Subscription imports stripe::Subscription {
    team Team? @relation(onDelete: Cascade)

    @@allow('read', team.members?[user == auth()])
    @@allow('all', team.members?[user == auth() && role == 'ADMIN'])
}

model Team {
    id String @id
    members TeamMember[]
    subscription Subscription?
}
// pause the team's current subscription
await prisma.team.update({
    where: { id },
    data: { subscription: { update: { status: 'paused' } } }
});

Potential Approach

  • Plugins can contribute “virtual” models.
  • Plugins can provide CRUD handlers for the virtual models.
  • CRUD handlers mainly interface with 3rd party APIs, work synchronously, and have built-in retries, but don’t guarantee transactional atomicity and consistency.
  • [MAYBE] Plugins can contribute real models so that it creates database tables to their own data.

Open Questions

  • Is it appropriate to use CRUD to model all interactions with 3rd-party systems? Or is it necessary for plugins to contribute extension methods as well?
  • Calling remote APIs inside DB transactions can result in negative impacts due to long-running transactions. Maybe there should be an asynchonous “fire-and-forget” mode for mutation?
  • Related to the previous one: what should a transaction actually mean if it mixes database operations and foreign service updates? Should db operations actually be completed and then proceed with service updates?

Related

Supabase FDW

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions