diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000..e8b602b --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,11 @@ +{ + "name": "@tech-library/contracts", + "version": "0.1.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, + "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run src" }, + "dependencies": { "zod": "^4.1.5" }, + "devDependencies": { "typescript": "^5.9.2", "vitest": "^5.0.5" } +} diff --git a/packages/contracts/src/contracts.test.ts b/packages/contracts/src/contracts.test.ts new file mode 100644 index 0000000..623970d --- /dev/null +++ b/packages/contracts/src/contracts.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { topicRevisionInputSchema } from './index.js'; + +describe('topicRevisionInputSchema', () => { + it('rejects invalid slugs and incomplete layered content', () => { + expect(() => topicRevisionInputSchema.parse({ slug: 'Not valid' })).toThrow(); + }); +}); diff --git a/packages/contracts/src/index.d.ts b/packages/contracts/src/index.d.ts new file mode 100644 index 0000000..1c7255c --- /dev/null +++ b/packages/contracts/src/index.d.ts @@ -0,0 +1,119 @@ +import { z } from 'zod'; +export declare const topicTypes: readonly ["technology", "framework", "library", "language", "runtime", "tool", "platform", "standard", "practice", "architecture"]; +export declare const relationTypes: readonly ["uses", "depends_on", "alternative_to", "integrates_with", "supersedes", "related_to"]; +export declare const sourceTypes: readonly ["documentation", "changelog", "release_feed", "standards_body", "official_repository"]; +export declare const topicSectionSchema: z.ZodObject<{ + introduction: z.ZodString; + concepts: z.ZodArray; + useCases: z.ZodArray; + benefits: z.ZodArray; + limitations: z.ZodArray; + avoidWhen: z.ZodArray; + compatibility: z.ZodString; + requirements: z.ZodArray; + examples: z.ZodArray>; + migrationNotes: z.ZodArray; + deprecations: z.ZodArray; + securityNotes: z.ZodArray; +}, z.core.$strip>; +export declare const topicRevisionInputSchema: z.ZodObject<{ + title: z.ZodString; + slug: z.ZodString; + summary: z.ZodString; + audience: z.ZodArray>; + type: z.ZodEnum<{ + technology: "technology"; + framework: "framework"; + library: "library"; + language: "language"; + runtime: "runtime"; + tool: "tool"; + platform: "platform"; + standard: "standard"; + practice: "practice"; + architecture: "architecture"; + }>; + domains: z.ZodArray; + tags: z.ZodArray; + aliases: z.ZodArray; + currentVersion: z.ZodNullable; + supportedVersions: z.ZodArray; + content: z.ZodObject<{ + introduction: z.ZodString; + concepts: z.ZodArray; + useCases: z.ZodArray; + benefits: z.ZodArray; + limitations: z.ZodArray; + avoidWhen: z.ZodArray; + compatibility: z.ZodString; + requirements: z.ZodArray; + examples: z.ZodArray>; + migrationNotes: z.ZodArray; + deprecations: z.ZodArray; + securityNotes: z.ZodArray; + }, z.core.$strip>; + changeNote: z.ZodString; +}, z.core.$strip>; +export type TopicRevisionInput = z.infer; +export type TopicSection = z.infer; +export type TopicType = (typeof topicTypes)[number]; +export type RelationType = (typeof relationTypes)[number]; +export type SourceType = (typeof sourceTypes)[number]; +export interface TopicListItem { + id: string; + slug: string; + title: string; + summary: string; + type: TopicType; + domains: string[]; + tags: string[]; + currentVersion: string | null; + verifiedAt: string; +} +export interface TopicDetail extends TopicListItem { + aliases: string[]; + audience: string[]; + supportedVersions: string[]; + content: TopicSection; + revisionId: string; + revisionNumber: number; + relations: Array<{ + type: RelationType; + topic: TopicListItem; + }>; + sources: Array<{ + id: string; + title: string; + url: string; + publisher: string; + type: SourceType; + verifiedAt: string; + claims: string[]; + }>; +} +export interface Paginated { + items: T[]; + total: number; + page: number; + pageSize: number; +} +export declare const findingStatusSchema: z.ZodEnum<{ + approved: "approved"; + rejected: "rejected"; + pending: "pending"; + published: "published"; + failed: "failed"; +}>; +export type FindingStatus = z.infer; diff --git a/packages/contracts/src/index.js b/packages/contracts/src/index.js new file mode 100644 index 0000000..b652a8e --- /dev/null +++ b/packages/contracts/src/index.js @@ -0,0 +1,34 @@ +import { z } from 'zod'; +export const topicTypes = ['technology', 'framework', 'library', 'language', 'runtime', 'tool', 'platform', 'standard', 'practice', 'architecture']; +export const relationTypes = ['uses', 'depends_on', 'alternative_to', 'integrates_with', 'supersedes', 'related_to']; +export const sourceTypes = ['documentation', 'changelog', 'release_feed', 'standards_body', 'official_repository']; +export const topicSectionSchema = z.object({ + introduction: z.string().min(1), + concepts: z.array(z.string()), + useCases: z.array(z.string()), + benefits: z.array(z.string()), + limitations: z.array(z.string()), + avoidWhen: z.array(z.string()), + compatibility: z.string(), + requirements: z.array(z.string()), + examples: z.array(z.object({ title: z.string(), language: z.string(), code: z.string() })), + migrationNotes: z.array(z.string()), + deprecations: z.array(z.string()), + securityNotes: z.array(z.string()) +}); +export const topicRevisionInputSchema = z.object({ + title: z.string().min(2).max(120), + slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), + summary: z.string().min(20).max(500), + audience: z.array(z.enum(['beginner', 'intermediate', 'advanced'])).min(1), + type: z.enum(topicTypes), + domains: z.array(z.string()).min(1), + tags: z.array(z.string()), + aliases: z.array(z.string()), + currentVersion: z.string().nullable(), + supportedVersions: z.array(z.string()), + content: topicSectionSchema, + changeNote: z.string().min(3) +}); +export const findingStatusSchema = z.enum(['pending', 'approved', 'rejected', 'published', 'failed']); +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/contracts/src/index.js.map b/packages/contracts/src/index.js.map new file mode 100644 index 0000000..e0782db --- /dev/null +++ b/packages/contracts/src/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,cAAc,CAAU,CAAC;AAC7J,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,MAAM,EAAE,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,YAAY,EAAE,YAAY,CAAU,CAAC;AAC9H,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,eAAe,EAAE,WAAW,EAAE,cAAc,EAAE,gBAAgB,EAAE,qBAAqB,CAAU,CAAC;AAE5H,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IACzC,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAC/B,QAAQ,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IAC7B,QAAQ,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IAC7B,QAAQ,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IAC7B,WAAW,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IAChC,SAAS,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IAC9B,aAAa,EAAE,CAAC,CAAC,MAAM,EAAE;IACzB,YAAY,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IACjC,QAAQ,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1F,cAAc,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IACnC,YAAY,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IACjC,aAAa,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;CACnC,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC/C,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC;IACjC,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,4BAA4B,CAAC;IACpD,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC;IACpC,QAAQ,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,UAAU,EAAE,cAAc,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IAC1E,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC;IACxB,OAAO,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IACnC,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IACzB,OAAO,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IAC5B,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACrC,iBAAiB,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;IACtC,OAAO,EAAE,kBAAkB;IAC3B,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;CAC9B,CAAC,CAAC;AAiCH,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,QAAQ,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 0000000..6a4ee94 --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +export const topicTypes = ['technology', 'framework', 'library', 'language', 'runtime', 'tool', 'platform', 'standard', 'practice', 'architecture'] as const; +export const relationTypes = ['uses', 'depends_on', 'alternative_to', 'integrates_with', 'supersedes', 'related_to'] as const; +export const sourceTypes = ['documentation', 'changelog', 'release_feed', 'standards_body', 'official_repository'] as const; + +export const topicSectionSchema = z.object({ + introduction: z.string().min(1), + concepts: z.array(z.string()), + useCases: z.array(z.string()), + benefits: z.array(z.string()), + limitations: z.array(z.string()), + avoidWhen: z.array(z.string()), + compatibility: z.string(), + requirements: z.array(z.string()), + examples: z.array(z.object({ title: z.string(), language: z.string(), code: z.string() })), + migrationNotes: z.array(z.string()), + deprecations: z.array(z.string()), + securityNotes: z.array(z.string()) +}); + +export const topicRevisionInputSchema = z.object({ + title: z.string().min(2).max(120), + slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), + summary: z.string().min(20).max(500), + audience: z.array(z.enum(['beginner', 'intermediate', 'advanced'])).min(1), + type: z.enum(topicTypes), + domains: z.array(z.string()).min(1), + tags: z.array(z.string()), + aliases: z.array(z.string()), + currentVersion: z.string().nullable(), + supportedVersions: z.array(z.string()), + content: topicSectionSchema, + changeNote: z.string().min(3) +}); + +export type TopicRevisionInput = z.infer; +export type TopicSection = z.infer; +export type TopicType = (typeof topicTypes)[number]; +export type RelationType = (typeof relationTypes)[number]; +export type SourceType = (typeof sourceTypes)[number]; + +export interface TopicListItem { + id: string; + slug: string; + title: string; + summary: string; + type: TopicType; + domains: string[]; + tags: string[]; + currentVersion: string | null; + verifiedAt: string; +} + +export interface TopicDetail extends TopicListItem { + aliases: string[]; + audience: string[]; + supportedVersions: string[]; + content: TopicSection; + revisionId: string; + revisionNumber: number; + relations: Array<{ type: RelationType; topic: TopicListItem }>; + sources: Array<{ id: string; title: string; url: string; publisher: string; type: SourceType; verifiedAt: string; claims: string[] }>; +} + +export interface Paginated { items: T[]; total: number; page: number; pageSize: number } + +export const findingStatusSchema = z.enum(['pending', 'approved', 'rejected', 'published', 'failed']); +export type FindingStatus = z.infer; diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 0000000..44f4ad8 --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*.ts"] +} diff --git a/packages/database/package.json b/packages/database/package.json new file mode 100644 index 0000000..4587f5f --- /dev/null +++ b/packages/database/package.json @@ -0,0 +1,26 @@ +{ + "name": "@tech-library/database", + "version": "0.1.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run --passWithNoTests", + "db:migrate": "tsx src/cli/migrate.ts", + "db:seed": "tsx src/cli/seed.ts" + }, + "dependencies": { + "@tech-library/contracts": "*", + "better-sqlite3": "^12.2.0", + "drizzle-orm": "^0.45.4" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "tsx": "^4.20.5", + "typescript": "^5.9.2", + "vitest": "^5.0.5" + } +} diff --git a/packages/database/src/cli/migrate.ts b/packages/database/src/cli/migrate.ts new file mode 100644 index 0000000..9e065fd --- /dev/null +++ b/packages/database/src/cli/migrate.ts @@ -0,0 +1,4 @@ +import { openDatabase } from '../index.js'; +const { sqlite } = openDatabase(); +console.log('Database migrations applied.'); +sqlite.close(); diff --git a/packages/database/src/cli/seed.ts b/packages/database/src/cli/seed.ts new file mode 100644 index 0000000..24d6e2c --- /dev/null +++ b/packages/database/src/cli/seed.ts @@ -0,0 +1,5 @@ +import { openDatabase, seedDatabase } from '../index.js'; +const { sqlite } = openDatabase(); +const inserted = seedDatabase(sqlite); +console.log(`Seed complete: ${inserted} topic(s) inserted.`); +sqlite.close(); diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts new file mode 100644 index 0000000..95097e1 --- /dev/null +++ b/packages/database/src/index.ts @@ -0,0 +1,24 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { migrate } from './migrations.js'; +import * as schema from './schema.js'; + +export * from './schema.js'; +export { seedDatabase } from './seed.js'; + +export interface DatabaseContext { + sqlite: Database.Database; + db: ReturnType; +} + +export function openDatabase(filename = process.env.DATABASE_URL ?? './data/tech-library.db'): DatabaseContext { + const absolute = filename === ':memory:' ? filename : resolve(filename); + if (absolute !== ':memory:') mkdirSync(dirname(absolute), { recursive: true }); + const sqlite = new Database(absolute); + sqlite.pragma('journal_mode = WAL'); + sqlite.pragma('foreign_keys = ON'); + migrate(sqlite); + return { sqlite, db: drizzle(sqlite, { schema }) }; +} diff --git a/packages/database/src/migrations.ts b/packages/database/src/migrations.ts new file mode 100644 index 0000000..bcf23e5 --- /dev/null +++ b/packages/database/src/migrations.ts @@ -0,0 +1,30 @@ +import type Database from 'better-sqlite3'; + +const migrations = [ + `CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS topics (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, active_revision_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS revisions (id TEXT PRIMARY KEY, topic_id TEXT NOT NULL REFERENCES topics(id), number INTEGER NOT NULL, status TEXT NOT NULL CHECK(status IN ('draft','approved','rejected')), payload TEXT NOT NULL, change_note TEXT NOT NULL, created_at TEXT NOT NULL, reviewed_at TEXT, published_at TEXT, UNIQUE(topic_id, number)); + CREATE TABLE IF NOT EXISTS sources (id TEXT PRIMARY KEY, url TEXT NOT NULL, publisher TEXT NOT NULL, source_type TEXT NOT NULL, title TEXT NOT NULL, published_at TEXT, fetched_at TEXT NOT NULL, verified_at TEXT NOT NULL, fingerprint TEXT NOT NULL, claims TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS revision_sources (revision_id TEXT NOT NULL REFERENCES revisions(id), source_id TEXT NOT NULL REFERENCES sources(id), PRIMARY KEY(revision_id, source_id)); + CREATE TABLE IF NOT EXISTS relations (id TEXT PRIMARY KEY, source_topic_id TEXT NOT NULL REFERENCES topics(id), target_topic_id TEXT NOT NULL REFERENCES topics(id), type TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(source_topic_id, target_topic_id, type)); + CREATE TABLE IF NOT EXISTS findings (id TEXT PRIMARY KEY, adapter TEXT NOT NULL, topic_id TEXT REFERENCES topics(id), source_url TEXT NOT NULL, fingerprint TEXT NOT NULL UNIQUE, status TEXT NOT NULL, title TEXT NOT NULL, summary TEXT NOT NULL, proposed_payload TEXT, raw_metadata TEXT NOT NULL, discovered_at TEXT NOT NULL, reviewed_at TEXT, error TEXT); + CREATE TABLE IF NOT EXISTS source_subscriptions (id TEXT PRIMARY KEY, topic_id TEXT REFERENCES topics(id), adapter TEXT NOT NULL, url TEXT NOT NULL UNIQUE, enabled INTEGER NOT NULL DEFAULT 1, last_fingerprint TEXT, last_checked_at TEXT, last_error TEXT); + CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, csrf_token TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS audit_log (id TEXT PRIMARY KEY, action TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, metadata TEXT NOT NULL, created_at TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS worker_jobs (id TEXT PRIMARY KEY, type TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, started_at TEXT, completed_at TEXT, result TEXT); + CREATE VIRTUAL TABLE IF NOT EXISTS topic_search USING fts5(topic_id UNINDEXED, title, summary, aliases, content, tags, tokenize='porter unicode61'); + CREATE INDEX IF NOT EXISTS revisions_topic_id ON revisions(topic_id); + CREATE INDEX IF NOT EXISTS findings_status ON findings(status); + CREATE INDEX IF NOT EXISTS relations_source ON relations(source_topic_id);` +]; + +export function migrate(sqlite: Database.Database): void { + sqlite.exec('CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)'); + const applied = sqlite.prepare('SELECT 1 FROM _migrations WHERE version = ?'); + const record = sqlite.prepare('INSERT INTO _migrations(version, applied_at) VALUES (?, ?)'); + migrations.forEach((sql, index) => { + const version = index + 1; + if (applied.get(version)) return; + sqlite.transaction(() => { sqlite.exec(sql); record.run(version, new Date().toISOString()); })(); + }); +} diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts new file mode 100644 index 0000000..fb3a259 --- /dev/null +++ b/packages/database/src/schema.ts @@ -0,0 +1,100 @@ +import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; + +export const topics = sqliteTable('topics', { + id: text('id').primaryKey(), + slug: text('slug').notNull().unique(), + activeRevisionId: text('active_revision_id'), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull() +}); + +export const revisions = sqliteTable('revisions', { + id: text('id').primaryKey(), + topicId: text('topic_id').notNull().references(() => topics.id), + number: integer('number').notNull(), + status: text('status', { enum: ['draft', 'approved', 'rejected'] }).notNull(), + payload: text('payload', { mode: 'json' }).notNull(), + changeNote: text('change_note').notNull(), + createdAt: text('created_at').notNull(), + reviewedAt: text('reviewed_at'), + publishedAt: text('published_at') +}, (table) => [uniqueIndex('revision_topic_number').on(table.topicId, table.number)]); + +export const sources = sqliteTable('sources', { + id: text('id').primaryKey(), + url: text('url').notNull(), + publisher: text('publisher').notNull(), + sourceType: text('source_type').notNull(), + title: text('title').notNull(), + publishedAt: text('published_at'), + fetchedAt: text('fetched_at').notNull(), + verifiedAt: text('verified_at').notNull(), + fingerprint: text('fingerprint').notNull(), + claims: text('claims', { mode: 'json' }).notNull() +}); + +export const revisionSources = sqliteTable('revision_sources', { + revisionId: text('revision_id').notNull().references(() => revisions.id), + sourceId: text('source_id').notNull().references(() => sources.id) +}); + +export const relations = sqliteTable('relations', { + id: text('id').primaryKey(), + sourceTopicId: text('source_topic_id').notNull().references(() => topics.id), + targetTopicId: text('target_topic_id').notNull().references(() => topics.id), + type: text('type').notNull(), + createdAt: text('created_at').notNull() +}); + +export const findings = sqliteTable('findings', { + id: text('id').primaryKey(), + adapter: text('adapter').notNull(), + topicId: text('topic_id').references(() => topics.id), + sourceUrl: text('source_url').notNull(), + fingerprint: text('fingerprint').notNull().unique(), + status: text('status').notNull(), + title: text('title').notNull(), + summary: text('summary').notNull(), + proposedPayload: text('proposed_payload', { mode: 'json' }), + rawMetadata: text('raw_metadata', { mode: 'json' }).notNull(), + discoveredAt: text('discovered_at').notNull(), + reviewedAt: text('reviewed_at'), + error: text('error') +}); + +export const sourceSubscriptions = sqliteTable('source_subscriptions', { + id: text('id').primaryKey(), + topicId: text('topic_id').references(() => topics.id), + adapter: text('adapter').notNull(), + url: text('url').notNull().unique(), + enabled: integer('enabled', { mode: 'boolean' }).notNull(), + lastFingerprint: text('last_fingerprint'), + lastCheckedAt: text('last_checked_at'), + lastError: text('last_error') +}); + +export const sessions = sqliteTable('sessions', { + id: text('id').primaryKey(), + csrfToken: text('csrf_token').notNull(), + expiresAt: text('expires_at').notNull(), + createdAt: text('created_at').notNull() +}); + +export const auditLog = sqliteTable('audit_log', { + id: text('id').primaryKey(), + action: text('action').notNull(), + entityType: text('entity_type').notNull(), + entityId: text('entity_id').notNull(), + metadata: text('metadata', { mode: 'json' }).notNull(), + createdAt: text('created_at').notNull() +}); + +export const workerJobs = sqliteTable('worker_jobs', { + id: text('id').primaryKey(), + type: text('type').notNull(), + status: text('status').notNull(), + createdAt: text('created_at').notNull(), + startedAt: text('started_at'), + completedAt: text('completed_at'), + result: text('result', { mode: 'json' }) +}); diff --git a/packages/database/src/seed.ts b/packages/database/src/seed.ts new file mode 100644 index 0000000..600da61 --- /dev/null +++ b/packages/database/src/seed.ts @@ -0,0 +1,91 @@ +import type Database from 'better-sqlite3'; +import { createHash, randomUUID } from 'node:crypto'; +import type { TopicRevisionInput, TopicType } from '@tech-library/contracts'; + +type SeedTopic = { slug: string; title: string; summary: string; type: TopicType; domain: string; url: string; publisher: string; version?: string }; + +export const seedTopics: SeedTopic[] = [ + { slug: 'html', title: 'HTML', summary: 'The semantic markup language that gives web documents their structure and meaning.', type: 'language', domain: 'Web platform', url: 'https://html.spec.whatwg.org/', publisher: 'WHATWG' }, + { slug: 'css', title: 'CSS', summary: 'The styling language for responsive layouts, visual design, animation, and presentation on the web.', type: 'language', domain: 'Web platform', url: 'https://www.w3.org/Style/CSS/', publisher: 'W3C' }, + { slug: 'javascript', title: 'JavaScript', summary: 'The web platform programming language, standardized as ECMAScript and used across browsers and servers.', type: 'language', domain: 'Web platform', url: 'https://tc39.es/ecma262/', publisher: 'TC39' }, + { slug: 'typescript', title: 'TypeScript', summary: 'A statically typed language that extends JavaScript and compiles to standards-compatible JavaScript.', type: 'language', domain: 'Frontend and UI', url: 'https://www.typescriptlang.org/docs/', publisher: 'Microsoft' }, + { slug: 'web-components', title: 'Web Components', summary: 'Browser standards for reusable custom elements with encapsulated templates and styling.', type: 'standard', domain: 'Web platform', url: 'https://developer.mozilla.org/en-US/docs/Web/API/Web_components', publisher: 'Mozilla' }, + { slug: 'accessibility', title: 'Web Accessibility', summary: 'Practices and standards for making digital experiences perceivable, operable, understandable, and robust.', type: 'practice', domain: 'Web platform', url: 'https://www.w3.org/WAI/standards-guidelines/wcag/', publisher: 'W3C' }, + { slug: 'react', title: 'React', summary: 'A component-oriented library for building interactive user interfaces from declarative state.', type: 'library', domain: 'Frontend and UI', url: 'https://react.dev/', publisher: 'Meta' }, + { slug: 'vite', title: 'Vite', summary: 'A fast frontend development server and production build tool based on native ES modules and Rollup.', type: 'tool', domain: 'Frontend and UI', url: 'https://vite.dev/guide/', publisher: 'Vite' }, + { slug: 'pwa', title: 'Progressive Web Apps', summary: 'Installable web applications that use platform capabilities such as service workers and manifests.', type: 'platform', domain: 'Frontend and UI', url: 'https://web.dev/explore/progressive-web-apps', publisher: 'Google' }, + { slug: 'design-systems', title: 'Design Systems', summary: 'Shared foundations, components, patterns, and governance for consistent product interfaces.', type: 'practice', domain: 'Frontend and UI', url: 'https://www.w3.org/WAI/ARIA/apg/', publisher: 'W3C' }, + { slug: 'nodejs', title: 'Node.js', summary: 'A cross-platform JavaScript runtime for servers, command-line tools, automation, and build systems.', type: 'runtime', domain: 'Backend and APIs', url: 'https://nodejs.org/docs/latest/api/', publisher: 'OpenJS Foundation' }, + { slug: 'rest', title: 'REST', summary: 'An architectural style for networked systems centered on resources and uniform HTTP semantics.', type: 'architecture', domain: 'Backend and APIs', url: 'https://www.rfc-editor.org/rfc/rfc9110', publisher: 'IETF' }, + { slug: 'graphql', title: 'GraphQL', summary: 'A typed query language and execution model for APIs where clients select the data they need.', type: 'standard', domain: 'Backend and APIs', url: 'https://spec.graphql.org/', publisher: 'GraphQL Foundation' }, + { slug: 'openapi', title: 'OpenAPI', summary: 'A machine-readable specification for describing HTTP APIs, operations, schemas, and authentication.', type: 'standard', domain: 'Backend and APIs', url: 'https://spec.openapis.org/oas/latest.html', publisher: 'OpenAPI Initiative' }, + { slug: 'websockets', title: 'WebSocket', summary: 'A persistent, full-duplex protocol for bidirectional communication between clients and servers.', type: 'standard', domain: 'Backend and APIs', url: 'https://www.rfc-editor.org/rfc/rfc6455', publisher: 'IETF' }, + { slug: 'sqlite', title: 'SQLite', summary: 'An embedded transactional relational database stored in a portable single-file format.', type: 'technology', domain: 'Data', url: 'https://sqlite.org/docs.html', publisher: 'SQLite Consortium' }, + { slug: 'postgresql', title: 'PostgreSQL', summary: 'An extensible open-source relational database with strong transactional and standards support.', type: 'technology', domain: 'Data', url: 'https://www.postgresql.org/docs/', publisher: 'PostgreSQL Global Development Group' }, + { slug: 'database-indexing', title: 'Database Indexing', summary: 'Data structures and query-planning techniques that trade storage and write cost for faster reads.', type: 'practice', domain: 'Data', url: 'https://www.postgresql.org/docs/current/indexes.html', publisher: 'PostgreSQL Global Development Group' }, + { slug: 'data-migrations', title: 'Database Migrations', summary: 'Version-controlled, repeatable changes that evolve database schemas and data safely over time.', type: 'practice', domain: 'Data', url: 'https://www.sqlite.org/lang_altertable.html', publisher: 'SQLite Consortium' }, + { slug: 'react-native', title: 'React Native', summary: 'A React renderer for creating native mobile applications using JavaScript and platform components.', type: 'framework', domain: 'Mobile and desktop', url: 'https://reactnative.dev/docs/getting-started', publisher: 'Meta' }, + { slug: 'electron', title: 'Electron', summary: 'A framework for desktop applications that combines Chromium, Node.js, and web technologies.', type: 'framework', domain: 'Mobile and desktop', url: 'https://www.electronjs.org/docs/latest/', publisher: 'OpenJS Foundation' }, + { slug: 'tauri', title: 'Tauri', summary: 'A toolkit for small desktop and mobile applications using a system webview and a Rust backend.', type: 'framework', domain: 'Mobile and desktop', url: 'https://v2.tauri.app/', publisher: 'Tauri Programme' }, + { slug: 'docker', title: 'Docker', summary: 'A platform for packaging and running applications as isolated, reproducible containers.', type: 'platform', domain: 'DevOps and cloud', url: 'https://docs.docker.com/', publisher: 'Docker' }, + { slug: 'kubernetes', title: 'Kubernetes', summary: 'A declarative orchestration platform for deploying, scaling, and operating container workloads.', type: 'platform', domain: 'DevOps and cloud', url: 'https://kubernetes.io/docs/', publisher: 'CNCF' }, + { slug: 'github-actions', title: 'GitHub Actions', summary: 'Repository-integrated automation for continuous integration, delivery, and maintenance workflows.', type: 'platform', domain: 'DevOps and cloud', url: 'https://docs.github.com/actions', publisher: 'GitHub' }, + { slug: 'infrastructure-as-code', title: 'Infrastructure as Code', summary: 'Managing infrastructure through versioned, reviewable, and repeatable declarative definitions.', type: 'practice', domain: 'DevOps and cloud', url: 'https://developer.hashicorp.com/terraform/docs', publisher: 'HashiCorp' }, + { slug: 'automated-testing', title: 'Automated Testing', summary: 'Repeatable checks at multiple levels that provide confidence in software behavior and change.', type: 'practice', domain: 'Testing and observability', url: 'https://vitest.dev/guide/', publisher: 'Vitest' }, + { slug: 'observability', title: 'Observability', summary: 'Understanding system behavior through correlated telemetry such as logs, metrics, and traces.', type: 'practice', domain: 'Testing and observability', url: 'https://opentelemetry.io/docs/', publisher: 'CNCF' }, + { slug: 'web-performance', title: 'Web Performance', summary: 'Measuring and improving loading, responsiveness, rendering stability, and resource efficiency.', type: 'practice', domain: 'Testing and observability', url: 'https://web.dev/explore/learn-performance', publisher: 'Google' }, + { slug: 'owasp-top-ten', title: 'OWASP Top 10', summary: 'A widely used awareness document describing major security risks to modern web applications.', type: 'standard', domain: 'Security and privacy', url: 'https://owasp.org/www-project-top-ten/', publisher: 'OWASP' }, + { slug: 'oauth-2', title: 'OAuth 2.0', summary: 'An authorization framework for delegated access to protected HTTP resources.', type: 'standard', domain: 'Security and privacy', url: 'https://www.rfc-editor.org/rfc/rfc6749', publisher: 'IETF' }, + { slug: 'content-security-policy', title: 'Content Security Policy', summary: 'A browser security standard that restricts resource loading and mitigates injection attacks.', type: 'standard', domain: 'Security and privacy', url: 'https://www.w3.org/TR/CSP3/', publisher: 'W3C' } +]; + +function payload(topic: SeedTopic): TopicRevisionInput { + return { + title: topic.title, slug: topic.slug, summary: topic.summary, + audience: ['beginner', 'intermediate', 'advanced'], type: topic.type, + domains: [topic.domain], tags: [topic.domain.toLowerCase(), topic.type], aliases: [], + currentVersion: topic.version ?? null, supportedVersions: [], + content: { + introduction: `${topic.title} is a core part of modern software development. This entry provides a practical map of the technology, its trade-offs, and its official sources.`, + concepts: ['Core model and terminology', 'Ecosystem and interoperability', 'Lifecycle and maintenance'], + useCases: [`Evaluate ${topic.title} for new and existing systems`, 'Understand integration boundaries and operational impact'], + benefits: ['Backed by an official, traceable primary source', 'Presented in layers for readers with different experience levels'], + limitations: ['Exact capabilities and support vary by version and platform'], + avoidWhen: ['The operational cost or compatibility constraints exceed the value for the project'], + compatibility: 'Consult the linked official documentation for current platform and version compatibility.', + requirements: ['Verify supported versions before adoption', 'Test the intended production environment'], + examples: [], migrationNotes: [], deprecations: [], + securityNotes: ['Review the official security guidance and supported release policy before production use.'] + }, + changeNote: 'Initial curated catalog entry' + }; +} + +export function seedDatabase(sqlite: Database.Database): number { + const now = new Date().toISOString(); + const insertTopic = sqlite.prepare('INSERT OR IGNORE INTO topics(id, slug, active_revision_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'); + const insertRevision = sqlite.prepare('INSERT OR IGNORE INTO revisions(id, topic_id, number, status, payload, change_note, created_at, reviewed_at, published_at) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)'); + const insertSource = sqlite.prepare('INSERT OR IGNORE INTO sources(id, url, publisher, source_type, title, fetched_at, verified_at, fingerprint, claims) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); + const linkSource = sqlite.prepare('INSERT OR IGNORE INTO revision_sources(revision_id, source_id) VALUES (?, ?)'); + const indexSearch = sqlite.prepare('INSERT INTO topic_search(topic_id, title, summary, aliases, content, tags) VALUES (?, ?, ?, ?, ?, ?)'); + const countBefore = Number((sqlite.prepare('SELECT count(*) AS count FROM topics').get() as { count: number }).count); + + sqlite.transaction(() => { + for (const item of seedTopics) { + const topicId = `topic_${item.slug}`; + const revisionId = `revision_${item.slug}_1`; + const sourceId = `source_${item.slug}_official`; + const data = payload(item); + insertTopic.run(topicId, item.slug, revisionId, now, now); + insertRevision.run(revisionId, topicId, 'approved', JSON.stringify(data), data.changeNote, now, now, now); + insertSource.run(sourceId, item.url, item.publisher, 'documentation', `${item.title} official documentation`, now, now, createHash('sha256').update(item.url).digest('hex'), JSON.stringify(['Official definition and current technical guidance'])); + linkSource.run(revisionId, sourceId); + if (!(sqlite.prepare('SELECT 1 FROM topic_search WHERE topic_id = ?').get(topicId))) { + indexSearch.run(topicId, data.title, data.summary, data.aliases.join(' '), JSON.stringify(data.content), data.tags.join(' ')); + } + sqlite.prepare('INSERT OR IGNORE INTO source_subscriptions(id, topic_id, adapter, url, enabled) VALUES (?, ?, ?, ?, 1)').run(randomUUID(), topicId, 'official-page', item.url); + } + })(); + const countAfter = Number((sqlite.prepare('SELECT count(*) AS count FROM topics').get() as { count: number }).count); + return countAfter - countBefore; +} diff --git a/packages/database/tsconfig.json b/packages/database/tsconfig.json new file mode 100644 index 0000000..44f4ad8 --- /dev/null +++ b/packages/database/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*.ts"] +}