Skip to content

Commit a30e1ef

Browse files
authored
Merge pull request #506 from dahlia/kvstore-list
Make `KvStore.list()` method required instead of optional
2 parents 9208a1b + 83f17c3 commit a30e1ef

14 files changed

Lines changed: 108 additions & 131 deletions

File tree

CHANGES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ To be released.
9090
- Removed `@fedify/fedify/x/sveltekit` in favor of `@fedify/sveltekit`.
9191
- Removed `@fedify/fedify/x/fresh` (Fresh integration). [[#466]]
9292

93+
- The `KvStore.list()` method is now required instead of optional.
94+
This method was added as optional in version 1.10.0 to give existing
95+
implementations time to add support. All official `KvStore` implementations
96+
already support this method. [[#499], [#506]]
97+
9398
[#280]: https://github.com/fedify-dev/fedify/issues/280
9499
[#366]: https://github.com/fedify-dev/fedify/issues/366
95100
[#376]: https://github.com/fedify-dev/fedify/issues/376
@@ -102,6 +107,8 @@ To be released.
102107
[#451]: https://github.com/fedify-dev/fedify/pull/451
103108
[#391]: https://github.com/fedify-dev/fedify/pull/391
104109
[#466]: https://github.com/fedify-dev/fedify/issues/466
110+
[#499]: https://github.com/fedify-dev/fedify/issues/499
111+
[#506]: https://github.com/fedify-dev/fedify/pull/506
105112

106113
### @fedify/cli
107114

docs/manual/kv.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -403,8 +403,8 @@ If the provided implementations don't meet your needs, you can create a custom
403403
### Implement the `KvStore` interface
404404

405405
Create a class that implements the `KvStore` interface. The interface defines
406-
three methods: `~KvStore.get()`, `~KvStore.set()`, `~KvStore.delete()`, and
407-
optionally `~KvStore.cas()` and `~KvStore.list()`.
406+
four methods: `~KvStore.get()`, `~KvStore.set()`, `~KvStore.delete()`, and
407+
`~KvStore.list()`, and optionally `~KvStore.cas()`.
408408

409409
~~~~ typescript twoslash
410410
import type {
@@ -446,7 +446,7 @@ class MyCustomKvStore implements KvStore {
446446
}
447447

448448
async *list(prefix?: KvKey): AsyncIterable<KvStoreListEntry> {
449-
// Implement list logic if needed
449+
// Implement list logic
450450
}
451451
}
452452
~~~~
@@ -506,6 +506,7 @@ class MyCustomKvStore implements KvStore {
506506
options?: KvStoreSetOptions
507507
): Promise<void> { }
508508
async delete(key: KvKey): Promise<void> { }
509+
async *list(): AsyncIterable<{ key: KvKey; value: unknown }> { }
509510
// ---cut-before---
510511
async get<T = unknown>(key: KvKey): Promise<T | undefined> {
511512
const serializedKey = this.serializeKey(key);
@@ -555,6 +556,7 @@ class MyCustomKvStore implements KvStore {
555556
return undefined;
556557
}
557558
async delete(key: KvKey): Promise<void> { }
559+
async *list(): AsyncIterable<{ key: KvKey; value: unknown }> { }
558560
// ---cut-before---
559561
async set(
560562
key: KvKey,
@@ -611,6 +613,7 @@ class MyCustomKvStore implements KvStore {
611613
value: unknown,
612614
options?: KvStoreSetOptions
613615
): Promise<void> { }
616+
async *list(): AsyncIterable<{ key: KvKey; value: unknown }> { }
614617
// ---cut-before---
615618
async delete(key: KvKey): Promise<void> {
616619
const serializedKey = this.serializeKey(key);
@@ -667,6 +670,7 @@ class MyCustomKvStore implements KvStore {
667670
options?: KvStoreSetOptions
668671
): Promise<void> { }
669672
async delete(key: KvKey): Promise<void> { }
673+
async *list(): AsyncIterable<{ key: KvKey; value: unknown }> { }
670674
// ---cut-before---
671675
async cas(
672676
key: KvKey,
@@ -684,14 +688,16 @@ async cas(
684688
}
685689
~~~~
686690

687-
### Implement `~KvStore.list()` method (optional)
691+
### Implement `~KvStore.list()` method
688692

689693
*This API is available since Fedify 1.10.0.*
690694

691-
If your storage backend supports prefix scanning, you can implement the
692-
`~KvStore.list()` method. This method allows you to enumerate all entries
693-
whose keys start with a given prefix. This is useful for implementing
694-
batch operations or iterating over related entries.
695+
*Since Fedify 2.0.0, this method is required instead of optional.*
696+
697+
The `~KvStore.list()` method allows you to enumerate all entries whose keys
698+
start with a given prefix. This is useful for implementing batch operations
699+
or iterating over related entries. If your storage backend supports prefix
700+
scanning, you can use it to implement this method efficiently.
695701

696702
~~~~ typescript twoslash
697703
import type {
@@ -778,6 +784,8 @@ class MyCustomKvStore implements KvStore {
778784
}
779785
async delete(key: KvKey): Promise<void> {
780786
}
787+
async *list(): AsyncIterable<{ key: KvKey; value: unknown }> {
788+
}
781789
}
782790
// ---cut-before---
783791
import { createFederation } from "@fedify/fedify";

docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
},
6363
"scripts": {
6464
"dev": "cd ../ && pnpm run --filter '!{docs}' --filter='!./examples/**' -r build && cd docs/ && vitepress dev --host",
65-
"build": "cd ../ && pnpm run --filter '!{docs}' --filter '!./examples/**' -r build && cd docs/ && vitepress build",
65+
"build": "cd ../ && pnpm run --filter '!{docs}' --filter '!./examples/**' -r build && cd docs/ && NODE_OPTIONS='--max-old-space-size=8192' vitepress build",
6666
"preview": "cd ../ && pnpm run --filter '!{docs}' --filter '!./examples/**' -r build && cd docs/ && vitepress preview"
6767
}
6868
}

packages/cfworkers/test/kv.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe("WorkersKvStore", () => {
4949
await store.set(["list-other", "x"], "value-x");
5050

5151
const entries: { key: readonly unknown[]; value: unknown }[] = [];
52-
for await (const entry of store.list!(["list-prefix"])) {
52+
for await (const entry of store.list(["list-prefix"])) {
5353
entries.push({ key: entry.key, value: entry.value });
5454
}
5555

@@ -68,7 +68,7 @@ describe("WorkersKvStore", () => {
6868
await store.set(["single-b"], "value-b");
6969

7070
const entries: { key: readonly unknown[]; value: unknown }[] = [];
71-
for await (const entry of store.list!(["single-a"])) {
71+
for await (const entry of store.list(["single-a"])) {
7272
entries.push({ key: entry.key, value: entry.value });
7373
}
7474

@@ -85,7 +85,7 @@ describe("WorkersKvStore", () => {
8585
await store.set(["empty-test", "d", "e", "f"], "value-def");
8686

8787
const entries: { key: readonly unknown[]; value: unknown }[] = [];
88-
for await (const entry of store.list!()) {
88+
for await (const entry of store.list()) {
8989
// Only count our test entries
9090
if (
9191
Array.isArray(entry.key) && entry.key[0] === "empty-test"

packages/denokv/src/mod.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Deno.test("DenoKvStore", async (t) => {
4141
await store.set(["other", "x"], "value-x");
4242

4343
const entries: { key: Deno.KvKey; value: unknown }[] = [];
44-
for await (const entry of store.list!(["prefix"])) {
44+
for await (const entry of store.list(["prefix"])) {
4545
entries.push(entry);
4646
}
4747

@@ -63,7 +63,7 @@ Deno.test("DenoKvStore", async (t) => {
6363
await store.set(["b"], "value-b");
6464

6565
const entries: { key: Deno.KvKey; value: unknown }[] = [];
66-
for await (const entry of store.list!(["a"])) {
66+
for await (const entry of store.list(["a"])) {
6767
entries.push(entry);
6868
}
6969

@@ -85,7 +85,7 @@ Deno.test("DenoKvStore", async (t) => {
8585
await store.set(["exact", "child2"], "child2-value");
8686

8787
const entries: { key: Deno.KvKey; value: unknown }[] = [];
88-
for await (const entry of store.list!(["exact"])) {
88+
for await (const entry of store.list(["exact"])) {
8989
entries.push(entry);
9090
}
9191

@@ -118,7 +118,7 @@ Deno.test("DenoKvStore", async (t) => {
118118
await store.set(["d", "e", "f"], "value-def");
119119

120120
const entries: { key: Deno.KvKey; value: unknown }[] = [];
121-
for await (const entry of store.list!()) {
121+
for await (const entry of store.list()) {
122122
entries.push(entry);
123123
}
124124

packages/fedify/src/federation/kv.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,15 @@ test("MemoryKvStore", async (t) => {
4444
await store.set(["prefix"], "exact-match");
4545

4646
// Test: list with prefix
47-
const entries = await Array.fromAsync(store.list!(["prefix"]));
47+
const entries = await Array.fromAsync(store.list(["prefix"]));
4848
assertEquals(entries.length, 4); // prefix, prefix/a, prefix/b, prefix/nested/c
4949

5050
// Test: verify a value
5151
const entryA = entries.find((e) => e.key.length === 2 && e.key[1] === "a");
5252
assertEquals(entryA?.value, "value-a");
5353

5454
// Test: non-matching prefix returns empty
55-
const noMatch = await Array.fromAsync(store.list!(["nonexistent"]));
55+
const noMatch = await Array.fromAsync(store.list(["nonexistent"]));
5656
assertEquals(noMatch.length, 0);
5757

5858
// Cleanup
@@ -71,7 +71,7 @@ test("MemoryKvStore", async (t) => {
7171

7272
await new Promise((r) => setTimeout(r, 10));
7373

74-
const entries = await Array.fromAsync(store.list!(["expired"]));
74+
const entries = await Array.fromAsync(store.list(["expired"]));
7575

7676
assertEquals(entries.length, 1);
7777
assertEquals(entries[0].value, "valid-value");
@@ -89,7 +89,7 @@ test("MemoryKvStore", async (t) => {
8989
await store.set(["d", "e", "f"], "value-def");
9090

9191
// Test: empty prefix should return all entries
92-
const entries = await Array.fromAsync(store.list!());
92+
const entries = await Array.fromAsync(store.list());
9393

9494
assertEquals(entries.length, 3);
9595

packages/fedify/src/federation/kv.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,9 @@ export interface KvStore {
8787
* are returned.
8888
* @returns An async iterable of entries matching the prefix.
8989
* @since 1.10.0
90+
* @since 2.0.0 This method is now required instead of optional.
9091
*/
91-
list?: (prefix?: KvKey) => AsyncIterable<KvStoreListEntry>;
92+
list(prefix?: KvKey): AsyncIterable<KvStoreListEntry>;
9293
}
9394

9495
/**

packages/fedify/src/otel/exporter.test.ts

Lines changed: 16 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
import { assertEquals, assertThrows } from "@std/assert";
1+
import { assertEquals } from "@std/assert";
22
import type { HrTime, SpanContext, SpanStatus } from "@opentelemetry/api";
33
import { SpanKind, SpanStatusCode, TraceFlags } from "@opentelemetry/api";
44
import type { ReadableSpan, TimedEvent } from "@opentelemetry/sdk-trace-base";
5-
import { type KvKey, type KvStore, MemoryKvStore } from "../federation/kv.ts";
5+
import {
6+
type KvKey,
7+
type KvStore,
8+
type KvStoreListEntry,
9+
MemoryKvStore,
10+
} from "../federation/kv.ts";
611
import { test } from "../testing/mod.ts";
712
import { FedifySpanExporter } from "./exporter.ts";
813

@@ -86,40 +91,12 @@ function createActivitySentEvent(options: {
8691
}
8792

8893
test("FedifySpanExporter", async (t) => {
89-
await t.step(
90-
"constructor throws if KvStore has neither list() nor cas()",
91-
() => {
92-
const kv: KvStore = {
93-
get: () => Promise.resolve(undefined),
94-
set: () => Promise.resolve(),
95-
delete: () => Promise.resolve(),
96-
};
97-
98-
assertThrows(
99-
() => new FedifySpanExporter(kv),
100-
Error,
101-
"KvStore must support either list() or cas()",
102-
);
103-
},
104-
);
105-
10694
await t.step("constructor accepts KvStore with list()", () => {
10795
const kv = new MemoryKvStore();
10896
const exporter = new FedifySpanExporter(kv);
10997
assertEquals(exporter instanceof FedifySpanExporter, true);
11098
});
11199

112-
await t.step("constructor accepts KvStore with cas() only", () => {
113-
const kv: KvStore = {
114-
get: () => Promise.resolve(undefined),
115-
set: () => Promise.resolve(),
116-
delete: () => Promise.resolve(),
117-
cas: () => Promise.resolve(true),
118-
};
119-
const exporter = new FedifySpanExporter(kv);
120-
assertEquals(exporter instanceof FedifySpanExporter, true);
121-
});
122-
123100
await t.step("export() stores inbound activity from span event", async () => {
124101
const kv = new MemoryKvStore();
125102
const exporter = new FedifySpanExporter(kv);
@@ -369,7 +346,7 @@ test("FedifySpanExporter", async (t) => {
369346
await exporter.shutdown();
370347
});
371348

372-
await t.step("works with cas()-only KvStore", async () => {
349+
await t.step("works with custom KvStore implementation", async () => {
373350
const storedData: Record<string, unknown> = {};
374351

375352
const kv: KvStore = {
@@ -387,14 +364,15 @@ test("FedifySpanExporter", async (t) => {
387364
delete storedData[k];
388365
return Promise.resolve();
389366
},
390-
cas: (key: KvKey, expected: unknown, newValue: unknown) => {
391-
const k = JSON.stringify(key);
392-
const current = storedData[k];
393-
if (JSON.stringify(current) === JSON.stringify(expected)) {
394-
storedData[k] = newValue;
395-
return Promise.resolve(true);
367+
async *list(prefix?: KvKey): AsyncIterable<KvStoreListEntry> {
368+
for (const [encodedKey, value] of Object.entries(storedData)) {
369+
const key = JSON.parse(encodedKey) as KvKey;
370+
if (prefix != null) {
371+
if (key.length < prefix.length) continue;
372+
if (!prefix.every((p, i) => key[i] === p)) continue;
373+
}
374+
yield { key, value };
396375
}
397-
return Promise.resolve(false);
398376
},
399377
};
400378

0 commit comments

Comments
 (0)