@hasna/logs 0.3.24 → 0.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,167 +0,0 @@
1
- import type { Database } from "bun:sqlite"
2
- import { getDb } from "../db/index.ts"
3
- import { PG_MIGRATIONS } from "../db/pg-migrations.ts"
4
- import { PgAdapterAsync } from "./remote-storage.ts"
5
-
6
- export const CLOUD_TABLES = [
7
- "projects",
8
- "pages",
9
- "logs",
10
- "scan_jobs",
11
- "scan_runs",
12
- "performance_snapshots",
13
- "alert_rules",
14
- "issues",
15
- "page_auth",
16
- "feedback",
17
- ] as const
18
-
19
- type Row = Record<string, unknown>
20
-
21
- export function getCloudDatabaseUrl(): string | null {
22
- return process.env.HASNA_LOGS_CLOUD_DATABASE_URL
23
- ?? process.env.LOGS_CLOUD_DATABASE_URL
24
- ?? null
25
- }
26
-
27
- export async function getCloudPg(): Promise<PgAdapterAsync> {
28
- const url = getCloudDatabaseUrl()
29
- if (!url) throw new Error("Missing HASNA_LOGS_CLOUD_DATABASE_URL or LOGS_CLOUD_DATABASE_URL")
30
- return new PgAdapterAsync(url)
31
- }
32
-
33
- export async function runCloudMigrations(remote: PgAdapterAsync): Promise<void> {
34
- for (const sql of PG_MIGRATIONS) await remote.run(sql)
35
- }
36
-
37
- export async function cloudPush(opts?: { tables?: string[] }): Promise<{ rows: number }> {
38
- const remote = await getCloudPg()
39
- try {
40
- await runCloudMigrations(remote)
41
- const db = getDb()
42
- let rows = 0
43
- for (const table of resolveTables(opts?.tables)) rows += await pushTable(db, remote, table)
44
- return { rows }
45
- } finally {
46
- await remote.close()
47
- }
48
- }
49
-
50
- export async function cloudPull(opts?: { tables?: string[] }): Promise<{ rows: number }> {
51
- const remote = await getCloudPg()
52
- try {
53
- await runCloudMigrations(remote)
54
- const db = getDb()
55
- let rows = 0
56
- for (const table of resolveTables(opts?.tables)) rows += await pullTable(remote, db, table)
57
- return { rows }
58
- } finally {
59
- await remote.close()
60
- }
61
- }
62
-
63
- export async function cloudSync(opts?: { tables?: string[] }): Promise<{ push: number; pull: number }> {
64
- const push = await cloudPush(opts)
65
- const pull = await cloudPull(opts)
66
- return { push: push.rows, pull: pull.rows }
67
- }
68
-
69
- function resolveTables(tables?: string[]): string[] {
70
- if (!tables || tables.length === 0) return [...CLOUD_TABLES]
71
- const allowed = new Set<string>(CLOUD_TABLES)
72
- const requested = tables.map(table => table.trim()).filter(Boolean)
73
- const invalid = requested.filter(table => !allowed.has(table))
74
- if (invalid.length > 0) throw new Error(`Unknown logs sync table(s): ${invalid.join(", ")}`)
75
- return requested
76
- }
77
-
78
- function quoteIdent(identifier: string): string {
79
- return `"${identifier.replace(/"/g, "\"\"")}"`
80
- }
81
-
82
- async function pushTable(db: Database, remote: PgAdapterAsync, table: string): Promise<number> {
83
- const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all() as Row[]
84
- if (rows.length === 0) return 0
85
- const columns = await filterRemoteColumns(remote, table, Object.keys(rows[0]!))
86
- await upsertPg(remote, table, columns, rows)
87
- return rows.length
88
- }
89
-
90
- async function pullTable(remote: PgAdapterAsync, db: Database, table: string): Promise<number> {
91
- const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`) as Row[]
92
- if (rows.length === 0) return 0
93
- const columns = filterLocalColumns(db, table, Object.keys(rows[0]!))
94
- upsertSqlite(db, table, columns, rows)
95
- return rows.length
96
- }
97
-
98
- async function filterRemoteColumns(remote: PgAdapterAsync, table: string, columns: string[]): Promise<string[]> {
99
- const rows = await remote.all(`
100
- SELECT column_name
101
- FROM information_schema.columns
102
- WHERE table_schema = 'public' AND table_name = ?
103
- `, table) as Array<{ column_name: string }>
104
- if (rows.length === 0) return columns
105
- const allowed = new Set(rows.map(row => row.column_name))
106
- return columns.filter(column => allowed.has(column))
107
- }
108
-
109
- function filterLocalColumns(db: Database, table: string, columns: string[]): string[] {
110
- const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all() as Array<{ name: string }>
111
- const allowed = new Set(rows.map(row => row.name))
112
- return columns.filter(column => allowed.has(column))
113
- }
114
-
115
- async function upsertPg(remote: PgAdapterAsync, table: string, columns: string[], rows: Row[]): Promise<void> {
116
- if (columns.length === 0) return
117
- const columnList = columns.map(quoteIdent).join(", ")
118
- const updateColumns = columns.filter(column => column !== "id")
119
- const setClause = updateColumns.length > 0
120
- ? updateColumns.map(column => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ")
121
- : `${quoteIdent("id")} = EXCLUDED.${quoteIdent("id")}`
122
-
123
- for (const row of rows) {
124
- const placeholders = columns.map((_, index) => `$${index + 1}`).join(", ")
125
- const params = columns.map(column => coerceForPg(table, column, row[column]))
126
- await remote.run(
127
- `INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
128
- ON CONFLICT (${quoteIdent("id")}) DO UPDATE SET ${setClause}`,
129
- ...params,
130
- )
131
- }
132
- }
133
-
134
- function upsertSqlite(db: Database, table: string, columns: string[], rows: Row[]): void {
135
- if (columns.length === 0) return
136
- const columnList = columns.map(quoteIdent).join(", ")
137
- const placeholders = columns.map(() => "?").join(", ")
138
- const updateColumns = columns.filter(column => column !== "id")
139
- const setClause = updateColumns.length > 0
140
- ? updateColumns.map(column => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ")
141
- : `${quoteIdent("id")} = excluded.${quoteIdent("id")}`
142
- const statement = db.prepare(
143
- `INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
144
- ON CONFLICT (${quoteIdent("id")}) DO UPDATE SET ${setClause}`,
145
- )
146
- const insert = db.transaction((batch: Row[]) => {
147
- for (const row of batch) statement.run(...columns.map(column => coerceForSqlite(row[column])))
148
- })
149
- insert(rows)
150
- }
151
-
152
- function coerceForPg(table: string, column: string, value: unknown): unknown {
153
- if (value === undefined) return null
154
- if ((table === "scan_jobs" || table === "alert_rules") && column === "enabled") {
155
- return Boolean(value)
156
- }
157
- return value
158
- }
159
-
160
- function coerceForSqlite(value: unknown): string | number | bigint | boolean | null | Uint8Array {
161
- if (value === undefined || value === null) return null
162
- if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") return value
163
- if (value instanceof Date) return value.toISOString()
164
- if (Buffer.isBuffer(value) || value instanceof Uint8Array) return value
165
- if (typeof value === "object") return JSON.stringify(value)
166
- return String(value)
167
- }
@@ -1,45 +0,0 @@
1
- import pg from "pg"
2
- import type { Pool } from "pg"
3
-
4
- function translatePlaceholders(sql: string): string {
5
- let index = 0
6
- return sql.replace(/\?/g, () => `$${++index}`)
7
- }
8
-
9
- function normalizeParams(params: unknown[]): unknown[] {
10
- const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params
11
- return flat.map(value => value === undefined ? null : value)
12
- }
13
-
14
- function sslConfigFor(connectionString: string): { rejectUnauthorized: boolean } | undefined {
15
- return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true")
16
- ? { rejectUnauthorized: false }
17
- : undefined
18
- }
19
-
20
- export class PgAdapterAsync {
21
- private readonly pool: Pool
22
-
23
- constructor(connectionString: string) {
24
- this.pool = new pg.Pool({ connectionString, ssl: sslConfigFor(connectionString) })
25
- }
26
-
27
- async run(sql: string, ...params: unknown[]): Promise<{ changes: number }> {
28
- const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params))
29
- return { changes: result.rowCount ?? 0 }
30
- }
31
-
32
- async get(sql: string, ...params: unknown[]): Promise<unknown> {
33
- const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params))
34
- return result.rows[0] ?? null
35
- }
36
-
37
- async all(sql: string, ...params: unknown[]): Promise<unknown[]> {
38
- const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params))
39
- return result.rows
40
- }
41
-
42
- async close(): Promise<void> {
43
- await this.pool.end()
44
- }
45
- }