@flaghoist/adapter-cloudflare-kv 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,6 +41,9 @@ than showing up as broken rows, so nothing breaks, but you pay a read for each o
41
41
  Changing the prefix on a running deployment hides the flags written under the old one. They are
42
42
  still in KV, the adapter is just no longer looking there.
43
43
 
44
+ Webhook endpoints (if you use them) live in the same namespace, under a separate `webhook:` prefix
45
+ by default. Change it with `{ webhookPrefix: '...' }`.
46
+
44
47
  Worth knowing: KV is eventually consistent. A flag you just changed can take a few seconds to reach
45
48
  every edge location, which is fine for flags and would not be fine for a bank balance.
46
49
 
package/dist/index.cjs CHANGED
@@ -24,6 +24,8 @@ __export(index_exports, {
24
24
  });
25
25
  module.exports = __toCommonJS(index_exports);
26
26
  var import_core = require("@flaghoist/core");
27
+ var markAs = (kind) => ({ metadata: { flaghoist: kind } });
28
+ var isMarkedNonFlag = (metadata) => typeof metadata === "object" && metadata !== null && (metadata.flaghoist === "webhook" || metadata.flaghoist === "record");
27
29
  function safeParse(raw) {
28
30
  if (raw == null) return null;
29
31
  try {
@@ -32,8 +34,40 @@ function safeParse(raw) {
32
34
  return null;
33
35
  }
34
36
  }
37
+ function safeParseWebhook(raw) {
38
+ if (raw == null) return null;
39
+ try {
40
+ return JSON.parse(raw);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+ var wrapRecord = (value) => JSON.stringify({ flaghoistRecord: 1, value });
46
+ function unwrapRecord(raw) {
47
+ if (raw == null) return null;
48
+ try {
49
+ const parsed = JSON.parse(raw);
50
+ if (parsed === null || typeof parsed !== "object" || parsed.flaghoistRecord !== 1) return null;
51
+ return parsed.value ?? null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+ async function listAll(kv, prefix) {
57
+ const keys = [];
58
+ let cursor;
59
+ do {
60
+ const page = await kv.list({ prefix, cursor });
61
+ keys.push(...page.keys);
62
+ cursor = page.list_complete ? void 0 : page.cursor;
63
+ } while (cursor);
64
+ return keys;
65
+ }
35
66
  function cloudflareKV(kv, options = {}) {
36
67
  const prefix = options.prefix ?? "";
68
+ const whPrefix = options.webhookPrefix ?? "webhook:";
69
+ const recPrefix = options.recordPrefix ?? "record:";
70
+ const recordKey = (collection, id) => `${recPrefix}${collection}:${id}`;
37
71
  return {
38
72
  async get(key) {
39
73
  return safeParse(await kv.get(prefix + key, { type: "text" }));
@@ -45,20 +79,57 @@ function cloudflareKV(kv, options = {}) {
45
79
  await kv.delete(prefix + key);
46
80
  },
47
81
  async list() {
82
+ const keys = (await listAll(kv, prefix)).filter((k) => !isMarkedNonFlag(k.metadata));
83
+ const raws = await Promise.all(keys.map((k) => kv.get(k.name, { type: "text" })));
48
84
  const flags = [];
49
- let cursor;
50
- do {
51
- const page = await kv.list({ prefix, cursor });
52
- const raws = await Promise.all(
53
- page.keys.map((entry) => kv.get(entry.name, { type: "text" }))
54
- );
55
- for (const raw of raws) {
56
- const flag = safeParse(raw);
57
- if (flag) flags.push(flag);
58
- }
59
- cursor = page.list_complete ? void 0 : page.cursor;
60
- } while (cursor);
85
+ for (const raw of raws) {
86
+ const flag = safeParse(raw);
87
+ if (flag) flags.push(flag);
88
+ }
61
89
  return flags;
90
+ },
91
+ async putWebhook(id, webhook) {
92
+ await kv.put(whPrefix + id, JSON.stringify(webhook), markAs("webhook"));
93
+ },
94
+ async getWebhook(id) {
95
+ return safeParseWebhook(await kv.get(whPrefix + id, { type: "text" }));
96
+ },
97
+ async deleteWebhook(id) {
98
+ await kv.delete(whPrefix + id);
99
+ },
100
+ async listWebhooks() {
101
+ const keys = await listAll(kv, whPrefix);
102
+ const raws = await Promise.all(keys.map((k) => kv.get(k.name, { type: "text" })));
103
+ const hooks = [];
104
+ for (const raw of raws) {
105
+ const hook = safeParseWebhook(raw);
106
+ if (hook) hooks.push(hook);
107
+ }
108
+ return hooks;
109
+ },
110
+ async getRecord(collection, id) {
111
+ (0, import_core.assertRecordAddress)(collection, id);
112
+ return unwrapRecord(await kv.get(recordKey(collection, id), { type: "text" }));
113
+ },
114
+ async putRecord(collection, id, value) {
115
+ (0, import_core.assertRecordAddress)(collection, id);
116
+ await kv.put(recordKey(collection, id), wrapRecord(value), markAs("record"));
117
+ },
118
+ async deleteRecord(collection, id) {
119
+ (0, import_core.assertRecordAddress)(collection, id);
120
+ await kv.delete(recordKey(collection, id));
121
+ },
122
+ async listRecords(collection) {
123
+ (0, import_core.assertRecordAddress)(collection);
124
+ const keyPrefix = `${recPrefix}${collection}:`;
125
+ const keys = await listAll(kv, keyPrefix);
126
+ const raws = await Promise.all(keys.map((k) => kv.get(k.name, { type: "text" })));
127
+ const entries = [];
128
+ keys.forEach((k, i) => {
129
+ const value = unwrapRecord(raws[i] ?? null);
130
+ if (value !== null) entries.push({ id: k.name.slice(keyPrefix.length), value });
131
+ });
132
+ return entries;
62
133
  }
63
134
  };
64
135
  }
package/dist/index.d.cts CHANGED
@@ -9,7 +9,9 @@ interface KVNamespaceLike {
9
9
  get(key: string, options?: {
10
10
  type?: 'text';
11
11
  }): Promise<string | null>;
12
- put(key: string, value: string): Promise<void>;
12
+ put(key: string, value: string, options?: {
13
+ metadata?: unknown;
14
+ }): Promise<void>;
13
15
  delete(key: string): Promise<void>;
14
16
  list(options?: {
15
17
  prefix?: string;
@@ -18,6 +20,7 @@ interface KVNamespaceLike {
18
20
  }): Promise<{
19
21
  keys: Array<{
20
22
  name: string;
23
+ metadata?: unknown;
21
24
  }>;
22
25
  list_complete: boolean;
23
26
  cursor?: string;
@@ -36,6 +39,10 @@ interface CloudflareKVOptions {
36
39
  * @example cloudflareKV(env.FLAGS, { prefix: 'flag:' })
37
40
  */
38
41
  prefix?: string;
42
+ /** Key prefix for webhook entries. Default: `"webhook:"`. */
43
+ webhookPrefix?: string;
44
+ /** Key prefix for record-store entries, stored as `<prefix><collection>:<id>`. Default: `"record:"`. */
45
+ recordPrefix?: string;
39
46
  }
40
47
  /**
41
48
  * A StorageAdapter backed by Cloudflare Workers KV, the default Flaghoist storage backend.
package/dist/index.d.ts CHANGED
@@ -9,7 +9,9 @@ interface KVNamespaceLike {
9
9
  get(key: string, options?: {
10
10
  type?: 'text';
11
11
  }): Promise<string | null>;
12
- put(key: string, value: string): Promise<void>;
12
+ put(key: string, value: string, options?: {
13
+ metadata?: unknown;
14
+ }): Promise<void>;
13
15
  delete(key: string): Promise<void>;
14
16
  list(options?: {
15
17
  prefix?: string;
@@ -18,6 +20,7 @@ interface KVNamespaceLike {
18
20
  }): Promise<{
19
21
  keys: Array<{
20
22
  name: string;
23
+ metadata?: unknown;
21
24
  }>;
22
25
  list_complete: boolean;
23
26
  cursor?: string;
@@ -36,6 +39,10 @@ interface CloudflareKVOptions {
36
39
  * @example cloudflareKV(env.FLAGS, { prefix: 'flag:' })
37
40
  */
38
41
  prefix?: string;
42
+ /** Key prefix for webhook entries. Default: `"webhook:"`. */
43
+ webhookPrefix?: string;
44
+ /** Key prefix for record-store entries, stored as `<prefix><collection>:<id>`. Default: `"record:"`. */
45
+ recordPrefix?: string;
39
46
  }
40
47
  /**
41
48
  * A StorageAdapter backed by Cloudflare Workers KV, the default Flaghoist storage backend.
package/dist/index.js CHANGED
@@ -1,5 +1,10 @@
1
1
  // src/index.ts
2
- import { parseFlag } from "@flaghoist/core";
2
+ import {
3
+ assertRecordAddress,
4
+ parseFlag
5
+ } from "@flaghoist/core";
6
+ var markAs = (kind) => ({ metadata: { flaghoist: kind } });
7
+ var isMarkedNonFlag = (metadata) => typeof metadata === "object" && metadata !== null && (metadata.flaghoist === "webhook" || metadata.flaghoist === "record");
3
8
  function safeParse(raw) {
4
9
  if (raw == null) return null;
5
10
  try {
@@ -8,8 +13,40 @@ function safeParse(raw) {
8
13
  return null;
9
14
  }
10
15
  }
16
+ function safeParseWebhook(raw) {
17
+ if (raw == null) return null;
18
+ try {
19
+ return JSON.parse(raw);
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+ var wrapRecord = (value) => JSON.stringify({ flaghoistRecord: 1, value });
25
+ function unwrapRecord(raw) {
26
+ if (raw == null) return null;
27
+ try {
28
+ const parsed = JSON.parse(raw);
29
+ if (parsed === null || typeof parsed !== "object" || parsed.flaghoistRecord !== 1) return null;
30
+ return parsed.value ?? null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+ async function listAll(kv, prefix) {
36
+ const keys = [];
37
+ let cursor;
38
+ do {
39
+ const page = await kv.list({ prefix, cursor });
40
+ keys.push(...page.keys);
41
+ cursor = page.list_complete ? void 0 : page.cursor;
42
+ } while (cursor);
43
+ return keys;
44
+ }
11
45
  function cloudflareKV(kv, options = {}) {
12
46
  const prefix = options.prefix ?? "";
47
+ const whPrefix = options.webhookPrefix ?? "webhook:";
48
+ const recPrefix = options.recordPrefix ?? "record:";
49
+ const recordKey = (collection, id) => `${recPrefix}${collection}:${id}`;
13
50
  return {
14
51
  async get(key) {
15
52
  return safeParse(await kv.get(prefix + key, { type: "text" }));
@@ -21,20 +58,57 @@ function cloudflareKV(kv, options = {}) {
21
58
  await kv.delete(prefix + key);
22
59
  },
23
60
  async list() {
61
+ const keys = (await listAll(kv, prefix)).filter((k) => !isMarkedNonFlag(k.metadata));
62
+ const raws = await Promise.all(keys.map((k) => kv.get(k.name, { type: "text" })));
24
63
  const flags = [];
25
- let cursor;
26
- do {
27
- const page = await kv.list({ prefix, cursor });
28
- const raws = await Promise.all(
29
- page.keys.map((entry) => kv.get(entry.name, { type: "text" }))
30
- );
31
- for (const raw of raws) {
32
- const flag = safeParse(raw);
33
- if (flag) flags.push(flag);
34
- }
35
- cursor = page.list_complete ? void 0 : page.cursor;
36
- } while (cursor);
64
+ for (const raw of raws) {
65
+ const flag = safeParse(raw);
66
+ if (flag) flags.push(flag);
67
+ }
37
68
  return flags;
69
+ },
70
+ async putWebhook(id, webhook) {
71
+ await kv.put(whPrefix + id, JSON.stringify(webhook), markAs("webhook"));
72
+ },
73
+ async getWebhook(id) {
74
+ return safeParseWebhook(await kv.get(whPrefix + id, { type: "text" }));
75
+ },
76
+ async deleteWebhook(id) {
77
+ await kv.delete(whPrefix + id);
78
+ },
79
+ async listWebhooks() {
80
+ const keys = await listAll(kv, whPrefix);
81
+ const raws = await Promise.all(keys.map((k) => kv.get(k.name, { type: "text" })));
82
+ const hooks = [];
83
+ for (const raw of raws) {
84
+ const hook = safeParseWebhook(raw);
85
+ if (hook) hooks.push(hook);
86
+ }
87
+ return hooks;
88
+ },
89
+ async getRecord(collection, id) {
90
+ assertRecordAddress(collection, id);
91
+ return unwrapRecord(await kv.get(recordKey(collection, id), { type: "text" }));
92
+ },
93
+ async putRecord(collection, id, value) {
94
+ assertRecordAddress(collection, id);
95
+ await kv.put(recordKey(collection, id), wrapRecord(value), markAs("record"));
96
+ },
97
+ async deleteRecord(collection, id) {
98
+ assertRecordAddress(collection, id);
99
+ await kv.delete(recordKey(collection, id));
100
+ },
101
+ async listRecords(collection) {
102
+ assertRecordAddress(collection);
103
+ const keyPrefix = `${recPrefix}${collection}:`;
104
+ const keys = await listAll(kv, keyPrefix);
105
+ const raws = await Promise.all(keys.map((k) => kv.get(k.name, { type: "text" })));
106
+ const entries = [];
107
+ keys.forEach((k, i) => {
108
+ const value = unwrapRecord(raws[i] ?? null);
109
+ if (value !== null) entries.push({ id: k.name.slice(keyPrefix.length), value });
110
+ });
111
+ return entries;
38
112
  }
39
113
  };
40
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaghoist/adapter-cloudflare-kv",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Cloudflare Workers KV StorageAdapter for Flaghoist — the default storage backend.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "node": ">=20"
28
28
  },
29
29
  "dependencies": {
30
- "@flaghoist/core": "0.1.2"
30
+ "@flaghoist/core": "0.2.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@flaghoist/adapter-conformance": "0.0.0"