import { RebaseApiError } from "../errors.js"; import type { EntityStatus, EntityValues } from "../types/entities.js"; import type { CollectionConfig, FilterValues } from "../types/collections.js"; import type { OrderByTuple } from "../types/filter-operators.js"; import type { RebaseCallContext } from "../call_context.js"; import type { IncludeSpec, LogicalCondition } from "./data.js"; import type { CollectionUpdateMeta } from "../types/websockets.js"; /** * @internal */ export interface FetchOneProps = Record> { path: string; id: string | number; databaseId?: string; collection?: CollectionConfig; /** * See {@link FetchCollectionProps.withDeleted}. A soft-deleted row is a 404 * here by default, so `findById` and `find` agree about which rows exist — * a row you cannot find in a listing and can still open by id is the kind * of inconsistency that makes a feature untrustworthy. */ withDeleted?: boolean | "only"; } /** * @internal */ export type ListenOneProps = Record> = FetchOneProps & { onUpdate: (row: Record | null) => void; onError?: (error: Error) => void; }; /** * Configuration for vector similarity search queries. * Vector search applies an ORDER BY distance expression and optionally * filters results by a distance threshold. */ export interface VectorSearchParams { /** Property name containing the vector column */ property: string; /** Query vector to compare against */ vector: number[]; /** Distance function (default: "cosine") */ distance?: "cosine" | "l2" | "inner_product"; /** Only return results within this distance threshold */ threshold?: number; } /** Rows returned for a plain / text-search list read when the client sends no `limit`. */ export declare const DEFAULT_LIST_LIMIT = 50; /** Rows returned for a vector-search list read when the client sends no `limit`. */ export declare const DEFAULT_VECTOR_LIST_LIMIT = 10; /** Largest `limit` a client may ask for on any surface. Above it, the read is refused. */ export declare const MAX_LIST_LIMIT = 1000; /** Overridable bounds for {@link resolveClientListLimit}. */ export interface ListLimitBounds { /** Default page size for plain and text-search reads. */ defaultLimit?: number; /** Default page size for vector-search reads. */ vectorDefaultLimit?: number; /** Largest limit a client may ask for. A larger one is rejected, not clamped. */ maxLimit?: number; } /** * Thrown by {@link resolveClientListLimit} for a `limit` the platform will not * serve. Carries an HTTP status so an ingress that speaks HTTP can forward it * verbatim, and `maxLimit` so one can be built without re-deriving the ceiling. * * @group Errors */ export declare class ListLimitError extends RebaseApiError { /** The ceiling that was exceeded — what the caller should page by instead. */ readonly maxLimit: number; constructor(message: string, maxLimit: number); } /** * Resolve a client-supplied list `limit` into a safe, always-defined value. * * - An absent / blank limit falls back to the mode default: * `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`. * - A limit that is present must be an integer in `[1, maxLimit]`. Anything * else — `0`, a negative, `1.5`, `abc`, `100000000` — throws * {@link ListLimitError} rather than being coerced into range, because every * coercion answers a question the caller did not ask with a page it cannot * tell apart from the whole collection. * * The return is never `undefined` — no ingress that routes its client limit * through this can produce an unbounded read. * * @throws {ListLimitError} when a present `limit` is not an integer in range. */ export declare function resolveClientListLimit(rawLimit: number | string | null | undefined, opts?: ListLimitBounds & { vectorSearch?: boolean; }): number; /** * @internal */ export interface FetchCollectionProps = Record> { path: string; collection?: CollectionConfig; filter?: FilterValues>; /** * An `or(...)`/`and(...)` group, applied alongside `filter`. * * The REST layer parsed `?or=` into this and then had nowhere to put it, so * the group was dropped and the read ran unfiltered — returning every row * the caller's policies allowed rather than the ones they asked for. */ logical?: LogicalCondition; limit?: number; offset?: number; startAfter?: unknown; /** * The sort, in either of two spellings: * * - a field name, whose direction is the separate `order` below — the * original single-column contract, which every existing driver reads; * - a list of `[field, direction]` tuples applied in order of significance, * which carries a multi-column sort and ignores `order` entirely. * * `normalizeDriverOrderBy` in `@rebasepro/common` collapses the pair to the * list form. A driver that has not been taught the list form should read it * through that helper rather than assume a string: handed an array, `String()` * would produce a field name like `roles,asc` and the sort would 400 (or, * with unknown-field warnings on, silently vanish). */ orderBy?: string | OrderByTuple[]; searchString?: string; /** Ask each row which declared search field matched — populates `_matches`. */ searchExplain?: boolean; /** Direction for the string form of `orderBy`. Ignored when `orderBy` is a list. */ order?: "desc" | "asc"; /** Vector similarity search configuration */ vectorSearch?: VectorSearchParams; /** * What to do about rows a soft delete has stamped. * * Unset (the default) hides them, which is the whole point of the feature: * a deleted row is deleted as far as the application is concerned. `true` * includes them alongside the live ones — a trash view, an admin audit. * `"only"` returns nothing but them, which is the trash view proper and is * not expressible as a filter, because the field is not part of the * caller's vocabulary. * * Ignored by collections that do not declare {@link * PostgresCollectionConfig.softDelete}: there is no stamp to look at, and * silently returning nothing for `"only"` on such a collection would be a * worse answer than ignoring it. */ withDeleted?: boolean | "only"; /** * Relations to load — see {@link IncludeSpec}. * * Absent means *no* relations, the same as it does over REST. It used to be * absent from this contract entirely, and the driver's own fetch then loaded * every relation of every row unconditionally: `find()` returned a row with * a foreign key and `listen()` returned the same row with a nested object * where that key was, for the same query. */ include?: IncludeSpec; /** Columns to read, as a projection. See `FindParams.fields`. */ fields?: string[]; /** `SELECT DISTINCT` over the projection. See `FindParams.distinct`. */ distinct?: boolean; } /** * @internal */ export type ListenCollectionProps = Record> = FetchCollectionProps & { /** * Page number (1-indexed), as `FindParams.page`. * * A subscription could name a `limit` and an `offset` but not a `page`, * so a live list on page three had to compute the offset itself — and * the two spellings then disagreed about what a page was. */ page?: number; onUpdate: (rows: Record[], meta?: CollectionUpdateMeta) => void; onError?: (error: Error) => void; }; /** * @internal */ export interface SaveProps = Record> { path: string; values: Partial>; id?: string | number; previousValues?: Partial>; collection?: CollectionConfig; status: EntityStatus; /** * Write the row with INSERT ... ON CONFLICT DO UPDATE on the primary key * instead of choosing between insert and update up front. * * One statement, so it does not lose the race a read-then-write can, and it * succeeds whether or not the row is already there — what a re-runnable * import needs. Requires every primary key column to be present; without * them there is no conflict target and the row is inserted normally. */ upsert?: boolean; /** * The columns the upsert matches a conflict on, instead of the primary key. * * The key is the only target that always exists, and it is the wrong one * for the write an upsert is usually reached for: "this user, identified by * their email, exists with these values". Keyed on the primary key that is * an insert, because the caller does not know the serial id — so the row is * duplicated on every run. * * Only column sets carrying a uniqueness guarantee are legal here; Postgres * refuses anything else with 42P10, from inside a transaction. The REST * layer checks the target against the collection's declarations first (see * `resolveConflictTarget`), so the answer is a 400 naming the available * targets rather than a 500 naming a constraint the caller never wrote. */ onConflict?: readonly string[]; } /** * @internal */ export interface SaveManyProps = Record> { path: string; /** * The rows to write. A row carrying its primary key updates (or, with * `upsert`, inserts-or-updates) that row; one without inserts. */ rows: Partial>[]; collection?: CollectionConfig; /** Apply every row as INSERT ... ON CONFLICT DO UPDATE. See {@link SaveProps.upsert}. */ upsert?: boolean; /** The conflict target for those upserts. See {@link SaveProps.onConflict}. */ onConflict?: readonly string[]; } /** * @internal */ export interface UpdateManyProps = Record> { path: string; /** * The rows to update, each named by its address. * * Distinct from {@link SaveManyProps.rows}, which carries keys *inside* the * values and is insert-shaped — `saveMany` passes `status: "new"` and no * `id`, so it cannot express "update exactly this row". This can, and it is * why bulk update is a separate driver method rather than a flag on that one. */ updates: { id: string | number; values: Partial>; }[]; collection?: CollectionConfig; } /** * @internal */ export interface DeleteProps = Record> { row: { id: string | number; path: string; values?: Partial>; }; collection?: CollectionConfig; /** * Issue a real `DELETE` on a collection that declares * {@link PostgresCollectionConfig.softDelete}. * * The row and every cascade behind it go. It needs the same permission an * ordinary delete does and nothing more: it is the same verb, and a second * access-control surface for one operation is a second thing to get wrong. * No effect on a collection without soft delete, where every delete is * already this one. */ hard?: boolean; } /** * @internal */ export interface DeleteManyProps = Record> { path: string; ids: (string | number)[]; collection?: CollectionConfig; /** See {@link DeleteProps.hard}. */ hard?: boolean; } /** * One operation of a {@link DataDriver.batchWrite}. * * `path` rather than a slug, because a batch entry addresses rows exactly as * the single-row props do and a nested path is a legal address there. * * @internal */ export interface BatchWriteOperation = Record> { op: "create" | "update" | "upsert" | "delete"; path: string; /** Required for `update` and `delete`. May be a `$ref` marker; see `batchWrite`. */ id?: unknown; values?: Partial>; collection?: CollectionConfig; /** See {@link SaveProps.onConflict}. `upsert` only. */ onConflict?: readonly string[]; /** Names this operation's result, for a later `$ref`. */ ref?: string; } /** * @internal */ export interface BatchWriteProps = Record> { operations: BatchWriteOperation[]; } export type FilterCombinationValidProps = { path: string; databaseId?: string; collection: CollectionConfig; filterValues: FilterValues; sortBy?: [string, "asc" | "desc"]; }; /** * The integration SPI for plugging a data backend into Rebase. * * Implement this interface to connect a custom backend (or use a built-in * driver such as the Firestore one) and register it on * ``. Rebase wraps drivers via `buildRebaseData` and * routes collections to them by their `dataSource` key. * * For *consuming* data in application code, use `RebaseData` / * `context.data` instead — this interface is only for providing it. * * @group Datasource */ export interface DataDriver { /** * Key that identifies this driver */ key?: string; /** * If the driver has been initialised */ initialised?: boolean; /** * Fetch data from a collection * @param props * @return Promise of flat rows */ fetchCollection = Record>(props: FetchCollectionProps): Promise[]>; /** * Listen to a collection in a given path. If you don't implement this method * `fetchCollection` will be used instead, with no real time updates. * @param props * @return Function to cancel subscription */ listenCollection? = Record>(props: ListenCollectionProps): () => void; /** * Retrieve a single row given a path and a collection * @param props */ fetchOne = Record>(props: FetchOneProps): Promise | undefined>; /** * Get realtime updates on one row. * @param props * @return Function to cancel subscription */ listenOne? = Record>(props: ListenOneProps): () => void; /** * Save a row to the specified path * @param props */ save = Record>(props: SaveProps): Promise>; /** * Save many rows as one unit of work. * * Every row runs the same pipeline as {@link save} — callbacks, relations * and row-level security all still apply — but they share a single * transaction, so the batch either lands whole or not at all. That, and the * single round trip, is what makes importing tens of thousands of rows * viable without dropping to raw SQL. * * Optional: drivers that cannot do this leave it undefined and callers fall * back to `save` per row. */ saveMany? = Record>(props: SaveManyProps): Promise[]>; /** * Update many rows in one transaction, each addressed by id. * * Optional for the same reason `saveMany` is: a driver that cannot make the * batch atomic should not pretend to. The REST layer reports * `BULK_UNSUPPORTED` rather than silently falling back to a loop of single * writes, which would be neither atomic nor one round trip — the two things * a caller reaches for a batch to get. */ updateMany? = Record>(props: UpdateManyProps): Promise[]>; /** * Delete the row `props.row` addresses. * * **Resolving means the row is gone because this call removed it.** A * delete that matched nothing must reject with a not-found error * (`ApiError.notFound`, `statusCode: 404`) rather than resolving quietly. * * The rule is here rather than in each driver because the two * implementations answered differently and each had a test pinning its own * habit: Postgres threw, Mongo logged a warning and resolved. Three things * decide it in favour of rejecting. * * The REST layer already says 404 — `DELETE /api/data//` reads the * row before removing it — so a quiet resolve made the driver API disagree * with the HTTP API about the same operation, and only in-process * `rebase.data` callers could see the difference. * * A caller cannot tell "deleted" from "there was nothing there" without it, * and those are different facts: one means the caller's model of the data * was right, the other that it was stale. Silence hands back the wrong one * and the caller carries on. * * And on a driver with row-level security, "matched nothing" is *also* how * a policy refusal arrives — Postgres filters `DELETE` through `USING` * rather than raising. A driver that resolves on zero rows therefore * reports a refused delete as a completed one, which is the defect * `explainZeroRowWrite` exists to prevent (see `write-denial.ts`). * * Conformance for both server drivers lives in * `packages/server/test/contract/delete-contract.ts`, run by each driver's * own suite against its own database. `packages/firebase`'s Firestore * driver does not honour it: `deleteDoc` resolves for a missing document * and reporting otherwise would cost a read on every delete. It runs in the * browser against Firestore's own semantics rather than behind * `rebase.data`, and that exception is stated here rather than left to be * discovered. */ delete = Record>(props: DeleteProps): Promise; /** * Delete all entities from a collection. * @param path Collection path */ deleteAll?(path: string): Promise; /** * Delete many rows in one transaction, addressed by id. * * Ids rather than a filter, deliberately — see * {@link SDKCollectionClient.deleteMany}. Optional, as `saveMany` is. */ deleteMany? = Record>(props: DeleteManyProps): Promise; /** * Apply a mixed list of writes across collections as one unit of work. * * The capability `saveMany` and `deleteMany` cannot express between them: a * batch that touches two tables. Sent as two requests those can * half-succeed, and the recovery — read back, work out which half landed, * undo it — is code nobody writes. * * Every operation runs the pipeline its single-row equivalent runs, in * order, in one transaction, under the caller's own role. Operations may * carry `{ "$ref": "." }` markers in `values` or `id`, which * the driver resolves against the rows earlier operations wrote — the * driver, because inside the transaction is the only place those rows * exist. `@rebasepro/server` exports `resolveBatchRefs` so the resolution * is one implementation rather than one per driver. * * Resolves to one entry per operation, aligned to the input: the written * row for a create, update or upsert, and `null` for a delete. * * Optional for the same reason `saveMany` is: a driver that cannot make it * atomic must not pretend to. The REST layer answers `BATCH_UNSUPPORTED` * rather than falling back to a loop, which would be the non-atomic * sequence the caller reached for this to avoid. */ batchWrite? = Record>(props: BatchWriteProps): Promise<(Record | null)[]>; /** * Check if the given property is unique in the given collection * @param path Collection path * @param name of the property * @param value * @param id * @param collection * @return `true` if there are no other fields besides the given entity */ checkUniqueField(path: string, name: string, value: unknown, id?: string | number, collection?: CollectionConfig): Promise; /** * Count the number of entities in a collection */ count? = Record>(props: FetchCollectionProps): Promise; /** * Check if the given filter combination is valid * @param props */ isFilterCombinationValid?(props: Omit & { databaseId?: string; }): boolean; /** * Get the object to generate the current time in the driver */ currentTime?: () => unknown; delegateToCMSModel?: (data: unknown) => unknown; cmsToDelegateModel?: (data: unknown) => unknown; initTextSearch?: (props: { context: RebaseCallContext; path: string; databaseId?: string; collection: CollectionConfig; parentCollectionSlugs?: string[]; parentEntityIds?: string[]; }) => Promise; /** * Flag to indicate if the driver has requested the initialization of the text search index */ needsInitTextSearch?: boolean; /** * Optional REST-optimised fetch service. When present, the REST API * generator uses these methods instead of the generic `fetchOne` / * `fetchCollection` pipeline, enabling include-aware eager-loading. */ restFetchService?: RestFetchService; /** * Return the admin capabilities of this driver. * @see SQLAdmin * @see DocumentAdmin * @see SchemaAdmin */ admin?: import("../types/backend.js").DatabaseAdmin; } /** * REST-optimised fetch service exposed by drivers that support * eager-loading of relations via `include`. * * The methods return flattened rows — exactly the table's columns, under their * own names and with the types the database returned — and included relations * inlined as plain nested rows. This is the shape served to app developers * through the REST API / SDK client. * * No synthesized `id`: identity is a primary key, which may be named anything * and span several columns, so an address is derived by whoever needs one (see * `buildCompositeId`) rather than written into the row on top of the data. * * @group DataDriver */ export interface RestFetchService { /** * Fetch a collection of flattened entities with optional relation includes. */ fetchCollectionForRest(collectionPath: string, options?: { filter?: FilterValues; /** An `or(...)`/`and(...)` group, applied alongside `filter`. */ logical?: LogicalCondition; /** See `FetchCollectionProps.orderBy`: a field name plus `order`, or a list of tuples. */ orderBy?: string | OrderByTuple[]; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record; searchString?: string; /** Ask each row which declared search fields matched — populates `_matches`. */ searchExplain?: boolean; databaseId?: string; vectorSearch?: VectorSearchParams; /** See {@link FetchCollectionProps.withDeleted}. */ withDeleted?: boolean | "only"; /** * Columns to read. A projection pushed into the SELECT, not a trim * of the response — `excludeFromApi` still applies on top, and the * primary key is always read whether or not it is named. */ fields?: string[]; /** `SELECT DISTINCT` over the projection. See `FindParams.distinct`. */ distinct?: boolean; }, include?: IncludeSpec): Promise[]>; /** * The opaque cursor that continues a listing after `row`. * * On the driver rather than the route because deriving it needs the * collection's primary key — which may be named anything and span several * columns — and that is the driver's knowledge. The route holds the last * row and the sort keys and asks for the string. * * `undefined` where no cursor can describe the page: an ordering with no * stored value to compare against (relevance), or a row missing a value for * one of the sort keys. The listing then reports no `nextCursor` and the * caller pages by offset, which is what it did before cursors existed. * * Optional: a driver that cannot seek simply never issues one, and * `meta.nextCursor` is absent for every read it serves. */ cursorFor?(collectionPath: string, row: Record, orderBy?: OrderByTuple[]): string | undefined; /** * `count`/`sum`/`avg`/`min`/`max` over the rows a filter selects, * optionally grouped. * * Optional, and the REST route answers 501 where a driver does not * implement it — an aggregate is not a thing to approximate, and an empty * result set would read as "nothing matched". * * Any implementation **must apply the same row-level authorization as a * read**. An aggregate is an efficient way to learn about rows you cannot * select, and `count(*)` over a table whose policies would return nothing * has to be zero. */ aggregate?(collectionPath: string, options: { aggregates: { fn: "count" | "sum" | "avg" | "min" | "max"; field?: string; alias: string; }[]; groupBy?: string[]; filter?: FilterValues; logical?: LogicalCondition; searchString?: string; limit?: number; /** See {@link FetchCollectionProps.withDeleted}. */ withDeleted?: boolean | "only"; }): Promise[]>; /** * Fetch a single flattened entity with optional relation includes. */ fetchOneForRest(collectionPath: string, id: string | number, include?: IncludeSpec, databaseId?: string, options?: { /** See `FetchCollectionProps.fields`. */ fields?: string[]; /** See {@link FetchOneProps.withDeleted}. */ withDeleted?: boolean | "only"; }): Promise | null>; }