From 47ea0768fd520c64e2c851e0c8c5ccd096822aff Mon Sep 17 00:00:00 2001 From: root Date: Sun, 31 May 2026 11:02:58 +1000 Subject: [PATCH] feat(repos): tags, polymorphic entity_links, attachments --- lib/db/repos/attachments.js | 27 +++++++++++++++++++++ lib/db/repos/links.js | 33 +++++++++++++++++++++++++ lib/db/repos/tags.js | 43 +++++++++++++++++++++++++++++++++ tests/repos/attachments.test.js | 26 ++++++++++++++++++++ tests/repos/links.test.js | 27 +++++++++++++++++++++ tests/repos/tags.test.js | 34 ++++++++++++++++++++++++++ 6 files changed, 190 insertions(+) create mode 100644 lib/db/repos/attachments.js create mode 100644 lib/db/repos/links.js create mode 100644 lib/db/repos/tags.js create mode 100644 tests/repos/attachments.test.js create mode 100644 tests/repos/links.test.js create mode 100644 tests/repos/tags.test.js diff --git a/lib/db/repos/attachments.js b/lib/db/repos/attachments.js new file mode 100644 index 0000000..6e52e29 --- /dev/null +++ b/lib/db/repos/attachments.js @@ -0,0 +1,27 @@ +import { pool } from '../pool.js'; + +export async function create({ entity_type, entity_id, filename, mime_type, size_bytes, blob_path, checksum }) { + const { rows: [r] } = await pool.query( + `INSERT INTO attachments(entity_type, entity_id, filename, mime_type, size_bytes, blob_path, checksum) + VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [entity_type, entity_id, filename, mime_type || null, size_bytes || null, blob_path, checksum || null] + ); + return r; +} + +export async function listForEntity(entity_type, entity_id) { + const { rows } = await pool.query( + `SELECT * FROM attachments WHERE entity_type=$1 AND entity_id=$2 ORDER BY uploaded_at DESC`, + [entity_type, entity_id] + ); + return rows; +} + +export async function getById(id) { + const { rows: [r] } = await pool.query(`SELECT * FROM attachments WHERE id=$1`, [id]); + return r; +} + +export async function del(id) { + await pool.query(`DELETE FROM attachments WHERE id=$1`, [id]); +} diff --git a/lib/db/repos/links.js b/lib/db/repos/links.js new file mode 100644 index 0000000..9b74b42 --- /dev/null +++ b/lib/db/repos/links.js @@ -0,0 +1,33 @@ +import { pool } from '../pool.js'; + +export async function create(from_type, from_id, to_type, to_id, relation = 'attached') { + const { rows: [r] } = await pool.query( + `INSERT INTO entity_links(from_type, from_id, to_type, to_id, relation) + VALUES($1,$2,$3,$4,$5) + ON CONFLICT (from_type, from_id, to_type, to_id, relation) + DO UPDATE SET relation=EXCLUDED.relation + RETURNING *`, + [from_type, from_id, to_type, to_id, relation] + ); + return r; +} + +export async function listFrom(from_type, from_id) { + const { rows } = await pool.query( + `SELECT * FROM entity_links WHERE from_type=$1 AND from_id=$2`, + [from_type, from_id] + ); + return rows; +} + +export async function listTo(to_type, to_id) { + const { rows } = await pool.query( + `SELECT * FROM entity_links WHERE to_type=$1 AND to_id=$2`, + [to_type, to_id] + ); + return rows; +} + +export async function remove(id) { + await pool.query(`DELETE FROM entity_links WHERE id=$1`, [id]); +} diff --git a/lib/db/repos/tags.js b/lib/db/repos/tags.js new file mode 100644 index 0000000..27b6f04 --- /dev/null +++ b/lib/db/repos/tags.js @@ -0,0 +1,43 @@ +import { pool } from '../pool.js'; + +export async function upsert(name, { description, color } = {}) { + const { rows: [r] } = await pool.query( + `INSERT INTO tags(name, description, color) + VALUES($1,$2,$3) + ON CONFLICT(name) DO UPDATE SET + description=COALESCE(EXCLUDED.description, tags.description), + color=COALESCE(EXCLUDED.color, tags.color) + RETURNING *`, + [name, description || null, color || null] + ); + return r; +} + +export async function list() { + const { rows } = await pool.query(`SELECT * FROM tags ORDER BY name`); + return rows; +} + +export async function attach(entity_type, entity_id, tag_id) { + await pool.query( + `INSERT INTO entity_tags(entity_type, entity_id, tag_id) + VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, + [entity_type, entity_id, tag_id] + ); +} + +export async function detach(entity_type, entity_id, tag_id) { + await pool.query( + `DELETE FROM entity_tags WHERE entity_type=$1 AND entity_id=$2 AND tag_id=$3`, + [entity_type, entity_id, tag_id] + ); +} + +export async function listForEntity(entity_type, entity_id) { + const { rows } = await pool.query( + `SELECT t.* FROM tags t JOIN entity_tags et ON et.tag_id=t.id + WHERE et.entity_type=$1 AND et.entity_id=$2 ORDER BY t.name`, + [entity_type, entity_id] + ); + return rows; +} diff --git a/tests/repos/attachments.test.js b/tests/repos/attachments.test.js new file mode 100644 index 0000000..03ce993 --- /dev/null +++ b/tests/repos/attachments.test.js @@ -0,0 +1,26 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as attachments from '../../lib/db/repos/attachments.js'; + +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('attachments repo', () => { + it('records an attachment', async () => { + const a = await attachments.create({ + entity_type: 'page', + entity_id: '11111111-1111-1111-1111-111111111111', + filename: 'spec.pdf', mime_type: 'application/pdf', + size_bytes: 1024, blob_path: 'aa/abc', checksum: 'abc' + }); + expect(a.filename).toBe('spec.pdf'); + }); + + it('listForEntity returns descending by uploaded_at', async () => { + const eid = '11111111-1111-1111-1111-111111111111'; + await attachments.create({ entity_type: 'page', entity_id: eid, filename: 'a', blob_path: 'a' }); + await attachments.create({ entity_type: 'page', entity_id: eid, filename: 'b', blob_path: 'b' }); + const list = await attachments.listForEntity('page', eid); + expect(list).toHaveLength(2); + }); +}); diff --git a/tests/repos/links.test.js b/tests/repos/links.test.js new file mode 100644 index 0000000..3a235a8 --- /dev/null +++ b/tests/repos/links.test.js @@ -0,0 +1,27 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as links from '../../lib/db/repos/links.js'; + +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('links repo', () => { + it('create + listFrom + listTo + remove', async () => { + const a = '11111111-1111-1111-1111-111111111111'; + const b = '22222222-2222-2222-2222-222222222222'; + const link = await links.create('project', a, 'page', b, 'mentions'); + expect(link.relation).toBe('mentions'); + expect(await links.listFrom('project', a)).toHaveLength(1); + expect(await links.listTo('page', b)).toHaveLength(1); + await links.remove(link.id); + expect(await links.listFrom('project', a)).toHaveLength(0); + }); + + it('idempotent on the unique tuple', async () => { + const a = '11111111-1111-1111-1111-111111111111'; + const b = '22222222-2222-2222-2222-222222222222'; + const l1 = await links.create('project', a, 'page', b, 'mentions'); + const l2 = await links.create('project', a, 'page', b, 'mentions'); + expect(l2.id).toBe(l1.id); + }); +}); diff --git a/tests/repos/tags.test.js b/tests/repos/tags.test.js new file mode 100644 index 0000000..55b1215 --- /dev/null +++ b/tests/repos/tags.test.js @@ -0,0 +1,34 @@ +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 tags from '../../lib/db/repos/tags.js'; + +const owner = { kind: 'user', id: null }; +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('tags repo', () => { + it('upserts a tag by name', async () => { + const t1 = await tags.upsert('homelab'); + const t2 = await tags.upsert('homelab'); + expect(t2.id).toBe(t1.id); + }); + + it('attach + detach + listForEntity', async () => { + const s = await spaces.create({ slug: 'h', name: 'H' }, owner); + const t = await tags.upsert('urgent'); + await tags.attach('space', s.id, t.id); + const list = await tags.listForEntity('space', s.id); + expect(list).toHaveLength(1); + await tags.detach('space', s.id, t.id); + expect(await tags.listForEntity('space', s.id)).toHaveLength(0); + }); + + it('attach is idempotent', async () => { + const s = await spaces.create({ slug: 'h', name: 'H' }, owner); + const t = await tags.upsert('urgent'); + await tags.attach('space', s.id, t.id); + await tags.attach('space', s.id, t.id); + expect(await tags.listForEntity('space', s.id)).toHaveLength(1); + }); +});