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 | 25 | namespace: string, |
| 26 | 26 | key: string, |
| 27 | 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 | 37 | readonly put: ( |
| 29 | 38 | namespace: string, |
| 30 | 39 | key: string, |
| @@ -90,9 +99,12 @@ export const pluginBlobStore = ( · 59 unmodified lines | ||
| 90 | 99 | ): PluginBlobStore => ({ |
| 91 | 100 | get: (key) => |
| 92 | 101 | Effect.gen(function* () { |
| 93 | − for (const scope of scopes) { | |
| 94 | − const value = yield* store.get(nsFor(scope, pluginId), key); | |
| 95 | − if (value !== null) return value; | |
| 102 | + const namespaces = scopes.map((s) => nsFor(s, pluginId)); | |
| 103 | + const hits = yield* store.getMany(namespaces, key); | |
| 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 | 109 | return null; |
| 98 | 110 | }), |
| @@ -105,13 +117,12 @@ export const pluginBlobStore = ( · 6 unmodified lines | ||
| 105 | 117 | store.delete(nsFor(options.scope, pluginId), key), |
| 106 | 118 | ), |
| 107 | 119 | has: (key) => |
| 108 | − Effect.gen(function* () { | |
| 109 | − for (const scope of scopes) { | |
| 110 | − const found = yield* store.has(nsFor(scope, pluginId), key); | |
| 111 | − if (found) return true; | |
| 112 | − } | |
| 113 | − return false; | |
| 114 | − }), | |
| 120 | + store | |
| 121 | + .getMany( | |
| 122 | + scopes.map((s) => nsFor(s, pluginId)), | |
| 123 | + key, | |
| 124 | + ) | |
| 125 | + .pipe(Effect.map((hits) => hits.size > 0)), | |
| 115 | 126 | }); |
| 116 | 127 | |
| 117 | 128 | /** |
| @@ -128,6 +139,15 @@ export const makeInMemoryBlobStore = (): BlobStore => { · 10 unmodified lines | ||
| 128 | 139 | const k = (ns: string, key: string) => `${ns}::${key}`; |
| 129 | 140 | return { |
| 130 | 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 | 151 | put: (ns, key, value) => |
| 132 | 152 | Effect.sync(() => { |
| 133 | 153 | store.set(k(ns, key), value); |
| @@ -9,7 +9,7 @@ · 8 unmodified lines | ||
| 9 | 9 | // --------------------------------------------------------------------------- |
| 10 | 10 | |
| 11 | 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 | 13 | import { primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; |
| 14 | 14 | |
| 15 | 15 | import type { BlobStore } from "@executor/sdk"; |
| @@ -63,6 +63,33 @@ export const makeSqliteBlobStore = ( · 47 unmodified lines | ||
| 63 | 63 | catch: wrapErr("get"), |
| 64 | 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 | 93 | put: (namespace, key, value) => |
| 67 | 94 | Effect.try({ |
| 68 | 95 | try: () => |
| @@ -10,7 +10,7 @@ · 9 unmodified lines | ||
| 10 | 10 | // --------------------------------------------------------------------------- |
| 11 | 11 | |
| 12 | 12 | import { Effect } from "effect"; |
| 13 | −import { and, eq } from "drizzle-orm"; | |
| 13 | +import { and, eq, inArray } from "drizzle-orm"; | |
| 14 | 14 | import type { PgDatabase } from "drizzle-orm/pg-core"; |
| 15 | 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 | 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 | 90 | put: (namespace, key, value) => |
| 68 | 91 | Effect.tryPromise({ |
| 69 | 92 | try: async () => { |