release: WRNexusJS 0.4.0

This commit is contained in:
2026-07-27 12:42:18 +05:30
parent 8b728a3e5d
commit 30e5721e84
250 changed files with 10065 additions and 3923 deletions
+139
View File
@@ -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`;
}
+3
View File
@@ -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";
+12 -7
View File
@@ -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);