feat(database): add revisioned knowledge model and seed catalog
This commit is contained in:
parent
454ae1c915
commit
44c332c0df
15 changed files with 532 additions and 0 deletions
11
packages/contracts/package.json
Normal file
11
packages/contracts/package.json
Normal file
|
|
@ -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" }
|
||||
}
|
||||
8
packages/contracts/src/contracts.test.ts
Normal file
8
packages/contracts/src/contracts.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
119
packages/contracts/src/index.d.ts
vendored
Normal file
119
packages/contracts/src/index.d.ts
vendored
Normal file
|
|
@ -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<z.ZodString>;
|
||||
useCases: z.ZodArray<z.ZodString>;
|
||||
benefits: z.ZodArray<z.ZodString>;
|
||||
limitations: z.ZodArray<z.ZodString>;
|
||||
avoidWhen: z.ZodArray<z.ZodString>;
|
||||
compatibility: z.ZodString;
|
||||
requirements: z.ZodArray<z.ZodString>;
|
||||
examples: z.ZodArray<z.ZodObject<{
|
||||
title: z.ZodString;
|
||||
language: z.ZodString;
|
||||
code: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
migrationNotes: z.ZodArray<z.ZodString>;
|
||||
deprecations: z.ZodArray<z.ZodString>;
|
||||
securityNotes: z.ZodArray<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
export declare const topicRevisionInputSchema: z.ZodObject<{
|
||||
title: z.ZodString;
|
||||
slug: z.ZodString;
|
||||
summary: z.ZodString;
|
||||
audience: z.ZodArray<z.ZodEnum<{
|
||||
beginner: "beginner";
|
||||
intermediate: "intermediate";
|
||||
advanced: "advanced";
|
||||
}>>;
|
||||
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<z.ZodString>;
|
||||
tags: z.ZodArray<z.ZodString>;
|
||||
aliases: z.ZodArray<z.ZodString>;
|
||||
currentVersion: z.ZodNullable<z.ZodString>;
|
||||
supportedVersions: z.ZodArray<z.ZodString>;
|
||||
content: z.ZodObject<{
|
||||
introduction: z.ZodString;
|
||||
concepts: z.ZodArray<z.ZodString>;
|
||||
useCases: z.ZodArray<z.ZodString>;
|
||||
benefits: z.ZodArray<z.ZodString>;
|
||||
limitations: z.ZodArray<z.ZodString>;
|
||||
avoidWhen: z.ZodArray<z.ZodString>;
|
||||
compatibility: z.ZodString;
|
||||
requirements: z.ZodArray<z.ZodString>;
|
||||
examples: z.ZodArray<z.ZodObject<{
|
||||
title: z.ZodString;
|
||||
language: z.ZodString;
|
||||
code: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
migrationNotes: z.ZodArray<z.ZodString>;
|
||||
deprecations: z.ZodArray<z.ZodString>;
|
||||
securityNotes: z.ZodArray<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
changeNote: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
export type TopicRevisionInput = z.infer<typeof topicRevisionInputSchema>;
|
||||
export type TopicSection = z.infer<typeof topicSectionSchema>;
|
||||
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<T> {
|
||||
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<typeof findingStatusSchema>;
|
||||
34
packages/contracts/src/index.js
Normal file
34
packages/contracts/src/index.js
Normal file
|
|
@ -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
|
||||
1
packages/contracts/src/index.js.map
Normal file
1
packages/contracts/src/index.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"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"}
|
||||
69
packages/contracts/src/index.ts
Normal file
69
packages/contracts/src/index.ts
Normal file
|
|
@ -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<typeof topicRevisionInputSchema>;
|
||||
export type TopicSection = z.infer<typeof topicSectionSchema>;
|
||||
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<T> { items: T[]; total: number; page: number; pageSize: number }
|
||||
|
||||
export const findingStatusSchema = z.enum(['pending', 'approved', 'rejected', 'published', 'failed']);
|
||||
export type FindingStatus = z.infer<typeof findingStatusSchema>;
|
||||
5
packages/contracts/tsconfig.json
Normal file
5
packages/contracts/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
26
packages/database/package.json
Normal file
26
packages/database/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
4
packages/database/src/cli/migrate.ts
Normal file
4
packages/database/src/cli/migrate.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { openDatabase } from '../index.js';
|
||||
const { sqlite } = openDatabase();
|
||||
console.log('Database migrations applied.');
|
||||
sqlite.close();
|
||||
5
packages/database/src/cli/seed.ts
Normal file
5
packages/database/src/cli/seed.ts
Normal file
|
|
@ -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();
|
||||
24
packages/database/src/index.ts
Normal file
24
packages/database/src/index.ts
Normal file
|
|
@ -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<typeof drizzle>;
|
||||
}
|
||||
|
||||
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 }) };
|
||||
}
|
||||
30
packages/database/src/migrations.ts
Normal file
30
packages/database/src/migrations.ts
Normal file
|
|
@ -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()); })();
|
||||
});
|
||||
}
|
||||
100
packages/database/src/schema.ts
Normal file
100
packages/database/src/schema.ts
Normal file
|
|
@ -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' })
|
||||
});
|
||||
91
packages/database/src/seed.ts
Normal file
91
packages/database/src/seed.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
5
packages/database/tsconfig.json
Normal file
5
packages/database/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue