diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d92458c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +deno.lock +.npmrc \ No newline at end of file diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..6a62f3d --- /dev/null +++ b/deno.json @@ -0,0 +1,33 @@ +{ + "name": "@alkdev/storage", + "version": "0.1.0", + "license": "MIT", + "exports": { + ".": "./mod.ts", + "./graphs": "./src/graphs/mod.ts", + "./sqlite": "./src/sqlite/mod.ts", + "./pg": "./src/pg/mod.ts" + }, + "imports": { + "@alkdev/typebox": "npm:@alkdev/typebox", + "@alkdev/drizzlebox": "npm:@alkdev/drizzlebox", + "drizzle-orm": "npm:drizzle-orm", + "drizzle-orm/sqlite-core": "npm:drizzle-orm/sqlite-core", + "drizzle-orm/pg-core": "npm:drizzle-orm/pg-core", + "@libsql/client": "npm:@libsql/client", + "postgres": "npm:postgres", + "@std/assert": "jsr:@std/assert" + }, + "lint": { + "rules": { + "exclude": ["no-slow-types"] + } + }, + "tasks": { + "check": "deno check mod.ts src/graphs/mod.ts src/sqlite/mod.ts", + "test": "deno test --allow-all test/", + "lint": "deno lint", + "fmt": "deno fmt", + "publish:dry": "deno publish --allow-slow-types --dry-run" + } +} \ No newline at end of file diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..085d835 --- /dev/null +++ b/mod.ts @@ -0,0 +1 @@ +export * from "./src/graphs/mod.ts"; \ No newline at end of file diff --git a/src/graphs/mod.ts b/src/graphs/mod.ts new file mode 100644 index 0000000..ad6a91b --- /dev/null +++ b/src/graphs/mod.ts @@ -0,0 +1,2 @@ +export * from "./types.ts"; +export * from "./schemaBuilder.ts"; \ No newline at end of file diff --git a/src/graphs/schemaBuilder.ts b/src/graphs/schemaBuilder.ts new file mode 100644 index 0000000..b138583 --- /dev/null +++ b/src/graphs/schemaBuilder.ts @@ -0,0 +1,63 @@ +import { KindGuard, type TSchema } from "@alkdev/typebox"; +import { Value } from "@alkdev/typebox/value"; +import { assert } from "@std/assert"; +import { GraphSchema, GraphConfig, NodeType, EdgeType } from "./types.ts"; + +export class SchemaBuilder { + private schema: { + config: Record; + nodeTypes: Record; + edgeTypes: Record; + } = { + config: {}, + nodeTypes: {}, + edgeTypes: {}, + }; + + config(config: Partial): SchemaBuilder { + const configObj = Value.Default(GraphConfig, config) as GraphConfig; + this.check(GraphConfig, configObj); + this.schema.config = configObj as Record; + return this; + } + + nodeType(name: string, schema: TSchema): SchemaBuilder { + assert(KindGuard.IsSchema(schema), `type '${name}' is not a valid json schema.`); + + if (!this.schema.nodeTypes) this.schema.nodeTypes = {}; + const nodeTypeObj: NodeType = { name, schema }; + + this.check(NodeType, nodeTypeObj); + this.schema.nodeTypes[name] = nodeTypeObj; + return this; + } + + edgeType( + name: string, + schema: TSchema, + options?: { allowedSourceTypes?: string[]; allowedTargetTypes?: string[] }, + ): SchemaBuilder { + assert(KindGuard.IsSchema(schema), `type '${name}' is not a valid json schema.`); + + if (!this.schema.edgeTypes) this.schema.edgeTypes = {}; + const edgeTypeObj: EdgeType = { name, schema, ...options }; + + this.check(EdgeType, edgeTypeObj); + this.schema.edgeTypes[name] = edgeTypeObj; + return this; + } + + check(schema: TSchema, value: unknown): void { + if (!Value.Check(schema, value)) { + const errors = [...Value.Errors(schema, value)]; + throw new Error( + `Invalid schema structure: ${JSON.stringify(errors.map((e) => `${e.path}: ${e.message}`))}`, + ); + } + } + + build(): GraphSchema { + this.check(GraphSchema, this.schema); + return this.schema as GraphSchema; + } +} \ No newline at end of file diff --git a/src/graphs/types.ts b/src/graphs/types.ts new file mode 100644 index 0000000..b5e8c20 --- /dev/null +++ b/src/graphs/types.ts @@ -0,0 +1,70 @@ +import { Type, type Static, type TSchema } from "@alkdev/typebox"; + +export const BaseNodeAttributes: TSchema = Type.Object({ + created: Type.Optional(Type.String({ format: "date-time" })), + modified: Type.Optional(Type.String({ format: "date-time" })), + metadata: Type.Optional(Type.Record(Type.String(), Type.Any())), +}); + +export type BaseNodeAttributes = Static; + +export const BaseEdgeAttributes: TSchema = Type.Object({ + type: Type.String(), + metadata: Type.Optional(Type.Record(Type.String(), Type.Any())), +}); + +export type BaseEdgeAttributes = Static; + +export const GraphConfig: TSchema = Type.Object({ + type: Type.Union([ + Type.Literal("directed"), + Type.Literal("undirected"), + Type.Literal("mixed"), + ], { default: "mixed" }), + multi: Type.Boolean({ default: true }), + allowSelfLoops: Type.Boolean({ default: true }), +}); + +export type GraphConfig = Static; + +export const NodeType: TSchema = Type.Object({ + name: Type.String(), + schema: Type.Any(), +}); + +export type NodeType = Static; + +export const EdgeType: TSchema = Type.Object({ + name: Type.String(), + schema: Type.Any(), + allowedSourceTypes: Type.Optional(Type.Array(Type.String())), + allowedTargetTypes: Type.Optional(Type.Array(Type.String())), +}); + +export type EdgeType = Static; + +export const GraphSchema: TSchema = Type.Object({ + config: GraphConfig, + nodeTypes: Type.Record(Type.String(), NodeType), + edgeTypes: Type.Record(Type.String(), EdgeType), +}); + +export type GraphSchema = Static; + +export enum EnumGraphStatus { + Active = "active", + Archived = "archived", + Draft = "draft", +} + +export type GraphStatus = Static; +export const GraphStatus: TSchema = Type.Enum(EnumGraphStatus); + +export enum EnumGraphBaseType { + Directed = "directed", + Undirected = "undirected", + Mixed = "mixed", +} + +export type GraphBaseType = Static; +export const GraphBaseType: TSchema = Type.Enum(EnumGraphBaseType); \ No newline at end of file diff --git a/src/pg/mod.ts b/src/pg/mod.ts new file mode 100644 index 0000000..1376f52 --- /dev/null +++ b/src/pg/mod.ts @@ -0,0 +1,3 @@ +// Postgres host — not yet implemented +// Will mirror sqlite/ structure with pgTable, jsonb(), timestamp(), pgEnum, etc. +// Import pattern: import { ... } from "@alkdev/storage/pg" \ No newline at end of file diff --git a/src/sqlite/client.ts b/src/sqlite/client.ts new file mode 100644 index 0000000..5177118 --- /dev/null +++ b/src/sqlite/client.ts @@ -0,0 +1,11 @@ +import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql"; +import type { Client } from "@libsql/client"; +import * as schema from "./schema.ts"; + +export type SqliteSchema = typeof schema; + +export type SqliteDatabase = LibSQLDatabase; + +export function createSqliteDatabase(client: Client): SqliteDatabase { + return drizzle(client, { schema }) as SqliteDatabase; +} \ No newline at end of file diff --git a/src/sqlite/mod.ts b/src/sqlite/mod.ts new file mode 100644 index 0000000..84a76fc --- /dev/null +++ b/src/sqlite/mod.ts @@ -0,0 +1,2 @@ +export * from "./schema.ts"; +export * from "./client.ts"; \ No newline at end of file diff --git a/src/sqlite/relations.ts b/src/sqlite/relations.ts new file mode 100644 index 0000000..ec4c281 --- /dev/null +++ b/src/sqlite/relations.ts @@ -0,0 +1,68 @@ +import { relations } from "drizzle-orm"; +import { + graphTypes, + nodeTypes, + edgeTypes, + graphs, + nodes, + edges, +} from "./tables/index.ts"; + +export const graphTypeRelations = relations(graphTypes, ({ many }) => ({ + nodeTypes: many(nodeTypes), + edgeTypes: many(edgeTypes), + graphs: many(graphs), +})); + +export const nodeTypesRelations = relations(nodeTypes, ({ one }) => ({ + graphType: one(graphTypes, { + fields: [nodeTypes.graphTypeId], + references: [graphTypes.id], + }), +})); + +export const edgeTypesRelations = relations(edgeTypes, ({ one }) => ({ + graphType: one(graphTypes, { + fields: [edgeTypes.graphTypeId], + references: [graphTypes.id], + }), +})); + +export const graphsRelations = relations(graphs, ({ one, many }) => ({ + graphType: one(graphTypes, { + fields: [graphs.graphTypeId], + references: [graphTypes.id], + }), + nodes: many(nodes), + edges: many(edges), +})); + +export const edgesRelations = relations(edges, ({ one }) => ({ + graph: one(graphs, { + fields: [edges.graphId], + references: [graphs.id], + }), + sourceNode: one(nodes, { + fields: [edges.graphId, edges.sourceNodeKey], + references: [nodes.graphId, nodes.key], + relationName: "sourceNode", + }), + targetNode: one(nodes, { + fields: [edges.graphId, edges.targetNodeKey], + references: [nodes.graphId, nodes.key], + relationName: "targetNode", + }), +})); + +export const nodesRelations = relations(nodes, ({ one, many }) => ({ + graph: one(graphs, { + fields: [nodes.graphId], + references: [graphs.id], + }), + outgoingEdges: many(edges, { + relationName: "sourceNode", + }), + incomingEdges: many(edges, { + relationName: "targetNode", + }), +})); \ No newline at end of file diff --git a/src/sqlite/schema.ts b/src/sqlite/schema.ts new file mode 100644 index 0000000..c9e7961 --- /dev/null +++ b/src/sqlite/schema.ts @@ -0,0 +1,2 @@ +export * from "./tables/index.ts"; +export * from "./relations.ts"; \ No newline at end of file diff --git a/src/sqlite/tables/actors.ts b/src/sqlite/tables/actors.ts new file mode 100644 index 0000000..7291e70 --- /dev/null +++ b/src/sqlite/tables/actors.ts @@ -0,0 +1,30 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { createInsertSchema, createSelectSchema } from "@alkdev/drizzlebox"; +import { Type, type Static } from "@alkdev/typebox"; +import { commonCols, ACTOR_TYPE } from "./common.ts"; + +export const actors = sqliteTable("actors", { + ...commonCols, + name: text("name").notNull(), + type: text("type", { enum: ["human", "llm", "agent"] }).notNull(), +}); + +export const SelectActor = createSelectSchema(actors, { + metadata: Type.Object({}, { additionalProperties: true }), + createdAt: Type.Date(), + updatedAt: Type.Date(), +}); + +export type SelectActor = Static; + +export const InsertActor = createInsertSchema(actors, { + name: Type.String({ minLength: 1, maxLength: 255 }), + type: Type.Union([ + Type.Literal(ACTOR_TYPE.Human), + Type.Literal(ACTOR_TYPE.Llm), + Type.Literal(ACTOR_TYPE.Agent), + ]), + metadata: Type.Optional(Type.Object({}, { additionalProperties: true })), +}); + +export type InsertActor = Static; \ No newline at end of file diff --git a/src/sqlite/tables/common.ts b/src/sqlite/tables/common.ts new file mode 100644 index 0000000..796a4f6 --- /dev/null +++ b/src/sqlite/tables/common.ts @@ -0,0 +1,21 @@ +import { sql } from "drizzle-orm"; +import { text, integer } from "drizzle-orm/sqlite-core"; + +export const commonCols = { + id: text("id").primaryKey(), + metadata: text("metadata", { mode: "json" }).$type>().default({}), + createdAt: integer("created_at", { mode: "timestamp" }) + .default(sql`(strftime('%s', 'now'))`) + .notNull(), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .default(sql`(strftime('%s', 'now'))`) + .notNull(), +}; + +export const ACTOR_TYPE = { + Human: "human", + Llm: "llm", + Agent: "agent", +} as const; + +export type EnumValues> = T[keyof T]; \ No newline at end of file diff --git a/src/sqlite/tables/edgeTypes.ts b/src/sqlite/tables/edgeTypes.ts new file mode 100644 index 0000000..901cd28 --- /dev/null +++ b/src/sqlite/tables/edgeTypes.ts @@ -0,0 +1,37 @@ +import { sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; +import { createInsertSchema, createSelectSchema } from "@alkdev/drizzlebox"; +import { Type, type Static } from "@alkdev/typebox"; +import { commonCols } from "./common.ts"; +import { graphTypes } from "./graphTypes.ts"; + +export const edgeTypes = sqliteTable("edge_types", { + ...commonCols, + graphTypeId: text("graph_type_id").notNull().references(() => graphTypes.id, { onDelete: "cascade" }), + name: text("name").notNull(), + description: text("description").default(""), + schema: text("schema", { mode: "json" }).$type>().notNull(), + allowedSourceTypes: text("allowed_source_types", { mode: "json" }).$type().default([]), + allowedTargetTypes: text("allowed_target_types", { mode: "json" }).$type().default([]), +}, (table) => ({ + graphTypeNameIdx: unique().on(table.graphTypeId, table.name), +})); + +export const SelectEdgeType = createSelectSchema(edgeTypes, { + schema: Type.Unknown(), + allowedSourceTypes: Type.Array(Type.String()), + allowedTargetTypes: Type.Array(Type.String()), + metadata: Type.Object({}, { additionalProperties: true }), + createdAt: Type.Date(), + updatedAt: Type.Date(), +}); + +export type SelectEdgeType = Static; + +export const InsertEdgeType = createInsertSchema(edgeTypes, { + name: Type.String({ minLength: 2 }), + schema: Type.Unknown(), + allowedSourceTypes: Type.Optional(Type.Array(Type.String())), + allowedTargetTypes: Type.Optional(Type.Array(Type.String())), +}); + +export type InsertEdgeType = Static; \ No newline at end of file diff --git a/src/sqlite/tables/edges.ts b/src/sqlite/tables/edges.ts new file mode 100644 index 0000000..1fea60a --- /dev/null +++ b/src/sqlite/tables/edges.ts @@ -0,0 +1,44 @@ +import { sqliteTable, text, integer, unique, foreignKey } from "drizzle-orm/sqlite-core"; +import { createInsertSchema, createSelectSchema } from "@alkdev/drizzlebox"; +import { Type, type Static } from "@alkdev/typebox"; +import { commonCols } from "./common.ts"; +import { graphs } from "./graphs.ts"; +import { nodes } from "./nodes.ts"; + +const AttributesSchema = Type.Record(Type.String(), Type.Any()); + +export const edges = sqliteTable("edges", { + ...commonCols, + graphId: text("graph_id").notNull().references(() => graphs.id, { onDelete: "cascade" }), + key: text("key"), + sourceNodeKey: text("source_node_key").notNull(), + targetNodeKey: text("target_node_key").notNull(), + attributes: text("attributes", { mode: "json" }).notNull().default({}), + undirected: integer("undirected", { mode: "boolean" }).default(false), +}, (table) => ({ + graphKeyIdx: unique().on(table.graphId, table.key), + sourceFk: foreignKey({ + columns: [table.graphId, table.sourceNodeKey], + foreignColumns: [nodes.graphId, nodes.key], + }).onDelete("cascade"), + targetFk: foreignKey({ + columns: [table.graphId, table.targetNodeKey], + foreignColumns: [nodes.graphId, nodes.key], + }).onDelete("cascade"), +})); + +export const SelectEdge = createSelectSchema(edges, { + attributes: AttributesSchema, + metadata: Type.Object({}, { additionalProperties: true }), + createdAt: Type.Date(), + updatedAt: Type.Date(), +}); + +export type SelectEdge = Static; + +export const InsertEdge = createInsertSchema(edges, { + key: Type.String({ minLength: 1 }), + attributes: AttributesSchema, +}); + +export type InsertEdge = Static; \ No newline at end of file diff --git a/src/sqlite/tables/graphTypes.ts b/src/sqlite/tables/graphTypes.ts new file mode 100644 index 0000000..ac0ee5d --- /dev/null +++ b/src/sqlite/tables/graphTypes.ts @@ -0,0 +1,38 @@ +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { createInsertSchema, createSelectSchema } from "@alkdev/drizzlebox"; +import { Type, type Static } from "@alkdev/typebox"; +import { commonCols } from "./common.ts"; + + +const ConfigSchema = Type.Object({ + type: Type.Union([ + Type.Literal("directed"), + Type.Literal("undirected"), + Type.Literal("mixed"), + ], { default: "mixed" }), + multi: Type.Boolean({ default: true }), + allowSelfLoops: Type.Boolean({ default: true }), +}); + +export const graphTypes = sqliteTable("graph_types", { + ...commonCols, + name: text("name").notNull().unique(), + description: text("description").default(""), + config: text("config", { mode: "json" }).$type>().notNull(), + version: integer("version").notNull().default(1), +}); + +export const SelectGraphType = createSelectSchema(graphTypes, { + metadata: Type.Object({}, { additionalProperties: true }), + createdAt: Type.Date(), + updatedAt: Type.Date(), +}); + +export type SelectGraphType = Static; + +export const InsertGraphType = createInsertSchema(graphTypes, { + name: Type.String({ minLength: 2, maxLength: 255 }), + description: Type.Optional(Type.String()), +}); + +export type InsertGraphType = Static; \ No newline at end of file diff --git a/src/sqlite/tables/graphs.ts b/src/sqlite/tables/graphs.ts new file mode 100644 index 0000000..8550de7 --- /dev/null +++ b/src/sqlite/tables/graphs.ts @@ -0,0 +1,35 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { createInsertSchema, createSelectSchema } from "@alkdev/drizzlebox"; +import { Type, type Static } from "@alkdev/typebox"; +import { commonCols } from "./common.ts"; +import { graphTypes } from "./graphTypes.ts"; +import { EnumGraphStatus } from "../../graphs/types.ts"; + +export const graphs = sqliteTable("graphs", { + ...commonCols, + graphTypeId: text("graph_type_id").references(() => graphTypes.id, { onDelete: "set null" }), + name: text("name").notNull(), + description: text("description").default(""), + status: text("status", { enum: ["active", "archived", "draft"] }) + .default("draft") + .notNull(), +}); + +export const SelectGraph = createSelectSchema(graphs, { + metadata: Type.Object({}, { additionalProperties: true }), + createdAt: Type.Date(), + updatedAt: Type.Date(), +}); + +export type SelectGraph = Static; + +export const InsertGraph = createInsertSchema(graphs, { + name: Type.String({ minLength: 2 }), + status: Type.Optional(Type.Union([ + Type.Literal(EnumGraphStatus.Active), + Type.Literal(EnumGraphStatus.Archived), + Type.Literal(EnumGraphStatus.Draft), + ])), +}); + +export type InsertGraph = Static; \ No newline at end of file diff --git a/src/sqlite/tables/index.ts b/src/sqlite/tables/index.ts new file mode 100644 index 0000000..345c066 --- /dev/null +++ b/src/sqlite/tables/index.ts @@ -0,0 +1,23 @@ +export { graphs } from "./graphs.ts"; +export type { SelectGraph, InsertGraph } from "./graphs.ts"; +export { SelectGraph as SelectGraphSchema, InsertGraph as InsertGraphSchema } from "./graphs.ts"; +export { nodes } from "./nodes.ts"; +export type { InsertNode } from "./nodes.ts"; +export { InsertNodeSchema } from "./nodes.ts"; +export { edges } from "./edges.ts"; +export type { SelectEdge, InsertEdge } from "./edges.ts"; +export { SelectEdge as SelectEdgeSchema, InsertEdge as InsertEdgeSchema } from "./edges.ts"; +export { graphTypes } from "./graphTypes.ts"; +export type { SelectGraphType, InsertGraphType } from "./graphTypes.ts"; +export { SelectGraphType as SelectGraphTypeSchema, InsertGraphType as InsertGraphTypeSchema } from "./graphTypes.ts"; +export { nodeTypes } from "./nodeTypes.ts"; +export type { SelectNodeType, InsertNodeType } from "./nodeTypes.ts"; +export { SelectNodeType as SelectNodeTypeSchema, InsertNodeType as InsertNodeTypeSchema } from "./nodeTypes.ts"; +export { edgeTypes } from "./edgeTypes.ts"; +export type { SelectEdgeType, InsertEdgeType } from "./edgeTypes.ts"; +export { SelectEdgeType as SelectEdgeTypeSchema, InsertEdgeType as InsertEdgeTypeSchema } from "./edgeTypes.ts"; +export { actors } from "./actors.ts"; +export type { SelectActor, InsertActor } from "./actors.ts"; +export { SelectActor as SelectActorSchema, InsertActor as InsertActorSchema } from "./actors.ts"; +export { commonCols, ACTOR_TYPE } from "./common.ts"; +export type { EnumValues } from "./common.ts"; \ No newline at end of file diff --git a/src/sqlite/tables/nodeTypes.ts b/src/sqlite/tables/nodeTypes.ts new file mode 100644 index 0000000..23839ab --- /dev/null +++ b/src/sqlite/tables/nodeTypes.ts @@ -0,0 +1,31 @@ +import { sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; +import { createInsertSchema, createSelectSchema } from "@alkdev/drizzlebox"; +import { Type, type Static } from "@alkdev/typebox"; +import { commonCols } from "./common.ts"; +import { graphTypes } from "./graphTypes.ts"; + +export const nodeTypes = sqliteTable("node_types", { + ...commonCols, + graphTypeId: text("graph_type_id").notNull().references(() => graphTypes.id, { onDelete: "cascade" }), + name: text("name").notNull(), + description: text("description").default(""), + schema: text("schema", { mode: "json" }).$type>().notNull(), +}, (table) => ({ + graphTypeNameIdx: unique().on(table.graphTypeId, table.name), +})); + +export const SelectNodeType = createSelectSchema(nodeTypes, { + schema: Type.Unknown(), + metadata: Type.Object({}, { additionalProperties: true }), + createdAt: Type.Date(), + updatedAt: Type.Date(), +}); + +export type SelectNodeType = Static; + +export const InsertNodeType = createInsertSchema(nodeTypes, { + name: Type.String({ minLength: 2 }), + schema: Type.Unknown(), +}); + +export type InsertNodeType = Static; \ No newline at end of file diff --git a/src/sqlite/tables/nodes.ts b/src/sqlite/tables/nodes.ts new file mode 100644 index 0000000..8dc54ca --- /dev/null +++ b/src/sqlite/tables/nodes.ts @@ -0,0 +1,23 @@ +import { sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; +import { createInsertSchema } from "@alkdev/drizzlebox"; +import { Type, type Static } from "@alkdev/typebox"; +import { commonCols } from "./common.ts"; +import { graphs } from "./graphs.ts"; + +const AttributesSchema = Type.Record(Type.String(), Type.Any()); + +export const nodes = sqliteTable("nodes", { + ...commonCols, + graphId: text("graph_id").notNull().references(() => graphs.id, { onDelete: "cascade" }), + key: text("key").notNull(), + attributes: text("attributes", { mode: "json" }).notNull().default({}), +}, (table) => ({ + graphKeyIdx: unique().on(table.graphId, table.key), +})); + +export const InsertNodeSchema = createInsertSchema(nodes, { + key: Type.String({ minLength: 1 }), + attributes: AttributesSchema, +}); + +export type InsertNode = Static; \ No newline at end of file