Skip to content

Commit 9b3b10a

Browse files
authored
feat(kv): add Entity class (#159)
* feat(cfw.kv): add `Entity` class * feaet: add Entity read/write/delete hooks
1 parent 42b8dc5 commit 9b3b10a

File tree

5 files changed

+356
-3
lines changed

5 files changed

+356
-3
lines changed

packages/worktop/src/cfw.kv.d.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Dict } from 'worktop/utils';
1+
import type { Dict, Promisable } from 'worktop/utils';
22

33
export namespace KV {
44
type Metadata = Dict<any>;
@@ -106,3 +106,47 @@ export function until<X extends string>(
106106
toMake: () => X,
107107
toSearch: (val: X) => Promise<unknown | false>
108108
): Promise<X>;
109+
110+
export declare class Entity {
111+
/**
112+
* The KV Namespace for this entity.
113+
*/
114+
readonly ns: KV.Namespace;
115+
116+
/**
117+
* Cache.Entity operations
118+
*/
119+
readonly cache: {
120+
get(key: string): Promise<Response|void>;
121+
put<T>(key: string, value: T|null, ttl: number): Promise<boolean>;
122+
delete(key: string): Promise<boolean>;
123+
};
124+
125+
/**
126+
* cache ttl (seconds)
127+
* @default 0
128+
*/
129+
ttl?: number;
130+
131+
/**
132+
* key prefix
133+
* @default ""
134+
*/
135+
prefix?: string;
136+
137+
constructor(binding: KV.Namespace);
138+
139+
onread?(key: string, value: unknown): Promisable<void>;
140+
onwrite?(key: string, value: unknown): Promisable<void>;
141+
ondelete?(key: string, value: unknown): Promisable<void>;
142+
143+
list(options?: KV.Options.List): Promise<string[]>;
144+
145+
get(key: string, type: 'text'): Promise<string|null>;
146+
get<T=unknown>(key: string, type?: 'json'): Promise<T|null>;
147+
get(key: string, type: 'arrayBuffer'): Promise<ArrayBuffer|null>;
148+
149+
put<T>(key: string, value: T|null): Promise<boolean>;
150+
151+
delete(key: string, format?: 'text' | 'json' | 'arrayBuffer'): Promise<boolean>;
152+
}

packages/worktop/src/cfw.kv.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,3 +339,51 @@ until('should loop until first `nullish` value', async () => {
339339
});
340340

341341
until.run();
342+
343+
// ---
344+
345+
const Entity = suite('Entity');
346+
347+
Entity('should be a function', () => {
348+
assert.type(KV.Entity, 'function');
349+
});
350+
351+
Entity('should return `Entity` interface', () => {
352+
let binding = Namespace();
353+
let thing = new KV.Entity(binding);
354+
355+
assert.type(thing.get, 'function');
356+
assert.type(thing.list, 'function');
357+
assert.type(thing.delete, 'function');
358+
assert.type(thing.put, 'function');
359+
360+
assert.type(thing.cache, 'object');
361+
assert.type(thing.cache.get, 'function');
362+
assert.type(thing.cache.delete, 'function');
363+
assert.type(thing.cache.put, 'function');
364+
365+
assert.type(thing.ttl, 'number');
366+
assert.is(thing.ns, binding);
367+
});
368+
369+
Entity('should include property defaults', () => {
370+
let binding = Namespace();
371+
let thing = new KV.Entity(binding);
372+
assert.is(thing.prefix, '');
373+
assert.is(thing.ttl, 0);
374+
});
375+
376+
Entity('should allow class extension', () => {
377+
class User extends KV.Entity {
378+
ttl = 3600;
379+
prefix = 'user';
380+
}
381+
382+
let binding = Namespace();
383+
let thing = new User(binding);
384+
385+
assert.is(thing.prefix, 'user');
386+
assert.is(thing.ttl, 3600);
387+
});
388+
389+
Entity.run();

packages/worktop/src/cfw.kv.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import type { KV, Options, Database as DB } from 'worktop/cfw.kv';
1+
import * as Cache from './internal/cfw.cache';
2+
import type { KV, Options, Database as DB, Entity as E } from 'worktop/cfw.kv';
3+
import type { Promisable } from 'worktop/utils';
24

35
export function Database<Models, I extends Record<keyof Models, string> = { [P in keyof Models]: string }>(binding: KV.Namespace): DB<Models, I> {
46
var $ = <K extends keyof I>(type: K, uid: I[K]) => `${type}__${uid}`;
@@ -77,3 +79,117 @@ export async function until<X extends string>(
7779
if (exists == null) return tmp;
7880
}
7981
}
82+
83+
export class Entity implements E {
84+
readonly ns: KV.Namespace;
85+
readonly cache: Cache.Entity;
86+
87+
prefix = '';
88+
ttl = 0;
89+
90+
onread?(key: string, value: unknown): Promisable<void>;
91+
onwrite?(key: string, value: unknown): Promisable<void>;
92+
ondelete?(key: string, value: unknown): Promisable<void>;
93+
94+
constructor(ns: KV.Namespace) {
95+
this.cache = new Cache.Entity;
96+
this.ns = ns;
97+
}
98+
99+
async list(options?: KV.Options.List): Promise<string[]> {
100+
options = options || {};
101+
let { limit, prefix='' } = options;
102+
103+
if (this.prefix) {
104+
options.prefix = prefix.startsWith(this.prefix) ? prefix : (this.prefix + prefix);
105+
}
106+
107+
if (limit) {
108+
options.limit = Math.min(1000, limit);
109+
}
110+
111+
let iter = list(this.ns, {
112+
...options,
113+
metadata: false,
114+
});
115+
116+
let output: string[] = [];
117+
118+
for await (let chunk of iter) {
119+
for (let i=0, len=this.prefix.length; i < chunk.keys.length; i++) {
120+
output.push((chunk.keys[i] as string).substring(len));
121+
if (limit && output.length === limit) return output;
122+
}
123+
if (chunk.done) break;
124+
}
125+
126+
return output;
127+
}
128+
129+
async get<T>(key: string, format: Exclude<KV.GetFormat, 'stream'> = 'json'): Promise<T|null> {
130+
if (this.prefix) key = this.prefix + key;
131+
132+
let value: T|null;
133+
let res = this.ttl && await this.cache.get(key);
134+
135+
if (res) {
136+
value = await res[format]();
137+
} else {
138+
// @ts-ignore - TODO fix overload types
139+
value = await this.ns.get<T>(key, format);
140+
if (this.ttl) await this.cache.put(key, value, this.ttl);
141+
}
142+
143+
if (this.onread) {
144+
await this.onread(key, value);
145+
}
146+
147+
return value;
148+
}
149+
150+
async put<T>(key: string, value: T|null): Promise<boolean> {
151+
if (this.prefix) key = this.prefix + key;
152+
153+
let input = Cache.normalize(value);
154+
let bool = await this.ns.put(key, input).then(
155+
() => true,
156+
() => false
157+
);
158+
159+
if (bool && this.ttl) {
160+
// allow cache to see `null` value
161+
let x = value == null ? null : input;
162+
bool = await this.cache.put(key, x, this.ttl);
163+
}
164+
165+
if (bool && this.onwrite) {
166+
await this.onwrite(key, value);
167+
}
168+
169+
return bool;
170+
}
171+
172+
async delete(key: string, format: Exclude<KV.GetFormat, 'stream'> = 'json'): Promise<boolean> {
173+
if (this.prefix) key = this.prefix + key;
174+
175+
let hasHook = typeof this.ondelete === 'function';
176+
177+
// @ts-ignore - TODO fix overload types
178+
let value = hasHook && await this.ns.get(key, format);
179+
180+
let bool = await this.ns.delete(key).then(
181+
() => true,
182+
() => false
183+
);
184+
185+
if (bool && this.ttl) {
186+
bool = await this.cache.delete(key);
187+
}
188+
189+
if (bool && hasHook) {
190+
await this.ondelete!(key, value);
191+
}
192+
193+
return bool;
194+
}
195+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { KV } from 'worktop/cfw.kv';
2+
3+
export function normalize(value: unknown): KV.Value {
4+
return (
5+
typeof value === 'string'
6+
|| value instanceof ArrayBuffer
7+
|| value instanceof ReadableStream
8+
) ? value : JSON.stringify(value);
9+
}
10+
11+
export class Entity {
12+
get(key: string): Promise<Response|void> {
13+
return caches.default.match(key);
14+
}
15+
16+
put<T>(key: string, value: T|null, ttl: number): Promise<boolean> {
17+
if (!ttl) return Promise.resolve(true);
18+
19+
let headers = { 'cache-control': `public,max-age=${ttl}` };
20+
let body = value == null ? null : normalize(value);
21+
let res = new Response(body, { headers });
22+
23+
return caches.default.put(key, res).then(
24+
() => true,
25+
() => false
26+
);
27+
}
28+
29+
delete(key: string): Promise<boolean> {
30+
return caches.default.delete(key);
31+
}
32+
}

packages/worktop/types/cfw.kv.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Database, list, paginate, until } from 'worktop/cfw.kv';
1+
import { Database, Entity, list, paginate, until } from 'worktop/cfw.kv';
22
import type { KV } from 'worktop/cfw.kv';
33

44
/**
@@ -137,3 +137,116 @@ async function storage() {
137137
let keys = await paginate(APPS, { page: 2, limit: 12, prefix: 'hello' });
138138
assert<string>(keys[0]);
139139
}
140+
141+
/**
142+
* Entity
143+
*/
144+
145+
let apps = new Entity(APPS);
146+
147+
assert<unknown>(
148+
await apps.get('abc123')
149+
);
150+
151+
assert<IApp|null>(
152+
await apps.get<IApp>('abc123')
153+
);
154+
155+
assert<IApp|null>(
156+
await apps.get<IApp>('abc123', 'json')
157+
);
158+
159+
assert<string|null>(
160+
await apps.get('abc123', 'text')
161+
);
162+
163+
assert<ArrayBuffer|null>(
164+
await apps.get('abc123', 'arrayBuffer')
165+
);
166+
167+
// @ts-expect-error - T w/ "text"
168+
await apps.get<number>('abc123', 'text');
169+
170+
// @ts-expect-error - T w/ "arrayBuffer"
171+
await apps.get<number>('abc123', 'arrayBuffer');
172+
173+
assert<boolean>(
174+
await apps.put('abc123', null)
175+
);
176+
177+
assert<boolean>(
178+
await apps.put('abc123', 'foobar')
179+
);
180+
181+
assert<boolean>(
182+
await apps.put<IApp>('abc123', {
183+
uid: toUID(),
184+
name: 'foobar'
185+
})
186+
);
187+
188+
assert<boolean>(
189+
await apps.put('abc123', new ArrayBuffer(1))
190+
);
191+
192+
assert<boolean>(
193+
await apps.delete('abc123')
194+
);
195+
196+
assert<number>(apps.ttl!);
197+
assert<string>(apps.prefix!);
198+
199+
// ---
200+
201+
class User extends Entity {
202+
ttl = 3600; // 1hr
203+
prefix = 'user';
204+
205+
async onread(key: string, value: IApp) {
206+
// do something with the value
207+
}
208+
}
209+
210+
declare let ns: KV.Namespace;
211+
let users = new User(ns);
212+
213+
assert<unknown>(
214+
await users.get('abc123')
215+
);
216+
217+
assert<string|null>(
218+
await users.get('abc123', 'text')
219+
);
220+
221+
assert<ArrayBuffer|null>(
222+
await users.get('abc123', 'arrayBuffer')
223+
);
224+
225+
// @ts-expect-error - T w/ "text"
226+
await users.get<number>('abc123', 'text');
227+
228+
// @ts-expect-error - T w/ "arrayBuffer"
229+
await users.get<number>('abc123', 'arrayBuffer');
230+
231+
assert<boolean>(
232+
await users.put('abc123', null)
233+
);
234+
235+
assert<boolean>(
236+
await users.put('abc123', 'foobar')
237+
);
238+
239+
assert<boolean>(
240+
await users.put<IApp>('abc123', {
241+
uid: toUID(),
242+
name: 'foobar'
243+
})
244+
);
245+
246+
assert<boolean>(
247+
await users.put('abc123', new ArrayBuffer(1))
248+
);
249+
250+
assert<boolean>(
251+
await users.delete('abc123')
252+
);

0 commit comments

Comments
 (0)