release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import type { PutMeta, StorageDriver } from "./driver.ts";
|
||||
|
||||
export interface QuotaUsage {
|
||||
owner: string;
|
||||
bytes: number;
|
||||
objects: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
export interface QuotaStore {
|
||||
get(owner: string): Promise<QuotaUsage>;
|
||||
reserve(
|
||||
owner: string,
|
||||
bytes: number,
|
||||
limits: { bytes: number; objects?: number },
|
||||
): Promise<boolean>;
|
||||
release(owner: string, bytes: number): Promise<void>;
|
||||
}
|
||||
|
||||
export function memoryQuotaStore(): QuotaStore {
|
||||
const values = new Map<string, QuotaUsage>();
|
||||
return {
|
||||
async get(owner) {
|
||||
return structuredClone(
|
||||
values.get(owner) ?? { owner, bytes: 0, objects: 0, updatedAt: Date.now() },
|
||||
);
|
||||
},
|
||||
async reserve(owner, bytes, limits) {
|
||||
if (!Number.isInteger(bytes) || bytes < 0)
|
||||
throw new RangeError("Quota bytes must be non-negative");
|
||||
const current = values.get(owner) ?? { owner, bytes: 0, objects: 0, updatedAt: Date.now() };
|
||||
if (
|
||||
current.bytes + bytes > limits.bytes ||
|
||||
current.objects + 1 > (limits.objects ?? Number.MAX_SAFE_INTEGER)
|
||||
)
|
||||
return false;
|
||||
values.set(owner, {
|
||||
owner,
|
||||
bytes: current.bytes + bytes,
|
||||
objects: current.objects + 1,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
async release(owner, bytes) {
|
||||
const current = values.get(owner);
|
||||
if (!current) return;
|
||||
values.set(owner, {
|
||||
...current,
|
||||
bytes: Math.max(0, current.bytes - Math.max(0, bytes)),
|
||||
objects: Math.max(0, current.objects - 1),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface QuotaSqlClient {
|
||||
query<T = any>(sql: string, parameters?: unknown[]): Promise<{ rows: T[] }>;
|
||||
}
|
||||
|
||||
/** PostgreSQL quota accounting using a single atomic conditional upsert. */
|
||||
export function postgresQuotaStore(
|
||||
db: QuotaSqlClient,
|
||||
table = "wrnexus_storage_quota",
|
||||
): QuotaStore {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error("Invalid quota table name");
|
||||
return {
|
||||
async get(owner) {
|
||||
const result = await db.query<QuotaUsage>(
|
||||
`SELECT owner,bytes,objects,updated_at AS "updatedAt" FROM ${table} WHERE owner=$1`,
|
||||
[owner],
|
||||
);
|
||||
return result.rows[0] ?? { owner, bytes: 0, objects: 0, updatedAt: Date.now() };
|
||||
},
|
||||
async reserve(owner, bytes, limits) {
|
||||
const result = await db.query(
|
||||
`INSERT INTO ${table} (owner,bytes,objects,updated_at) VALUES ($1,$2,1,$5) ON CONFLICT (owner) DO UPDATE SET bytes=${table}.bytes+$2,objects=${table}.objects+1,updated_at=$5 WHERE ${table}.bytes+$2 <= $3 AND ${table}.objects+1 <= $4 RETURNING owner`,
|
||||
[owner, bytes, limits.bytes, limits.objects ?? 2_147_483_647, Date.now()],
|
||||
);
|
||||
return result.rows.length === 1;
|
||||
},
|
||||
async release(owner, bytes) {
|
||||
await db.query(
|
||||
`UPDATE ${table} SET bytes=GREATEST(0,bytes-$2),objects=GREATEST(0,objects-1),updated_at=$3 WHERE owner=$1`,
|
||||
[owner, bytes, Date.now()],
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const POSTGRES_QUOTA_SCHEMA = `CREATE TABLE IF NOT EXISTS wrnexus_storage_quota (owner text PRIMARY KEY, bytes bigint NOT NULL DEFAULT 0, objects integer NOT NULL DEFAULT 0, updated_at bigint NOT NULL);`;
|
||||
|
||||
export interface MultipartObjectClient {
|
||||
create(key: string, meta: PutMeta): Promise<string>;
|
||||
uploadPart(uploadId: string, key: string, part: number, bytes: Uint8Array): Promise<string>;
|
||||
complete(
|
||||
uploadId: string,
|
||||
key: string,
|
||||
parts: Array<{ part: number; etag: string }>,
|
||||
): Promise<void>;
|
||||
abort(uploadId: string, key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export async function multipartUpload(
|
||||
client: MultipartObjectClient,
|
||||
key: string,
|
||||
bytes: Uint8Array,
|
||||
meta: PutMeta,
|
||||
options: { partBytes?: number; concurrency?: number } = {},
|
||||
): Promise<void> {
|
||||
const partBytes = options.partBytes ?? 8 * 1024 * 1024;
|
||||
const concurrency = options.concurrency ?? 4;
|
||||
if (partBytes < 5 * 1024 * 1024) throw new RangeError("Multipart parts must be at least 5 MiB");
|
||||
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 32)
|
||||
throw new RangeError("Multipart concurrency must be between 1 and 32");
|
||||
const uploadId = await client.create(key, meta);
|
||||
const chunks = Array.from({ length: Math.ceil(bytes.length / partBytes) }, (_, index) => ({
|
||||
part: index + 1,
|
||||
bytes: bytes.slice(index * partBytes, (index + 1) * partBytes),
|
||||
}));
|
||||
const completed: Array<{ part: number; etag: string }> = [];
|
||||
try {
|
||||
for (let offset = 0; offset < chunks.length; offset += concurrency) {
|
||||
completed.push(
|
||||
...(await Promise.all(
|
||||
chunks.slice(offset, offset + concurrency).map(async (chunk) => ({
|
||||
part: chunk.part,
|
||||
etag: await client.uploadPart(uploadId, key, chunk.part, chunk.bytes),
|
||||
})),
|
||||
)),
|
||||
);
|
||||
}
|
||||
await client.complete(uploadId, key, completed);
|
||||
} catch (error) {
|
||||
await client.abort(uploadId, key);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface TemporaryObject {
|
||||
key: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
export function createTemporaryObjectCleaner(
|
||||
driver: StorageDriver,
|
||||
options: { now?: () => number; limit?: number } = {},
|
||||
) {
|
||||
const objects = new Map<string, number>();
|
||||
const now = options.now ?? Date.now;
|
||||
const limit = options.limit ?? 10_000;
|
||||
return {
|
||||
track(key: string, ttlMs: number) {
|
||||
if (objects.size >= limit) throw new Error("WRN-UPLOAD-TEMP-CAPACITY");
|
||||
if (ttlMs < 1) throw new RangeError("Temporary TTL must be positive");
|
||||
objects.set(key, now() + ttlMs);
|
||||
},
|
||||
async cleanup(at = now()) {
|
||||
const due = [...objects].filter(([, expiry]) => expiry <= at);
|
||||
for (const [key] of due) {
|
||||
await driver.delete(key);
|
||||
objects.delete(key);
|
||||
}
|
||||
return due.length;
|
||||
},
|
||||
snapshot: () => ({
|
||||
tracked: objects.size,
|
||||
nextExpiry: objects.size ? Math.min(...objects.values()) : undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface VideoTranscodeOptions {
|
||||
format: "mp4" | "webm";
|
||||
width?: number;
|
||||
height?: number;
|
||||
videoBitrateKbps?: number;
|
||||
}
|
||||
export function ffmpegVideoTranscoder(
|
||||
options: { executable?: string; spawn?: (args: string[]) => { exited: Promise<number> } } = {},
|
||||
) {
|
||||
return async (input: string, output: string, config: VideoTranscodeOptions): Promise<void> => {
|
||||
if (!/^[\w .:\\/-]+$/.test(input) || !/^[\w .:\\/-]+$/.test(output))
|
||||
throw new Error("Invalid video path");
|
||||
const args = [
|
||||
options.executable ?? "ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
input,
|
||||
...(config.width || config.height
|
||||
? ["-vf", `scale=${config.width ?? -2}:${config.height ?? -2}`]
|
||||
: []),
|
||||
...(config.videoBitrateKbps ? ["-b:v", `${config.videoBitrateKbps}k`] : []),
|
||||
"-f",
|
||||
config.format,
|
||||
output,
|
||||
];
|
||||
const child = options.spawn?.(args) ?? Bun.spawn(args, { stdout: "ignore", stderr: "ignore" });
|
||||
if ((await child.exited) !== 0) throw new Error("WRN-VIDEO-TRANSCODE-FAILED");
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user