From c8649d753fec275e492f956ed5fe1876a204cac1 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 31 May 2026 02:19:23 +1000 Subject: [PATCH] feat(repos): resources (+ deps + creds) and source_docs --- lib/db/repos/resources.js | 94 +++++++++++++++++++++++++++++++++ lib/db/repos/source_docs.js | 53 +++++++++++++++++++ tests/repos/resources.test.js | 30 +++++++++++ tests/repos/source_docs.test.js | 21 ++++++++ 4 files changed, 198 insertions(+) create mode 100644 lib/db/repos/resources.js create mode 100644 lib/db/repos/source_docs.js create mode 100644 tests/repos/resources.test.js create mode 100644 tests/repos/source_docs.test.js diff --git a/lib/db/repos/resources.js b/lib/db/repos/resources.js new file mode 100644 index 0000000..2d975e9 --- /dev/null +++ b/lib/db/repos/resources.js @@ -0,0 +1,94 @@ +import { pool } from '../pool.js'; +import { recordAudit } from './audit_stub.js'; + +const FIELDS = ['space_id','slug','name','runtime_type','host','url','version','status','monitoring','metadata','last_check','maintenance_until']; + +export async function create(input, actor) { + const cols = [], vals = [], ph = []; + let i = 1; + for (const f of FIELDS) { + if (input[f] !== undefined) { cols.push(f); vals.push(input[f]); ph.push(`$${i++}`); } + } + const { rows: [r] } = await pool.query( + `INSERT INTO resources(${cols.join(',')}) VALUES(${ph.join(',')}) RETURNING *`, + vals + ); + await recordAudit(actor, 'create', 'resource', r.id, null, r); + return r; +} + +export async function getById(id) { + const { rows: [r] } = await pool.query(`SELECT * FROM resources WHERE id=$1`, [id]); + return r; +} + +export async function listBySpace(space_id) { + const { rows } = await pool.query( + `SELECT * FROM resources WHERE space_id=$1 ORDER BY name`, [space_id] + ); + return rows; +} + +export async function update(id, patch, actor) { + const before = await getById(id); + const sets = [], vals = []; + let i = 1; + for (const f of FIELDS) { + if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); } + } + sets.push(`updated_at=now()`); + vals.push(id); + const { rows: [r] } = await pool.query( + `UPDATE resources SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`, + vals + ); + await recordAudit(actor, 'update', 'resource', id, before, r); + return r; +} + +export async function del(id, actor) { + const before = await getById(id); + await pool.query(`DELETE FROM resources WHERE id=$1`, [id]); + await recordAudit(actor, 'delete', 'resource', id, before, null); +} + +export async function addDependency(resource_id, depends_on, kind) { + if (resource_id === depends_on) throw new Error('resource cannot depend on itself'); + await pool.query( + `INSERT INTO resource_dependencies(resource_id, depends_on, kind) + VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, + [resource_id, depends_on, kind || null] + ); +} + +export async function removeDependency(resource_id, depends_on) { + await pool.query( + `DELETE FROM resource_dependencies WHERE resource_id=$1 AND depends_on=$2`, + [resource_id, depends_on] + ); +} + +export async function listDependencies(resource_id) { + const { rows } = await pool.query( + `SELECT * FROM resource_dependencies WHERE resource_id=$1`, [resource_id] + ); + return rows; +} + +export async function addCredential(resource_id, { label, vault_path, kind, notes }) { + const { rows: [r] } = await pool.query( + `INSERT INTO resource_credentials(resource_id, label, vault_path, kind, notes) + VALUES($1,$2,$3,$4,$5) RETURNING *`, + [resource_id, label, vault_path, kind || null, notes || null] + ); + return r; +} + +export async function listCredentials(resource_id) { + const { rows } = await pool.query( + `SELECT id, resource_id, label, vault_path, kind, notes, created_at + FROM resource_credentials WHERE resource_id=$1 ORDER BY label`, + [resource_id] + ); + return rows; +} diff --git a/lib/db/repos/source_docs.js b/lib/db/repos/source_docs.js new file mode 100644 index 0000000..b20606f --- /dev/null +++ b/lib/db/repos/source_docs.js @@ -0,0 +1,53 @@ +import { pool } from '../pool.js'; +import { recordAudit } from './audit_stub.js'; + +const FIELDS = ['resource_id','name','upstream_url','version','format','sync_source','local_path','body_text','embedding','last_synced','metadata']; + +export async function create(input, actor) { + const cols = [], vals = [], ph = []; + let i = 1; + for (const f of FIELDS) { + if (input[f] !== undefined) { cols.push(f); vals.push(input[f]); ph.push(`$${i++}`); } + } + const { rows: [r] } = await pool.query( + `INSERT INTO source_docs(${cols.join(',')}) VALUES(${ph.join(',')}) RETURNING *`, + vals + ); + await recordAudit(actor, 'create', 'source_doc', r.id, null, r); + return r; +} + +export async function getById(id) { + const { rows: [r] } = await pool.query(`SELECT * FROM source_docs WHERE id=$1`, [id]); + return r; +} + +export async function listByResource(resource_id) { + const { rows } = await pool.query( + `SELECT * FROM source_docs WHERE resource_id=$1 ORDER BY name`, [resource_id] + ); + return rows; +} + +export async function update(id, patch, actor) { + const before = await getById(id); + const sets = [], vals = []; + let i = 1; + for (const f of FIELDS) { + if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); } + } + sets.push(`updated_at=now()`); + vals.push(id); + const { rows: [r] } = await pool.query( + `UPDATE source_docs SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`, + vals + ); + await recordAudit(actor, 'update', 'source_doc', id, before, r); + return r; +} + +export async function del(id, actor) { + const before = await getById(id); + await pool.query(`DELETE FROM source_docs WHERE id=$1`, [id]); + await recordAudit(actor, 'delete', 'source_doc', id, before, null); +} diff --git a/tests/repos/resources.test.js b/tests/repos/resources.test.js new file mode 100644 index 0000000..30d73af --- /dev/null +++ b/tests/repos/resources.test.js @@ -0,0 +1,30 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as spaces from '../../lib/db/repos/spaces.js'; +import * as resources from '../../lib/db/repos/resources.js'; + +const owner = { kind: 'user', id: null }; +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('resources repo', () => { + it('creates a resource', async () => { + const s = await spaces.create({ slug: 'h', name: 'H' }, owner); + const r = await resources.create({ + space_id: s.id, slug: 'kk', name: 'Karakeep', + runtime_type: 'docker', host: '192.168.1.230', url: 'https://karakeep.hynesy.com' + }, owner); + expect(r.status).toBe('unknown'); + }); + + it('addDependency creates a link, prevents self-dep', async () => { + const s = await spaces.create({ slug: 'h', name: 'H' }, owner); + const a = await resources.create({ space_id: s.id, slug: 'a', name: 'A', runtime_type: 'lxc' }, owner); + const b = await resources.create({ space_id: s.id, slug: 'b', name: 'B', runtime_type: 'lxc' }, owner); + await resources.addDependency(a.id, b.id, 'data'); + const deps = await resources.listDependencies(a.id); + expect(deps).toHaveLength(1); + expect(deps[0].depends_on).toBe(b.id); + await expect(resources.addDependency(a.id, a.id, 'self')).rejects.toThrow(); + }); +}); diff --git a/tests/repos/source_docs.test.js b/tests/repos/source_docs.test.js new file mode 100644 index 0000000..a7e02df --- /dev/null +++ b/tests/repos/source_docs.test.js @@ -0,0 +1,21 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as spaces from '../../lib/db/repos/spaces.js'; +import * as resources from '../../lib/db/repos/resources.js'; +import * as sourceDocs from '../../lib/db/repos/source_docs.js'; + +const owner = { kind: 'user', id: null }; +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('source_docs repo', () => { + it('creates a source doc bound to a resource', async () => { + const s = await spaces.create({ slug: 'h', name: 'H' }, owner); + const r = await resources.create({ space_id: s.id, slug: 'kk', name: 'KK', runtime_type: 'docker' }, owner); + const sd = await sourceDocs.create({ + resource_id: r.id, name: 'Karakeep docs', + upstream_url: 'https://docs.karakeep.app', version: '0.20', format: 'markdown' + }, owner); + expect(sd.resource_id).toBe(r.id); + }); +});