diff --git a/src/attachment-service.ts b/src/attachment-service.ts new file mode 100644 index 0000000..8da84b9 --- /dev/null +++ b/src/attachment-service.ts @@ -0,0 +1,199 @@ +import type { Env } from "./env"; +import type { CurrentUser } from "./auth-session"; +import { HttpProblem } from "./problem"; +import { newId } from "./ids"; +import { optionalString, requiredString } from "./json"; +import { getObjectOrNull, objectKeyFor, putObject } from "./object-store"; +import { + findAttachmentForUser, + insertAttachment, + insertAuditEvent, + markAvailable, + markUploaded, + softDeleteAttachment, + type AttachmentRecord, +} from "./attachment-store"; + +const DEFAULT_MAX_UPLOAD_BYTES = 52_428_800; + +function nowIso(): string { + return new Date().toISOString(); +} + +function attachmentNotFound(): HttpProblem { + return new HttpProblem(404, "attachment-not-found", "Attachment was not found"); +} + +function parseMaxUploadBytes(env: Env): number { + const parsed = Number.parseInt(env.MAX_UPLOAD_BYTES ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_UPLOAD_BYTES; +} + +async function recordAudit( + env: Env, + user: CurrentUser, + attachmentId: string, + action: string, + detail: Record, +): Promise { + await insertAuditEvent(env.DB, { + id: newId("aud"), + user_id: user.id, + attachment_id: attachmentId, + actor_user_id: user.id, + action, + detail_json: JSON.stringify(detail), + created_at: nowIso(), + }); +} + +export async function createAttachment( + env: Env, + user: CurrentUser, + body: Record, +): Promise { + const filename = requiredString(body, "filename"); + const content_type = requiredString(body, "content_type"); + const visibility = optionalString(body, "visibility") ?? "private"; + const description = optionalString(body, "description") ?? null; + const category_id = optionalString(body, "categoryId") ?? optionalString(body, "category_id") ?? null; + const now = nowIso(); + const id = newId("att"); + + const record: AttachmentRecord = { + id, + user_id: user.id, + status: "upload_pending", + filename, + content_type, + byte_size: null, + checksum_sha256: null, + visibility, + category_id, + description, + object_key: null, + etag: null, + created_by_user_id: user.id, + updated_by_user_id: user.id, + deleted_by_user_id: null, + created_at: now, + updated_at: now, + deleted_at: null, + }; + + await insertAttachment(env.DB, record); + await recordAudit(env, user, id, "attachment.created", { filename, content_type }); + return record; +} + +export async function uploadAttachmentContent( + env: Env, + user: CurrentUser, + id: string, + request: Request, +): Promise { + const record = await findAttachmentForUser(env.DB, user.id, id); + if (!record) { + throw attachmentNotFound(); + } + if (record.status !== "upload_pending") { + throw new HttpProblem(409, "attachment-state-conflict", "Attachment is not awaiting an upload", { + status: record.status, + }); + } + + const maxBytes = parseMaxUploadBytes(env); + const declaredLength = Number.parseInt(request.headers.get("content-length") ?? "", 10); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new HttpProblem(413, "payload-too-large", "Upload exceeds the maximum allowed size", { + max_bytes: maxBytes, + }); + } + + const objectKey = objectKeyFor(user.id, id); + const r2Object = await putObject(env.ATTACHMENTS, objectKey, request, record.content_type); + const etag = (r2Object.etag ?? "").replace(/^"|"$/g, ""); + await markUploaded(env.DB, user.id, id, objectKey, etag, r2Object.size); + await recordAudit(env, user, id, "attachment.uploaded", { object_key: objectKey, byte_size: r2Object.size }); + + const refreshed = await findAttachmentForUser(env.DB, user.id, id); + return refreshed ?? record; +} + +export async function finalizeAttachment( + env: Env, + user: CurrentUser, + id: string, +): Promise { + const record = await findAttachmentForUser(env.DB, user.id, id); + if (!record) { + throw attachmentNotFound(); + } + if (record.status !== "uploaded") { + throw new HttpProblem(409, "attachment-state-conflict", "Attachment is not ready to finalize", { + status: record.status, + }); + } + + await markAvailable(env.DB, user.id, id); + await recordAudit(env, user, id, "attachment.finalized", {}); + + const refreshed = await findAttachmentForUser(env.DB, user.id, id); + return refreshed ?? record; +} + +export async function getAttachment( + env: Env, + user: CurrentUser, + id: string, +): Promise { + const record = await findAttachmentForUser(env.DB, user.id, id); + if (!record) { + throw attachmentNotFound(); + } + return record; +} + +export async function deleteAttachment(env: Env, user: CurrentUser, id: string): Promise { + const record = await findAttachmentForUser(env.DB, user.id, id); + if (!record) { + throw attachmentNotFound(); + } + + await softDeleteAttachment(env.DB, user.id, id, user.id); + if (record.object_key) { + await env.ATTACHMENTS.delete(record.object_key).catch(() => undefined); + } + await recordAudit(env, user, id, "attachment.deleted", {}); +} + +export async function downloadAttachment(env: Env, user: CurrentUser, id: string): Promise { + const record = await findAttachmentForUser(env.DB, user.id, id); + if (!record) { + throw attachmentNotFound(); + } + if (record.status !== "available") { + throw new HttpProblem(409, "object-not-available", "Attachment is not available for download", { + status: record.status, + }); + } + if (!record.object_key) { + throw new HttpProblem(410, "object-gone", "Attachment object is missing"); + } + + const object = await getObjectOrNull(env.ATTACHMENTS, record.object_key); + if (!object) { + throw new HttpProblem(410, "object-gone", "Attachment object is missing"); + } + + const headers = new Headers({ + "content-type": record.content_type, + "content-length": String(object.size), + "content-disposition": `attachment; filename="${sanitizeFilename(record.filename)}"`, + }); + return new Response(object.body, { headers }); +} + +function sanitizeFilename(name: string): string { + return name.replace(/["\\]/g, "_"); +} diff --git a/src/attachment-store.ts b/src/attachment-store.ts new file mode 100644 index 0000000..afb10b7 --- /dev/null +++ b/src/attachment-store.ts @@ -0,0 +1,127 @@ +export type AttachmentStatus = "upload_pending" | "uploaded" | "available" | "deleted"; + +export interface AttachmentRecord { + id: string; + user_id: string; + status: AttachmentStatus; + filename: string; + content_type: string; + byte_size: number | null; + checksum_sha256: string | null; + visibility: string; + category_id: string | null; + description: string | null; + object_key: string | null; + etag: string | null; + created_by_user_id: string; + updated_by_user_id: string; + deleted_by_user_id: string | null; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +const ATTACHMENT_COLUMNS = + "id, user_id, status, filename, content_type, byte_size, checksum_sha256, visibility, category_id, description, object_key, etag, created_by_user_id, updated_by_user_id, deleted_by_user_id, created_at, updated_at, deleted_at"; + +export async function insertAttachment(db: D1Database, record: AttachmentRecord): Promise { + await db + .prepare(`INSERT INTO attachments (${ATTACHMENT_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .bind( + record.id, + record.user_id, + record.status, + record.filename, + record.content_type, + record.byte_size, + record.checksum_sha256, + record.visibility, + record.category_id, + record.description, + record.object_key, + record.etag, + record.created_by_user_id, + record.updated_by_user_id, + record.deleted_by_user_id, + record.created_at, + record.updated_at, + record.deleted_at, + ) + .run(); +} + +export async function findAttachmentForUser( + db: D1Database, + userId: string, + id: string, +): Promise { + return db + .prepare(`SELECT ${ATTACHMENT_COLUMNS} FROM attachments WHERE id = ? AND user_id = ? AND deleted_at IS NULL`) + .bind(id, userId) + .first(); +} + +export async function markUploaded( + db: D1Database, + userId: string, + id: string, + objectKey: string, + etag: string | null, + byteSize: number | null, +): Promise { + await db + .prepare( + `UPDATE attachments + SET status = 'uploaded', object_key = ?, etag = ?, byte_size = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, + ) + .bind(objectKey, etag, byteSize, new Date().toISOString(), id, userId) + .run(); +} + +export async function markAvailable(db: D1Database, userId: string, id: string): Promise { + await db + .prepare( + `UPDATE attachments SET status = 'available', updated_at = ? + WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, + ) + .bind(new Date().toISOString(), id, userId) + .run(); +} + +export async function softDeleteAttachment( + db: D1Database, + userId: string, + id: string, + deletedByUserId: string, +): Promise { + const now = new Date().toISOString(); + await db + .prepare( + `UPDATE attachments + SET status = 'deleted', deleted_at = ?, deleted_by_user_id = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, + ) + .bind(now, deletedByUserId, now, id, userId) + .run(); +} + +export interface AuditEvent { + id: string; + user_id: string; + attachment_id: string | null; + actor_user_id: string; + action: string; + detail_json: string; + created_at: string; +} + +export async function insertAuditEvent(db: D1Database, event: AuditEvent): Promise { + await db + .prepare( + `INSERT INTO attachment_audit_events (id, user_id, attachment_id, actor_user_id, action, detail_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .bind(event.id, event.user_id, event.attachment_id, event.actor_user_id, event.action, event.detail_json, event.created_at) + .run(); +} diff --git a/src/ids.ts b/src/ids.ts new file mode 100644 index 0000000..2e2b4e4 --- /dev/null +++ b/src/ids.ts @@ -0,0 +1,5 @@ +export function newId(prefix: string): string { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${prefix}_${hex}`; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..cf19227 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +import { createRoutes } from "./routes"; + +export default createRoutes(); diff --git a/src/json.ts b/src/json.ts new file mode 100644 index 0000000..d35683d --- /dev/null +++ b/src/json.ts @@ -0,0 +1,26 @@ +import { HttpProblem } from "./problem"; + +export async function readJsonObject(request: Request): Promise> { + const body = await request.json().catch(() => undefined); + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new HttpProblem(422, "validation-failed", "Request body must be a JSON object"); + } + return body as Record; +} + +export function requiredString(body: Record, key: string): string { + const value = body[key]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new HttpProblem(422, "validation-failed", `${key} is required`, { field: key }); + } + return value.trim(); +} + +export function optionalString(body: Record, key: string): string | undefined { + const value = body[key]; + if (value === undefined || value === null || value === "") return undefined; + if (typeof value !== "string") { + throw new HttpProblem(422, "validation-failed", `${key} must be a string`, { field: key }); + } + return value.trim(); +} diff --git a/src/object-store.ts b/src/object-store.ts new file mode 100644 index 0000000..587d096 --- /dev/null +++ b/src/object-store.ts @@ -0,0 +1,24 @@ +export function objectKeyFor(userId: string, attachmentId: string): string { + const date = new Date(); + const yyyy = String(date.getUTCFullYear()); + const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); + return `users/${userId}/attachments/${yyyy}/${mm}/${attachmentId}/original`; +} + +export async function putObject( + bucket: R2Bucket, + key: string, + request: Request, + contentType: string, +): Promise { + const uploaded = await bucket.put(key, request.body, { + httpMetadata: { + contentType, + }, + }); + return uploaded as R2Object; +} + +export async function getObjectOrNull(bucket: R2Bucket, key: string): Promise { + return bucket.get(key); +} diff --git a/src/routes.ts b/src/routes.ts new file mode 100644 index 0000000..f2ffca0 --- /dev/null +++ b/src/routes.ts @@ -0,0 +1,58 @@ +import { Hono } from "hono"; +import { resolveCurrentUser } from "./auth-session"; +import type { Env } from "./env"; +import { readJsonObject } from "./json"; +import { problemResponse } from "./problem"; +import { + createAttachment, + deleteAttachment, + downloadAttachment, + finalizeAttachment, + getAttachment, + uploadAttachmentContent, +} from "./attachment-service"; + +export function createRoutes(): Hono<{ Bindings: Env }> { + const app = new Hono<{ Bindings: Env }>(); + + app.onError((error) => problemResponse(error)); + + app.get("/healthz", (c) => c.json({ ok: true, service: "cfw-attachment" })); + + app.post("/api/attachments", async (c) => { + const user = await resolveCurrentUser(c.req.raw, c.env); + const body = await readJsonObject(c.req.raw); + const attachment = await createAttachment(c.env, user, body); + return c.json({ attachment }, 201); + }); + + app.get("/api/attachments/:id", async (c) => { + const user = await resolveCurrentUser(c.req.raw, c.env); + return c.json({ attachment: await getAttachment(c.env, user, c.req.param("id")) }); + }); + + app.put("/api/attachments/:id/content", async (c) => { + const user = await resolveCurrentUser(c.req.raw, c.env); + return c.json({ + attachment: await uploadAttachmentContent(c.env, user, c.req.param("id"), c.req.raw), + }); + }); + + app.post("/api/attachments/:id/finalize", async (c) => { + const user = await resolveCurrentUser(c.req.raw, c.env); + return c.json({ attachment: await finalizeAttachment(c.env, user, c.req.param("id")) }); + }); + + app.get("/api/attachments/:id/download", async (c) => { + const user = await resolveCurrentUser(c.req.raw, c.env); + return downloadAttachment(c.env, user, c.req.param("id")); + }); + + app.delete("/api/attachments/:id", async (c) => { + const user = await resolveCurrentUser(c.req.raw, c.env); + await deleteAttachment(c.env, user, c.req.param("id")); + return c.body(null, 204); + }); + + return app; +} diff --git a/tests/attachment-worker.test.ts b/tests/attachment-worker.test.ts new file mode 100644 index 0000000..7a74b58 --- /dev/null +++ b/tests/attachment-worker.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import app from "../src/index"; +import { makeEnv, sharedStores } from "./fakes"; + +const BASE = "https://attachment.local"; + +async function call( + method: string, + path: string, + env: unknown, + init: { body?: BodyInit; headers?: Record } = {}, +): Promise { + const request = new Request(`${BASE}${path}`, { + method, + body: init.body, + headers: init.headers, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return app.fetch(request, env as any); +} + +async function createAttachment( + env: unknown, + body: Record, +): Promise<{ attachment: { id: string; status: string; user_id: string } }> { + const response = await call("POST", "/api/attachments", env, { body: JSON.stringify(body) }); + expect(response.status).toBe(201); + return (await response.json()) as { attachment: { id: string; status: string; user_id: string } }; +} + +describe("cfw-attachment worker (core flow)", () => { + it("creates an attachment for the current user and hides it from another user", async () => { + const stores = sharedStores(); + const userA = makeEnv(stores, "user_a"); + const userB = makeEnv(stores, "user_b"); + + const created = await createAttachment(userA, { + filename: "invoice.pdf", + content_type: "application/pdf", + }); + expect(created.attachment.status).toBe("upload_pending"); + expect(created.attachment.user_id).toBe("user_a"); + + const asOwner = await call("GET", `/api/attachments/${created.attachment.id}`, userA); + expect(asOwner.status).toBe(200); + + const asOther = await call("GET", `/api/attachments/${created.attachment.id}`, userB); + expect(asOther.status).toBe(404); + expect(asOther.headers.get("content-type")).toBe("application/problem+json"); + }); + + it("uploads content, finalizes the attachment, and downloads the same bytes", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + const payload = "hello attachment bytes"; + + const created = await createAttachment(env, { + filename: "note.txt", + content_type: "text/plain", + }); + + const uploaded = await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, { + body: payload, + headers: { "content-length": String(new TextEncoder().encode(payload).byteLength) }, + }); + expect(uploaded.status).toBe(200); + const uploadedBody = (await uploaded.json()) as { attachment: { status: string; byte_size: number } }; + expect(uploadedBody.attachment.status).toBe("uploaded"); + expect(uploadedBody.attachment.byte_size).toBe(new TextEncoder().encode(payload).byteLength); + + const finalized = await call("POST", `/api/attachments/${created.attachment.id}/finalize`, env); + expect(finalized.status).toBe(200); + expect(((await finalized.json()) as { attachment: { status: string } }).attachment.status).toBe("available"); + + const download = await call("GET", `/api/attachments/${created.attachment.id}/download`, env); + expect(download.status).toBe(200); + expect(download.headers.get("content-type")).toBe("text/plain"); + expect(await download.text()).toBe(payload); + }); + + it("does not download before finalize", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + const payload = "not yet ready"; + + const created = await createAttachment(env, { filename: "draft.txt", content_type: "text/plain" }); + await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, { body: payload }); + + const download = await call("GET", `/api/attachments/${created.attachment.id}/download`, env); + expect(download.status).toBe(409); + const problem = (await download.json()) as { code: string }; + expect(problem.code).toBe("object-not-available"); + }); + + it("soft deletes an attachment and removes it from detail reads", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + + const created = await createAttachment(env, { filename: "temp.txt", content_type: "text/plain" }); + + const deleted = await call("DELETE", `/api/attachments/${created.attachment.id}`, env); + expect(deleted.status).toBe(204); + + const detail = await call("GET", `/api/attachments/${created.attachment.id}`, env); + expect(detail.status).toBe(404); + }); + + it("rejects uploads that exceed the configured size limit", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a", "3"); + const created = await createAttachment(env, { filename: "big.bin", content_type: "application/octet-stream" }); + + const tooLarge = await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, { + body: "more than three bytes", + headers: { "content-length": "21" }, + }); + expect(tooLarge.status).toBe(413); + }); + + it("returns 401 when there is no session", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, null); + + const response = await call("POST", "/api/attachments", env, { + body: JSON.stringify({ filename: "x.txt", content_type: "text/plain" }), + }); + expect(response.status).toBe(401); + }); + + it("returns 422 when required fields are missing", async () => { + const stores = sharedStores(); + const env = makeEnv(stores, "user_a"); + + const response = await call("POST", "/api/attachments", env, { body: JSON.stringify({ content_type: "text/plain" }) }); + expect(response.status).toBe(422); + }); +}); diff --git a/tests/fakes.ts b/tests/fakes.ts new file mode 100644 index 0000000..e93959e --- /dev/null +++ b/tests/fakes.ts @@ -0,0 +1,155 @@ +import { DatabaseSync } from "node:sqlite"; +import migrationSql from "../migrations/0001_initial.sql?raw"; +import { vi } from "vitest"; +import type { Env, FetcherLike } from "../src/env"; + +/** + * 内存版 D1,基于 node:sqlite 执行真实 SQL。 + * 直接复用 migrations/0001_initial.sql 建表,保证 store 层 SQL 被真实跑过。 + */ +export class FakeD1 { + private db: DatabaseSync; + + constructor() { + this.db = new DatabaseSync(":memory:"); + this.db.exec(migrationSql); + } + + prepare(sql: string) { + const exec = (values: unknown[]) => { + const statement = this.db.prepare(sql); + return { + first: async (): Promise => (statement.get(...values) as T | null) ?? null, + all: async () => ({ results: statement.all(...values) as T[], success: true as const }), + run: async () => { + statement.run(...values); + return { results: [] as T[], success: true as const }; + }, + bind: (...vals: unknown[]) => exec(vals), + }; + }; + return exec([]); + } +} + +function fakeEtag(bytes: Uint8Array): string { + let hash = 5381; + for (let i = 0; i < bytes.length; i += 1) { + hash = ((hash << 5) + hash + bytes[i]) >>> 0; + } + return hash.toString(16).padStart(8, "0"); +} + +interface StoredObject { + body: Uint8Array; + httpMetadata: R2HTTPMetadata; + uploaded: number; +} + +function toR2Object(key: string, stored: StoredObject): R2Object { + return { + key, + size: stored.body.byteLength, + etag: fakeEtag(stored.body), + version: "fake-version", + httpEtag: `"${fakeEtag(stored.body)}"`, + uploaded: new Date(stored.uploaded), + httpMetadata: stored.httpMetadata, + customMetadata: {}, + storageClass: "Standard", + writeHttpMetadata: () => new Headers(), + storageUsage: 0, + } as unknown as R2Object; +} + +/** + * 内存版 R2 bucket,用 Map 保存对象字节,覆盖 put/get/head/delete。 + */ +export class FakeR2 { + private store = new Map(); + + async put( + key: string, + body: ReadableStream | ArrayBuffer | ArrayBufferView | string | null, + options: { httpMetadata?: R2HTTPMetadata } = {}, + ): Promise { + let bytes: Uint8Array; + if (body === null) { + bytes = new Uint8Array(); + } else if (typeof body === "string") { + bytes = new TextEncoder().encode(body); + } else if (body instanceof ArrayBuffer) { + bytes = new Uint8Array(body); + } else if (ArrayBuffer.isView(body)) { + bytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength); + } else { + const buffer = await new Response(body as ReadableStream).arrayBuffer(); + bytes = new Uint8Array(buffer); + } + + const stored: StoredObject = { body: bytes, httpMetadata: options.httpMetadata ?? {}, uploaded: Date.now() }; + this.store.set(key, stored); + return toR2Object(key, stored); + } + + async get(key: string): Promise { + const stored = this.store.get(key); + if (!stored) return null; + const head = toR2Object(key, stored); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(stored.body); + controller.close(); + }, + }); + return { + ...head, + body, + bodyUsed: false, + arrayBuffer: async () => + stored.body.buffer.slice(stored.body.byteOffset, stored.body.byteOffset + stored.body.byteLength), + text: async () => new TextDecoder().decode(stored.body), + json: async () => JSON.parse(new TextDecoder().decode(stored.body)) as T, + blob: async () => new Blob([stored.body as BlobPart]), + } as unknown as R2ObjectBody; + } + + async head(key: string): Promise { + const stored = this.store.get(key); + return stored ? toR2Object(key, stored) : null; + } + + async delete(key: string): Promise { + this.store.delete(key); + } + + has(key: string): boolean { + return this.store.has(key); + } +} + +export function fakeAuth(userId: string | null): FetcherLike { + return { + fetch: vi.fn(async () => + Response.json(userId ? { user: { id: userId } } : null, { status: userId ? 200 : 401 }), + ), + }; +} + +export interface SharedStores { + db: FakeD1; + r2: FakeR2; +} + +export function sharedStores(): SharedStores { + return { db: new FakeD1(), r2: new FakeR2() }; +} + +export function makeEnv(stores: SharedStores, userId: string | null, maxUploadBytes = "52428800"): Env { + return { + AUTH: fakeAuth(userId), + DB: stores.db as unknown as D1Database, + ATTACHMENTS: stores.r2 as unknown as R2Bucket, + MAX_UPLOAD_BYTES: maxUploadBytes, + }; +} diff --git a/tests/globals.d.ts b/tests/globals.d.ts new file mode 100644 index 0000000..3fe99f1 --- /dev/null +++ b/tests/globals.d.ts @@ -0,0 +1,20 @@ +// 为测试 fake 提供的最小类型声明,避免引入 @types/node 与 workers-types 全局冲突。 + +declare module "node:sqlite" { + export interface StatementSync { + run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint }; + get(...params: unknown[]): unknown; + all(...params: unknown[]): unknown[]; + } + export class DatabaseSync { + constructor(location: string); + exec(sql: string): void; + prepare(sql: string): StatementSync; + close(): void; + } +} + +declare module "*.sql?raw" { + const content: string; + export default content; +}