@fedify/netlify 2.4.0-pr.1020.43 → 2.4.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
@@ -1,7 +1,7 @@
1
1
  <!-- deno-fmt-ignore-file -->
2
2
 
3
- @fedify/netlify: Run Fedify queues with Netlify Async Workloads
4
- ===============================================================
3
+ @fedify/netlify: Run Fedify with Netlify blobs and async workloads
4
+ ==================================================================
5
5
 
6
6
  [![JSR][JSR badge]][JSR]
7
7
  [![npm][npm badge]][npm]
@@ -9,10 +9,11 @@
9
9
 
10
10
  *This package is available since Fedify 2.4.0.*
11
11
 
12
- This package connects [Fedify]'s [`MessageQueue`] API to
13
- [Netlify Async Workloads]. `NetlifyMessageQueue` publishes durable events,
14
- while `createNetlifyQueueHandler()` turns a Netlify Function into their
15
- consumer.
12
+ This package connects [Fedify]'s [`KvStore`] and [`MessageQueue`] APIs to
13
+ Netlify. `NetlifyBlobsKvStore` stores federation state in [Netlify Blobs],
14
+ `NetlifyMessageQueue` publishes durable events through
15
+ [Netlify Async Workloads], and `createNetlifyQueueHandler()` turns a Netlify
16
+ Function into their consumer.
16
17
 
17
18
  The initial release targets Netlify Functions, not Netlify Edge Functions.
18
19
 
@@ -23,13 +24,17 @@ The initial release targets Netlify Functions, not Netlify Edge Functions.
23
24
  [@fedify@hackers.pub badge]: https://fedi-badge.minhee.org/@fedify@hackers.pub/followers.svg
24
25
  [@fedify@hackers.pub]: https://hackers.pub/@fedify
25
26
  [Fedify]: https://fedify.dev/
27
+ [`KvStore`]: https://jsr.io/@fedify/fedify/doc/federation/~/KvStore
26
28
  [`MessageQueue`]: https://jsr.io/@fedify/fedify/doc/federation/~/MessageQueue
29
+ [Netlify Blobs]: https://docs.netlify.com/build/data-and-storage/netlify-blobs/
27
30
  [Netlify Async Workloads]: https://docs.netlify.com/build/async-workloads/get-started/
28
31
 
29
32
 
30
33
  Installation
31
34
  ------------
32
35
 
36
+ For queue-only usage, install the package with Netlify Async Workloads:
37
+
33
38
  ~~~~ sh
34
39
  deno add jsr:@fedify/netlify npm:@netlify/async-workloads # Deno
35
40
  npm add @fedify/netlify @netlify/async-workloads # npm
@@ -38,8 +43,62 @@ yarn add @fedify/netlify @netlify/async-workloads # Yarn
38
43
  bun add @fedify/netlify @netlify/async-workloads # Bun
39
44
  ~~~~
40
45
 
41
- The PostgreSQL-backed `orderingKv` example below also needs
42
- `@fedify/postgres`, `@netlify/database`, and `postgres`:
46
+
47
+ Usage
48
+ -----
49
+
50
+ To use `NetlifyBlobsKvStore`, also install Netlify Blobs:
51
+
52
+ ~~~~ sh
53
+ deno add npm:@netlify/blobs # Deno
54
+ npm add @netlify/blobs # npm
55
+ pnpm add @netlify/blobs # pnpm
56
+ yarn add @netlify/blobs # Yarn
57
+ bun add @netlify/blobs # Bun
58
+ ~~~~
59
+
60
+ Create one store and queue for both the web application and the workload
61
+ function. `NetlifyBlobsKvStore` provides the compare-and-set (CAS) support
62
+ required when Fedify emits messages with an `orderingKey`, without requiring a
63
+ separate database:
64
+
65
+ ~~~~ typescript
66
+ import { AsyncWorkloadsClient } from "@netlify/async-workloads";
67
+ import { getStore } from "@netlify/blobs";
68
+ import {
69
+ NetlifyBlobsKvStore,
70
+ NetlifyMessageQueue,
71
+ } from "@fedify/netlify";
72
+
73
+ export const kv = new NetlifyBlobsKvStore(getStore({
74
+ name: "fedify",
75
+ consistency: "strong",
76
+ }));
77
+ export const queue = new NetlifyMessageQueue({
78
+ client: new AsyncWorkloadsClient(),
79
+ orderingKv: kv,
80
+ });
81
+ ~~~~
82
+
83
+ CAS operations always use strong reads and conditional writes. The
84
+ `consistency: "strong"` setting above also gives ordinary `get()` calls and
85
+ listings strong consistency. You can omit it when lower-latency, eventually
86
+ consistent ordinary reads are acceptable.
87
+
88
+ TTL expiration is logical: expired blobs remain in Netlify Blobs but are
89
+ hidden by `get()` and `list()`. CAS deletion similarly writes a tombstone so
90
+ that a concurrent writer cannot recreate the key undetected. Tombstones are
91
+ also hidden and can be replaced by a later CAS operation, but they are not
92
+ removed automatically. Use a dedicated Netlify Blobs store and account for
93
+ these retained blobs. Only remove them during maintenance when concurrent CAS
94
+ operations for those keys have stopped; physical deletion during a race would
95
+ remove the version token that protects the key.
96
+
97
+ ### PostgreSQL alternative
98
+
99
+ If the application already uses PostgreSQL, `PostgresKvStore` can provide the
100
+ same CAS-capable ordering storage. Install `@fedify/postgres`,
101
+ `@netlify/database`, and `postgres` in addition to the base dependencies:
43
102
 
44
103
  ~~~~ sh
45
104
  deno add jsr:@fedify/postgres npm:@netlify/database npm:postgres # Deno
@@ -49,29 +108,20 @@ yarn add @fedify/postgres @netlify/database postgres # Yarn
49
108
  bun add @fedify/postgres @netlify/database postgres # Bun
50
109
  ~~~~
51
110
 
52
-
53
- Usage
54
- -----
55
-
56
- Create one queue for both the web application and the workload function. A
57
- CAS-capable `KvStore`, such as `PostgresKvStore`, is required if Fedify emits
58
- messages with an `orderingKey`:
111
+ Then replace the `kv` declaration from the main example:
59
112
 
60
113
  ~~~~ typescript
61
- import { AsyncWorkloadsClient } from "@netlify/async-workloads";
62
114
  import { getConnectionString } from "@netlify/database";
63
- import { NetlifyMessageQueue } from "@fedify/netlify";
64
115
  import { PostgresKvStore } from "@fedify/postgres";
65
116
  import postgres from "postgres";
66
117
 
67
118
  const sql = postgres(getConnectionString());
68
119
  export const kv = new PostgresKvStore(sql);
69
- export const queue = new NetlifyMessageQueue({
70
- client: new AsyncWorkloadsClient(),
71
- orderingKv: kv,
72
- });
73
120
  ~~~~
74
121
 
122
+ Ordering state must use crash-safe storage. `PostgresKvStore` creates a logged
123
+ table by default; do not pass `unlogged: true` for `orderingKv`.
124
+
75
125
  Pass the queue to Fedify with `manuallyStartQueue: true`. Async Workloads
76
126
  invokes the consumer, so `NetlifyMessageQueue.listen()` is intentionally not
77
127
  available:
@@ -142,9 +192,6 @@ because a lost response does not prove that the router rejected the event.
142
192
  Use the error's `orderingKey` and `orderingSequence` for manual recovery only
143
193
  after ruling out delivery.
144
194
 
145
- Ordering state must use crash-safe storage. `PostgresKvStore` creates a logged
146
- table by default; do not pass `unlogged: true` for `orderingKv`.
147
-
148
195
  Netlify limits an event payload to 500 KB. Fedify messages, including any
149
196
  embedded activity, must remain below that limit.
150
197
 
@@ -156,7 +203,7 @@ Fedify rejects a multi-message `enqueueTaskMany()` call with one
156
203
  batch enqueue so a failed partial send cannot produce duplicates on retry.
157
204
 
158
205
  See the [deployment manual] and the [Netlify Astro example] for a complete
159
- setup with Netlify Database.
206
+ setup using the PostgreSQL alternative with Netlify Database.
160
207
 
161
208
  [deployment manual]: https://fedify.dev/manual/deploy#netlify-functions
162
209
  [Netlify Astro example]: https://github.com/fedify-dev/fedify/tree/main/examples/netlify-astro
package/dist/mod.cjs CHANGED
@@ -1,6 +1,8 @@
1
1
 
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  let _netlify_async_workloads = require("@netlify/async-workloads");
4
+ let byte_encodings_base64url = require("byte-encodings/base64url");
5
+ let es_toolkit = require("es-toolkit");
4
6
  //#region ../../node_modules/.pnpm/temporal-polyfill@1.0.1/node_modules/temporal-polyfill/chunks/root.js
5
7
  const NativeTemporal = globalThis.Temporal;
6
8
  //#endregion
@@ -3546,7 +3548,7 @@ const Now = /*@__PURE__*/ Object.defineProperties({}, {
3546
3548
  }
3547
3549
  })
3548
3550
  });
3549
- const Temporal$2 = /*@__PURE__*/ Object.defineProperties({}, {
3551
+ const Temporal$3 = /*@__PURE__*/ Object.defineProperties({}, {
3550
3552
  ...createStringTagDescriptors("Temporal"),
3551
3553
  ...createPropDescriptors({
3552
3554
  PlainYearMonth,
@@ -3562,13 +3564,13 @@ const Temporal$2 = /*@__PURE__*/ Object.defineProperties({}, {
3562
3564
  });
3563
3565
  //#endregion
3564
3566
  //#region ../../node_modules/.pnpm/temporal-polyfill@1.0.1/node_modules/temporal-polyfill/index.js
3565
- const Temporal$1 = NativeTemporal || Temporal$2;
3567
+ const Temporal$2 = NativeTemporal || Temporal$3;
3566
3568
  NativeTemporal && Date.prototype.toTemporalInstant;
3567
3569
  //#endregion
3568
3570
  //#region src/mq.ts
3569
- const Temporal = Temporal$1;
3571
+ const Temporal$1 = Temporal$2;
3570
3572
  const defaultEventName = "fedify:queue";
3571
- const defaultOrderingRetryDelay = Temporal.Duration.from({ seconds: 5 });
3573
+ const defaultOrderingRetryDelay = Temporal$1.Duration.from({ seconds: 5 });
3572
3574
  /**
3573
3575
  * An error raised when an event send is not acknowledged.
3574
3576
  *
@@ -3597,7 +3599,7 @@ var NetlifyMessageQueueSendError = class extends Error {
3597
3599
  };
3598
3600
  const orderingOptions = /* @__PURE__ */ new WeakMap();
3599
3601
  function duration(value, defaultValue, name) {
3600
- const result = value == null ? defaultValue : Temporal.Duration.from(value);
3602
+ const result = value == null ? defaultValue : Temporal$1.Duration.from(value);
3601
3603
  const milliseconds = result.total("milliseconds");
3602
3604
  if (!Number.isFinite(milliseconds) || milliseconds <= 0) throw new RangeError(`${name} must be a positive finite duration.`);
3603
3605
  return result;
@@ -3884,6 +3886,117 @@ function createNetlifyQueueHandler(options) {
3884
3886
  return createNetlifyQueueHandlerWith(_netlify_async_workloads.asyncWorkloadFn, options);
3885
3887
  }
3886
3888
  //#endregion
3889
+ //#region src/kv.ts
3890
+ const Temporal = Temporal$2;
3891
+ const MAX_BLOB_KEY_BYTES = 600;
3892
+ const BLOB_KEY_PREFIX = "fedify1.";
3893
+ const BLOB_KEY_SEPARATOR = ".";
3894
+ const textDecoder = new TextDecoder();
3895
+ /**
3896
+ * A key-value store that uses Netlify Blobs.
3897
+ *
3898
+ * @since 2.4.0
3899
+ */
3900
+ var NetlifyBlobsKvStore = class {
3901
+ #store;
3902
+ /**
3903
+ * Creates a new Netlify Blobs-backed key-value store.
3904
+ * @param store The Netlify Blobs store to use.
3905
+ */
3906
+ constructor(store) {
3907
+ this.#store = store;
3908
+ }
3909
+ serializeKey(key) {
3910
+ const serialized = BLOB_KEY_PREFIX + key.map((part) => (0, byte_encodings_base64url.encodeBase64Url)(part)).join(BLOB_KEY_SEPARATOR) + BLOB_KEY_SEPARATOR;
3911
+ if (new TextEncoder().encode(serialized).byteLength > MAX_BLOB_KEY_BYTES) throw new RangeError("The encoded key exceeds Netlify Blobs' 600-byte key limit.");
3912
+ return serialized;
3913
+ }
3914
+ deserializeKey(key) {
3915
+ return key.slice(8, -1).split(BLOB_KEY_SEPARATOR).map((part) => textDecoder.decode((0, byte_encodings_base64url.decodeBase64Url)(part)));
3916
+ }
3917
+ serializePrefix(prefix) {
3918
+ return prefix == null ? BLOB_KEY_PREFIX : this.serializeKey(prefix);
3919
+ }
3920
+ isAbsent(metadata) {
3921
+ if (metadata.tombstone === true) return true;
3922
+ const expireIn = metadata.expireIn;
3923
+ return typeof expireIn === "number" && expireIn <= Temporal.Now.instant().epochMilliseconds;
3924
+ }
3925
+ getEtag(entry) {
3926
+ if (entry?.etag == null) throw new Error("Netlify Blobs did not return an ETag.");
3927
+ return entry.etag;
3928
+ }
3929
+ /**
3930
+ * {@inheritDoc KvStore.get}
3931
+ */
3932
+ async get(key) {
3933
+ const result = await this.#store.getWithMetadata(this.serializeKey(key), { type: "json" });
3934
+ if (result === null || this.isAbsent(result.metadata)) return void 0;
3935
+ return result.data;
3936
+ }
3937
+ /**
3938
+ * {@inheritDoc KvStore.set}
3939
+ */
3940
+ async set(key, value, options) {
3941
+ const expireIn = options?.ttl == null ? null : Temporal.Now.instant().epochMilliseconds + options.ttl.total("millisecond");
3942
+ await this.#store.setJSON(this.serializeKey(key), value, { metadata: { "expireIn": expireIn } });
3943
+ }
3944
+ /**
3945
+ * {@inheritDoc KvStore.delete}
3946
+ */
3947
+ delete(key) {
3948
+ return this.#store.delete(this.serializeKey(key));
3949
+ }
3950
+ /**
3951
+ * {@inheritDoc KvStore.list}
3952
+ */
3953
+ async *list(prefix) {
3954
+ const pages = this.#store.list({
3955
+ paginate: true,
3956
+ prefix: this.serializePrefix(prefix)
3957
+ });
3958
+ for await (const page of pages) for (const blob of page.blobs) {
3959
+ const key = this.deserializeKey(blob.key);
3960
+ const value = await this.get(key);
3961
+ if (value === void 0) continue;
3962
+ yield {
3963
+ key,
3964
+ value
3965
+ };
3966
+ }
3967
+ }
3968
+ /**
3969
+ * {@inheritDoc KvStore.cas}
3970
+ */
3971
+ async cas(key, expectedValue, newValue, options) {
3972
+ while (true) {
3973
+ const entry = await this.#store.getWithMetadata(this.serializeKey(key), {
3974
+ consistency: "strong",
3975
+ type: "json"
3976
+ });
3977
+ if (!(0, es_toolkit.isEqual)(entry === null || this.isAbsent(entry.metadata) ? void 0 : entry.data, expectedValue)) return false;
3978
+ if (entry === null && newValue === void 0) return true;
3979
+ const serializedKey = this.serializeKey(key);
3980
+ if (newValue === void 0) {
3981
+ if ((await this.#store.setJSON(serializedKey, null, {
3982
+ onlyIfMatch: this.getEtag(entry),
3983
+ metadata: { tombstone: true }
3984
+ })).modified) return true;
3985
+ continue;
3986
+ }
3987
+ const metadata = { "expireIn": options?.ttl == null ? null : Temporal.Now.instant().epochMilliseconds + options.ttl.total("millisecond") };
3988
+ if ((entry === null ? await this.#store.setJSON(serializedKey, newValue, {
3989
+ onlyIfNew: true,
3990
+ metadata
3991
+ }) : await this.#store.setJSON(serializedKey, newValue, {
3992
+ onlyIfMatch: this.getEtag(entry),
3993
+ metadata
3994
+ })).modified) return true;
3995
+ }
3996
+ }
3997
+ };
3998
+ //#endregion
3999
+ exports.NetlifyBlobsKvStore = NetlifyBlobsKvStore;
3887
4000
  exports.NetlifyMessageQueue = NetlifyMessageQueue;
3888
4001
  exports.NetlifyMessageQueueSendError = NetlifyMessageQueueSendError;
3889
4002
  exports.createNetlifyQueueHandler = createNetlifyQueueHandler;
package/dist/mod.d.cts CHANGED
@@ -1,9 +1,49 @@
1
1
  /// <reference lib="esnext.temporal" />
2
- import { Federation, KvStore, Message, MessageQueue, MessageQueueEnqueueOptions, MessageQueueListenOptions } from "@fedify/fedify/federation";
2
+ import { Federation, KvKey, KvStore, KvStoreListEntry, KvStoreSetOptions, Message, MessageQueue, MessageQueueEnqueueOptions, MessageQueueListenOptions } from "@fedify/fedify/federation";
3
3
  import { AsyncWorkloadEvent, CustomAsyncWorkloadEvent, asyncWorkloadFn } from "@netlify/async-workloads";
4
4
 
5
5
  //#region src/types.d.ts
6
6
  /**
7
+ * The subset of Netlify's Blobs `Store` used by this package.
8
+ *
9
+ * The real Netlify Blobs `Store` satisfies this interface. The narrower
10
+ * interface also keeps Netlify Blobs optional and makes it possible to supply
11
+ * a test double.
12
+ *
13
+ * @since 2.4.0
14
+ */
15
+ interface NetlifyBlobsStore {
16
+ getWithMetadata(key: string, options: {
17
+ readonly consistency?: "eventual" | "strong";
18
+ readonly type: "json";
19
+ }): Promise<{
20
+ readonly data: unknown;
21
+ readonly etag?: string;
22
+ readonly metadata: Record<string, unknown>;
23
+ } | null>;
24
+ setJSON(key: string, data: unknown, options?: {
25
+ readonly metadata?: Record<string, unknown>;
26
+ } & ({
27
+ readonly onlyIfNew?: boolean;
28
+ readonly onlyIfMatch?: never;
29
+ } | {
30
+ readonly onlyIfMatch?: string;
31
+ readonly onlyIfNew?: never;
32
+ })): Promise<{
33
+ readonly etag?: string;
34
+ readonly modified: boolean;
35
+ }>;
36
+ delete(key: string): Promise<void>;
37
+ list(options: {
38
+ readonly paginate: true;
39
+ readonly prefix?: string;
40
+ }): AsyncIterable<{
41
+ readonly blobs: readonly {
42
+ readonly key: string;
43
+ }[];
44
+ }>;
45
+ }
46
+ /**
7
47
  * The subset of Netlify's `AsyncWorkloadsClient` used by this package.
8
48
  *
9
49
  * The real `AsyncWorkloadsClient` satisfies this interface. The narrower
@@ -176,4 +216,44 @@ type WorkloadFunction = ReturnType<typeof asyncWorkloadFn<NetlifyQueueEvent>>;
176
216
  */
177
217
  declare function createNetlifyQueueHandler<TContextData>(options: NetlifyQueueHandlerOptions<TContextData>): WorkloadFunction;
178
218
  //#endregion
179
- export { type NetlifyAsyncWorkloadsClient, NetlifyMessageQueue, type NetlifyMessageQueueOptions, NetlifyMessageQueueSendError, type NetlifyQueueEvent, type NetlifyQueueEventData, type NetlifyQueueHandlerOptions, createNetlifyQueueHandler };
219
+ //#region src/kv.d.ts
220
+ /**
221
+ * A key-value store that uses Netlify Blobs.
222
+ *
223
+ * @since 2.4.0
224
+ */
225
+ declare class NetlifyBlobsKvStore implements KvStore {
226
+ #private;
227
+ /**
228
+ * Creates a new Netlify Blobs-backed key-value store.
229
+ * @param store The Netlify Blobs store to use.
230
+ */
231
+ constructor(store: NetlifyBlobsStore);
232
+ private serializeKey;
233
+ private deserializeKey;
234
+ private serializePrefix;
235
+ private isAbsent;
236
+ private getEtag;
237
+ /**
238
+ * {@inheritDoc KvStore.get}
239
+ */
240
+ get<T = unknown>(key: KvKey): Promise<T | undefined>;
241
+ /**
242
+ * {@inheritDoc KvStore.set}
243
+ */
244
+ set(key: KvKey, value: unknown, options?: KvStoreSetOptions | undefined): Promise<void>;
245
+ /**
246
+ * {@inheritDoc KvStore.delete}
247
+ */
248
+ delete(key: KvKey): Promise<void>;
249
+ /**
250
+ * {@inheritDoc KvStore.list}
251
+ */
252
+ list(prefix?: KvKey): AsyncIterable<KvStoreListEntry>;
253
+ /**
254
+ * {@inheritDoc KvStore.cas}
255
+ */
256
+ cas(key: KvKey, expectedValue: unknown, newValue: unknown, options?: KvStoreSetOptions): Promise<boolean>;
257
+ }
258
+ //#endregion
259
+ export { type NetlifyAsyncWorkloadsClient, NetlifyBlobsKvStore, type NetlifyBlobsStore, NetlifyMessageQueue, type NetlifyMessageQueueOptions, NetlifyMessageQueueSendError, type NetlifyQueueEvent, type NetlifyQueueEventData, type NetlifyQueueHandlerOptions, createNetlifyQueueHandler };
package/dist/mod.d.ts CHANGED
@@ -1,9 +1,49 @@
1
1
  /// <reference lib="esnext.temporal" />
2
2
  import { AsyncWorkloadEvent, CustomAsyncWorkloadEvent, asyncWorkloadFn } from "@netlify/async-workloads";
3
- import { Federation, KvStore, Message, MessageQueue, MessageQueueEnqueueOptions, MessageQueueListenOptions } from "@fedify/fedify/federation";
3
+ import { Federation, KvKey, KvStore, KvStoreListEntry, KvStoreSetOptions, Message, MessageQueue, MessageQueueEnqueueOptions, MessageQueueListenOptions } from "@fedify/fedify/federation";
4
4
 
5
5
  //#region src/types.d.ts
6
6
  /**
7
+ * The subset of Netlify's Blobs `Store` used by this package.
8
+ *
9
+ * The real Netlify Blobs `Store` satisfies this interface. The narrower
10
+ * interface also keeps Netlify Blobs optional and makes it possible to supply
11
+ * a test double.
12
+ *
13
+ * @since 2.4.0
14
+ */
15
+ interface NetlifyBlobsStore {
16
+ getWithMetadata(key: string, options: {
17
+ readonly consistency?: "eventual" | "strong";
18
+ readonly type: "json";
19
+ }): Promise<{
20
+ readonly data: unknown;
21
+ readonly etag?: string;
22
+ readonly metadata: Record<string, unknown>;
23
+ } | null>;
24
+ setJSON(key: string, data: unknown, options?: {
25
+ readonly metadata?: Record<string, unknown>;
26
+ } & ({
27
+ readonly onlyIfNew?: boolean;
28
+ readonly onlyIfMatch?: never;
29
+ } | {
30
+ readonly onlyIfMatch?: string;
31
+ readonly onlyIfNew?: never;
32
+ })): Promise<{
33
+ readonly etag?: string;
34
+ readonly modified: boolean;
35
+ }>;
36
+ delete(key: string): Promise<void>;
37
+ list(options: {
38
+ readonly paginate: true;
39
+ readonly prefix?: string;
40
+ }): AsyncIterable<{
41
+ readonly blobs: readonly {
42
+ readonly key: string;
43
+ }[];
44
+ }>;
45
+ }
46
+ /**
7
47
  * The subset of Netlify's `AsyncWorkloadsClient` used by this package.
8
48
  *
9
49
  * The real `AsyncWorkloadsClient` satisfies this interface. The narrower
@@ -176,4 +216,44 @@ type WorkloadFunction = ReturnType<typeof asyncWorkloadFn<NetlifyQueueEvent>>;
176
216
  */
177
217
  declare function createNetlifyQueueHandler<TContextData>(options: NetlifyQueueHandlerOptions<TContextData>): WorkloadFunction;
178
218
  //#endregion
179
- export { type NetlifyAsyncWorkloadsClient, NetlifyMessageQueue, type NetlifyMessageQueueOptions, NetlifyMessageQueueSendError, type NetlifyQueueEvent, type NetlifyQueueEventData, type NetlifyQueueHandlerOptions, createNetlifyQueueHandler };
219
+ //#region src/kv.d.ts
220
+ /**
221
+ * A key-value store that uses Netlify Blobs.
222
+ *
223
+ * @since 2.4.0
224
+ */
225
+ declare class NetlifyBlobsKvStore implements KvStore {
226
+ #private;
227
+ /**
228
+ * Creates a new Netlify Blobs-backed key-value store.
229
+ * @param store The Netlify Blobs store to use.
230
+ */
231
+ constructor(store: NetlifyBlobsStore);
232
+ private serializeKey;
233
+ private deserializeKey;
234
+ private serializePrefix;
235
+ private isAbsent;
236
+ private getEtag;
237
+ /**
238
+ * {@inheritDoc KvStore.get}
239
+ */
240
+ get<T = unknown>(key: KvKey): Promise<T | undefined>;
241
+ /**
242
+ * {@inheritDoc KvStore.set}
243
+ */
244
+ set(key: KvKey, value: unknown, options?: KvStoreSetOptions | undefined): Promise<void>;
245
+ /**
246
+ * {@inheritDoc KvStore.delete}
247
+ */
248
+ delete(key: KvKey): Promise<void>;
249
+ /**
250
+ * {@inheritDoc KvStore.list}
251
+ */
252
+ list(prefix?: KvKey): AsyncIterable<KvStoreListEntry>;
253
+ /**
254
+ * {@inheritDoc KvStore.cas}
255
+ */
256
+ cas(key: KvKey, expectedValue: unknown, newValue: unknown, options?: KvStoreSetOptions): Promise<boolean>;
257
+ }
258
+ //#endregion
259
+ export { type NetlifyAsyncWorkloadsClient, NetlifyBlobsKvStore, type NetlifyBlobsStore, NetlifyMessageQueue, type NetlifyMessageQueueOptions, NetlifyMessageQueueSendError, type NetlifyQueueEvent, type NetlifyQueueEventData, type NetlifyQueueHandlerOptions, createNetlifyQueueHandler };
package/dist/mod.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { Temporal } from "temporal-polyfill";
2
2
  import { ErrorDoNotRetry, asyncWorkloadFn } from "@netlify/async-workloads";
3
+ import { decodeBase64Url, encodeBase64Url } from "byte-encodings/base64url";
4
+ import { isEqual } from "es-toolkit";
3
5
  //#region src/mq.ts
4
6
  const defaultEventName = "fedify:queue";
5
7
  const defaultOrderingRetryDelay = Temporal.Duration.from({ seconds: 5 });
@@ -318,4 +320,113 @@ function createNetlifyQueueHandler(options) {
318
320
  return createNetlifyQueueHandlerWith(asyncWorkloadFn, options);
319
321
  }
320
322
  //#endregion
321
- export { NetlifyMessageQueue, NetlifyMessageQueueSendError, createNetlifyQueueHandler };
323
+ //#region src/kv.ts
324
+ const MAX_BLOB_KEY_BYTES = 600;
325
+ const BLOB_KEY_PREFIX = "fedify1.";
326
+ const BLOB_KEY_SEPARATOR = ".";
327
+ const textDecoder = new TextDecoder();
328
+ /**
329
+ * A key-value store that uses Netlify Blobs.
330
+ *
331
+ * @since 2.4.0
332
+ */
333
+ var NetlifyBlobsKvStore = class {
334
+ #store;
335
+ /**
336
+ * Creates a new Netlify Blobs-backed key-value store.
337
+ * @param store The Netlify Blobs store to use.
338
+ */
339
+ constructor(store) {
340
+ this.#store = store;
341
+ }
342
+ serializeKey(key) {
343
+ const serialized = BLOB_KEY_PREFIX + key.map((part) => encodeBase64Url(part)).join(BLOB_KEY_SEPARATOR) + BLOB_KEY_SEPARATOR;
344
+ if (new TextEncoder().encode(serialized).byteLength > MAX_BLOB_KEY_BYTES) throw new RangeError("The encoded key exceeds Netlify Blobs' 600-byte key limit.");
345
+ return serialized;
346
+ }
347
+ deserializeKey(key) {
348
+ return key.slice(8, -1).split(BLOB_KEY_SEPARATOR).map((part) => textDecoder.decode(decodeBase64Url(part)));
349
+ }
350
+ serializePrefix(prefix) {
351
+ return prefix == null ? BLOB_KEY_PREFIX : this.serializeKey(prefix);
352
+ }
353
+ isAbsent(metadata) {
354
+ if (metadata.tombstone === true) return true;
355
+ const expireIn = metadata.expireIn;
356
+ return typeof expireIn === "number" && expireIn <= Temporal.Now.instant().epochMilliseconds;
357
+ }
358
+ getEtag(entry) {
359
+ if (entry?.etag == null) throw new Error("Netlify Blobs did not return an ETag.");
360
+ return entry.etag;
361
+ }
362
+ /**
363
+ * {@inheritDoc KvStore.get}
364
+ */
365
+ async get(key) {
366
+ const result = await this.#store.getWithMetadata(this.serializeKey(key), { type: "json" });
367
+ if (result === null || this.isAbsent(result.metadata)) return void 0;
368
+ return result.data;
369
+ }
370
+ /**
371
+ * {@inheritDoc KvStore.set}
372
+ */
373
+ async set(key, value, options) {
374
+ const expireIn = options?.ttl == null ? null : Temporal.Now.instant().epochMilliseconds + options.ttl.total("millisecond");
375
+ await this.#store.setJSON(this.serializeKey(key), value, { metadata: { "expireIn": expireIn } });
376
+ }
377
+ /**
378
+ * {@inheritDoc KvStore.delete}
379
+ */
380
+ delete(key) {
381
+ return this.#store.delete(this.serializeKey(key));
382
+ }
383
+ /**
384
+ * {@inheritDoc KvStore.list}
385
+ */
386
+ async *list(prefix) {
387
+ const pages = this.#store.list({
388
+ paginate: true,
389
+ prefix: this.serializePrefix(prefix)
390
+ });
391
+ for await (const page of pages) for (const blob of page.blobs) {
392
+ const key = this.deserializeKey(blob.key);
393
+ const value = await this.get(key);
394
+ if (value === void 0) continue;
395
+ yield {
396
+ key,
397
+ value
398
+ };
399
+ }
400
+ }
401
+ /**
402
+ * {@inheritDoc KvStore.cas}
403
+ */
404
+ async cas(key, expectedValue, newValue, options) {
405
+ while (true) {
406
+ const entry = await this.#store.getWithMetadata(this.serializeKey(key), {
407
+ consistency: "strong",
408
+ type: "json"
409
+ });
410
+ if (!isEqual(entry === null || this.isAbsent(entry.metadata) ? void 0 : entry.data, expectedValue)) return false;
411
+ if (entry === null && newValue === void 0) return true;
412
+ const serializedKey = this.serializeKey(key);
413
+ if (newValue === void 0) {
414
+ if ((await this.#store.setJSON(serializedKey, null, {
415
+ onlyIfMatch: this.getEtag(entry),
416
+ metadata: { tombstone: true }
417
+ })).modified) return true;
418
+ continue;
419
+ }
420
+ const metadata = { "expireIn": options?.ttl == null ? null : Temporal.Now.instant().epochMilliseconds + options.ttl.total("millisecond") };
421
+ if ((entry === null ? await this.#store.setJSON(serializedKey, newValue, {
422
+ onlyIfNew: true,
423
+ metadata
424
+ }) : await this.#store.setJSON(serializedKey, newValue, {
425
+ onlyIfMatch: this.getEtag(entry),
426
+ metadata
427
+ })).modified) return true;
428
+ }
429
+ }
430
+ };
431
+ //#endregion
432
+ export { NetlifyBlobsKvStore, NetlifyMessageQueue, NetlifyMessageQueueSendError, createNetlifyQueueHandler };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/netlify",
3
- "version": "2.4.0-pr.1020.43+41cebe9c",
3
+ "version": "2.4.0",
4
4
  "description": "Integrate Fedify with Netlify Async Workloads",
5
5
  "keywords": [
6
6
  "Fedify",
@@ -47,22 +47,22 @@
47
47
  "package.json"
48
48
  ],
49
49
  "dependencies": {
50
+ "byte-encodings": "^1.0.11",
51
+ "es-toolkit": "1.46.1",
50
52
  "temporal-polyfill": "^1.0.1"
51
53
  },
52
54
  "peerDependencies": {
53
55
  "@netlify/async-workloads": "^0.0.106",
54
- "@fedify/fedify": "^2.4.0-pr.1020.43+41cebe9c"
56
+ "@fedify/fedify": "^2.4.0"
55
57
  },
56
58
  "devDependencies": {
57
59
  "@js-temporal/polyfill": "^0.5.1",
58
- "@netlify/database": "^1.1.0",
59
60
  "@standard-schema/spec": "^1.1.0",
60
- "@types/node": "^22.17.0",
61
- "netlify-cli": "^26.2.0",
62
- "postgres": "^3.4.7",
61
+ "@netlify/blobs": "11.0.3",
62
+ "@types/node": "^24.12.2",
63
+ "netlify-cli": "^27.4.2",
63
64
  "tsdown": "^0.22.0",
64
- "typescript": "^6.0.0",
65
- "@fedify/postgres": "^2.4.0-pr.1020.43+41cebe9c"
65
+ "typescript": "^6.0.0"
66
66
  },
67
67
  "scripts": {
68
68
  "build:self": "tsdown",