Zum Inhalt springen

tapedb

Generiert aus den TypeScript-Deklarationen des öffentlichen tapedb-Entry-Points. Signaturen und Kommentare sind der API-Vertrag dieser Preview.

A predicate over the caller and the row. Throwing denies.

declare function check<F extends Fields = Fields>(test: (context: CheckContext<F>) => boolean): CheckDecision<F>;

Creates a client for one workspace.

declare function createClient<M extends Models>(config: ClientConfig<M>, deps?: ClientDeps): Client<M>;

read only: the rows the caller may see, as a filter compiled into SQL. An array is OR of its filters.

declare function filter<F extends Fields = Fields>(build: (context: {
auth: AccessAuth;
}) => Where<F> | Where<F>[]): FilterDecision<F>;
declare function hasRole(claim: RoleClaim, minimum: WorkspaceRole): boolean;
declare function hasRoleLevel(role: WorkspaceRole, minimum: WorkspaceRole): boolean;

Returns a UUIDv7 (or a random UUID when the platform cannot do better).

Ids from one client sort in the order they were made, even within one millisecond: the 12 bits after the version carry a counter (RFC 9562, method 1), seeded randomly in the lower half each millisecond so there is room to count up. When the counter runs out the timestamp moves forward by one, which keeps order at the cost of a millisecond of drift.

declare function id(): string;
declare function model<F extends Fields>(fields: F, options?: ModelOptions): Model<F>;

A minimum role, checked before any SQL.

declare function role(name: RoleName): RoleDecision;

Compiles models into the engine schema plus the client’s relation graph. Throws on anything the engine would only discover later: a relation to a model that does not exist, an ambiguous inverse, a reserved name.

declare function schema<M extends Models>(models: M): Schema<M>;

A bulk write that failed after some of its batches committed. written rows are already changed; cause is the error that stopped the next batch. Rerunning the same filter continues where it stopped.

declare class BulkWriteError extends Error {
readonly written: number;
readonly cause: unknown;
readonly code: string;
constructor(written: number, cause: unknown);
}
declare class PageQuery<R> extends Query<Page<R>> {
/** Live only: appends the next page to this subscription. False when there is none. */
loadMore(): Promise<boolean>;
}
declare class Query<T> implements PromiseLike<T> {
protected readonly runtime: Runtime;
readonly document: QueryDocument;
private readonly project;
/** Identifies the query: equal keys share one live subscription. */
readonly key: string;
constructor(runtime: Runtime, document: QueryDocument, project: Project<T>, kind: string);
then<R1 = T, R2 = never>(onFulfilled?: ((value: T) => R1 | PromiseLike<R1>) | null, onRejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null): Promise<R1 | R2>;
catch<R = never>(onRejected?: ((reason: unknown) => R | PromiseLike<R>) | null): Promise<T | R>;
finally(onFinally?: (() => void) | null): Promise<T>;
/** Live results. The listener runs now with the current state, then on every change. */
subscribe(listener: (state: LiveState<T>) => void): () => void;
/**
* The current live state. Referentially stable while nothing changed, so it
* can back `useSyncExternalStore`.
*/
snapshot(): LiveState<T>;
}
declare class Write implements PromiseLike<WriteResult> {
private readonly runtime;
readonly ops: TxOp[];
readonly id: string;
private sent;
constructor(runtime: Runtime, ops: TxOp[], id: string);
then<R1 = WriteResult, R2 = never>(onFulfilled?: ((value: WriteResult) => R1 | PromiseLike<R1>) | null, onRejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null): Promise<R1 | R2>;
catch<R = never>(onRejected?: ((reason: unknown) => R | PromiseLike<R>) | null): Promise<WriteResult | R>;
finally(onFinally?: (() => void) | null): Promise<WriteResult>;
}
interface Access<F extends Fields = Fields> {
read?: RoleDecision | FilterDecision<F> | CheckDecision<F>;
create?: RoleDecision | CheckDecision<F>;
update?: RoleDecision | CheckDecision<F>;
delete?: RoleDecision | CheckDecision<F>;
}
interface AccessAuth {
/** The caller's subject, from your identity provider. */
sub: string;
role: WorkspaceRole;
workspaceId: string;
}

A broadcast topic: fire-and-forget messages between clients of a workspace.

interface BroadcastTopic<T = unknown> {
/**
* Sends to every other client listening on the topic. Returns false, and
* drops the message, when the socket is not open: a broadcast is never
* delivered late.
*/
send(message: T): boolean;
/**
* Listens until the returned function is called. Your own messages are not
* delivered back to you. `onError` hears refusals, such as an `authorize`
* hook saying no.
*/
subscribe(listener: (message: T, from: TopicSender) => void, options?: {
onError?: (error: QueryError) => void;
}): () => void;
}
interface CheckContext<F extends Fields = Fields> {
auth: AccessAuth;
/** The stored row, for update, delete and read. */
row?: Partial<Row<F>> & {
id: string;
};
/** The incoming values, for create and update. */
data?: Record<string, unknown>;
/** Field names this write sets. */
changed?: string[];
}
interface ClientConfig<M extends Models> {
schema: Schema<M>;
/** The Worker's origin, e.g. https://api.example.com or http://localhost:8787. */
url: string;
/** Workspace id (a UUID): which database this client talks to. */
workspace: string;
/** Returns a short-lived claim for every request. Omit in `tapedb dev`. */
getToken?: () => Promise<string>;
/** A queued write the engine refused for good during a background flush. */
onWriteRejected?: (rejection: WriteRejection) => void;
}

Worker bindings and vars. tapedb init writes exactly these.

Three resources: one Durable Object namespace, two R2 buckets. There is no D1 and no identity database: the engine consumes a verified claim and never reads a session.

interface Env {
/** The database room. One instance per workspace. */
WORKSPACE: DurableObjectNamespace<WorkspaceRoom>;
/** Transaction log segments and snapshots. */
DATA: R2Bucket;
/** Uploaded files. Optional unless `db.$files` is used. */
FILES?: R2Bucket;
ENVIRONMENT?: string;
SERVICE_VERSION?: string;
ENABLE_SMOKE?: string;
ALLOWED_ORIGINS?: string;
/** Shared HS256 secret used to verify role claims. */
TAPEDB_CLAIM_SECRET: string;
/** Optional expected `iss` claim; checked only when set. */
TAPEDB_CLAIM_ISSUER?: string;
/** Optional expected `aud` claim; checked only when set. */
TAPEDB_CLAIM_AUDIENCE?: string;
/**
* Enables the development claim issuer. Set to `"true"` by `tapedb dev` on
* the command line and never written to `wrangler.jsonc`, so a plain
* `wrangler deploy` has no dev claim route.
*/
TAPEDB_DEV_CLAIMS?: string;
}
interface FieldFilter<T = Scalar> {
equals?: T;
not?: T | FieldFilter<T>;
in?: T[];
notIn?: T[];
gt?: T;
gte?: T;
lt?: T;
lte?: T;
}
interface FilesClient {
upload(file: Blob, options: {
path: string;
}): Promise<StorageObject>;
download(path: string): Promise<Blob>;
delete(path: string): Promise<void>;
list(options?: {
prefix?: string;
}): Promise<StoredFile[]>;
}
interface FindArgs<M extends Models, F extends Fields> {
where?: Where<F, M>;
orderBy?: OrderBy<F>;
take?: number;
include?: Include<M, F>;
/**
* Return only these fields. The engine reads and sends only them, plus the
* id and sequence it needs for live updates; the result type narrows to
* match. Relations named here are included. Not combined with `include`.
*/
select?: Select<M, F>;
}

createClient(): one accessor per model, plus $transaction, $files and $presence.

Reads return a Query: await it for one result over HTTP, or subscribe to it for live results over the workspace socket. Writes return a Write: await it to send it alone, or pass several to $transaction to send them as one log entry. Both are lazy, so building one costs nothing.

Everything below translates into the engine’s query documents and ops; the socket, cache, outbox and retry logic are the same ones the engine client has always used.

interface LiveState<T> {
/** Undefined until the first result arrives. */
data: T | undefined;
isLoading: boolean;
error: QueryError | null;
}
interface ModelClient<M extends Models, F extends Fields> {
findMany<A extends FindArgs<M, F> = FindArgs<M, F>>(args?: A): Query<Result<M, F, A>[]>;
findFirst<A extends FindArgs<M, F> = FindArgs<M, F>>(args?: A): Query<Result<M, F, A> | null>;
findUnique<A extends {
where: {
id: string;
};
include?: Include<M, F>;
select?: Select<M, F>;
}>(args: A): Query<Result<M, F, A> | null>;
/** One page in a stable order. Defaults to `orderBy: { id: "asc" }` (ids are time-ordered). */
findPage<A extends PageArgs<M, F> = PageArgs<M, F>>(args?: A): PageQuery<Result<M, F, A>>;
count(args?: {
where?: Where<F, M>;
}): Query<number>;
/**
* Count, sum, average, minimum and maximum over the rows matching `where`,
* as the caller's read rules see them. Await it or subscribe to it.
*/
aggregate<A extends AggregateArgs<M, F>>(args: A): Query<AggregateOutput<F, A>>;
/** The same measures, one row per distinct value of the `by` fields. */
groupBy<A extends GroupByArgs<M, F>>(args: A): Query<GroupRow<F, A>[]>;
create(args: {
data: CreateData<F, M>;
}): Write;
/**
* Writes every row matching `where`. Sent straight to the engine, which
* resolves the rows: unlike single-row writes it is not applied
* optimistically and not queued offline, because which rows match is the
* engine's answer, not the cache's.
*
* Up to 1000 rows commit as one transaction. Past that the rows are written
* in batches of 1000, in id order, each its own transaction; if one fails,
* a `BulkWriteError` reports how many rows the earlier batches wrote, and
* running the same call again continues with the rows still matching.
*/
updateMany(args: {
where: Where<F, M>;
data: UpdateData<F, M>;
}): Promise<BulkResult>;
/** Deletes every row matching `where`. Batched like `updateMany`. */
deleteMany(args: {
where: Where<F, M>;
}): Promise<BulkResult>;
update(args: {
where: {
id: string;
};
data: UpdateData<F, M>;
}): Write;
upsert(args: {
where: {
id: string;
};
create: CreateData<F, M>;
update: UpdateData<F, M>;
}): Write;
delete(args: {
where: {
id: string;
};
}): Write;
}
interface Page<R> {
rows: R[];
hasMore: boolean;
/** Pass back as `after` for the next page; null on the last page. */
cursor: string | null;
}
interface PageArgs<M extends Models, F extends Fields> extends FindArgs<M, F> {
/** A previous page's `cursor`. */
after?: string | null;
}
interface PresencePeer {
clientId: string;
presence: unknown;
cursor?: unknown;
drag?: unknown;
}
interface PresenceRoom {
set(presence: unknown): void;
cursor(cursor: unknown): void;
drag(drag: unknown): void;
clearDrag(): void;
subscribe(listener: (peers: PresencePeer[]) => void): () => void;
leave(): void;
}
interface QueryError {
code: string;
message: string;
}
interface RoleClaim {
/** Platform user id. */
sub: string;
/** Platform workspace id (the DO name). */
workspaceId: string;
/** Numeric workspace role level. */
role: WorkspaceRole;
/** Expiry, seconds since epoch. */
exp: number;
/** Issued at, seconds since epoch. */
iat: number;
iss?: string;
aud?: string;
}
interface Schema<M extends Models = Models> extends TapeDBSchema {
readonly models: M;
readonly graph: Record<string, ModelInfo>;
}
interface StorageObject {
id: string;
key: string;
size: number;
etag: string;
contentType: string;
}

One row of the workspace’s file index.

interface StoredFile {
id: string;
path: string;
r2Key: string;
size: number;
contentType: string | null;
createdAt: number;
updatedAt: number;
}

Who sent a broadcast message.

interface TopicSender {
/** The sending socket's client id: one per open client. */
clientId: string;
/** The claim subject (user id) the sender connected with. */
userId: string;
}

A queued write the server refused for good and the outbox dropped.

interface WriteRejection {
clientTxId: string;
ops: TxOp[];
code: string;
message: string;
}
interface WriteResult {
id: string;
/** "queued" when the send failed transiently and the outbox will retry. */
status: "applied" | "queued";
}
type Client<M extends Models> = {
[K in keyof M & string]: ModelClient<M, FieldsOf<M[K]>>;
} & Omit<ClientExtras, "$export"> & {
$export(): AsyncIterable<ExportRecord<M>>;
};
type CreateData<F, M extends Models = Models> = {
id?: string;
} & RequiredParents<F> & {
[K in RequiredScalarKeys<F>]: InputOf<F[K]>;
} & {
[K in OptionalScalarKeys<F>]?: InputOf<F[K]>;
} & {
[K in BelongsToKeys<F>]?: BelongsToInput<F[K], M>;
} & {
[K in BelongsToKeys<F> as `${K}Id`]?: string | null;
} & {
[K in HasManyKeys<F>]?: HasManyInput<F[K], M>;
};
type DeleteBehaviour = "setNull" | "cascade" | "restrict";

One line of $export().

type ExportRecord<M extends Models = Models> = {
type: "header";
format: "tapedb-export";
version: 1;
workspaceId: string;
/** Workspace sequence when the export started. */
seq: number;
exportedAt: number;
schemaVersion: string | null;
/** Models exported, in order. */
models: string[];
/** Models the caller may not read, and why. */
skipped: Array<{
model: string;
reason: string;
}>;
} | {
[K in keyof M & string]: {
type: "row";
model: K;
data: Row<FieldsOf<M[K]>>;
};
}[keyof M & string] | {
type: "file";
file: StoredFile & {
id: string;
r2Key: string;
createdAt: number;
updatedAt: number;
};
} | {
type: "end";
startSeq: number;
/** Workspace sequence when the export finished. */
seq: number;
rows: number;
counts: Record<string, number>;
/** True when no write landed during the export: it is the workspace exactly at `seq`. */
consistent: boolean;
} | {
type: "error";
code: string;
message: string;
};
type Include<M extends Models, F extends Fields> = {
[K in RelationKeys<F>]?: true | IncludeArgs<M, TargetFields<F[K], M>>;
};
type OrderBy<F> = Partial<Record<FilterKeys<F>, "asc" | "desc">> | Partial<Record<FilterKeys<F>, "asc" | "desc">>[];
type Result<M extends Models, F extends Fields, A> = A extends {
select: infer S;
} ? Selected<M, F, S> : Row<F> & (A extends {
include: infer I;
} ? WithInclude<M, F, I> : unknown);

A stored row: its fields, the foreign keys of its belongsTo relations, and the system fields.

type Row<F> = {
id: string;
createdAt: number;
updatedAt: number;
} & {
[K in ScalarKeys<F>]: OutputOf<F[K]>;
} & {
[K in BelongsToKeys<F> as `${K}Id`]: ForeignKeyOf<F[K]>;
};

Field and relation builders: the t in model({ title: t.string() }).

Every builder is immutable: a modifier returns a new builder, so a shared base field cannot be changed by one model’s .optional(). The type parameters carry what the client needs to infer rows and inputs.

type ScalarKind = "string" | "number" | "boolean" | "date" | "json";
type UpdateData<F, M extends Models = Models> = {
[K in ScalarKeys<F>]?: InputOf<F[K]>;
} & {
[K in BelongsToKeys<F>]?: BelongsToInput<F[K], M>;
} & {
[K in BelongsToKeys<F> as `${K}Id`]?: string | null;
} & {
[K in HasManyKeys<F>]?: HasManyInput<F[K], M>;
};
type Where<F extends Fields = Fields, M extends Models = Models> = Scalars<F> & {
[K in RelationKeys<F>]?: F[K] extends HasMany ? ManyFilter<WhereLeaf<TargetFieldsOf<F[K], M>>> : WhereLeaf<TargetFieldsOf<F[K], M>> | null;
} & {
AND?: Where<F, M> | Where<F, M>[];
OR?: Where<F, M>[];
NOT?: Where<F, M> | Where<F, M>[];
};
type WorkspaceRole = (typeof WORKSPACE_ROLE_LEVELS)[keyof typeof WORKSPACE_ROLE_LEVELS];
declare const t: {
string: () => Field<"string", false, false>;
number: () => Field<"number", false, false>;
boolean: () => Field<"boolean", false, false>;
/** Stored as epoch milliseconds; accepts a `Date`, milliseconds or an ISO string. */
date: () => Field<"date", false, false>;
/** Any JSON value. Not filterable or sortable. */
json: () => Field<"json", false, false>;
belongsTo: <T extends string>(target: T, options?: BelongsToOptions) => BelongsTo<T, false>;
hasMany: <T extends string>(target: T, options?: HasManyOptions) => HasMany<T>;
};

Shared role-claim contract between the Worker (JWT verification) and the workspace engine (permission checks). consumes this shape.

declare const WORKSPACE_ROLE_LEVELS: {
readonly GUEST: 0;
readonly USER: 1;
readonly MEMBER: 2;
readonly EDITOR: 3;
readonly MODERATOR: 4;
readonly ADMIN: 5;
readonly OWNER: 6;
readonly FOUNDER: 7;
readonly DEVELOPER: 8;
};