File Storage
Model files as part of your entity graph so uploads, downloads, and cleanup follow the same ownership and policy model as the rest of your data.
Most stacks split file storage into a second system: one set of rules for database rows, another for buckets and object paths. Kizaki takes a narrower approach. A file belongs to an entity, and the entity remains the source of truth.
The Recommended Mental Model
Use a file field when uploaded content is part of a row in your app.
entity Attachment {
name: string,
file: file,
ownerId: __User.id,
@grant read, write, delete where resource.ownerId == principal.id
}This gives you the right defaults automatically:
- upload permission follows write permission
- download permission follows read permission
- cleanup follows row replacement or row deletion
- tenant scoping follows the entity namespace
Files stop being a parallel permission system. They follow the same rules as everything else.
Start With Ownership
The cleanest first version of file storage is almost always an ownership rule:
entity UserProfile {
userId: __User.id,
avatar: file?,
@grant read to *
@grant write where resource.userId == principal.id
}A simple, legible model:
- anyone allowed to read the profile can see the avatar field
- only the profile owner can replace it
If your app's file rules are hard to explain in one sentence, look at the data model first, not the storage layer.
Use Separate Entities For Collections
If you need multiple files, do not force them into one row conceptually.
Use a separate attachment entity:
entity TaskAttachment {
taskId: Task.id @onDelete(cascade),
file: file,
uploadedBy: __User.id,
label: string?,
@grant read to * via TeamMembership
where TeamMembership.teamId == resource.task.teamId
@grant insert where resource.task.createdBy == principal.id
@grant delete where resource.uploadedBy == principal.id
}This is the right pattern for:
- galleries
- message attachments
- document versions
- task files
- reusable uploaded assets with their own labels or ordering
Once multiple files need their own identity, make them rows. Do not invent a more clever file field.
Think In Terms Of Lifecycle
The useful question to ask is: what should happen to the bytes when the row changes?
With Kizaki's model, the answer stays straightforward:
- replace the field: replace the underlying file
- clear the field: remove the underlying file
- delete the row: remove the underlying file
- cascade-delete child rows: remove the child-row files too
No manual object cleanup jobs scattered across the app.
The Upload Workflow
The runtime uses a three-step model:
- request an upload from the platform
- upload directly to object storage
- confirm the upload so the entity adopts the file
The entity should not point at arbitrary uploaded bytes just because a client managed to PUT something somewhere. The confirmation step turns an uploaded object into application state.
The Authorization Workflow
Treat file authorization exactly like entity authorization:
- can this principal write the row or field?
- can this principal read the row or field?
- should this row be public, shared, role-scoped, or owner-only?
Do not design a separate mental model for files. If you find yourself thinking about "bucket permissions" instead of entity policies, you are drifting away from the intended approach.
Downloads
In normal use, the application does not serve file bytes itself. The platform issues a download URL after it has established that the principal can read the relevant entity field.
This is the right default for most apps:
- your app decides who can access the entity
- the platform decides how to issue the file
- object storage handles the actual transfer
Declarative File Validation
Use @maxSize and @accept to enforce file constraints at the schema level:
entity Document {
title: string,
attachment: file? @maxSize(5242880) @accept("image/*,application/pdf"),
}@maxSize(n)— maximum file size in bytes. The upload request is rejected if the declared size exceeds this limit.@accept("patterns")— comma-separated MIME type patterns. Supports wildcards likeimage/*. The upload request is rejected if the MIME type does not match any pattern.
Both annotations are enforced at upload time (when the presigned URL is requested). They are only valid on file fields.
SDK File Helpers
The SDK provides three functions for file operations inside @expose functions. All three use the current request's principal automatically.
Request Upload
import { uploadFile } from "@kizaki/sdk";
const { fileToken, uploadUrl, headers } = await uploadFile(
"Document", // entity name
"attachment", // file field name
"report.pdf", // original filename
"application/pdf", // MIME type
204800, // file size in bytes
);
// Return uploadUrl + headers to the client for direct uploadConfirm Upload
After the client uploads bytes to the presigned URL, confirm the upload so the entity adopts the file:
import { confirmFile } from "@kizaki/sdk";
const result = await confirmFile(fileToken, entityId);
// result: { sizeBytes, mimeType }Pass entityId to link the file to an existing entity row. The platform re-checks authorization before writing metadata.
Get Download URL
import { getFileUrl } from "@kizaki/sdk";
const { downloadUrl, expiresInSeconds } = await getFileUrl(
"Document", // entity name
"attachment", // file field name
entityId, // row ID
);The platform checks read access on the entity field before issuing the URL.
Recommended Usage
- use a
filefield when one row owns one file - use attachment entities when many files belong to one parent
- keep all access control on the entity that owns the file
- model replacement and deletion as normal row lifecycle, not storage-specific operations
- treat direct object-store details as infrastructure, not application design