From 4f3992c5685ae3a0951ca93f0df9ae9504edb525 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Mon, 13 Jan 2025 17:09:22 -0800 Subject: [PATCH 1/3] refactor: clean up bindings dir and move tests around --- .../hf-transformers/bindings/all_inmemory.ts | 17 --------- .../hf-transformers/bindings/all_sqlite.ts | 36 ------------------ .../{local_hf.ts => registerTasks.ts} | 0 .../src/hf-transformers/browser.ts | 3 +- .../test/HFTransformersBinding.test.ts | 37 +++++++++++++++++++ .../src/tf-mediapipe/bindings/all_inmemory.ts | 19 ---------- .../src/tf-mediapipe/bindings/all_sqlite.ts | 36 ------------------ .../{local_mp.ts => registerTasks.ts} | 0 .../ai-provider/src/tf-mediapipe/browser.ts | 3 +- .../src/tf-mediapipe/test/TfMediaPipe.test.ts | 37 +++++++++++++++++++ packages/storage/package.json | 6 +-- .../inmemory/test/InMemoryJobQueue.test.ts} | 7 ++-- .../test/InMemoryTaskGraphRepository.test.ts | 2 +- .../test/InMemoryTaskOutputRepository.test.ts | 2 +- ...-Sqlite.test.ts => SqliteJobQueue.test.ts} | 0 15 files changed, 84 insertions(+), 121 deletions(-) delete mode 100644 packages/ai-provider/src/hf-transformers/bindings/all_inmemory.ts delete mode 100644 packages/ai-provider/src/hf-transformers/bindings/all_sqlite.ts rename packages/ai-provider/src/hf-transformers/bindings/{local_hf.ts => registerTasks.ts} (100%) create mode 100644 packages/ai-provider/src/hf-transformers/test/HFTransformersBinding.test.ts delete mode 100644 packages/ai-provider/src/tf-mediapipe/bindings/all_inmemory.ts delete mode 100644 packages/ai-provider/src/tf-mediapipe/bindings/all_sqlite.ts rename packages/ai-provider/src/tf-mediapipe/bindings/{local_mp.ts => registerTasks.ts} (100%) create mode 100644 packages/ai-provider/src/tf-mediapipe/test/TfMediaPipe.test.ts rename packages/{core/src/job/test/JobQueue-InMemory.test.ts => storage/src/browser/inmemory/test/InMemoryJobQueue.test.ts} (96%) rename packages/storage/src/bun/sqlite/test/{JobQueue-Sqlite.test.ts => SqliteJobQueue.test.ts} (100%) diff --git a/packages/ai-provider/src/hf-transformers/bindings/all_inmemory.ts b/packages/ai-provider/src/hf-transformers/bindings/all_inmemory.ts deleted file mode 100644 index fec0069..0000000 --- a/packages/ai-provider/src/hf-transformers/bindings/all_inmemory.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ConcurrencyLimiter, TaskInput, TaskOutput } from "ellmers-core"; -import { getProviderRegistry, ModelProcessorEnum } from "ellmers-ai"; -import { InMemoryJobQueue } from "ellmers-storage/inmemory"; -import { registerHuggingfaceLocalTasks } from "./local_hf"; -import "../model/ONNXModelSamples"; - -export async function registerHuggingfaceLocalTasksInMemory() { - registerHuggingfaceLocalTasks(); - const ProviderRegistry = getProviderRegistry(); - const jobQueue = new InMemoryJobQueue( - "local_hf", - new ConcurrencyLimiter(1, 10), - 10 - ); - ProviderRegistry.registerQueue(ModelProcessorEnum.LOCAL_ONNX_TRANSFORMERJS, jobQueue); - jobQueue.start(); -} diff --git a/packages/ai-provider/src/hf-transformers/bindings/all_sqlite.ts b/packages/ai-provider/src/hf-transformers/bindings/all_sqlite.ts deleted file mode 100644 index 3535d37..0000000 --- a/packages/ai-provider/src/hf-transformers/bindings/all_sqlite.ts +++ /dev/null @@ -1,36 +0,0 @@ -// import { registerHuggingfaceLocalTasks } from "./local_hf"; -// import { registerMediaPipeTfJsLocalTasks } from "./local_mp"; -// import { getProviderRegistry } from "../provider/ProviderRegistry"; -// import { ModelProcessorEnum } from "../model/Model"; -// import { ConcurrencyLimiter } from "../job/ConcurrencyLimiter"; -// import { SqliteJobQueue } from "../job/SqliteJobQueue"; -// import { getDatabase } from "../util/db_sqlite"; -// import { TaskInput, TaskOutput } from "../task/base/Task"; -// import { mkdirSync } from "node:fs"; - -// mkdirSync("./.cache", { recursive: true }); -// const db = getDatabase("./.cache/local.db"); - -// export async function registerHuggingfaceLocalTasksSqlite() { -// registerHuggingfaceLocalTasks(); -// const ProviderRegistry = getProviderRegistry(); -// const jobQueue = new SqliteJobQueue( -// db, -// "local_hf", -// new ConcurrencyLimiter(1, 10) -// ); -// ProviderRegistry.registerQueue(ModelProcessorEnum.LOCAL_ONNX_TRANSFORMERJS, jobQueue); -// jobQueue.start(); -// } - -// export async function registerMediaPipeTfJsLocalSqlite() { -// registerMediaPipeTfJsLocalTasks(); -// const ProviderRegistry = getProviderRegistry(); -// const jobQueue = new SqliteJobQueue( -// db, -// "local_media_pipe", -// new ConcurrencyLimiter(1, 10) -// ); -// ProviderRegistry.registerQueue(ModelProcessorEnum.MEDIA_PIPE_TFJS_MODEL, jobQueue); -// jobQueue.start(); -// } diff --git a/packages/ai-provider/src/hf-transformers/bindings/local_hf.ts b/packages/ai-provider/src/hf-transformers/bindings/registerTasks.ts similarity index 100% rename from packages/ai-provider/src/hf-transformers/bindings/local_hf.ts rename to packages/ai-provider/src/hf-transformers/bindings/registerTasks.ts diff --git a/packages/ai-provider/src/hf-transformers/browser.ts b/packages/ai-provider/src/hf-transformers/browser.ts index b9657be..caa4d0b 100644 --- a/packages/ai-provider/src/hf-transformers/browser.ts +++ b/packages/ai-provider/src/hf-transformers/browser.ts @@ -7,5 +7,4 @@ export * from "./provider/HuggingFaceLocal_TaskRun"; export * from "./model/ONNXTransformerJsModel"; -export * from "./bindings/local_hf"; -export * from "./bindings/all_inmemory"; +export * from "./bindings/registerTasks"; diff --git a/packages/ai-provider/src/hf-transformers/test/HFTransformersBinding.test.ts b/packages/ai-provider/src/hf-transformers/test/HFTransformersBinding.test.ts new file mode 100644 index 0000000..749cba2 --- /dev/null +++ b/packages/ai-provider/src/hf-transformers/test/HFTransformersBinding.test.ts @@ -0,0 +1,37 @@ +// ******************************************************************************* +// * ELLMERS: Embedding Large Language Model Experiential Retrieval Service * +// * * +// * Copyright Steven Roussey * +// * Licensed under the Apache License, Version 2.0 (the "License"); * +// ******************************************************************************* + +import { describe, expect, it } from "bun:test"; +import { ConcurrencyLimiter, TaskInput, TaskOutput } from "ellmers-core"; +import { getProviderRegistry, ModelProcessorEnum } from "ellmers-ai"; +import { InMemoryJobQueue } from "ellmers-storage/inmemory"; +import { registerHuggingfaceLocalTasks } from "../bindings/registerTasks"; +import "../model/ONNXModelSamples"; + +const HFQUEUE = "local_hf"; + +export async function registerHuggingfaceLocalTasksInMemory() { + registerHuggingfaceLocalTasks(); + const providerRegistry = getProviderRegistry(); + const jobQueue = new InMemoryJobQueue( + HFQUEUE, + new ConcurrencyLimiter(1, 10), + 10 + ); + providerRegistry.registerQueue(ModelProcessorEnum.LOCAL_ONNX_TRANSFORMERJS, jobQueue); + jobQueue.start(); + return providerRegistry; +} + +describe("HFTransformersBinding.", () => { + it("should not fail", async () => { + const providerRegistry = await registerHuggingfaceLocalTasksInMemory(); + const queue = providerRegistry.getQueue(ModelProcessorEnum.LOCAL_ONNX_TRANSFORMERJS); + expect(queue).toBeDefined(); + expect(queue?.queue).toEqual(HFQUEUE); + }); +}); diff --git a/packages/ai-provider/src/tf-mediapipe/bindings/all_inmemory.ts b/packages/ai-provider/src/tf-mediapipe/bindings/all_inmemory.ts deleted file mode 100644 index c1929fe..0000000 --- a/packages/ai-provider/src/tf-mediapipe/bindings/all_inmemory.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { getProviderRegistry } from "ellmers-ai"; -import { InMemoryJobQueue } from "ellmers-storage/inmemory"; -import { ModelProcessorEnum } from "ellmers-ai"; -import { ConcurrencyLimiter } from "ellmers-core"; -import { TaskInput, TaskOutput } from "ellmers-core"; -import { registerMediaPipeTfJsLocalTasks } from "./local_mp"; -import "../model/MediaPipeModelSamples"; - -export async function registerMediaPipeTfJsLocalInMemory() { - registerMediaPipeTfJsLocalTasks(); - const ProviderRegistry = getProviderRegistry(); - const jobQueue = new InMemoryJobQueue( - "local_media_pipe", - new ConcurrencyLimiter(1, 10), - 10 - ); - ProviderRegistry.registerQueue(ModelProcessorEnum.MEDIA_PIPE_TFJS_MODEL, jobQueue); - jobQueue.start(); -} diff --git a/packages/ai-provider/src/tf-mediapipe/bindings/all_sqlite.ts b/packages/ai-provider/src/tf-mediapipe/bindings/all_sqlite.ts deleted file mode 100644 index 3535d37..0000000 --- a/packages/ai-provider/src/tf-mediapipe/bindings/all_sqlite.ts +++ /dev/null @@ -1,36 +0,0 @@ -// import { registerHuggingfaceLocalTasks } from "./local_hf"; -// import { registerMediaPipeTfJsLocalTasks } from "./local_mp"; -// import { getProviderRegistry } from "../provider/ProviderRegistry"; -// import { ModelProcessorEnum } from "../model/Model"; -// import { ConcurrencyLimiter } from "../job/ConcurrencyLimiter"; -// import { SqliteJobQueue } from "../job/SqliteJobQueue"; -// import { getDatabase } from "../util/db_sqlite"; -// import { TaskInput, TaskOutput } from "../task/base/Task"; -// import { mkdirSync } from "node:fs"; - -// mkdirSync("./.cache", { recursive: true }); -// const db = getDatabase("./.cache/local.db"); - -// export async function registerHuggingfaceLocalTasksSqlite() { -// registerHuggingfaceLocalTasks(); -// const ProviderRegistry = getProviderRegistry(); -// const jobQueue = new SqliteJobQueue( -// db, -// "local_hf", -// new ConcurrencyLimiter(1, 10) -// ); -// ProviderRegistry.registerQueue(ModelProcessorEnum.LOCAL_ONNX_TRANSFORMERJS, jobQueue); -// jobQueue.start(); -// } - -// export async function registerMediaPipeTfJsLocalSqlite() { -// registerMediaPipeTfJsLocalTasks(); -// const ProviderRegistry = getProviderRegistry(); -// const jobQueue = new SqliteJobQueue( -// db, -// "local_media_pipe", -// new ConcurrencyLimiter(1, 10) -// ); -// ProviderRegistry.registerQueue(ModelProcessorEnum.MEDIA_PIPE_TFJS_MODEL, jobQueue); -// jobQueue.start(); -// } diff --git a/packages/ai-provider/src/tf-mediapipe/bindings/local_mp.ts b/packages/ai-provider/src/tf-mediapipe/bindings/registerTasks.ts similarity index 100% rename from packages/ai-provider/src/tf-mediapipe/bindings/local_mp.ts rename to packages/ai-provider/src/tf-mediapipe/bindings/registerTasks.ts diff --git a/packages/ai-provider/src/tf-mediapipe/browser.ts b/packages/ai-provider/src/tf-mediapipe/browser.ts index ad31311..6fc38a1 100644 --- a/packages/ai-provider/src/tf-mediapipe/browser.ts +++ b/packages/ai-provider/src/tf-mediapipe/browser.ts @@ -7,5 +7,4 @@ export * from "./provider/MediaPipeLocalTaskRun"; export * from "./model/MediaPipeModel"; -export * from "./bindings/local_mp"; -export * from "./bindings/all_inmemory"; +export * from "./bindings/registerTasks"; diff --git a/packages/ai-provider/src/tf-mediapipe/test/TfMediaPipe.test.ts b/packages/ai-provider/src/tf-mediapipe/test/TfMediaPipe.test.ts new file mode 100644 index 0000000..9651f21 --- /dev/null +++ b/packages/ai-provider/src/tf-mediapipe/test/TfMediaPipe.test.ts @@ -0,0 +1,37 @@ +// ******************************************************************************* +// * ELLMERS: Embedding Large Language Model Experiential Retrieval Service * +// * * +// * Copyright Steven Roussey * +// * Licensed under the Apache License, Version 2.0 (the "License"); * +// ******************************************************************************* + +import { describe, expect, it } from "bun:test"; +import { ConcurrencyLimiter, TaskInput, TaskOutput } from "ellmers-core"; +import { getProviderRegistry, ModelProcessorEnum } from "ellmers-ai"; +import { InMemoryJobQueue } from "ellmers-storage/inmemory"; +import { registerMediaPipeTfJsLocalTasks } from "../bindings/registerTasks"; +import "../model/MediaPipeModelSamples"; + +const TFQUEUE = "local_tf-mediapipe"; + +export async function registerMediaPipeTfJsLocalInMemory() { + registerMediaPipeTfJsLocalTasks(); + const ProviderRegistry = getProviderRegistry(); + const jobQueue = new InMemoryJobQueue( + TFQUEUE, + new ConcurrencyLimiter(1, 10), + 10 + ); + ProviderRegistry.registerQueue(ModelProcessorEnum.MEDIA_PIPE_TFJS_MODEL, jobQueue); + jobQueue.start(); + return ProviderRegistry; +} + +describe("TfMediaPipe.", () => { + it("should not fail", async () => { + const providerRegistry = await registerMediaPipeTfJsLocalInMemory(); + const queue = providerRegistry.getQueue(ModelProcessorEnum.MEDIA_PIPE_TFJS_MODEL); + expect(queue).toBeDefined(); + expect(queue?.queue).toEqual(TFQUEUE); + }); +}); diff --git a/packages/storage/package.json b/packages/storage/package.json index cc3da7e..d95a99e 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -5,9 +5,9 @@ "description": "Ellmers is a tool for building and running DAG pipelines of AI tasks.", "scripts": { "watch": "concurrently -c 'auto' 'bun:watch-*'", - "watch-browser": "bun build --watch --no-clear-screen--target=browser --sourcemap=external --external ellmers-core --outdir ./dist/browser ./src/browser/*/index.ts", - "watch-node": "bun build --watch --no-clear-screen--target=node --sourcemap=external --external ellmers-core --outdir ./dist ./src/node/*/index.ts", - "watch-bun": "bun build --watch --no-clear-screen--target=bun --sourcemap=external --external ellmers-core --outdir ./dist ./src/bun/*/index.ts", + "watch-browser": "bun build --watch --no-clear-screen --target=browser --sourcemap=external --external ellmers-core --outdir ./dist/browser ./src/browser/*/index.ts", + "watch-node": "bun build --watch --no-clear-screen --target=node --sourcemap=external --external ellmers-core --outdir ./dist ./src/node/*/index.ts", + "watch-bun": "bun build --watch --no-clear-screen --target=bun --sourcemap=external --external ellmers-core --outdir ./dist ./src/bun/*/index.ts", "watch-types": "tsc --watch --preserveWatchOutput", "build": "bun run build-clean && bun run build-types && bun run build-browser && bun run build-node && bun run build-bun", "build-clean": "rm -fr dist/* tsconfig.tsbuildinfo", diff --git a/packages/core/src/job/test/JobQueue-InMemory.test.ts b/packages/storage/src/browser/inmemory/test/InMemoryJobQueue.test.ts similarity index 96% rename from packages/core/src/job/test/JobQueue-InMemory.test.ts rename to packages/storage/src/browser/inmemory/test/InMemoryJobQueue.test.ts index 26eb4a4..857c745 100644 --- a/packages/core/src/job/test/JobQueue-InMemory.test.ts +++ b/packages/storage/src/browser/inmemory/test/InMemoryJobQueue.test.ts @@ -6,10 +6,9 @@ // ******************************************************************************* import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test"; -import { Job, JobStatus } from "../base/Job"; +import { Job, JobStatus, TaskInput, TaskOutput } from "ellmers-core"; import { InMemoryJobQueue, InMemoryRateLimiter } from "ellmers-storage/inmemory"; -import { sleep } from "../../util/Misc"; -import { TaskInput, TaskOutput } from "../../task/base/Task"; +import { sleep } from "ellmers-core"; class TestJob extends Job { public async execute() { @@ -17,7 +16,7 @@ class TestJob extends Job { } } -describe("LocalJobQueue", () => { +describe("InMemoryJobQueue", () => { let jobQueue: InMemoryJobQueue; beforeEach(() => { diff --git a/packages/storage/src/browser/inmemory/test/InMemoryTaskGraphRepository.test.ts b/packages/storage/src/browser/inmemory/test/InMemoryTaskGraphRepository.test.ts index 92fb8ec..c18b483 100644 --- a/packages/storage/src/browser/inmemory/test/InMemoryTaskGraphRepository.test.ts +++ b/packages/storage/src/browser/inmemory/test/InMemoryTaskGraphRepository.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it, beforeEach } from "bun:test"; import { rmdirSync } from "fs"; import { SingleTask, TaskOutput, DataFlow, TaskGraph, TaskRegistry } from "ellmers-core"; -import { InMemoryTaskGraphRepository } from "../InMemoryTaskGraphRepository"; +import { InMemoryTaskGraphRepository } from "ellmers-storage/inmemory"; class TestTask extends SingleTask { static readonly type = "TestTask"; diff --git a/packages/storage/src/browser/inmemory/test/InMemoryTaskOutputRepository.test.ts b/packages/storage/src/browser/inmemory/test/InMemoryTaskOutputRepository.test.ts index 8fcfb29..3144c23 100644 --- a/packages/storage/src/browser/inmemory/test/InMemoryTaskOutputRepository.test.ts +++ b/packages/storage/src/browser/inmemory/test/InMemoryTaskOutputRepository.test.ts @@ -6,8 +6,8 @@ // ******************************************************************************* import { describe, expect, it, beforeEach } from "bun:test"; -import { InMemoryTaskOutputRepository } from "../InMemoryTaskOutputRepository"; import { TaskInput, TaskOutput } from "ellmers-core"; +import { InMemoryTaskOutputRepository } from "ellmers-storage/inmemory"; describe("InMemoryTaskOutputRepository", () => { let repository: InMemoryTaskOutputRepository; diff --git a/packages/storage/src/bun/sqlite/test/JobQueue-Sqlite.test.ts b/packages/storage/src/bun/sqlite/test/SqliteJobQueue.test.ts similarity index 100% rename from packages/storage/src/bun/sqlite/test/JobQueue-Sqlite.test.ts rename to packages/storage/src/bun/sqlite/test/SqliteJobQueue.test.ts From 543c04e5b2509976038b00121981bcf640518c11 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Mon, 13 Jan 2025 21:41:01 -0800 Subject: [PATCH 2/3] refactor: move out sample data in prep for real model storage, and update some tests --- bun.lockb | Bin 232740 -> 233284 bytes docs/developers/01_getting_started.md | 10 +- examples/cli/src/ellmers.ts | 7 +- examples/web/src/App.tsx | 34 ++++++- examples/web/src/RunGraphFlow.tsx | 5 - package.json | 3 +- .../test/HFTransformersBinding.test.ts | 84 ++++++++++++----- .../src/tf-mediapipe/test/TfMediaPipe.test.ts | 37 -------- .../test/TfMediaPipeBinding.test.ts | 88 ++++++++++++++++++ packages/core/src/job/base/JobQueue.ts | 3 + .../src/browser/inmemory/InMemoryJobQueue.ts | 7 +- .../storage/src/bun/sqlite/SqliteJobQueue.ts | 6 +- .../bun/sqlite/test/SqliteJobQueue.test.ts | 4 +- packages/test/package.json | 32 +++++++ packages/test/src/index.ts | 32 +++++++ .../src/sample}/MediaPipeModelSamples.ts | 2 +- .../src/sample}/ONNXModelSamples.ts | 8 +- packages/test/src/util/db_sqlite.ts | 21 +++++ packages/test/tsconfig.json | 24 +++++ 19 files changed, 325 insertions(+), 82 deletions(-) delete mode 100644 packages/ai-provider/src/tf-mediapipe/test/TfMediaPipe.test.ts create mode 100644 packages/ai-provider/src/tf-mediapipe/test/TfMediaPipeBinding.test.ts create mode 100644 packages/test/package.json create mode 100644 packages/test/src/index.ts rename packages/{ai-provider/src/tf-mediapipe/model => test/src/sample}/MediaPipeModelSamples.ts (86%) rename packages/{ai-provider/src/hf-transformers/model => test/src/sample}/ONNXModelSamples.ts (92%) create mode 100644 packages/test/src/util/db_sqlite.ts create mode 100644 packages/test/tsconfig.json diff --git a/bun.lockb b/bun.lockb index 45369b4dff5e0a770d1bbd6d56676ede797094fd..82c165b9a0b17f9930346592118d6c11b485dc96 100755 GIT binary patch delta 33619 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 ZYU@@ptCiT^g(iGn-CzF0!ip40hHbs9YPOP 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 diff --git a/docs/developers/01_getting_started.md b/docs/developers/01_getting_started.md index f5cd725..7861f2d 100644 --- a/docs/developers/01_getting_started.md +++ b/docs/developers/01_getting_started.md @@ -22,9 +22,9 @@ - [`packages/storage`](#packagesstorage) - [`packages/ai`](#packagesai) - [`packages/ai-provider`](#packagesai-provider) - - [`examples/cli`](#samplescli) - - [`examples/web`](#samplesweb) - - [`examples/ngraph`](#samplesngraph) + - [`examples/cli`](#examplescli) + - [`examples/web`](#examplesweb) + - [`examples/ngraph`](#examplesngraph) # Developer Getting Started @@ -51,7 +51,7 @@ After this, plese read [Architecture](02_architecture.md) before attempting to [ ```ts import { TaskGraphBuilder } from "ellmers-core"; -import { registerHuggingfaceLocalTasksInMemory } from "ellmers-ai-provider/hf-transformers/server"; +import { registerHuggingfaceLocalTasksInMemory } from "ellmers-test"; // config and start up registerHuggingfaceLocalTasksInMemory(); @@ -79,8 +79,8 @@ import { DataFlow, TaskGraph, TaskGraphRunner, - registerHuggingfaceLocalTasksInMemory, } from "ellmers-core"; +import { registerHuggingfaceLocalTasksInMemory } from "ellmers-test"; // config and start up registerHuggingfaceLocalTasksInMemory(); diff --git a/examples/cli/src/ellmers.ts b/examples/cli/src/ellmers.ts index b16a53a..b164249 100755 --- a/examples/cli/src/ellmers.ts +++ b/examples/cli/src/ellmers.ts @@ -4,8 +4,11 @@ import { program } from "commander"; import { argv } from "process"; import { AddBaseCommands } from "./TaskCLI"; import { getProviderRegistry } from "ellmers-ai"; -import { registerHuggingfaceLocalTasksInMemory } from "ellmers-ai-provider/hf-transformers/server"; -import { registerMediaPipeTfJsLocalInMemory } from "ellmers-ai-provider/tf-mediapipe/server"; +import { + registerHuggingfaceLocalTasksInMemory, + registerMediaPipeTfJsLocalInMemory, +} from "ellmers-test"; +import "ellmers-test"; program.version("1.0.0").description("A CLI to run Ellmers."); diff --git a/examples/web/src/App.tsx b/examples/web/src/App.tsx index f84fc50..4a9b5b2 100644 --- a/examples/web/src/App.tsx +++ b/examples/web/src/App.tsx @@ -2,8 +2,17 @@ import React, { useCallback, useEffect, useState } from "react"; import { ReactFlowProvider } from "@xyflow/react"; import { RunGraphFlow } from "./RunGraphFlow"; import { JsonEditor } from "./JsonEditor"; -import { JsonTask, JsonTaskItem, TaskGraph, TaskGraphBuilder } from "ellmers-core"; import { + ConcurrencyLimiter, + JsonTask, + JsonTaskItem, + TaskGraph, + TaskGraphBuilder, + TaskInput, + TaskOutput, +} from "ellmers-core"; +import { + IndexedDbQueue, IndexedDbTaskGraphRepository, IndexedDbTaskOutputRepository, } from "ellmers-storage/browser/indexeddb"; @@ -11,10 +20,29 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./Resize"; import { QueuesStatus } from "./QueueSatus"; import { OutputRepositoryStatus } from "./OutputRepositoryStatus"; import { GraphStoreStatus } from "./GraphStoreStatus"; -import { registerHuggingfaceLocalTasksInMemory } from "ellmers-ai-provider/hf-transformers/browser"; +import { InMemoryJobQueue } from "ellmers-storage/inmemory"; +import { registerHuggingfaceLocalTasks } from "ellmers-ai-provider/hf-transformers/browser"; +import { getProviderRegistry, ModelProcessorEnum } from "ellmers-ai"; +import { registerMediaPipeTfJsLocalTasks } from "ellmers-ai-provider/tf-mediapipe/browser"; import "ellmers-task"; +import "ellmers-test"; + +const ProviderRegistry = getProviderRegistry(); + +registerHuggingfaceLocalTasks(); +ProviderRegistry.registerQueue( + ModelProcessorEnum.LOCAL_ONNX_TRANSFORMERJS, + new InMemoryJobQueue("local_hft", new ConcurrencyLimiter(1, 10), 10) +); + +registerMediaPipeTfJsLocalTasks(); +ProviderRegistry.registerQueue( + ModelProcessorEnum.MEDIA_PIPE_TFJS_MODEL, + new InMemoryJobQueue("local_mp", new ConcurrencyLimiter(1, 10), 10) +); -registerHuggingfaceLocalTasksInMemory(); +ProviderRegistry.clearQueues(); +ProviderRegistry.startQueues(); const taskOutputCache = new IndexedDbTaskOutputRepository(); const builder = new TaskGraphBuilder(taskOutputCache); diff --git a/examples/web/src/RunGraphFlow.tsx b/examples/web/src/RunGraphFlow.tsx index a87d9d6..cd20101 100644 --- a/examples/web/src/RunGraphFlow.tsx +++ b/examples/web/src/RunGraphFlow.tsx @@ -14,16 +14,11 @@ import { TurboNodeData, SingleNode, CompoundNode } from "./TurboNode"; import TurboEdge from "./TurboEdge"; import { FiFileText, FiClipboard, FiDownload, FiUpload } from "react-icons/fi"; import { Task, TaskGraph } from "ellmers-core"; -import { registerHuggingfaceLocalTasksInMemory } from "ellmers-ai-provider/hf-transformers/browser"; -import { registerMediaPipeTfJsLocalInMemory } from "ellmers-ai-provider/tf-mediapipe/browser"; import { GraphPipelineCenteredLayout, GraphPipelineLayout, computeLayout } from "./layout"; import "@xyflow/react/dist/base.css"; import "./RunGraphFlow.css"; -registerHuggingfaceLocalTasksInMemory(); -registerMediaPipeTfJsLocalInMemory(); - const categoryIcons = { "Text Model": , Input: , diff --git a/package.json b/package.json index 21ce6be..ed6f83d 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,12 @@ "build:ai-provider": "cd packages/ai-provider && bun run build", "build:storage": "cd packages/storage && bun run build", "build:task": "cd packages/task && bun run build", + "build:test": "cd packages/test && bun run build", "build:examples": "bun run bun run build:cli && bun run build:web", "build:cli": "cd examples/cli && bun run build", "build:web": "cd examples/web && bun run build", "clean": "rm -rf node_modules packages/*/node_modules packages/*/dist packages/*/src/**/*\\.d\\.ts packages/*/src/**/*\\.map examples/*/node_modules examples/*/dist examples/*/src/**/*\\.d\\.ts examples/*/src/**/*\\.map", - "watch:packages": "concurrently --kill-others -c 'auto' -n core,task,storage,ai,provider 'cd packages/core && bun run watch' 'sleep 3 && cd packages/task && bun run watch' 'sleep 3 && cd packages/storage && bun run watch' 'sleep 3 && cd packages/ai && bun run watch' 'sleep 6 && cd packages/ai-provider && bun run watch'", + "watch:packages": "concurrently --kill-others -c 'auto' -n core,task,storage,ai,provider,test 'cd packages/core && bun run watch' 'sleep 3 && cd packages/task && bun run watch' 'sleep 3 && cd packages/storage && bun run watch' 'sleep 3 && cd packages/ai && bun run watch' 'sleep 6 && cd packages/ai-provider && bun run watch' 'sleep 10 && cd packages/test && bun run watch'", "docs": "typedoc", "format": "eslint \"{packages|examples}/*/src/**/*.{js,ts,tsx,json}\" --fix && prettier \"{packages|examples}/*/src/**/*.{js,ts,tsx,json}\" --check --write", "release": "bun run build && bun publish", diff --git a/packages/ai-provider/src/hf-transformers/test/HFTransformersBinding.test.ts b/packages/ai-provider/src/hf-transformers/test/HFTransformersBinding.test.ts index 749cba2..3c1dded 100644 --- a/packages/ai-provider/src/hf-transformers/test/HFTransformersBinding.test.ts +++ b/packages/ai-provider/src/hf-transformers/test/HFTransformersBinding.test.ts @@ -6,32 +6,74 @@ // ******************************************************************************* import { describe, expect, it } from "bun:test"; -import { ConcurrencyLimiter, TaskInput, TaskOutput } from "ellmers-core"; -import { getProviderRegistry, ModelProcessorEnum } from "ellmers-ai"; +import { ConcurrencyLimiter, TaskGraphBuilder, TaskInput, TaskOutput } from "ellmers-core"; +import { getProviderRegistry, ModelProcessorEnum, ModelUseCaseEnum } from "ellmers-ai"; import { InMemoryJobQueue } from "ellmers-storage/inmemory"; +import { SqliteJobQueue } from "ellmers-storage/bun/sqlite"; import { registerHuggingfaceLocalTasks } from "../bindings/registerTasks"; -import "../model/ONNXModelSamples"; +import { getDatabase } from "../../../../storage/src/util/db_sqlite"; +import { sleep } from "bun"; +import { ONNXTransformerJsModel } from "../model/ONNXTransformerJsModel"; const HFQUEUE = "local_hf"; -export async function registerHuggingfaceLocalTasksInMemory() { - registerHuggingfaceLocalTasks(); - const providerRegistry = getProviderRegistry(); - const jobQueue = new InMemoryJobQueue( - HFQUEUE, - new ConcurrencyLimiter(1, 10), - 10 - ); - providerRegistry.registerQueue(ModelProcessorEnum.LOCAL_ONNX_TRANSFORMERJS, jobQueue); - jobQueue.start(); - return providerRegistry; -} +describe("HFTransformersBinding", () => { + describe("InMemoryJobQueue", () => { + it("Should have an item queued", async () => { + // the model gets self-registered + const flanT5p786m = new ONNXTransformerJsModel( + "Xenova/LaMini-Flan-T5-783M", + [ModelUseCaseEnum.TEXT_GENERATION, ModelUseCaseEnum.TEXT_REWRITING], + "text2text-generation" + ); + registerHuggingfaceLocalTasks(); + const providerRegistry = getProviderRegistry(); + const jobQueue = new InMemoryJobQueue( + HFQUEUE, + new ConcurrencyLimiter(1, 10), + 10 + ); + providerRegistry.registerQueue(ModelProcessorEnum.LOCAL_ONNX_TRANSFORMERJS, jobQueue); + const queue = providerRegistry.getQueue(ModelProcessorEnum.LOCAL_ONNX_TRANSFORMERJS); + expect(queue).toBeDefined(); + expect(queue?.queue).toEqual(HFQUEUE); -describe("HFTransformersBinding.", () => { - it("should not fail", async () => { - const providerRegistry = await registerHuggingfaceLocalTasksInMemory(); - const queue = providerRegistry.getQueue(ModelProcessorEnum.LOCAL_ONNX_TRANSFORMERJS); - expect(queue).toBeDefined(); - expect(queue?.queue).toEqual(HFQUEUE); + const builder = new TaskGraphBuilder(); + builder.DownloadModel({ + model: "Xenova/LaMini-Flan-T5-783M", + }); + builder.run(); + await sleep(1); + expect(await queue?.size()).toEqual(1); + await queue?.clear(); + }); + }); + + describe("SqliteJobQueue", () => { + it("Should have an item queued", async () => { + registerHuggingfaceLocalTasks(); + const providerRegistry = getProviderRegistry(); + const jobQueue = new SqliteJobQueue( + getDatabase(), + HFQUEUE, + new ConcurrencyLimiter(1, 10), + 10 + ); + jobQueue.ensureTableExists(); + providerRegistry.registerQueue(ModelProcessorEnum.LOCAL_ONNX_TRANSFORMERJS, jobQueue); + const queue = providerRegistry.getQueue(ModelProcessorEnum.LOCAL_ONNX_TRANSFORMERJS); + expect(queue).toBeDefined(); + expect(queue?.queue).toEqual(HFQUEUE); + + const builder = new TaskGraphBuilder(); + builder.DownloadModel({ + model: "Xenova/LaMini-Flan-T5-783M", + }); + builder.run(); + await sleep(1); + expect(await queue?.size()).toEqual(1); + builder.reset(); + await queue?.clear(); + }); }); }); diff --git a/packages/ai-provider/src/tf-mediapipe/test/TfMediaPipe.test.ts b/packages/ai-provider/src/tf-mediapipe/test/TfMediaPipe.test.ts deleted file mode 100644 index 9651f21..0000000 --- a/packages/ai-provider/src/tf-mediapipe/test/TfMediaPipe.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -// ******************************************************************************* -// * ELLMERS: Embedding Large Language Model Experiential Retrieval Service * -// * * -// * Copyright Steven Roussey * -// * Licensed under the Apache License, Version 2.0 (the "License"); * -// ******************************************************************************* - -import { describe, expect, it } from "bun:test"; -import { ConcurrencyLimiter, TaskInput, TaskOutput } from "ellmers-core"; -import { getProviderRegistry, ModelProcessorEnum } from "ellmers-ai"; -import { InMemoryJobQueue } from "ellmers-storage/inmemory"; -import { registerMediaPipeTfJsLocalTasks } from "../bindings/registerTasks"; -import "../model/MediaPipeModelSamples"; - -const TFQUEUE = "local_tf-mediapipe"; - -export async function registerMediaPipeTfJsLocalInMemory() { - registerMediaPipeTfJsLocalTasks(); - const ProviderRegistry = getProviderRegistry(); - const jobQueue = new InMemoryJobQueue( - TFQUEUE, - new ConcurrencyLimiter(1, 10), - 10 - ); - ProviderRegistry.registerQueue(ModelProcessorEnum.MEDIA_PIPE_TFJS_MODEL, jobQueue); - jobQueue.start(); - return ProviderRegistry; -} - -describe("TfMediaPipe.", () => { - it("should not fail", async () => { - const providerRegistry = await registerMediaPipeTfJsLocalInMemory(); - const queue = providerRegistry.getQueue(ModelProcessorEnum.MEDIA_PIPE_TFJS_MODEL); - expect(queue).toBeDefined(); - expect(queue?.queue).toEqual(TFQUEUE); - }); -}); diff --git a/packages/ai-provider/src/tf-mediapipe/test/TfMediaPipeBinding.test.ts b/packages/ai-provider/src/tf-mediapipe/test/TfMediaPipeBinding.test.ts new file mode 100644 index 0000000..ee9ed8d --- /dev/null +++ b/packages/ai-provider/src/tf-mediapipe/test/TfMediaPipeBinding.test.ts @@ -0,0 +1,88 @@ +// ******************************************************************************* +// * ELLMERS: Embedding Large Language Model Experiential Retrieval Service * +// * * +// * Copyright Steven Roussey * +// * Licensed under the Apache License, Version 2.0 (the "License"); * +// ******************************************************************************* + +import { describe, expect, it } from "bun:test"; +import { ConcurrencyLimiter, TaskGraphBuilder, TaskInput, TaskOutput } from "ellmers-core"; +import { getProviderRegistry, ModelProcessorEnum, ModelUseCaseEnum } from "ellmers-ai"; +import { InMemoryJobQueue } from "ellmers-storage/inmemory"; +import { SqliteJobQueue } from "ellmers-storage/bun/sqlite"; +import { registerMediaPipeTfJsLocalTasks } from "../bindings/registerTasks"; +import { sleep } from "ellmers-core"; +import { MediaPipeTfJsModel } from "../model/MediaPipeModel"; +import { getDatabase } from "../../../../storage/src/util/db_sqlite"; + +const TFQUEUE = "local_tf-mediapipe"; + +describe("TfMediaPipeBinding", () => { + describe("InMemoryJobQueue", () => { + it("should not fail", async () => { + // register on creation + const universal_sentence_encoder = new MediaPipeTfJsModel( + "Universal Sentence Encoder", + [ModelUseCaseEnum.TEXT_EMBEDDING], + "https://storage.googleapis.com/mediapipe-tasks/text_embedder/universal_sentence_encoder.tflite", + { dimensions: 100, browserOnly: true } + ); + registerMediaPipeTfJsLocalTasks(); + const ProviderRegistry = getProviderRegistry(); + const jobQueue = new InMemoryJobQueue( + TFQUEUE, + new ConcurrencyLimiter(1, 10), + 10 + ); + ProviderRegistry.registerQueue(ModelProcessorEnum.MEDIA_PIPE_TFJS_MODEL, jobQueue); + const queue = ProviderRegistry.getQueue(ModelProcessorEnum.MEDIA_PIPE_TFJS_MODEL); + expect(queue).toBeDefined(); + expect(queue?.queue).toEqual(TFQUEUE); + + const builder = new TaskGraphBuilder(); + builder.DownloadModel({ + model: "Universal Sentence Encoder", + }); + builder.run(); + await sleep(1); + // we are not in a browser context, so the model should not be registered + expect(await queue?.size()).toEqual(0); + builder.reset(); + await queue?.clear(); + }); + }); + describe("SqliteJobQueue", () => { + it("should not fail", async () => { + const universal_sentence_encoder = new MediaPipeTfJsModel( + "Universal Sentence Encoder", + [ModelUseCaseEnum.TEXT_EMBEDDING], + "https://storage.googleapis.com/mediapipe-tasks/text_embedder/universal_sentence_encoder.tflite", + { dimensions: 100, browserOnly: true } + ); + registerMediaPipeTfJsLocalTasks(); + const ProviderRegistry = getProviderRegistry(); + const jobQueue = new SqliteJobQueue( + getDatabase(":memory:"), + TFQUEUE, + new ConcurrencyLimiter(1, 10), + 10 + ); + jobQueue.ensureTableExists(); + ProviderRegistry.registerQueue(ModelProcessorEnum.MEDIA_PIPE_TFJS_MODEL, jobQueue); + const queue = ProviderRegistry.getQueue(ModelProcessorEnum.MEDIA_PIPE_TFJS_MODEL); + expect(queue).toBeDefined(); + expect(queue?.queue).toEqual(TFQUEUE); + + const builder = new TaskGraphBuilder(); + builder.DownloadModel({ + model: "Universal Sentence Encoder", + }); + builder.run(); + await sleep(1); + // we are not in a browser context, so the model should not be registered + expect(await queue?.size()).toEqual(0); + builder.reset(); + await queue?.clear(); + }); + }); +}); diff --git a/packages/core/src/job/base/JobQueue.ts b/packages/core/src/job/base/JobQueue.ts index 930b78c..62949b2 100644 --- a/packages/core/src/job/base/JobQueue.ts +++ b/packages/core/src/job/base/JobQueue.ts @@ -124,11 +124,13 @@ export abstract class JobQueue { this.running = true; this.events.emit("queue_start", this.queue); this.processJobs(); + return this; } async stop() { this.running = false; this.events.emit("queue_stop", this.queue); + return this; } async restart() { @@ -138,5 +140,6 @@ export abstract class JobQueue { this.waits.forEach(({ reject }) => reject("Queue Restarted")); this.waits.clear(); await this.start(); + return this; } } diff --git a/packages/storage/src/browser/inmemory/InMemoryJobQueue.ts b/packages/storage/src/browser/inmemory/InMemoryJobQueue.ts index 9aef9e2..bc5e350 100644 --- a/packages/storage/src/browser/inmemory/InMemoryJobQueue.ts +++ b/packages/storage/src/browser/inmemory/InMemoryJobQueue.ts @@ -10,7 +10,12 @@ import { Job, JobStatus, JobQueue, ILimiter } from "ellmers-core"; import { makeFingerprint } from "../../util/Misc"; export class InMemoryJobQueue extends JobQueue { - constructor(queue: string, limiter: ILimiter, waitDurationInMilliseconds = 100) { + constructor( + queue: string, + limiter: ILimiter, + waitDurationInMilliseconds = 100, + protected jobClass: typeof Job = Job + ) { super(queue, limiter, waitDurationInMilliseconds); this.jobQueue = []; } diff --git a/packages/storage/src/bun/sqlite/SqliteJobQueue.ts b/packages/storage/src/bun/sqlite/SqliteJobQueue.ts index dec6b6f..6df5ed5 100644 --- a/packages/storage/src/bun/sqlite/SqliteJobQueue.ts +++ b/packages/storage/src/bun/sqlite/SqliteJobQueue.ts @@ -16,14 +16,14 @@ export class SqliteJobQueue extends JobQueue { protected db: Database, queue: string, limiter: ILimiter, - protected jobClass: typeof Job = Job, - waitDurationInMilliseconds = 100 + waitDurationInMilliseconds = 100, + protected jobClass: typeof Job = Job ) { super(queue, limiter, waitDurationInMilliseconds); } public ensureTableExists() { - this.db.exec(` + const a = this.db.exec(` CREATE TABLE IF NOT EXISTS job_queue ( id INTEGER PRIMARY KEY, diff --git a/packages/storage/src/bun/sqlite/test/SqliteJobQueue.test.ts b/packages/storage/src/bun/sqlite/test/SqliteJobQueue.test.ts index c5730e8..48169bc 100644 --- a/packages/storage/src/bun/sqlite/test/SqliteJobQueue.test.ts +++ b/packages/storage/src/bun/sqlite/test/SqliteJobQueue.test.ts @@ -26,8 +26,8 @@ describe("SqliteJobQueue", () => { db, queueName, new SqliteRateLimiter(db, queueName, 4, 1).ensureTableExists(), - TestJob, - 0 + 0, + TestJob ).ensureTableExists(); afterEach(() => { diff --git a/packages/test/package.json b/packages/test/package.json new file mode 100644 index 0000000..9cf2779 --- /dev/null +++ b/packages/test/package.json @@ -0,0 +1,32 @@ +{ + "name": "ellmers-test", + "type": "module", + "version": "0.0.1", + "description": "Ellmers is a tool for building and running DAG pipelines of AI tasks.", + "scripts": { + "watch": "concurrently -c 'auto' 'bun:watch-*'", + "watch-code": "bun build --watch --no-clear-screen --target=browser --sourcemap=external --external @mediapipe/tasks-text --external @huggingface/transformers --external ellmers-core --external ellmers-ai --external ellmers-ai-provider --external ellmers-storage --outdir ./dist/ ./src/index.ts", + "watch-types": "tsc --watch --preserveWatchOutput", + "build": "bun run build-clean && bun run build-types && bun run build-hf-transformers && bun run build-tf-mediapipe", + "build-clean": "rm -fr dist/* tsconfig.tsbuildinfo", + "build-code": "bun build --target=browser --sourcemap=external --external @mediapipe/tasks-text --external @mediapipe/tasks-text --external @huggingface/transformers --external ellmers-core --external ellmers-ai --external ellmers-ai-provider --external ellmers-storage --outdir ./dist/ ./src/index.ts", + "build-types": "tsc", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "test": "bun test" + }, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "dependencies": { + "ellmers-core": "workspace:packages/core", + "ellmers-ai": "workspace:packages/ai", + "ellmers-ai-provider": "workspace:packages/ai-provider", + "ellmers-storage": "workspace:packages/storage" + } +} diff --git a/packages/test/src/index.ts b/packages/test/src/index.ts new file mode 100644 index 0000000..a0a4d19 --- /dev/null +++ b/packages/test/src/index.ts @@ -0,0 +1,32 @@ +import { getProviderRegistry, ModelProcessorEnum } from "ellmers-ai"; +import { registerHuggingfaceLocalTasks } from "ellmers-ai-provider/hf-transformers/browser"; +import { registerMediaPipeTfJsLocalTasks } from "ellmers-ai-provider/tf-mediapipe/browser"; +import { ConcurrencyLimiter, TaskInput, TaskOutput } from "ellmers-core"; +import { InMemoryJobQueue } from "ellmers-storage/inmemory"; + +export * from "./sample/MediaPipeModelSamples"; +export * from "./sample/ONNXModelSamples"; + +export async function registerHuggingfaceLocalTasksInMemory() { + registerHuggingfaceLocalTasks(); + const ProviderRegistry = getProviderRegistry(); + const jobQueue = new InMemoryJobQueue( + "local_hf", + new ConcurrencyLimiter(1, 10), + 10 + ); + ProviderRegistry.registerQueue(ModelProcessorEnum.LOCAL_ONNX_TRANSFORMERJS, jobQueue); + jobQueue.start(); +} + +export async function registerMediaPipeTfJsLocalInMemory() { + registerMediaPipeTfJsLocalTasks(); + const ProviderRegistry = getProviderRegistry(); + const jobQueue = new InMemoryJobQueue( + "local_media_pipe", + new ConcurrencyLimiter(1, 10), + 10 + ); + ProviderRegistry.registerQueue(ModelProcessorEnum.MEDIA_PIPE_TFJS_MODEL, jobQueue); + jobQueue.start(); +} diff --git a/packages/ai-provider/src/tf-mediapipe/model/MediaPipeModelSamples.ts b/packages/test/src/sample/MediaPipeModelSamples.ts similarity index 86% rename from packages/ai-provider/src/tf-mediapipe/model/MediaPipeModelSamples.ts rename to packages/test/src/sample/MediaPipeModelSamples.ts index 3e6e30a..6c04223 100644 --- a/packages/ai-provider/src/tf-mediapipe/model/MediaPipeModelSamples.ts +++ b/packages/test/src/sample/MediaPipeModelSamples.ts @@ -1,5 +1,5 @@ import { ModelUseCaseEnum } from "ellmers-ai"; -import { MediaPipeTfJsModel } from "./MediaPipeModel"; +import { MediaPipeTfJsModel } from "../../../ai-provider/src/tf-mediapipe/model/MediaPipeModel"; export const universal_sentence_encoder = new MediaPipeTfJsModel( "Universal Sentence Encoder", diff --git a/packages/ai-provider/src/hf-transformers/model/ONNXModelSamples.ts b/packages/test/src/sample/ONNXModelSamples.ts similarity index 92% rename from packages/ai-provider/src/hf-transformers/model/ONNXModelSamples.ts rename to packages/test/src/sample/ONNXModelSamples.ts index f57ccf0..3adc5b5 100644 --- a/packages/ai-provider/src/hf-transformers/model/ONNXModelSamples.ts +++ b/packages/test/src/sample/ONNXModelSamples.ts @@ -1,4 +1,4 @@ -import { DATA_TYPES, ONNXTransformerJsModel } from "./ONNXTransformerJsModel"; +import { DATA_TYPES, ONNXTransformerJsModel } from "ellmers-ai-provider/hf-transformers/browser"; import { ModelUseCaseEnum } from "ellmers-ai"; export const supabaseGteSmall = new ONNXTransformerJsModel( @@ -48,6 +48,12 @@ export const xenovaDistilbertMnli = new ONNXTransformerJsModel( "zero-shot-classification" ); +export const modernBertBase = new ONNXTransformerJsModel( + "answerdotai/ModernBERT-base", + [ModelUseCaseEnum.TEXT_CLASSIFICATION], + "fill-mask" +); + export const stentancetransformerMultiQaMpnetBaseDotV1 = new ONNXTransformerJsModel( "Xenova/multi-qa-mpnet-base-dot-v1", [ModelUseCaseEnum.TEXT_EMBEDDING], diff --git a/packages/test/src/util/db_sqlite.ts b/packages/test/src/util/db_sqlite.ts new file mode 100644 index 0000000..fa89038 --- /dev/null +++ b/packages/test/src/util/db_sqlite.ts @@ -0,0 +1,21 @@ +const wrapper = function () { + if (process["isBun"]) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require("bun:sqlite").Database; + } + + return require("better-sqlite3"); +}; + +const module = wrapper(); + +let db: any; + +export function getDatabase(name = ":memory:"): any { + if (!db) { + db = new module(name); + } + return db; +} + +export default module; diff --git a/packages/test/tsconfig.json b/packages/test/tsconfig.json new file mode 100644 index 0000000..ad55c7f --- /dev/null +++ b/packages/test/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "files": ["src/index.ts"], + "exclude": ["**/*.test.ts", "dist"], + "compilerOptions": { + "outDir": "./dist", + "baseUrl": "./src", + "rootDir": "./src", + "paths": { + "#/*": ["./src/*"], + "ellmers-core": ["../core/src"], + "ellmers-ai": ["../ai/src"], + "ellmers-ai-provider": ["../ai-provider/src"], + "ellmers-storage": ["../storage/src"] + } + }, + "references": [ + { "path": "../core" }, + { "path": "../ai" }, + { "path": "../ai-provider" }, + { "path": "../storage" } + ] +} From 01593dffcde7239868ca4a4bc84a59f9d1e55a08 Mon Sep 17 00:00:00 2001 From: Steven Roussey Date: Wed, 15 Jan 2025 21:21:00 -0800 Subject: [PATCH 3/3] refactor: big changeset to KVRepositories --- .../core/src/storage/base/KVRepository.ts | 134 +++++++++---- .../storage/taskgraph/TaskGraphRepository.ts | 21 ++- .../taskoutput/TaskOutputRepository.ts | 27 ++- .../IndexedDbTaskOutputRepository.ts | 11 +- .../indexeddb/base/IndexedDbKVRepository.ts | 50 +++-- .../browser/inmemory/InMemoryKVRepository.ts | 42 ----- .../inmemory/InMemoryTaskGraphRepository.ts | 8 +- .../inmemory/InMemoryTaskOutputRepository.ts | 25 ++- .../inmemory/base/InMemoryKVRepository.ts | 66 +++++++ .../storage/src/browser/inmemory/index.ts | 2 +- .../test/InMemoryKVRepository.test.ts | 73 ++++++++ .../test/InMemoryTaskGraphRepository.test.ts | 2 - .../bun/sqlite/SqliteTaskGraphRepository.ts | 6 +- .../bun/sqlite/SqliteTaskOutputRepository.ts | 21 ++- .../src/bun/sqlite/base/SqliteKVRepository.ts | 176 +++++++++++++----- .../sqlite/test/SqliteKVRepository.test.ts | 78 ++++++++ .../filesystem/FileTaskGraphRepository.ts | 6 +- .../filesystem/FileTaskOutputRepository.ts | 23 ++- .../node/filesystem/base/FileKVRepository.ts | 93 +++++---- .../filesystem/test/FileKVRepository.test.ts | 94 ++++++++++ .../postgres/PostgresTaskGraphRepository.ts | 9 +- .../postgres/PostgresTaskOutputRepository.ts | 21 ++- .../postgres/base/PostgresKVRepository.ts | 160 +++++++++++----- 23 files changed, 864 insertions(+), 284 deletions(-) delete mode 100644 packages/storage/src/browser/inmemory/InMemoryKVRepository.ts create mode 100644 packages/storage/src/browser/inmemory/base/InMemoryKVRepository.ts create mode 100644 packages/storage/src/browser/inmemory/test/InMemoryKVRepository.test.ts create mode 100644 packages/storage/src/bun/sqlite/test/SqliteKVRepository.test.ts create mode 100644 packages/storage/src/node/filesystem/test/FileKVRepository.test.ts diff --git a/packages/core/src/storage/base/KVRepository.ts b/packages/core/src/storage/base/KVRepository.ts index 3bc1ea6..e373251 100644 --- a/packages/core/src/storage/base/KVRepository.ts +++ b/packages/core/src/storage/base/KVRepository.ts @@ -6,15 +6,27 @@ // ******************************************************************************* import EventEmitter from "eventemitter3"; +import { makeFingerprint } from "../../util/Misc"; -export type KVEvents = "put" | "get" | "clear"; +export type KVEvents = "put" | "get" | "delete" | "clearall"; +export type BasicKeyType = string | number | bigint; +export type BasicValueType = string | number | bigint | boolean | null; -export type DiscriminatorSchema = Record; +export type BasePrimaryKeySchema = Record; +export type BaseValueSchema = Record; + +export type DefaultPrimaryKeyType = { "kv-key": string }; +export const DefaultPrimaryKeySchema: BasePrimaryKeySchema = { "kv-key": "string" } as const; + +export type DefaultValueType = { "kv-value": string }; +export const DefaultValueSchema: BaseValueSchema = { "kv-value": "string" } as const; export abstract class KVRepository< - Key, - Value, - Discriminators extends DiscriminatorSchema = DiscriminatorSchema, + Key extends Record = DefaultPrimaryKeyType, + Value extends Record = DefaultValueType, + PrimaryKeySchema extends BasePrimaryKeySchema = typeof DefaultPrimaryKeySchema, + ValueSchema extends BaseValueSchema = typeof DefaultValueSchema, + Combined extends Key & Value = Key & Value > { // KV repository event emitter private events = new EventEmitter(); @@ -37,45 +49,99 @@ export abstract class KVRepository< this.events.emit.call(this.events, name, ...args); } - // discriminators for KV repository store - protected discriminatorsSchema: Discriminators = {} as Discriminators; + protected primaryKeyIndex: string | undefined = undefined; + protected valueIndex: string | undefined = undefined; + constructor( + protected primaryKeySchema: PrimaryKeySchema = DefaultPrimaryKeySchema as PrimaryKeySchema, + protected valueSchema: ValueSchema = DefaultValueSchema as ValueSchema + ) { + this.primaryKeySchema = primaryKeySchema; + this.valueSchema = valueSchema; + if (Object.keys(primaryKeySchema).length === 1) { + this.primaryKeyIndex = String(this.primaryKeyColumns()[0]); + } + if (Object.keys(valueSchema).length === 1) { + this.valueIndex = String(this.valueColumns()[0]); + } + } // Abstract methods for KV repository store - abstract put(key: Key, value: Value): Promise; - abstract get(key: Key): Promise; - abstract clear(): Promise; + abstract putKeyValue(key: Key, value: Value): Promise; + abstract getKeyValue(key: Key): Promise; + abstract deleteKeyValue(key: Key | Combined): Promise; + abstract deleteAll(): Promise; abstract size(): Promise; - // Discriminator helper methods - protected primaryKeyColumnList(): string { - return this.primaryKeyColumns().join(", "); + public put(key: BasicKeyType | Key, value: Value | BasicValueType): Promise { + if (typeof key !== "object" && this.primaryKeyIndex) { + key = { [this.primaryKeyIndex]: key } as Key; + if (typeof value !== "object" && this.valueIndex) { + value = { [this.valueIndex]: value } as Value; + } + } + return this.putKeyValue(key as Key, value as Value); + } + + public async get(key: BasicKeyType | Key): Promise { + if (typeof key !== "object" && this.primaryKeyIndex) { + key = { [this.primaryKeyIndex]: key } as Key; + } + const value = await this.getKeyValue(key as Key); + if (typeof value !== "object") return value; + if (this.primaryKeyIndex && this.valueIndex) { + return value[this.valueIndex] as BasicValueType; + } + return value as Value; } - protected primaryKeyColumns(): string[] { - return Object.keys(this.discriminatorsSchema).concat("key"); + public async getCombined(key: Key): Promise { + const value = await this.getKeyValue(key); + if (typeof value !== "object") return undefined; + return Object.assign({}, key, value) as Combined; } - protected extractDiscriminators(keySimpleOrObject: any): { - discriminators: Record; - key: any; - } { - const discriminatorKeys = Object.keys(this.discriminatorsSchema); - const discriminators: DiscriminatorSchema = {}; - if (typeof keySimpleOrObject !== "object") { - return { discriminators, key: keySimpleOrObject }; + public delete(key: Key | BasicKeyType): Promise { + if (typeof key !== "object" && this.primaryKeyIndex) { + key = { [this.primaryKeyIndex]: key } as Key; } - let keyClone: any = { ...keySimpleOrObject }; - if (discriminatorKeys.length > 0) { - discriminatorKeys.forEach((k) => { - if (Object.prototype.hasOwnProperty.call(keyClone, k)) { - discriminators[k] = keyClone[k]; - delete keyClone[k]; - } - }); + return this.deleteKeyValue(key as Key); + } + + protected primaryKeyColumns(): Array { + return Object.keys(this.primaryKeySchema); + } + + protected valueColumns(): Array { + return Object.keys(this.valueSchema); + } + + protected separateKeyValueFromCombined(obj: Combined): { value: Value; key: Key } { + if (obj === null) { + console.warn("Key is null"); + return { value: {} as Value, key: {} as Key }; + } + if (typeof obj !== "object") { + console.warn("Object is not an object"); + return { value: {} as Value, key: {} as Key }; } - if (Object.keys(keyClone).length === 1) { - keyClone = keyClone[Object.keys(keyClone)[0]]; + const primaryKeyNames = this.primaryKeyColumns(); + const valueNames = this.valueColumns(); + const value: Partial = {}; + const key: Partial = {}; + for (const k of primaryKeyNames) { + key[k] = obj[k]; + } + for (const k of valueNames) { + value[k] = obj[k]; + } + + return { value: value as Value, key: key as Key }; + } + + protected async getKeyAsIdString(key: Key | BasicKeyType): Promise { + if (this.primaryKeyIndex && typeof key === "object") { + key = key[this.primaryKeyIndex]; } - return { discriminators, key: keyClone }; + return await makeFingerprint(key); } } diff --git a/packages/core/src/storage/taskgraph/TaskGraphRepository.ts b/packages/core/src/storage/taskgraph/TaskGraphRepository.ts index bcf7b6c..8fd5491 100644 --- a/packages/core/src/storage/taskgraph/TaskGraphRepository.ts +++ b/packages/core/src/storage/taskgraph/TaskGraphRepository.ts @@ -15,7 +15,7 @@ export type TaskGraphEvents = "graph_saved" | "graph_retrieved" | "graph_cleared export abstract class TaskGraphRepository { public type = "TaskGraphRepository"; - abstract kvRepository: KVRepository; + abstract kvRepository: KVRepository; private events = new EventEmitter(); on(name: TaskGraphEvents, fn: (...args: any[]) => void) { this.events.on.call(this.events, name, fn); @@ -69,26 +69,27 @@ export abstract class TaskGraphRepository { return subGraph; } - async saveTaskGraph(id: unknown, output: TaskGraph): Promise { - const jsonObj = output.toJSON(); - await this.kvRepository.put(id, jsonObj); - this.emit("graph_saved", id); + async saveTaskGraph(key: string, output: TaskGraph): Promise { + const value = JSON.stringify(output.toJSON()); + await this.kvRepository.put(key, value); + this.emit("graph_saved", key); } - async getTaskGraph(id: unknown): Promise { - const jsonObj = await this.kvRepository.get(id); - if (!jsonObj) { + async getTaskGraph(key: string): Promise { + const jsonStr = (await this.kvRepository.get(key)) as string; + if (!jsonStr) { return undefined; } + const jsonObj = JSON.parse(jsonStr); const graph = this.createSubGraph(jsonObj); - this.emit("graph_retrieved", id); + this.emit("graph_retrieved", key); return graph; } async clear(): Promise { - await this.kvRepository.clear(); + await this.kvRepository.deleteAll(); this.emit("graph_cleared"); } diff --git a/packages/core/src/storage/taskoutput/TaskOutputRepository.ts b/packages/core/src/storage/taskoutput/TaskOutputRepository.ts index a8a8686..97a087b 100644 --- a/packages/core/src/storage/taskoutput/TaskOutputRepository.ts +++ b/packages/core/src/storage/taskoutput/TaskOutputRepository.ts @@ -7,17 +7,27 @@ import EventEmitter from "eventemitter3"; import { TaskInput, TaskOutput } from "../../task/base/Task"; -import { KVRepository } from "../base/KVRepository"; +import { DefaultValueType, KVRepository } from "../base/KVRepository"; +import { makeFingerprint } from "../../util/Misc"; export type TaskOutputEvents = "output_saved" | "output_retrieved" | "output_cleared"; -export const TaskOutputDiscriminator = { +export type TaskOutputPrimaryKey = { + key: string; + taskType: string; +}; +export const TaskOutputPrimaryKeySchema = { + key: "string", taskType: "string", } as const; export abstract class TaskOutputRepository { public type = "TaskOutputRepository"; - abstract kvRepository: KVRepository; + abstract kvRepository: KVRepository< + TaskOutputPrimaryKey, + DefaultValueType, + typeof TaskOutputPrimaryKeySchema + >; private events = new EventEmitter(); on(name: TaskOutputEvents, fn: (...args: any[]) => void) { this.events.on.call(this.events, name, fn); @@ -30,18 +40,21 @@ export abstract class TaskOutputRepository { } async saveOutput(taskType: string, inputs: TaskInput, output: TaskOutput): Promise { - await this.kvRepository.put({ taskType, inputs }, output); + const key = await makeFingerprint(inputs); + const value = JSON.stringify(output); + await this.kvRepository.putKeyValue({ key, taskType }, { "kv-value": value }); this.emit("output_saved", taskType); } async getOutput(taskType: string, inputs: TaskInput): Promise { - const output = await this.kvRepository.get({ taskType, inputs }); + const key = await makeFingerprint(inputs); + const output = await this.kvRepository.getKeyValue({ key, taskType }); this.emit("output_retrieved", taskType); - return output as TaskOutput; + return output ? (JSON.parse(output["kv-value"]) as TaskOutput) : undefined; } async clear(): Promise { - await this.kvRepository.clear(); + await this.kvRepository.deleteAll(); this.emit("output_cleared"); } diff --git a/packages/storage/src/browser/indexeddb/IndexedDbTaskOutputRepository.ts b/packages/storage/src/browser/indexeddb/IndexedDbTaskOutputRepository.ts index 0f83e0d..f3b7b27 100644 --- a/packages/storage/src/browser/indexeddb/IndexedDbTaskOutputRepository.ts +++ b/packages/storage/src/browser/indexeddb/IndexedDbTaskOutputRepository.ts @@ -5,18 +5,23 @@ // * Licensed under the Apache License, Version 2.0 (the "License"); * // ******************************************************************************* -import { TaskInput, TaskOutput, TaskOutputDiscriminator, TaskOutputRepository } from "ellmers-core"; +import { + TaskInput, + TaskOutput, + TaskOutputPrimaryKeySchema, + TaskOutputRepository, +} from "ellmers-core"; import { IndexedDbKVRepository } from "./base/IndexedDbKVRepository"; export class IndexedDbTaskOutputRepository extends TaskOutputRepository { - kvRepository: IndexedDbKVRepository; + kvRepository: IndexedDbKVRepository; public type = "IndexedDbTaskOutputRepository" as const; constructor() { super(); this.kvRepository = new IndexedDbKVRepository< TaskInput, TaskOutput, - typeof TaskOutputDiscriminator + typeof TaskOutputPrimaryKeySchema >("task_outputs"); } } diff --git a/packages/storage/src/browser/indexeddb/base/IndexedDbKVRepository.ts b/packages/storage/src/browser/indexeddb/base/IndexedDbKVRepository.ts index 0895590..defcd84 100644 --- a/packages/storage/src/browser/indexeddb/base/IndexedDbKVRepository.ts +++ b/packages/storage/src/browser/indexeddb/base/IndexedDbKVRepository.ts @@ -5,7 +5,16 @@ // * Licensed under the Apache License, Version 2.0 (the "License"); * // ******************************************************************************* -import { DiscriminatorSchema, KVRepository } from "ellmers-core"; +import { + BaseValueSchema, + BasePrimaryKeySchema, + BasicKeyType, + DefaultValueType, + DefaultValueSchema, + DefaultPrimaryKeyType, + DefaultPrimaryKeySchema, + KVRepository, +} from "ellmers-core"; import { ensureIndexedDbTable } from "./IndexedDbTable"; import { makeFingerprint } from "../../../util/Misc"; @@ -13,10 +22,12 @@ import { makeFingerprint } from "../../../util/Misc"; // simple browser-based examples with no server-side component. It does not support di export class IndexedDbKVRepository< - Key = string, - Value = string, - Discriminator extends DiscriminatorSchema = DiscriminatorSchema -> extends KVRepository { + Key extends Record = DefaultPrimaryKeyType, + Value extends Record = DefaultValueType, + PrimaryKeySchema extends BasePrimaryKeySchema = typeof DefaultPrimaryKeySchema, + ValueSchema extends BaseValueSchema = typeof DefaultValueSchema, + Combined extends Key & Value = Key & Value +> extends KVRepository { private dbPromise: Promise; constructor(public table: string = "kv_store") { @@ -26,8 +37,8 @@ export class IndexedDbKVRepository< }); } - async put(key: Key, value: Value): Promise { - const id = typeof key === "object" ? await makeFingerprint(key) : String(key); + async putKeyValue(key: Key, value: Value): Promise { + const id = await makeFingerprint(key); const db = await this.dbPromise; return new Promise((resolve, reject) => { @@ -43,8 +54,8 @@ export class IndexedDbKVRepository< }); } - async get(key: Key): Promise { - const id = typeof key === "object" ? await makeFingerprint(key) : String(key); + async getKeyValue(key: Key): Promise { + const id = await makeFingerprint(key); const db = await this.dbPromise; return new Promise((resolve, reject) => { @@ -64,7 +75,24 @@ export class IndexedDbKVRepository< }); } - async clear(): Promise { + async deleteKeyValue(key: Key): Promise { + const id = await makeFingerprint(key); + const db = await this.dbPromise; + + return new Promise((resolve, reject) => { + const transaction = db.transaction(this.table, "readwrite"); + const store = transaction.objectStore(this.table); + const request = store.delete(id); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + this.emit("delete", id); + resolve(); + }; + }); + } + + async deleteAll(): Promise { const db = await this.dbPromise; return new Promise((resolve, reject) => { @@ -74,7 +102,7 @@ export class IndexedDbKVRepository< request.onerror = () => reject(request.error); request.onsuccess = () => { - this.emit("clear"); + this.emit("clearall"); resolve(); }; }); diff --git a/packages/storage/src/browser/inmemory/InMemoryKVRepository.ts b/packages/storage/src/browser/inmemory/InMemoryKVRepository.ts deleted file mode 100644 index 53078e6..0000000 --- a/packages/storage/src/browser/inmemory/InMemoryKVRepository.ts +++ /dev/null @@ -1,42 +0,0 @@ -// ******************************************************************************* -// * ELLMERS: Embedding Large Language Model Experiential Retrieval Service * -// * * -// * Copyright Steven Roussey * -// * Licensed under the Apache License, Version 2.0 (the "License"); * -// ******************************************************************************* - -import { DiscriminatorSchema, KVRepository } from "ellmers-core"; -import { makeFingerprint } from "../../util/Misc"; - -// InMemoryKVRepository is a simple in-memory key-value store that can be used for testing or as a cache -// It does not support discriminators - -export class InMemoryKVRepository< - Key = string, - Value = string, - Discriminator extends DiscriminatorSchema = DiscriminatorSchema -> extends KVRepository { - values = new Map(); - - async put(key: Key, value: Value): Promise { - const id = typeof key === "object" ? await makeFingerprint(key) : String(key); - this.values.set(id, value); - this.emit("put", id); - } - - async get(key: Key): Promise { - const id = typeof key === "object" ? await makeFingerprint(key) : String(key); - const out = this.values.get(id); - this.emit("get", id); - return out; - } - - async clear(): Promise { - this.values.clear(); - this.emit("clear"); - } - - async size(): Promise { - return this.values.size; - } -} diff --git a/packages/storage/src/browser/inmemory/InMemoryTaskGraphRepository.ts b/packages/storage/src/browser/inmemory/InMemoryTaskGraphRepository.ts index 92b7da9..84eb836 100644 --- a/packages/storage/src/browser/inmemory/InMemoryTaskGraphRepository.ts +++ b/packages/storage/src/browser/inmemory/InMemoryTaskGraphRepository.ts @@ -5,14 +5,14 @@ // * Licensed under the Apache License, Version 2.0 (the "License"); * // ******************************************************************************* -import { TaskGraphJson, TaskGraphRepository } from "ellmers-core"; -import { InMemoryKVRepository } from "./InMemoryKVRepository"; +import { TaskGraphRepository } from "ellmers-core"; +import { InMemoryKVRepository } from "./base/InMemoryKVRepository"; export class InMemoryTaskGraphRepository extends TaskGraphRepository { - kvRepository: InMemoryKVRepository; + kvRepository: InMemoryKVRepository; public type = "InMemoryTaskGraphRepository" as const; constructor() { super(); - this.kvRepository = new InMemoryKVRepository(); + this.kvRepository = new InMemoryKVRepository(); } } diff --git a/packages/storage/src/browser/inmemory/InMemoryTaskOutputRepository.ts b/packages/storage/src/browser/inmemory/InMemoryTaskOutputRepository.ts index 9f030f7..66b294a 100644 --- a/packages/storage/src/browser/inmemory/InMemoryTaskOutputRepository.ts +++ b/packages/storage/src/browser/inmemory/InMemoryTaskOutputRepository.ts @@ -5,18 +5,29 @@ // * Licensed under the Apache License, Version 2.0 (the "License"); * // ******************************************************************************* -import { TaskInput, TaskOutput, TaskOutputDiscriminator, TaskOutputRepository } from "ellmers-core"; -import { InMemoryKVRepository } from "./InMemoryKVRepository"; +import { + DefaultValueType, + TaskInput, + TaskOutput, + TaskOutputPrimaryKey, + TaskOutputPrimaryKeySchema, + TaskOutputRepository, +} from "ellmers-core"; +import { InMemoryKVRepository } from "./base/InMemoryKVRepository"; export class InMemoryTaskOutputRepository extends TaskOutputRepository { - kvRepository: InMemoryKVRepository; + kvRepository: InMemoryKVRepository< + TaskOutputPrimaryKey, + DefaultValueType, + typeof TaskOutputPrimaryKeySchema + >; public type = "InMemoryTaskOutputRepository" as const; constructor() { super(); this.kvRepository = new InMemoryKVRepository< - TaskInput, - TaskOutput, - typeof TaskOutputDiscriminator - >(); + TaskOutputPrimaryKey, + DefaultValueType, + typeof TaskOutputPrimaryKeySchema + >(TaskOutputPrimaryKeySchema); } } diff --git a/packages/storage/src/browser/inmemory/base/InMemoryKVRepository.ts b/packages/storage/src/browser/inmemory/base/InMemoryKVRepository.ts new file mode 100644 index 0000000..cb8f7c4 --- /dev/null +++ b/packages/storage/src/browser/inmemory/base/InMemoryKVRepository.ts @@ -0,0 +1,66 @@ +// ******************************************************************************* +// * ELLMERS: Embedding Large Language Model Experiential Retrieval Service * +// * * +// * Copyright Steven Roussey * +// * Licensed under the Apache License, Version 2.0 (the "License"); * +// ******************************************************************************* + +import { + BaseValueSchema, + BasePrimaryKeySchema, + BasicValueType, + BasicKeyType, + DefaultValueType, + DefaultValueSchema, + DefaultPrimaryKeyType, + DefaultPrimaryKeySchema, + KVRepository, +} from "ellmers-core"; +import { makeFingerprint } from "../../../util/Misc"; + +// InMemoryKVRepository is a simple in-memory key-value store that can be used for testing or as a cache + +export class InMemoryKVRepository< + Key extends Record = DefaultPrimaryKeyType, + Value extends Record = DefaultValueType, + PrimaryKeySchema extends BasePrimaryKeySchema = typeof DefaultPrimaryKeySchema, + ValueSchema extends BaseValueSchema = typeof DefaultValueSchema, + Combined extends Key & Value = Key & Value +> extends KVRepository { + values = new Map(); + + constructor( + primaryKeySchema: PrimaryKeySchema = DefaultPrimaryKeySchema as PrimaryKeySchema, + valueSchema: ValueSchema = DefaultValueSchema as ValueSchema + ) { + super(primaryKeySchema, valueSchema); + } + + async putKeyValue(key: Key, value: Value): Promise { + const id = await makeFingerprint(key); + this.values.set(id, value); + this.emit("put", id); + } + + async getKeyValue(key: Key): Promise { + const id = await makeFingerprint(key); + const out = this.values.get(id); + this.emit("get", id, out); + return out; + } + + async deleteKeyValue(key: Key): Promise { + const id = await makeFingerprint(key); + this.values.delete(id); + this.emit("delete", id); + } + + async deleteAll(): Promise { + this.values.clear(); + this.emit("clearall"); + } + + async size(): Promise { + return this.values.size; + } +} diff --git a/packages/storage/src/browser/inmemory/index.ts b/packages/storage/src/browser/inmemory/index.ts index 5e9a4fe..fe1fefc 100644 --- a/packages/storage/src/browser/inmemory/index.ts +++ b/packages/storage/src/browser/inmemory/index.ts @@ -1,4 +1,4 @@ -export * from "./InMemoryKVRepository"; +export * from "./base/InMemoryKVRepository"; export * from "./InMemoryTaskOutputRepository"; export * from "./InMemoryTaskGraphRepository"; export * from "./InMemoryJobQueue"; diff --git a/packages/storage/src/browser/inmemory/test/InMemoryKVRepository.test.ts b/packages/storage/src/browser/inmemory/test/InMemoryKVRepository.test.ts new file mode 100644 index 0000000..a9c6502 --- /dev/null +++ b/packages/storage/src/browser/inmemory/test/InMemoryKVRepository.test.ts @@ -0,0 +1,73 @@ +// ******************************************************************************* +// * ELLMERS: Embedding Large Language Model Experiential Retrieval Service * +// * * +// * Copyright Steven Roussey * +// * Licensed under the Apache License, Version 2.0 (the "License"); * +// ******************************************************************************* + +import { describe, expect, it, beforeEach } from "bun:test"; +import { InMemoryKVRepository } from "../base/InMemoryKVRepository"; +import { BaseValueSchema, BasePrimaryKeySchema } from "ellmers-core"; + +type PrimaryKey = { + name: string; + type: string; +}; +type Value = { + option: string; + success: boolean; +}; + +export const PrimaryKeySchema: BasePrimaryKeySchema = { name: "string", type: "string" } as const; +export const ValueSchema: BaseValueSchema = { option: "string", success: "boolean" } as const; + +describe("InMemoryKVRepository", () => { + describe("with default schemas (key and value)", () => { + let repository: InMemoryKVRepository; + + beforeEach(() => { + repository = new InMemoryKVRepository(); + }); + + it("should store and retrieve values for a key", async () => { + const key = "key"; + const value = "value"; + await repository.put(key, value); + const output = await repository.get(key); + + expect(output).toEqual(value); + }); + it("should get undefined for a key that doesn't exist", async () => { + const key = "key"; + const value = "value"; + await repository.put(key, value); + const output = await repository.get("not-a-key"); + + expect(output == undefined).toEqual(true); + }); + }); + + describe("with complex schemas", () => { + let repository: InMemoryKVRepository; + + beforeEach(() => { + repository = new InMemoryKVRepository(PrimaryKeySchema, ValueSchema); + }); + + it("should store and retrieve values for a key", async () => { + const key = { name: "key", type: "string" }; + const value = { option: "value", success: true }; + await repository.put(key, value); + const output = await repository.getKeyValue(key); + + expect(output?.option).toEqual("value"); + expect(!!output?.success).toEqual(true); // TODO need some conversion to boolean from 1 + }); + it("should get undefined for a key that doesn't exist", async () => { + const key = { name: "key", type: "string" }; + const output = await repository.get(key); + + expect(output == undefined).toEqual(true); + }); + }); +}); diff --git a/packages/storage/src/browser/inmemory/test/InMemoryTaskGraphRepository.test.ts b/packages/storage/src/browser/inmemory/test/InMemoryTaskGraphRepository.test.ts index c18b483..96c798d 100644 --- a/packages/storage/src/browser/inmemory/test/InMemoryTaskGraphRepository.test.ts +++ b/packages/storage/src/browser/inmemory/test/InMemoryTaskGraphRepository.test.ts @@ -6,7 +6,6 @@ // ******************************************************************************* import { describe, expect, it, beforeEach } from "bun:test"; -import { rmdirSync } from "fs"; import { SingleTask, TaskOutput, DataFlow, TaskGraph, TaskRegistry } from "ellmers-core"; import { InMemoryTaskGraphRepository } from "ellmers-storage/inmemory"; @@ -22,7 +21,6 @@ describe("FileTaskGraphRepository", () => { let repository: InMemoryTaskGraphRepository; beforeEach(() => { - rmdirSync(".cache/test/file-task-graph", { recursive: true }); repository = new InMemoryTaskGraphRepository(); }); diff --git a/packages/storage/src/bun/sqlite/SqliteTaskGraphRepository.ts b/packages/storage/src/bun/sqlite/SqliteTaskGraphRepository.ts index 6bf7c92..941880a 100644 --- a/packages/storage/src/bun/sqlite/SqliteTaskGraphRepository.ts +++ b/packages/storage/src/bun/sqlite/SqliteTaskGraphRepository.ts @@ -5,14 +5,14 @@ // * Licensed under the Apache License, Version 2.0 (the "License"); * // ******************************************************************************* -import { TaskGraphJson, TaskGraphRepository } from "ellmers-core"; +import { TaskGraphRepository } from "ellmers-core"; import { SqliteKVRepository } from "./base/SqliteKVRepository"; export class SqliteTaskGraphRepository extends TaskGraphRepository { - kvRepository: SqliteKVRepository; + kvRepository: SqliteKVRepository; public type = "SqliteTaskGraphRepository" as const; constructor(dbOrPath: string) { super(); - this.kvRepository = new SqliteKVRepository(dbOrPath, "task_graphs"); + this.kvRepository = new SqliteKVRepository(dbOrPath, "task_graphs"); } } diff --git a/packages/storage/src/bun/sqlite/SqliteTaskOutputRepository.ts b/packages/storage/src/bun/sqlite/SqliteTaskOutputRepository.ts index c8577ed..2ea177e 100644 --- a/packages/storage/src/bun/sqlite/SqliteTaskOutputRepository.ts +++ b/packages/storage/src/bun/sqlite/SqliteTaskOutputRepository.ts @@ -5,18 +5,27 @@ // * Licensed under the Apache License, Version 2.0 (the "License"); * // ******************************************************************************* -import { TaskOutputDiscriminator, TaskOutputRepository, TaskInput, TaskOutput } from "ellmers-core"; +import { + TaskOutputPrimaryKeySchema, + TaskOutputRepository, + TaskOutputPrimaryKey, + DefaultValueType, +} from "ellmers-core"; import { SqliteKVRepository } from "./base/SqliteKVRepository"; export class SqliteTaskOutputRepository extends TaskOutputRepository { - kvRepository: SqliteKVRepository; + kvRepository: SqliteKVRepository< + TaskOutputPrimaryKey, + DefaultValueType, + typeof TaskOutputPrimaryKeySchema + >; public type = "SqliteTaskOutputRepository" as const; constructor(dbOrPath: string) { super(); this.kvRepository = new SqliteKVRepository< - TaskInput, - TaskOutput, - typeof TaskOutputDiscriminator - >(dbOrPath, "task_outputs", TaskOutputDiscriminator); + TaskOutputPrimaryKey, + DefaultValueType, + typeof TaskOutputPrimaryKeySchema + >(dbOrPath, "task_outputs", TaskOutputPrimaryKeySchema); } } diff --git a/packages/storage/src/bun/sqlite/base/SqliteKVRepository.ts b/packages/storage/src/bun/sqlite/base/SqliteKVRepository.ts index 50a70a7..47257eb 100644 --- a/packages/storage/src/bun/sqlite/base/SqliteKVRepository.ts +++ b/packages/storage/src/bun/sqlite/base/SqliteKVRepository.ts @@ -6,55 +6,83 @@ // ******************************************************************************* import { Database } from "bun:sqlite"; -import { DiscriminatorSchema, KVRepository } from "ellmers-core"; -import { makeFingerprint } from "../../../util/Misc"; +import { + BaseValueSchema, + BasicKeyType, + BasePrimaryKeySchema, + DefaultValueType, + DefaultValueSchema, + DefaultPrimaryKeyType, + DefaultPrimaryKeySchema, + KVRepository, + BasicValueType, +} from "ellmers-core"; + +type SQLiteValueTypes = string | number | boolean | null; + // SqliteKVRepository is a key-value store that uses SQLite as the backend for -// in app data. It supports discriminators. +// in app data. export class SqliteKVRepository< - Key = string, - Value = string, - Discriminator extends DiscriminatorSchema = DiscriminatorSchema -> extends KVRepository { + Key extends Record = DefaultPrimaryKeyType, + Value extends Record = DefaultValueType, + PrimaryKeySchema extends BasePrimaryKeySchema = typeof DefaultPrimaryKeySchema, + ValueSchema extends BaseValueSchema = typeof DefaultValueSchema, + Combined extends Key & Value = Key & Value +> extends KVRepository { private db: Database; constructor( dbOrPath: string, public table: string = "kv_store", - discriminatorsSchema: Discriminator = {} as Discriminator + primaryKeySchema: PrimaryKeySchema = DefaultPrimaryKeySchema as PrimaryKeySchema, + valueSchema: ValueSchema = DefaultValueSchema as ValueSchema ) { - super(); + super(primaryKeySchema, valueSchema); if (typeof dbOrPath === "string") { this.db = new Database(dbOrPath); } else { this.db = dbOrPath; } - this.discriminatorsSchema = discriminatorsSchema; this.setupDatabase(); } - private setupDatabase(): void { - this.db.exec(` - CREATE TABLE IF NOT EXISTS ${this.table} ( - ${this.constructDiscriminatorColumns()} - key TEXT NOT NULL, - value TEXT NOT NULL, + public setupDatabase(): void { + const sql = ` + CREATE TABLE IF NOT EXISTS \`${this.table}\` ( + ${this.constructPrimaryKeyColumns()}, + ${this.constructValueColumns()}, PRIMARY KEY (${this.primaryKeyColumnList()}) ) - `); + `; + this.db.exec(sql); } - private constructDiscriminatorColumns(): string { - const cols = Object.entries(this.discriminatorsSchema) + private constructPrimaryKeyColumns(): string { + const cols = Object.entries(this.primaryKeySchema) .map(([key, type]) => { // Convert the provided type to a SQL type, assuming simple mappings; adjust as necessary const sqlType = this.mapTypeToSQL(type); - return `${key} ${sqlType} NOT NULL`; + return `\`${key}\` ${sqlType} NOT NULL`; }) .join(", "); - if (cols.length > 0) { - return `${cols}, `; - } - return ""; + return cols; + } + + private constructValueColumns(): string { + const cols = Object.entries(this.valueSchema) + .map(([key, type]) => { + const sqlType = this.mapTypeToSQL(type); + return `\`${key}\` ${sqlType} NULL`; + }) + .join(", "); + return cols; + } + + protected primaryKeyColumnList(): string { + return "`" + this.primaryKeyColumns().join("`, `") + "`"; + } + protected valueColumnList(): string { + return "`" + this.valueColumns().join("`, `") + "`"; } private mapTypeToSQL(type: string): string { @@ -70,44 +98,92 @@ export class SqliteKVRepository< } } - async put(keySimpleOrObject: Key, value: Value): Promise { - const { discriminators, key } = this.extractDiscriminators(keySimpleOrObject); - const id = typeof key === "object" ? await makeFingerprint(key) : String(key); - const stmt = this.db.prepare(` - INSERT OR REPLACE INTO ${this.table} (${this.primaryKeyColumnList()}, value) - VALUES (${this.primaryKeyColumns().map((i) => "?")}, ?) - `); - const values = Object.values(discriminators).concat(id, JSON.stringify(value)); - stmt.run(...values); - this.emit("put", id, discriminators); + // JS objects are not ordered, so we need to convert them to an ordered array + // so that we can use them as parameters in a SQL query + // we will order base on the valueSchema + getValueAsOrderedArray(value: Value): BasicValueType[] { + const orderedParams: BasicValueType[] = []; + // Iterate through valueSchema to maintain consistent order + for (const [key, type] of Object.entries(this.valueSchema)) { + if (key in value) { + orderedParams.push(value[key]); + } else { + throw new Error(`Missing required value field: ${key}`); + } + } + return orderedParams; } - async get(keySimpleOrObject: Key): Promise { - const { discriminators, key } = this.extractDiscriminators(keySimpleOrObject); - const id = typeof key === "object" ? await makeFingerprint(key) : String(key); + // JS objects are not ordered, so we need to convert them to an ordered array + // so that we can use them as parameters in a SQL query + // we will order base on the primaryKeySchema + getPrimaryKeyAsOrderedArray(key: Key): BasicKeyType[] { + const orderedParams: BasicKeyType[] = []; + // Iterate through primaryKeySchema to maintain consistent order + for (const [k, type] of Object.entries(this.primaryKeySchema)) { + if (k in key) { + orderedParams.push(key[k]); + } else { + throw new Error(`Missing required primary key field: ${k}`); + } + } + return orderedParams; + } - const whereClauses = this.primaryKeyColumns() - .map((discriminatorKey) => `${discriminatorKey} = ?`) - .join(" AND "); + async putKeyValue(key: Key, value: Value): Promise { + const sql = ` + INSERT OR REPLACE INTO ${ + this.table + } (${this.primaryKeyColumnList()}, ${this.valueColumnList()}) + VALUES ( + ${this.primaryKeyColumns().map((i) => "?")}, + ${this.valueColumns().map((i) => "?")} + ) + `; + const stmt = this.db.prepare(sql); - const stmt = this.db.prepare<{ value: string }, [key: string]>(` - SELECT value FROM ${this.table} WHERE ${whereClauses} - `); + const primaryKeyParams = this.getPrimaryKeyAsOrderedArray(key); + const valueParams = this.getValueAsOrderedArray(value); + const params = [...primaryKeyParams, ...valueParams]; - const values = Object.values(discriminators).concat(id); + const result = stmt.run(...params); - const row = stmt.get(...(values as [string])) as { value: string } | undefined; - if (row) { - this.emit("get", id, discriminators); - return JSON.parse(row.value) as Value; + this.emit("put", key); + } + + async getKeyValue(key: Key): Promise { + const whereClauses = (this.primaryKeyColumns() as string[]) + .map((key) => `\`${key}\` = ?`) + .join(" AND "); + + const sql = ` + SELECT ${this.valueColumnList()} FROM ${this.table} WHERE ${whereClauses} + `; + // const sql = `SELECT * FROM ${this.table} `; + const stmt = this.db.prepare(sql); + const params = this.getPrimaryKeyAsOrderedArray(key); + const value = stmt.get(...params); + if (value) { + this.emit("get", key, value); + return value; } else { return undefined; } } - async clear(): Promise { + async deleteKeyValue(key: Key): Promise { + const whereClauses = (this.primaryKeyColumns() as string[]) + .map((key) => `${key} = ?`) + .join(" AND "); + const params = this.getPrimaryKeyAsOrderedArray(key); + const stmt = this.db.prepare(`DELETE FROM ${this.table} WHERE ${whereClauses}`); + stmt.run(...params); + this.emit("delete", key); + } + + async deleteAll(): Promise { this.db.exec(`DELETE FROM ${this.table}`); - this.emit("clear"); + this.emit("clearall"); } async size(): Promise { diff --git a/packages/storage/src/bun/sqlite/test/SqliteKVRepository.test.ts b/packages/storage/src/bun/sqlite/test/SqliteKVRepository.test.ts new file mode 100644 index 0000000..ca6d397 --- /dev/null +++ b/packages/storage/src/bun/sqlite/test/SqliteKVRepository.test.ts @@ -0,0 +1,78 @@ +// ******************************************************************************* +// * ELLMERS: Embedding Large Language Model Experiential Retrieval Service * +// * * +// * Copyright Steven Roussey * +// * Licensed under the Apache License, Version 2.0 (the "License"); * +// ******************************************************************************* + +import { describe, expect, it, beforeEach } from "bun:test"; +import { SqliteKVRepository } from "../base/SqliteKVRepository"; +import { BaseValueSchema, BasePrimaryKeySchema } from "ellmers-core"; + +type PrimaryKey = { + name: string; + type: string; +}; +type Value = { + option: string; + success: boolean; +}; + +export const PrimaryKeySchema: BasePrimaryKeySchema = { name: "string", type: "string" } as const; +export const ValueSchema: BaseValueSchema = { option: "string", success: "boolean" } as const; + +describe("SqliteKVRepository", () => { + describe("with default schemas (key and value)", () => { + let repository: SqliteKVRepository; + + beforeEach(() => { + repository = new SqliteKVRepository(":memory:"); + }); + + it("should store and retrieve values for a key", async () => { + const key = "key"; + const value = "value"; + await repository.put(key, value); + const output = await repository.get(key); + + expect(output).toEqual(value); + }); + it("should get undefined for a key that doesn't exist", async () => { + const key = "key"; + const value = "value"; + await repository.put(key, value); + const output = await repository.get("not-a-key"); + + expect(output == undefined).toEqual(true); + }); + }); + + describe("with complex schemas", () => { + let repository: SqliteKVRepository; + + beforeEach(() => { + repository = new SqliteKVRepository( + ":memory:", + "complex_store", + PrimaryKeySchema, + ValueSchema + ); + }); + + it("should store and retrieve values for a key", async () => { + const key = { name: "key", type: "string" }; + const value = { option: "value", success: true }; + await repository.putKeyValue(key, value); + const output = await repository.getKeyValue(key); + + expect(output?.option).toEqual("value"); + expect(!!output?.success).toEqual(true); // TODO need some conversion to boolean from 1 + }); + it("should get undefined for a key that doesn't exist", async () => { + const key = { name: "key", type: "string" }; + const output = await repository.get(key); + + expect(output == undefined).toEqual(true); + }); + }); +}); diff --git a/packages/storage/src/node/filesystem/FileTaskGraphRepository.ts b/packages/storage/src/node/filesystem/FileTaskGraphRepository.ts index 5dac80b..378974d 100644 --- a/packages/storage/src/node/filesystem/FileTaskGraphRepository.ts +++ b/packages/storage/src/node/filesystem/FileTaskGraphRepository.ts @@ -5,14 +5,14 @@ // * Licensed under the Apache License, Version 2.0 (the "License"); * // ******************************************************************************* -import { TaskGraphJson, TaskGraphRepository } from "ellmers-core"; +import { TaskGraphRepository } from "ellmers-core"; import { FileKVRepository } from "./base/FileKVRepository"; export class FileTaskGraphRepository extends TaskGraphRepository { - kvRepository: FileKVRepository; + kvRepository: FileKVRepository; public type = "FileTaskGraphRepository" as const; constructor(folderPath: string) { super(); - this.kvRepository = new FileKVRepository(folderPath); + this.kvRepository = new FileKVRepository(folderPath); } } diff --git a/packages/storage/src/node/filesystem/FileTaskOutputRepository.ts b/packages/storage/src/node/filesystem/FileTaskOutputRepository.ts index 054fc5b..af762ae 100644 --- a/packages/storage/src/node/filesystem/FileTaskOutputRepository.ts +++ b/packages/storage/src/node/filesystem/FileTaskOutputRepository.ts @@ -5,17 +5,28 @@ // * Licensed under the Apache License, Version 2.0 (the "License"); * // ******************************************************************************* -import { TaskInput, TaskOutput, TaskOutputDiscriminator, TaskOutputRepository } from "ellmers-core"; +import { + DefaultValueType, + TaskInput, + TaskOutputPrimaryKey, + TaskOutputPrimaryKeySchema, + TaskOutputRepository, +} from "ellmers-core"; import { FileKVRepository } from "./base/FileKVRepository"; export class FileTaskOutputRepository extends TaskOutputRepository { - kvRepository: FileKVRepository; + kvRepository: FileKVRepository< + TaskOutputPrimaryKey, + DefaultValueType, + typeof TaskOutputPrimaryKeySchema + >; public type = "FileTaskOutputRepository" as const; constructor(folderPath: string) { super(); - this.kvRepository = new FileKVRepository( - folderPath, - TaskOutputDiscriminator - ); + this.kvRepository = new FileKVRepository< + TaskOutputPrimaryKey, + DefaultValueType, + typeof TaskOutputPrimaryKeySchema + >(folderPath, TaskOutputPrimaryKeySchema); } } diff --git a/packages/storage/src/node/filesystem/base/FileKVRepository.ts b/packages/storage/src/node/filesystem/base/FileKVRepository.ts index a0be385..fd7747a 100644 --- a/packages/storage/src/node/filesystem/base/FileKVRepository.ts +++ b/packages/storage/src/node/filesystem/base/FileKVRepository.ts @@ -6,55 +6,79 @@ // ******************************************************************************* import path from "node:path"; -import { readFile, writeFile, unlink, mkdir } from "node:fs/promises"; -import { DiscriminatorSchema, KVRepository } from "ellmers-core"; -import { makeFingerprint } from "../../../util/Misc"; +import { readFile, writeFile, rm } from "node:fs/promises"; +import { mkdirSync } from "node:fs"; import { glob } from "glob"; +import { + BaseValueSchema, + BasePrimaryKeySchema, + BasicKeyType, + DefaultValueType, + DefaultValueSchema, + DefaultPrimaryKeyType, + DefaultPrimaryKeySchema, + KVRepository, +} from "ellmers-core"; // FileKVRepository is a key-value store that uses the file system as the backend for -// simple scenarios. It does support discriminators. +// simple scenarios. export class FileKVRepository< - Key = string, - Value = string, - Discriminator extends DiscriminatorSchema = DiscriminatorSchema -> extends KVRepository { + Key extends Record = DefaultPrimaryKeyType, + Value extends Record = DefaultValueType, + PrimaryKeySchema extends BasePrimaryKeySchema = typeof DefaultPrimaryKeySchema, + ValueSchema extends BaseValueSchema = typeof DefaultValueSchema, + Combined extends Key & Value = Key & Value +> extends KVRepository { private folderPath: string; - constructor(folderPath: string, discriminatorsSchema: Discriminator = {} as Discriminator) { - super(); - this.discriminatorsSchema = discriminatorsSchema; - this.folderPath = folderPath; - mkdir(this.folderPath, { recursive: true }); + constructor( + folderPath: string, + primaryKeySchema: PrimaryKeySchema = DefaultPrimaryKeySchema as PrimaryKeySchema, + valueSchema: ValueSchema = DefaultValueSchema as ValueSchema + ) { + super(primaryKeySchema, valueSchema); + this.folderPath = path.dirname(folderPath); + mkdirSync(this.folderPath, { recursive: true }); } - async put(keySimpleOrObject: Key, value: Value): Promise { - const { discriminators, key } = this.extractDiscriminators(keySimpleOrObject); - const id = typeof key === "object" ? await makeFingerprint(key) : String(key); - const filePath = await this.getFilePath(key, discriminators); - await writeFile(filePath, JSON.stringify(value)); + async putKeyValue(key: Key, value: Value): Promise { + const filePath = await this.getFilePath(key); + try { + await writeFile(filePath, JSON.stringify(value)); + } catch (error) { + console.error("Error writing file", filePath, error); + } this.emit("put", key); } - async get(keySimpleOrObject: Key): Promise { - const { discriminators, key } = this.extractDiscriminators(keySimpleOrObject); - const id = typeof key === "object" ? await makeFingerprint(key) : String(key); - const filePath = await this.getFilePath(key, discriminators); + async getKeyValue(key: Key): Promise { + const filePath = await this.getFilePath(key); try { const data = await readFile(filePath, "utf-8"); - this.emit("get", key); - return JSON.parse(data); + const value = JSON.parse(data) as Value; + this.emit("get", key, value); + return value; } catch (error) { + // console.info("Error getting file (may not exist)", filePath); return undefined; // File not found or read error } } - async clear(): Promise { + async deleteKeyValue(key: Key): Promise { + const filePath = await this.getFilePath(key); + try { + await rm(filePath); + } catch (error) { + // console.error("Error deleting file", filePath, error); + } + this.emit("delete", key); + } + + async deleteAll(): Promise { // Delete all files in the folder ending in .json - const globPattern = path.join(this.folderPath, "*.json"); - const filesToDelete = await glob(globPattern); - await Promise.all(filesToDelete.map((file) => unlink(file))); - this.emit("clear"); + await rm(this.folderPath, { recursive: true, force: true }); + this.emit("clearall"); } async size(): Promise { @@ -64,12 +88,9 @@ export class FileKVRepository< return files.length; } - private async getFilePath( - key: Key, - discriminators: Record - ): Promise { - const id = typeof key === "object" ? await makeFingerprint(key) : String(key); - const filename = Object.values(discriminators).concat(id).join("_"); - return path.join(this.folderPath, `${filename}.json`); + private async getFilePath(key: Key | BasicKeyType): Promise { + const filename = await this.getKeyAsIdString(key); + const fullPath = path.join(this.folderPath, `${filename}.json`); + return fullPath; } } diff --git a/packages/storage/src/node/filesystem/test/FileKVRepository.test.ts b/packages/storage/src/node/filesystem/test/FileKVRepository.test.ts new file mode 100644 index 0000000..a9dea4f --- /dev/null +++ b/packages/storage/src/node/filesystem/test/FileKVRepository.test.ts @@ -0,0 +1,94 @@ +// ******************************************************************************* +// * ELLMERS: Embedding Large Language Model Experiential Retrieval Service * +// * * +// * Copyright Steven Roussey * +// * Licensed under the Apache License, Version 2.0 (the "License"); * +// ******************************************************************************* + +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { rmdirSync } from "fs"; +import { FileKVRepository } from "../base/FileKVRepository"; +import { BaseValueSchema, BasePrimaryKeySchema } from "ellmers-core"; + +type PrimaryKey = { + name: string; + type: string; +}; +type Value = { + option: string; + success: boolean; +}; + +export const PrimaryKeySchema: BasePrimaryKeySchema = { name: "string", type: "string" } as const; +export const ValueSchema: BaseValueSchema = { option: "string", success: "boolean" } as const; + +const testDir = ".cache/test/testing"; + +describe("FileKVRepository", () => { + let repository: FileKVRepository; + rmdirSync(testDir, { recursive: true }); + + beforeEach(() => { + repository = new FileKVRepository(testDir, {}); + }); + afterEach(() => { + repository.deleteAll(); + }); + + describe("with default schemas (key and value)", () => { + let repository: FileKVRepository; + + beforeEach(() => { + repository = new FileKVRepository(testDir); + }); + + it("should store and retrieve values for a key", async () => { + const key = "key"; + const value = "value"; + await repository.put(key, value); + const output = await repository.get(key); + + expect(output).toEqual(value); + }); + it("should get undefined for a key that doesn't exist", async () => { + const key = "key"; + const value = "value"; + await repository.put(key, value); + const output = await repository.get("not-a-key"); + + expect(output == undefined).toEqual(true); + }); + }); + + describe("with complex schemas", () => { + let repository: FileKVRepository; + + beforeEach(async () => { + repository = new FileKVRepository(testDir, PrimaryKeySchema, ValueSchema); + }); + afterEach(async () => { + await repository.deleteAll(); + }); + + it("should store and retrieve values for a key", async () => { + const key = { name: "key", type: "string" }; + const value = { option: "value", success: true }; + await repository.put(key, value); + const output = await repository.getKeyValue(key); + + expect(output?.option).toEqual("value"); + expect(!!output?.success).toEqual(true); // TODO need some conversion to boolean from 1 + + await repository.delete(key); + + const output2 = await repository.get(key); + expect(output2 == undefined).toEqual(true); + }); + it("should get undefined for a key that doesn't exist", async () => { + const key = { name: "key-unknown", type: "string" }; + const output = await repository.get(key); + + expect(output == undefined).toEqual(true); + }); + }); +}); diff --git a/packages/storage/src/node/postgres/PostgresTaskGraphRepository.ts b/packages/storage/src/node/postgres/PostgresTaskGraphRepository.ts index f0e3ce7..855e44a 100644 --- a/packages/storage/src/node/postgres/PostgresTaskGraphRepository.ts +++ b/packages/storage/src/node/postgres/PostgresTaskGraphRepository.ts @@ -5,17 +5,14 @@ // * Licensed under the Apache License, Version 2.0 (the "License"); * // ******************************************************************************* -import { TaskGraphJson, TaskGraphRepository } from "ellmers-core"; +import { TaskGraphRepository } from "ellmers-core"; import { PostgresKVRepository } from "./base/PostgresKVRepository"; export class PostgresTaskGraphRepository extends TaskGraphRepository { - kvRepository: PostgresKVRepository; + kvRepository: PostgresKVRepository; public type = "PostgresTaskGraphRepository" as const; constructor(connectionString: string) { super(); - this.kvRepository = new PostgresKVRepository( - connectionString, - "task_graphs" - ); + this.kvRepository = new PostgresKVRepository(connectionString, "task_graphs"); } } diff --git a/packages/storage/src/node/postgres/PostgresTaskOutputRepository.ts b/packages/storage/src/node/postgres/PostgresTaskOutputRepository.ts index cb75b2c..e69e24b 100644 --- a/packages/storage/src/node/postgres/PostgresTaskOutputRepository.ts +++ b/packages/storage/src/node/postgres/PostgresTaskOutputRepository.ts @@ -5,18 +5,27 @@ // * Licensed under the Apache License, Version 2.0 (the "License"); * // ******************************************************************************* -import { TaskInput, TaskOutput, TaskOutputDiscriminator, TaskOutputRepository } from "ellmers-core"; +import { + DefaultValueType, + TaskOutputPrimaryKey, + TaskOutputPrimaryKeySchema, + TaskOutputRepository, +} from "ellmers-core"; import { PostgresKVRepository } from "./base/PostgresKVRepository"; export class PostgresTaskOutputRepository extends TaskOutputRepository { - kvRepository: PostgresKVRepository; + kvRepository: PostgresKVRepository< + TaskOutputPrimaryKey, + DefaultValueType, + typeof TaskOutputPrimaryKeySchema + >; public type = "PostgresTaskOutputRepository" as const; constructor(connectionString: string) { super(); this.kvRepository = new PostgresKVRepository< - TaskInput, - TaskOutput, - typeof TaskOutputDiscriminator - >(connectionString, "task_outputs", TaskOutputDiscriminator); + TaskOutputPrimaryKey, + DefaultValueType, + typeof TaskOutputPrimaryKeySchema + >(connectionString, "task_outputs", TaskOutputPrimaryKeySchema); } } diff --git a/packages/storage/src/node/postgres/base/PostgresKVRepository.ts b/packages/storage/src/node/postgres/base/PostgresKVRepository.ts index 3973cea..6baa1d6 100644 --- a/packages/storage/src/node/postgres/base/PostgresKVRepository.ts +++ b/packages/storage/src/node/postgres/base/PostgresKVRepository.ts @@ -6,53 +6,77 @@ // ******************************************************************************* import { Pool } from "pg"; -import { DiscriminatorSchema, KVRepository } from "ellmers-core"; -import { makeFingerprint } from "../../../util/Misc"; +import { + BaseValueSchema, + BasePrimaryKeySchema, + BasicKeyType, + BasicValueType, + DefaultValueSchema, + DefaultPrimaryKeySchema, + DefaultPrimaryKeyType, + DefaultValueType, + KVRepository, +} from "ellmers-core"; // PostgresKVRepository is a key-value store that uses PostgreSQL as the backend for // multi-user scenarios. It supports discriminators. export class PostgresKVRepository< - Key = string, - Value = string, - Discriminator extends DiscriminatorSchema = DiscriminatorSchema -> extends KVRepository { + Key extends Record = DefaultPrimaryKeyType, + Value extends Record = DefaultValueType, + PrimaryKeySchema extends BasePrimaryKeySchema = typeof DefaultPrimaryKeySchema, + ValueSchema extends BaseValueSchema = typeof DefaultValueSchema, + Combined extends Key & Value = Key & Value +> extends KVRepository { private pool: Pool; constructor( connectionString: string, public table: string = "kv_store", - discriminatorsSchema: Discriminator = {} as Discriminator + primaryKeySchema: PrimaryKeySchema = DefaultPrimaryKeySchema as PrimaryKeySchema, + valueSchema: ValueSchema = DefaultValueSchema as ValueSchema ) { - super(); - this.discriminatorsSchema = discriminatorsSchema; + super(primaryKeySchema, valueSchema); this.pool = new Pool({ connectionString }); - this.setupDatabase(table); + this.setupDatabase(); } - private async setupDatabase(table: string): Promise { + private async setupDatabase(): Promise { await this.pool.query(` - CREATE TABLE IF NOT EXISTS ${this.table} ( - ${this.constructDiscriminatorColumns()} - key TEXT NOT NULL, - value JSONB NOT NULL, + CREATE TABLE IF NOT EXISTS \`${this.table}\` ( + ${this.constructPrimaryKeyColumns()}, + ${this.constructValueColumns()}, PRIMARY KEY (${this.primaryKeyColumnList()}) ) `); } - private constructDiscriminatorColumns(): string { - const cols = Object.entries(this.discriminatorsSchema) + private constructPrimaryKeyColumns(): string { + const cols = Object.entries(this.primaryKeySchema) .map(([key, type]) => { // Convert the provided type to a SQL type, assuming simple mappings; adjust as necessary const sqlType = this.mapTypeToSQL(type); - return `${key} ${sqlType} NOT NULL`; + return `\`${key}\` ${sqlType} NOT NULL`; }) .join(", "); - if (cols.length > 0) { - return `${cols}, `; - } - return ""; + return cols; + } + + private constructValueColumns(): string { + const cols = Object.entries(this.valueSchema) + .map(([key, type]) => { + const sqlType = this.mapTypeToSQL(type); + return `\`${key}\` ${sqlType} NULL`; + }) + .join(", "); + return cols; + } + + protected primaryKeyColumnList(): string { + return "`" + this.primaryKeyColumns().join("`, `") + "`"; + } + protected valueColumnList(): string { + return "`" + this.valueColumns().join("`, `") + "`"; } private mapTypeToSQL(type: string): string { @@ -68,46 +92,88 @@ export class PostgresKVRepository< } } - async put(keySimpleOrObject: Key, value: Value): Promise { - const { discriminators, key } = this.extractDiscriminators(keySimpleOrObject); - const id = typeof key === "object" ? await makeFingerprint(key) : String(key); - const values = Object.values(discriminators).concat(id, JSON.stringify(value)); - await this.pool.query( - `INSERT INTO ${this.table} (${this.primaryKeyColumnList()}, value) - VALUES (${this.primaryKeyColumns().map((i) => "?")}, ?) - ON CONFLICT (key) DO UPDATE - SET value = EXCLUDED.value`, - values - ); - this.emit("put", id, discriminators); + // JS objects are not ordered, so we need to convert them to an ordered array + // so that we can use them as parameters in a SQL query + // we will order base on the valueSchema + getValueAsOrderedArray(value: Value): BasicValueType[] { + const orderedParams: BasicValueType[] = []; + // Iterate through valueSchema to maintain consistent order + for (const [key, type] of Object.entries(this.valueSchema)) { + orderedParams.push(value[key] ?? null); + } + return orderedParams; } - async get(keySimpleOrObject: Key): Promise { - const { discriminators, key } = this.extractDiscriminators(keySimpleOrObject); - const id = typeof key === "object" ? await makeFingerprint(key) : String(key); + // JS objects are not ordered, so we need to convert them to an ordered array + // so that we can use them as parameters in a SQL query + // we will order base on the primaryKeySchema + getPrimaryKeyAsOrderedArray(key: Key): BasicKeyType[] { + const orderedParams: BasicKeyType[] = []; + // Iterate through primaryKeySchema to maintain consistent order + for (const [k, type] of Object.entries(this.primaryKeySchema)) { + if (k in key) { + orderedParams.push(key[k]); + } else { + throw new Error(`Missing required primary key field: ${k}`); + } + } + return orderedParams; + } + + async putKeyValue(key: Key, value: Value): Promise { + const sql = ` + INSERT INTO \`${this.table}\` ( + ${this.primaryKeyColumnList()}, + ${this.valueColumnList()} + ) + VALUES ( + ${this.primaryKeyColumns().map((i) => "?")} + ) + ON CONFLICT (${this.primaryKeyColumnList()}) DO UPDATE + SET + ${(this.valueColumns() as string[]).map((col) => `\`${col}\` = EXCLUDED.\`${col}\``).join(", ")} + `; + + const primaryKeyParams = this.getPrimaryKeyAsOrderedArray(key); + const valueParams = this.getValueAsOrderedArray(value); + const params = [...primaryKeyParams, ...valueParams]; + await this.pool.query(sql, params); + this.emit("put", key); + } - const whereClauses = this.primaryKeyColumns() - .map((discriminatorKey, i) => `${discriminatorKey} = $${i + 1}`) + async getKeyValue(key: Key): Promise { + const whereClauses = (this.primaryKeyColumns() as string[]) + .map((discriminatorKey, i) => `\`${discriminatorKey}\` = $${i + 1}`) .join(" AND "); - const values = Object.values(discriminators).concat(id); + const params = this.getPrimaryKeyAsOrderedArray(key); const result = await this.pool.query( - `SELECT value FROM ${this.table} WHERE ${whereClauses}`, - values + `SELECT ${this.valueColumnList()} FROM \`${this.table}\` WHERE ${whereClauses}`, + params ); if (result.rows.length > 0) { - this.emit("get", id, discriminators); - return result.rows[0].value as Value; + this.emit("get", key); + return result.rows[0] as Value; } else { return undefined; } } - async clear(): Promise { - await this.pool.query(`DELETE FROM ${this.table}`); - this.emit("clear"); + async deleteKeyValue(key: Key): Promise { + const whereClauses = (this.primaryKeyColumns() as string[]) + .map((key, i) => `\`${key}\` = $${i + 1}`) + .join(" AND "); + + const params = this.getPrimaryKeyAsOrderedArray(key); + await this.pool.query(`DELETE FROM \`${this.table}\` WHERE ${whereClauses}`, params); + this.emit("delete", key); + } + + async deleteAll(): Promise { + await this.pool.query(`DELETE FROM \`${this.table}\``); + this.emit("clearall"); } async size(): Promise {