release: WRNexusJS 0.4.0
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import type { Db, Row } from "./driver.ts";
|
||||
import type { Model } from "./schema.ts";
|
||||
import type { Dialect } from "./sql.ts";
|
||||
|
||||
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
function ident(value: string): string {
|
||||
if (!IDENT.test(value)) throw new Error(`Unsafe identifier: ${value}`);
|
||||
return value;
|
||||
}
|
||||
function ph(dialect: Dialect, index: number): string {
|
||||
return dialect === "postgres" ? `$${index}` : "?";
|
||||
}
|
||||
function encode(value: unknown): string {
|
||||
return btoa(unescape(encodeURIComponent(JSON.stringify(value))))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
function decode<T>(value: string): T {
|
||||
const pad = value.length % 4 ? "=".repeat(4 - (value.length % 4)) : "";
|
||||
return JSON.parse(
|
||||
decodeURIComponent(escape(atob(value.replace(/-/g, "+").replace(/_/g, "/") + pad))),
|
||||
) as T;
|
||||
}
|
||||
|
||||
export interface CursorPageOptions {
|
||||
limit?: number;
|
||||
after?: string;
|
||||
before?: string;
|
||||
column?: string;
|
||||
direction?: "asc" | "desc";
|
||||
maxLimit?: number;
|
||||
}
|
||||
export interface CursorPage<T> {
|
||||
items: T[];
|
||||
nextCursor?: string;
|
||||
previousCursor?: string;
|
||||
hasMore: boolean;
|
||||
}
|
||||
interface CursorValue {
|
||||
value: unknown;
|
||||
direction: "asc" | "desc";
|
||||
}
|
||||
|
||||
export async function cursorPaginate<T extends Row = Row>(
|
||||
db: Db,
|
||||
query: { sql: string; params?: unknown[]; model?: Model<T> },
|
||||
options: CursorPageOptions = {},
|
||||
): Promise<CursorPage<T>> {
|
||||
const column = ident(options.column ?? "id");
|
||||
const direction = options.direction ?? "asc";
|
||||
const max = options.maxLimit ?? 100;
|
||||
const limit = Math.min(max, Math.max(1, Math.floor(options.limit ?? 20)));
|
||||
const params = [...(query.params ?? [])];
|
||||
const cursor = options.after
|
||||
? decode<CursorValue>(options.after)
|
||||
: options.before
|
||||
? decode<CursorValue>(options.before)
|
||||
: undefined;
|
||||
const comparison = options.before
|
||||
? direction === "asc"
|
||||
? "<"
|
||||
: ">"
|
||||
: direction === "asc"
|
||||
? ">"
|
||||
: "<";
|
||||
const reverse = !!options.before;
|
||||
let sql = `SELECT * FROM (${query.sql}) AS __wrn_cursor`;
|
||||
if (cursor) {
|
||||
params.push(cursor.value);
|
||||
sql += ` WHERE ${column} ${comparison} ${ph(db.driver.dialect, params.length)}`;
|
||||
}
|
||||
const order = reverse ? (direction === "asc" ? "DESC" : "ASC") : direction.toUpperCase();
|
||||
params.push(limit + 1);
|
||||
sql += ` ORDER BY ${column} ${order} LIMIT ${ph(db.driver.dialect, params.length)}`;
|
||||
let rows = await db.all<T>(sql, params, query.model);
|
||||
const hasMore = rows.length > limit;
|
||||
if (hasMore) rows = rows.slice(0, limit);
|
||||
if (reverse) rows.reverse();
|
||||
const first = rows[0]?.[column];
|
||||
const last = rows.at(-1)?.[column];
|
||||
return {
|
||||
items: rows,
|
||||
hasMore,
|
||||
...(last !== undefined && (hasMore || rows.length === limit)
|
||||
? { nextCursor: encode({ value: last, direction }) }
|
||||
: {}),
|
||||
...(first !== undefined && (options.after || options.before)
|
||||
? { previousCursor: encode({ value: first, direction }) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function optimisticUpdate(
|
||||
db: Db,
|
||||
input: {
|
||||
table: string;
|
||||
idColumn?: string;
|
||||
id: unknown;
|
||||
versionColumn?: string;
|
||||
version: number;
|
||||
values: Record<string, unknown>;
|
||||
},
|
||||
): Promise<number> {
|
||||
const table = ident(input.table);
|
||||
const idColumn = ident(input.idColumn ?? "id");
|
||||
const versionColumn = ident(input.versionColumn ?? "version");
|
||||
const entries = Object.entries(input.values);
|
||||
if (!entries.length) return input.version;
|
||||
for (const [column] of entries) ident(column);
|
||||
const params = entries.map(([, value]) => value);
|
||||
const assignments = entries.map(
|
||||
([column], index) => `${column} = ${ph(db.driver.dialect, index + 1)}`,
|
||||
);
|
||||
assignments.push(`${versionColumn} = ${versionColumn} + 1`);
|
||||
params.push(input.id, input.version);
|
||||
const result = await db.exec(
|
||||
`UPDATE ${table} SET ${assignments.join(", ")} WHERE ${idColumn} = ${ph(db.driver.dialect, params.length - 1)} AND ${versionColumn} = ${ph(db.driver.dialect, params.length)}`,
|
||||
params,
|
||||
);
|
||||
if (result.changes !== 1) throw new Error("WRN-DB-OPTIMISTIC-LOCK");
|
||||
return input.version + 1;
|
||||
}
|
||||
|
||||
export function tenantScope(
|
||||
sql: string,
|
||||
tenantId: unknown,
|
||||
dialect: Dialect,
|
||||
existingParams = 0,
|
||||
column = "tenantId",
|
||||
): { sql: string; params: unknown[] } {
|
||||
ident(column);
|
||||
const wrapped = `SELECT * FROM (${sql}) AS __wrn_tenant WHERE ${column} = ${ph(dialect, existingParams + 1)}`;
|
||||
return { sql: wrapped, params: [tenantId] };
|
||||
}
|
||||
|
||||
export function softDeleteClause(column = "deletedAt"): string {
|
||||
return `${ident(column)} IS NULL`;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
parseMigration,
|
||||
loadMigrations,
|
||||
appliedMigrations,
|
||||
applyMigrations,
|
||||
migrate,
|
||||
rollback,
|
||||
status,
|
||||
@@ -27,3 +28,5 @@ export { parseQueries, generateQueriesFile } from "./generate.ts";
|
||||
export type { QueryDef, QueryKind, ModelRef } from "./generate.ts";
|
||||
export { paginate, loadRelated } from "./query.ts";
|
||||
export type { Paginated, PageOptions, RelationOptions } from "./query.ts";
|
||||
export { cursorPaginate, optimisticUpdate, tenantScope, softDeleteClause } from "./advanced.ts";
|
||||
export type { CursorPage, CursorPageOptions } from "./advanced.ts";
|
||||
|
||||
@@ -63,21 +63,26 @@ export async function appliedMigrations(db: Db): Promise<string[]> {
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
|
||||
/** Apply all pending migrations (each in a transaction). Returns applied names. */
|
||||
export async function migrate(db: Db, dir: string): Promise<string[]> {
|
||||
/** Apply an ordered migration list (each in a transaction). Returns applied names. */
|
||||
export async function applyMigrations(db: Db, migrations: readonly Migration[]): Promise<string[]> {
|
||||
const applied = new Set(await appliedMigrations(db));
|
||||
const pending = loadMigrations(dir).filter((m) => !applied.has(m.name));
|
||||
const pending = migrations.filter((migration) => !applied.has(migration.name));
|
||||
const done: string[] = [];
|
||||
for (const m of pending) {
|
||||
for (const migration of pending) {
|
||||
await db.tx(async (tx) => {
|
||||
if (m.up) await tx.exec(m.up);
|
||||
await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [m.name]);
|
||||
if (migration.up) await tx.exec(migration.up);
|
||||
await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]);
|
||||
});
|
||||
done.push(m.name);
|
||||
done.push(migration.name);
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
/** Apply all pending migrations from a directory. */
|
||||
export async function migrate(db: Db, dir: string): Promise<string[]> {
|
||||
return applyMigrations(db, loadMigrations(dir));
|
||||
}
|
||||
|
||||
/** Roll back the most recently applied migration. Returns its name, or null. */
|
||||
export async function rollback(db: Db, dir: string): Promise<string | null> {
|
||||
const applied = await appliedMigrations(db);
|
||||
|
||||
Reference in New Issue
Block a user