pluginBlobStore.get used to walk the scope stack with one store.get per scope, short-circuiting on first hit. On a miss that's N round- trips against a persistent backend (sqlite, postgres) — real cost on the postgres deployment where round-trip latency dominates. Adds BlobStore.getMany(namespaces[], key) that returns a ReadonlyMap<namespace, value> of the hits. Backends implement it as WHERE namespace IN (...) AND key = ? — one round-trip instead of N. pluginBlobStore.get iterates its scope list in precedence order and picks the innermost match from the map. pluginBlobStore.has reuses getMany and reports size > 0; no callers of has() today so the full-value fetch isn't a concern. Adds blob.test.ts covering shadowing, fall-through, no-hit, and plugin-id namespacing.
Unified · Split
| @@ -0,0 +1,95 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | |||
| 4 | import { makeInMemoryBlobStore, pluginBlobStore } from "./blob"; | ||
| 5 | |||
| 6 | describe("pluginBlobStore", () => { | ||
| 7 | it.effect("get returns innermost scope's value when both scopes have one", () => | ||
| 8 | Effect.gen(function* () { | ||
| 9 | const store = makeInMemoryBlobStore(); | ||
| 10 | yield* store.put("inner/my-plugin", "k", "inner-value"); | ||
| 11 | yield* store.put("outer/my-plugin", "k", "outer-value"); | ||
| 12 | |||
| 13 | const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin"); | ||
| 14 | const value = yield* plugin.get("k"); | ||
| 15 | expect(value).toBe("inner-value"); | ||
| 16 | }), | ||
| 17 | ); | ||
| 18 | |||
| 19 | it.effect("get falls through to outer scope when inner is empty", () => | ||
| 20 | Effect.gen(function* () { | ||
| 21 | const store = makeInMemoryBlobStore(); | ||
| 22 | yield* store.put("outer/my-plugin", "k", "outer-value"); | ||
| 23 | |||
| 24 | const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin"); | ||
| 25 | const value = yield* plugin.get("k"); | ||
| 26 | expect(value).toBe("outer-value"); | ||
| 27 | }), | ||
| 28 | ); | ||
| 29 | |||
| 30 | it.effect("get returns null when no scope has the key", () => | ||
| 31 | Effect.gen(function* () { | ||
| 32 | const store = makeInMemoryBlobStore(); | ||
| 33 | const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin"); | ||
| 34 | const value = yield* plugin.get("k"); | ||
| 35 | expect(value).toBeNull(); | ||
| 36 | }), | ||
| 37 | ); | ||
| 38 | |||
| 39 | it.effect("has returns true when any scope has the key", () => | ||
| 40 | Effect.gen(function* () { | ||
| 41 | const store = makeInMemoryBlobStore(); | ||
| 42 | yield* store.put("outer/my-plugin", "k", "v"); | ||
| 43 | |||
| 44 | const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin"); | ||
| 45 | const found = yield* plugin.has("k"); | ||
| 46 | expect(found).toBe(true); | ||
| 47 | }), | ||
| 48 | ); | ||
| 49 | |||
| 50 | it.effect("has returns false when no scope has the key", () => | ||
| 51 | Effect.gen(function* () { | ||
| 52 | const store = makeInMemoryBlobStore(); | ||
| 53 | const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin"); | ||
| 54 | const found = yield* plugin.has("k"); | ||
| 55 | expect(found).toBe(false); | ||
| 56 | }), | ||
| 57 | ); | ||
| 58 | |||
| 59 | it.effect("namespaces are keyed by scope/pluginId — different plugins don't collide", () => | ||
| 60 | Effect.gen(function* () { | ||
| 61 | const store = makeInMemoryBlobStore(); | ||
| 62 | yield* store.put("inner/plugin-a", "k", "a-value"); | ||
| 63 | yield* store.put("inner/plugin-b", "k", "b-value"); | ||
| 64 | |||
| 65 | const pluginA = pluginBlobStore(store, ["inner"], "plugin-a"); | ||
| 66 | const pluginB = pluginBlobStore(store, ["inner"], "plugin-b"); | ||
| 67 | expect(yield* pluginA.get("k")).toBe("a-value"); | ||
| 68 | expect(yield* pluginB.get("k")).toBe("b-value"); | ||
| 69 | }), | ||
| 70 | ); | ||
| 71 | }); | ||
| 72 | |||
| 73 | describe("BlobStore.getMany", () => { | ||
| 74 | it.effect("returns hits keyed by namespace", () => | ||
| 75 | Effect.gen(function* () { | ||
| 76 | const store = makeInMemoryBlobStore(); | ||
| 77 | yield* store.put("ns-a", "k", "a"); | ||
| 78 | yield* store.put("ns-c", "k", "c"); | ||
| 79 | |||
| 80 | const hits = yield* store.getMany(["ns-a", "ns-b", "ns-c"], "k"); | ||
| 81 | expect(hits.size).toBe(2); | ||
| 82 | expect(hits.get("ns-a")).toBe("a"); | ||
| 83 | expect(hits.get("ns-b")).toBeUndefined(); | ||
| 84 | expect(hits.get("ns-c")).toBe("c"); | ||
| 85 | }), | ||
| 86 | ); | ||
| 87 | |||
| 88 | it.effect("empty namespaces returns empty map", () => | ||
| 89 | Effect.gen(function* () { | ||
| 90 | const store = makeInMemoryBlobStore(); | ||
| 91 | const hits = yield* store.getMany([], "k"); | ||
| 92 | expect(hits.size).toBe(0); | ||
| 93 | }), | ||
| 94 | ); | ||
| 95 | }); | ||
| @@ -25,6 +25,15 @@ export interface BlobStore { · 24 unmodified lines | |||
| 25 | namespace: string, | 25 | namespace: string, |
| 26 | key: string, | 26 | key: string, |
| 27 | ) => Effect.Effect<string | null, StorageError>; | 27 | ) => Effect.Effect<string | null, StorageError>; |
| 28 | /** Multi-namespace lookup for a single key. Backends issue one query | ||
| 29 | * (`WHERE namespace IN (...) AND key = ?`) and return the hits keyed | ||
| 30 | * by namespace — the caller applies its own precedence. Lets | ||
| 31 | * `pluginBlobStore` walk the scope stack in O(1) round-trips instead | ||
| 32 | * of one per scope. */ | ||
| 33 | readonly getMany: ( | ||
| 34 | namespaces: readonly string[], | ||
| 35 | key: string, | ||
| 36 | ) => Effect.Effect<ReadonlyMap<string, string>, StorageError>; | ||
| 28 | readonly put: ( | 37 | readonly put: ( |
| 29 | namespace: string, | 38 | namespace: string, |
| 30 | key: string, | 39 | key: string, |
| @@ -90,9 +99,12 @@ export const pluginBlobStore = ( · 59 unmodified lines | |||
| 90 | ): PluginBlobStore => ({ | 99 | ): PluginBlobStore => ({ |
| 91 | get: (key) => | 100 | get: (key) => |
| 92 | Effect.gen(function* () { | 101 | Effect.gen(function* () { |
| 93 | for (const scope of scopes) { | 102 | const namespaces = scopes.map((s) => nsFor(s, pluginId)); |
| 94 | const value = yield* store.get(nsFor(scope, pluginId), key); | 103 | const hits = yield* store.getMany(namespaces, key); |
| 95 | if (value !== null) return value; | 104 | if (hits.size === 0) return null; |
| 105 | for (const ns of namespaces) { | ||
| 106 | const v = hits.get(ns); | ||
| 107 | if (v !== undefined) return v; | ||
| 96 | } | 108 | } |
| 97 | return null; | 109 | return null; |
| 98 | }), | 110 | }), |
| @@ -105,13 +117,12 @@ export const pluginBlobStore = ( · 6 unmodified lines | |||
| 105 | store.delete(nsFor(options.scope, pluginId), key), | 117 | store.delete(nsFor(options.scope, pluginId), key), |
| 106 | ), | 118 | ), |
| 107 | has: (key) => | 119 | has: (key) => |
| 108 | Effect.gen(function* () { | 120 | store |
| 109 | for (const scope of scopes) { | 121 | .getMany( |
| 110 | const found = yield* store.has(nsFor(scope, pluginId), key); | 122 | scopes.map((s) => nsFor(s, pluginId)), |
| 111 | if (found) return true; | 123 | key, |
| 112 | } | 124 | ) |
| 113 | return false; | 125 | .pipe(Effect.map((hits) => hits.size > 0)), |
| 114 | }), | ||
| 115 | }); | 126 | }); |
| 116 | 127 | ||
| 117 | /** | 128 | /** |
| @@ -128,6 +139,15 @@ export const makeInMemoryBlobStore = (): BlobStore => { · 10 unmodified lines | |||
| 128 | const k = (ns: string, key: string) => `${ns}::${key}`; | 139 | const k = (ns: string, key: string) => `${ns}::${key}`; |
| 129 | return { | 140 | return { |
| 130 | get: (ns, key) => Effect.sync(() => store.get(k(ns, key)) ?? null), | 141 | get: (ns, key) => Effect.sync(() => store.get(k(ns, key)) ?? null), |
| 142 | getMany: (namespaces, key) => | ||
| 143 | Effect.sync(() => { | ||
| 144 | const hits = new Map<string, string>(); | ||
| 145 | for (const ns of namespaces) { | ||
| 146 | const v = store.get(k(ns, key)); | ||
| 147 | if (v !== undefined) hits.set(ns, v); | ||
| 148 | } | ||
| 149 | return hits; | ||
| 150 | }), | ||
| 131 | put: (ns, key, value) => | 151 | put: (ns, key, value) => |
| 132 | Effect.sync(() => { | 152 | Effect.sync(() => { |
| 133 | store.set(k(ns, key), value); | 153 | store.set(k(ns, key), value); |
| @@ -9,7 +9,7 @@ · 8 unmodified lines | |||
| 9 | // --------------------------------------------------------------------------- | 9 | // --------------------------------------------------------------------------- |
| 10 | 10 | ||
| 11 | import { Effect } from "effect"; | 11 | import { Effect } from "effect"; |
| 12 | import { and, eq, sql as drizzleSql } from "drizzle-orm"; | 12 | import { and, eq, inArray, sql as drizzleSql } from "drizzle-orm"; |
| 13 | import { primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; | 13 | import { primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; |
| 14 | 14 | ||
| 15 | import type { BlobStore } from "@executor/sdk"; | 15 | import type { BlobStore } from "@executor/sdk"; |
| @@ -63,6 +63,33 @@ export const makeSqliteBlobStore = ( · 47 unmodified lines | |||
| 63 | catch: wrapErr("get"), | 63 | catch: wrapErr("get"), |
| 64 | }).pipe(Effect.map((rows) => rows[0]?.value ?? null)), | 64 | }).pipe(Effect.map((rows) => rows[0]?.value ?? null)), |
| 65 | 65 | ||
| 66 | getMany: (namespaces, key) => | ||
| 67 | namespaces.length === 0 | ||
| 68 | ? Effect.succeed(new Map<string, string>()) | ||
| 69 | : Effect.try({ | ||
| 70 | try: () => | ||
| 71 | options.db | ||
| 72 | .select({ | ||
| 73 | namespace: blobTable.namespace, | ||
| 74 | value: blobTable.value, | ||
| 75 | }) | ||
| 76 | .from(blobTable) | ||
| 77 | .where( | ||
| 78 | and( | ||
| 79 | inArray(blobTable.namespace, [...namespaces]), | ||
| 80 | eq(blobTable.key, key), | ||
| 81 | ), | ||
| 82 | ) | ||
| 83 | .all() as ReadonlyArray<{ namespace: string; value: string }>, | ||
| 84 | catch: wrapErr("getMany"), | ||
| 85 | }).pipe( | ||
| 86 | Effect.map((rows) => { | ||
| 87 | const out = new Map<string, string>(); | ||
| 88 | for (const row of rows) out.set(row.namespace, row.value); | ||
| 89 | return out; | ||
| 90 | }), | ||
| 91 | ), | ||
| 92 | |||
| 66 | put: (namespace, key, value) => | 93 | put: (namespace, key, value) => |
| 67 | Effect.try({ | 94 | Effect.try({ |
| 68 | try: () => | 95 | try: () => |
| @@ -10,7 +10,7 @@ · 9 unmodified lines | |||
| 10 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 11 | 11 | ||
| 12 | import { Effect } from "effect"; | 12 | import { Effect } from "effect"; |
| 13 | import { and, eq } from "drizzle-orm"; | 13 | import { and, eq, inArray } from "drizzle-orm"; |
| 14 | import type { PgDatabase } from "drizzle-orm/pg-core"; | 14 | import type { PgDatabase } from "drizzle-orm/pg-core"; |
| 15 | import { pgTable, primaryKey, text } from "drizzle-orm/pg-core"; | 15 | import { pgTable, primaryKey, text } from "drizzle-orm/pg-core"; |
| 16 | 16 | ||
| @@ -64,6 +64,29 @@ export const makePostgresBlobStore = ( · 47 unmodified lines | |||
| 64 | }, | 64 | }, |
| 65 | catch: wrapErr("get"), | 65 | catch: wrapErr("get"), |
| 66 | }), | 66 | }), |
| 67 | getMany: (namespaces, key) => | ||
| 68 | namespaces.length === 0 | ||
| 69 | ? Effect.succeed(new Map<string, string>()) | ||
| 70 | : Effect.tryPromise({ | ||
| 71 | try: async () => { | ||
| 72 | const rows = await options.db | ||
| 73 | .select({ | ||
| 74 | namespace: blobTable.namespace, | ||
| 75 | value: blobTable.value, | ||
| 76 | }) | ||
| 77 | .from(blobTable) | ||
| 78 | .where( | ||
| 79 | and( | ||
| 80 | inArray(blobTable.namespace, [...namespaces]), | ||
| 81 | eq(blobTable.key, key), | ||
| 82 | ), | ||
| 83 | ); | ||
| 84 | const out = new Map<string, string>(); | ||
| 85 | for (const row of rows) out.set(row.namespace, row.value); | ||
| 86 | return out as ReadonlyMap<string, string>; | ||
| 87 | }, | ||
| 88 | catch: wrapErr("getMany"), | ||
| 89 | }), | ||
| 67 | put: (namespace, key, value) => | 90 | put: (namespace, key, value) => |
| 68 | Effect.tryPromise({ | 91 | Effect.tryPromise({ |
| 69 | try: async () => { | 92 | try: async () => { |