jazz-webhook 0.18.28

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.
Files changed (46) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +16 -0
  3. package/LICENSE.txt +19 -0
  4. package/dist/index.d.ts +3 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +3 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/successMap.d.ts +13 -0
  9. package/dist/successMap.d.ts.map +1 -0
  10. package/dist/successMap.js +32 -0
  11. package/dist/successMap.js.map +1 -0
  12. package/dist/test/http-server.d.ts +73 -0
  13. package/dist/test/http-server.d.ts.map +1 -0
  14. package/dist/test/http-server.js +177 -0
  15. package/dist/test/http-server.js.map +1 -0
  16. package/dist/test/setup.d.ts +2 -0
  17. package/dist/test/setup.d.ts.map +1 -0
  18. package/dist/test/setup.js +11 -0
  19. package/dist/test/setup.js.map +1 -0
  20. package/dist/test/successMap.test.d.ts +2 -0
  21. package/dist/test/successMap.test.d.ts.map +1 -0
  22. package/dist/test/successMap.test.js +172 -0
  23. package/dist/test/successMap.test.js.map +1 -0
  24. package/dist/test/webhook.test.d.ts +2 -0
  25. package/dist/test/webhook.test.d.ts.map +1 -0
  26. package/dist/test/webhook.test.js +356 -0
  27. package/dist/test/webhook.test.js.map +1 -0
  28. package/dist/types.d.ts +87 -0
  29. package/dist/types.d.ts.map +1 -0
  30. package/dist/types.js +15 -0
  31. package/dist/types.js.map +1 -0
  32. package/dist/webhook.d.ts +67 -0
  33. package/dist/webhook.d.ts.map +1 -0
  34. package/dist/webhook.js +281 -0
  35. package/dist/webhook.js.map +1 -0
  36. package/package.json +34 -0
  37. package/src/index.ts +2 -0
  38. package/src/successMap.ts +55 -0
  39. package/src/test/http-server.ts +233 -0
  40. package/src/test/setup.ts +12 -0
  41. package/src/test/successMap.test.ts +215 -0
  42. package/src/test/webhook.test.ts +586 -0
  43. package/src/types.ts +106 -0
  44. package/src/webhook.ts +387 -0
  45. package/tsconfig.json +17 -0
  46. package/vitest.config.ts +14 -0
@@ -0,0 +1,281 @@
1
+ import { co, z, Account } from "jazz-tools";
2
+ import { getTransactionsToTry, getTxIdKey, markSuccessful, SuccessMap, } from "./successMap.js";
3
+ export const WebhookRegistration = co.map({
4
+ webhookUrl: z.string(),
5
+ coValueId: z.string(),
6
+ active: z.boolean(),
7
+ successMap: SuccessMap,
8
+ });
9
+ export const RegistryState = co.record(z.string(), WebhookRegistration);
10
+ export const DEFAULT_JazzWebhookOptions = {
11
+ maxRetries: 3,
12
+ baseDelayMs: 20000,
13
+ };
14
+ export class WebhookRegistry {
15
+ constructor(registry, options = {}) {
16
+ this.options = options;
17
+ this.activeSubscriptions = new Map();
18
+ this.rootUnsubscribe = null;
19
+ this.state = registry;
20
+ }
21
+ static createRegistry(owner) {
22
+ return RegistryState.create({}, owner);
23
+ }
24
+ static async loadAndStart(registryId, options = {}) {
25
+ const registry = await RegistryState.load(registryId);
26
+ if (!registry) {
27
+ throw new Error(`Webhook registry with ID ${registryId} not found`);
28
+ }
29
+ const webhook = new WebhookRegistry(registry, options);
30
+ webhook.start();
31
+ return webhook;
32
+ }
33
+ /**
34
+ * Registers a new webhook for a CoValue.
35
+ *
36
+ * @param webhookUrl - The HTTP URL to call when the CoValue changes
37
+ * @param coValueId - The ID of the CoValue to monitor (must start with "co_z")
38
+ * @returns The ID of the registered webhook
39
+ */
40
+ async register(webhookUrl, coValueId) {
41
+ const registrationId = await registerWebhook({
42
+ registryId: this.state.$jazz.id,
43
+ webhookUrl,
44
+ coValueId,
45
+ });
46
+ // wait for registration to become visible in the registry
47
+ return new Promise((resolve) => {
48
+ this.state.$jazz.subscribe((state, unsubscribe) => {
49
+ if (state.$jazz.refs[registrationId]) {
50
+ resolve(registrationId);
51
+ unsubscribe();
52
+ }
53
+ });
54
+ });
55
+ }
56
+ /**
57
+ * Unregisters a webhook and stops monitoring the CoValue.
58
+ *
59
+ * @param webhookId - The ID of the webhook to unregister
60
+ */
61
+ unregister(webhookId) {
62
+ const webhook = this.state[webhookId];
63
+ if (!webhook) {
64
+ throw new Error(`Webhook with ID ${webhookId} not found`);
65
+ }
66
+ this.state.$jazz.delete(webhookId);
67
+ webhook.$jazz.set("active", false);
68
+ }
69
+ /**
70
+ * Starts monitoring a CoValue and emitting webhooks when it changes.
71
+ *
72
+ * @param webhookId - The ID of the webhook to start monitoring
73
+ */
74
+ subscribe(webhook) {
75
+ const emitter = new WebhookEmitter(webhook, this.options);
76
+ this.activeSubscriptions.set(webhook.$jazz.id, () => {
77
+ emitter.stop();
78
+ this.activeSubscriptions.delete(webhook.$jazz.id);
79
+ });
80
+ }
81
+ /**
82
+ * Starts monitoring all active webhooks in the registry.
83
+ *
84
+ * This method iterates through all webhooks in the registry and starts
85
+ * subscriptions for any active webhooks that don't already have active
86
+ * subscriptions. This is useful for resuming webhook monitoring after
87
+ * a shutdown or when loading an existing registry.
88
+ */
89
+ async start() {
90
+ if (this.rootUnsubscribe) {
91
+ return;
92
+ }
93
+ // TODO: this would be much more efficient with subscription diffs
94
+ const createAndDeleteSubscriptions = (registry) => {
95
+ for (const webhook of Object.values(registry)) {
96
+ const exists = this.activeSubscriptions.has(webhook.$jazz.id);
97
+ if (webhook.active && !exists) {
98
+ this.subscribe(webhook);
99
+ }
100
+ }
101
+ for (const [id, unsubscribe] of this.activeSubscriptions.entries()) {
102
+ if (!registry[id]) {
103
+ unsubscribe();
104
+ this.activeSubscriptions.delete(id);
105
+ }
106
+ }
107
+ };
108
+ this.rootUnsubscribe = this.state.$jazz.subscribe({
109
+ resolve: {
110
+ $each: true,
111
+ },
112
+ }, createAndDeleteSubscriptions);
113
+ }
114
+ shutdown() {
115
+ for (const unsubscribe of this.activeSubscriptions.values()) {
116
+ unsubscribe();
117
+ }
118
+ this.rootUnsubscribe?.();
119
+ this.rootUnsubscribe = null;
120
+ }
121
+ }
122
+ class WebhookError extends Error {
123
+ constructor(message, retryAfterMs) {
124
+ super(message);
125
+ this.retryAfterMs = retryAfterMs;
126
+ }
127
+ }
128
+ /**
129
+ * Manages webhook emission with queuing, retry logic, and exponential backoff.
130
+ *
131
+ * The WebhookEmitter handles sending HTTP POST requests to webhook callbacks when
132
+ * a CoValue changes. It implements several key behaviors:
133
+ *
134
+ */
135
+ class WebhookEmitter {
136
+ constructor(webhook, options = {}) {
137
+ this.webhook = webhook;
138
+ this.options = options;
139
+ this.pending = new Map();
140
+ const unsubscribeWebhook = this.webhook.$jazz.subscribe((webhook) => {
141
+ this.webhook = webhook;
142
+ });
143
+ const unsubscribeValue = this.webhook.$jazz.localNode.subscribe(this.webhook.coValueId, async (value) => {
144
+ if (value === "unavailable") {
145
+ return;
146
+ }
147
+ if (!this.webhook.active) {
148
+ return;
149
+ }
150
+ const todo = getTransactionsToTry(await this.loadSuccessMap(), value.core.knownState());
151
+ for (const txID of todo) {
152
+ if (!this.pending.has(getTxIdKey(txID))) {
153
+ this.makeAttempt(txID);
154
+ }
155
+ }
156
+ });
157
+ this.unsubscribe = () => {
158
+ unsubscribeWebhook();
159
+ unsubscribeValue();
160
+ };
161
+ }
162
+ async loadSuccessMap() {
163
+ if (this.loadedSuccessMap) {
164
+ return this.loadedSuccessMap;
165
+ }
166
+ if (!this.successMap) {
167
+ this.successMap = this.webhook.$jazz
168
+ .ensureLoaded({ resolve: { successMap: true } })
169
+ .then((loaded) => loaded.successMap);
170
+ }
171
+ this.loadedSuccessMap = await this.successMap;
172
+ return this.loadedSuccessMap;
173
+ }
174
+ getRetryDelay(nRetries) {
175
+ const baseDelayMs = this.options.baseDelayMs || DEFAULT_JazzWebhookOptions.baseDelayMs;
176
+ return baseDelayMs * 2 ** nRetries;
177
+ }
178
+ getMaxRetries() {
179
+ return this.options.maxRetries || DEFAULT_JazzWebhookOptions.maxRetries;
180
+ }
181
+ stop() {
182
+ this.pending.forEach((entry) => {
183
+ clearTimeout(entry.timeout);
184
+ });
185
+ this.pending.clear();
186
+ this.unsubscribe();
187
+ }
188
+ makeAttempt(txID) {
189
+ const txIdKey = getTxIdKey(txID);
190
+ let entry = this.pending.get(txIdKey);
191
+ if (entry && entry.nRetries >= this.getMaxRetries()) {
192
+ // TODO: should we track failed transactions?
193
+ this.pending.delete(txIdKey);
194
+ clearTimeout(entry.timeout);
195
+ console.error(`Max retries reached for txID: ${txIdKey} on webhook: ${this.webhook.$jazz.id}`);
196
+ return;
197
+ }
198
+ if (entry) {
199
+ entry.nRetries++;
200
+ clearTimeout(entry.timeout);
201
+ }
202
+ else {
203
+ entry = { nRetries: 0 };
204
+ this.pending.set(txIdKey, entry);
205
+ }
206
+ const scheduleRetry = (delayMs) => {
207
+ const delay = delayMs ?? this.getRetryDelay(entry?.nRetries || 0);
208
+ entry.timeout = setTimeout(() => {
209
+ this.makeAttempt(txID);
210
+ }, delay);
211
+ };
212
+ fetch(this.webhook.webhookUrl, {
213
+ method: "POST",
214
+ headers: {
215
+ "Content-Type": "application/json",
216
+ },
217
+ body: JSON.stringify({
218
+ coValueId: this.webhook.coValueId,
219
+ txID: txID,
220
+ }),
221
+ })
222
+ .then(async (response) => {
223
+ if (response.ok) {
224
+ const successMap = await this.loadSuccessMap();
225
+ markSuccessful(successMap, txID);
226
+ this.pending.delete(txIdKey);
227
+ clearTimeout(entry.timeout);
228
+ return;
229
+ }
230
+ let retryAfterMs;
231
+ if (response.headers.has("Retry-After")) {
232
+ try {
233
+ retryAfterMs =
234
+ parseFloat(response.headers.get("Retry-After")) * 1000;
235
+ }
236
+ catch {
237
+ console.warn(`Invalid Retry-After header: ${response.headers.get("Retry-After")}`);
238
+ }
239
+ }
240
+ throw new WebhookError(`HTTP ${response.status}: ${response.statusText}`, retryAfterMs);
241
+ })
242
+ .catch((error) => {
243
+ scheduleRetry(error instanceof WebhookError ? error.retryAfterMs : undefined);
244
+ });
245
+ }
246
+ }
247
+ export const registerWebhook = async (options) => {
248
+ const { webhookUrl, coValueId, registryId } = {
249
+ registryId: process.env.JAZZ_WEBHOOK_REGISTRY_ID,
250
+ ...options,
251
+ };
252
+ if (!registryId) {
253
+ throw new Error("Invalid webhook secret");
254
+ }
255
+ try {
256
+ new URL(webhookUrl);
257
+ }
258
+ catch {
259
+ throw new Error(`Invalid webhook URL: ${webhookUrl}`);
260
+ }
261
+ if (!registryId.startsWith("co_z")) {
262
+ throw new Error(`Invalid Registry ID format: ${coValueId}. Expected format: co_z...`);
263
+ }
264
+ if (!coValueId.startsWith("co_z")) {
265
+ throw new Error(`Invalid CoValue ID format: ${coValueId}. Expected format: co_z...`);
266
+ }
267
+ const registry = await RegistryState.load(registryId);
268
+ if (!registry) {
269
+ throw new Error(`Couldn't load registry with ID ${registryId}`);
270
+ }
271
+ const registration = WebhookRegistration.create({
272
+ webhookUrl,
273
+ coValueId,
274
+ active: true,
275
+ successMap: SuccessMap.create({}, registry.$jazz.owner),
276
+ }, registry.$jazz.owner);
277
+ registry.$jazz.set(registration.$jazz.id, registration);
278
+ await Account.getMe().$jazz.waitForAllCoValuesSync();
279
+ return registration.$jazz.id;
280
+ };
281
+ //# sourceMappingURL=webhook.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook.js","sourceRoot":"","sources":["../src/webhook.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "jazz-webhook",
3
+ "module": "dist/index.js",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "version": "0.18.28",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "dependencies": {
16
+ "cojson": "0.18.28",
17
+ "cojson-storage-sqlite": "0.18.28",
18
+ "cojson-transport-ws": "0.18.28",
19
+ "jazz-tools": "0.18.28"
20
+ },
21
+ "devDependencies": {
22
+ "@types/ws": "8.5.10",
23
+ "typescript": "5.6.2",
24
+ "vitest": "3.2.4"
25
+ },
26
+ "scripts": {
27
+ "format-and-lint": "biome check .",
28
+ "format-and-lint:fix": "biome check . --write",
29
+ "build": "rm -rf ./dist && tsc --sourceMap --outDir dist && chmod +x ./dist/index.js",
30
+ "test": "vitest --run --root ../../ --project jazz-webhook",
31
+ "dev": "tsc --watch --sourceMap --outDir dist",
32
+ "build-and-run": "pnpm turbo build && ./dist/index.js sync --in-memory"
33
+ }
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./webhook.js";
2
+ export * from "./types.js";
@@ -0,0 +1,55 @@
1
+ import { CojsonInternalTypes, SessionID } from "cojson";
2
+ import { co, z } from "jazz-tools";
3
+
4
+ export const SuccessMap = co.record(
5
+ z.string(), // stringified transaction ID
6
+ z.boolean(),
7
+ );
8
+ export type SuccessMap = co.loaded<typeof SuccessMap>;
9
+
10
+ export type TxIdKey = `${SessionID}:${number}`;
11
+
12
+ export function getTxIdKey(txID: {
13
+ sessionID: SessionID;
14
+ txIndex: number;
15
+ }): TxIdKey {
16
+ return `${txID.sessionID}:${txID.txIndex}`;
17
+ }
18
+
19
+ export function markSuccessful(
20
+ successMap: SuccessMap,
21
+ txID: CojsonInternalTypes.TransactionID,
22
+ ) {
23
+ let success = successMap[getTxIdKey(txID)];
24
+ if (!success) {
25
+ successMap.$jazz.set(getTxIdKey(txID), true);
26
+ }
27
+ }
28
+
29
+ export function getTransactionsToTry(
30
+ successMap: SuccessMap,
31
+ knownState: CojsonInternalTypes.CoValueKnownState,
32
+ ) {
33
+ const result: CojsonInternalTypes.TransactionID[] = [];
34
+ // TODO: optimisation: we can likely avoid even constructing a CoMap view
35
+ // and just get/set raw transactions from the CoValueCore of SuccessMap
36
+ for (const [sessionID, knownTxCount] of Object.entries(knownState.sessions)) {
37
+ for (let txIndex = 0; txIndex < knownTxCount; txIndex++) {
38
+ if (!successMap[`${sessionID}:${txIndex}`]) {
39
+ result.push({
40
+ sessionID: sessionID as SessionID,
41
+ txIndex: txIndex,
42
+ });
43
+ }
44
+ }
45
+ }
46
+
47
+ return result;
48
+ }
49
+
50
+ export function isTxSuccessful(
51
+ successMap: SuccessMap,
52
+ txID: CojsonInternalTypes.TransactionID,
53
+ ) {
54
+ return successMap[getTxIdKey(txID)] ?? false;
55
+ }
@@ -0,0 +1,233 @@
1
+ import { createServer, IncomingMessage, ServerResponse, Server } from "http";
2
+ import { AddressInfo } from "net";
3
+ import { EventEmitter } from "events";
4
+ import { CojsonInternalTypes } from "cojson";
5
+
6
+ export interface WebhookRequest {
7
+ coValueId: string;
8
+ txID: CojsonInternalTypes.TransactionID;
9
+ timestamp: number;
10
+ }
11
+
12
+ export interface WebhookResponse {
13
+ statusCode: number;
14
+ body?: string;
15
+ delay?: number;
16
+ headers?: Record<string, string>;
17
+ }
18
+
19
+ export class WebhookTestServer {
20
+ private server: Server;
21
+ private port: number = 0;
22
+ private url: string = "";
23
+ private responseIndex: number = 0;
24
+
25
+ public readonly requests: WebhookRequest[] = [];
26
+ public readonly responses: WebhookResponse[] = [];
27
+
28
+ constructor() {
29
+ this.server = createServer(this.handleRequest.bind(this));
30
+ }
31
+
32
+ listeners = new Set<() => void>();
33
+ addRequestListener(listener: () => void): void {
34
+ this.listeners.add(listener);
35
+ }
36
+ removeRequestListener(listener: () => void): void {
37
+ this.listeners.delete(listener);
38
+ }
39
+
40
+ emitRequest(): void {
41
+ for (const listener of this.listeners) {
42
+ listener();
43
+ }
44
+ }
45
+
46
+ private handleRequest(req: IncomingMessage, res: ServerResponse): void {
47
+ if (req.method === "POST" && req.url === "/webhook") {
48
+ let body = "";
49
+
50
+ req.on("data", (chunk) => {
51
+ body += chunk.toString();
52
+ });
53
+
54
+ req.on("end", async () => {
55
+ try {
56
+ const response = this.responses[this.responseIndex] || {
57
+ statusCode: 200,
58
+ };
59
+ this.responseIndex++;
60
+
61
+ // Add delay if specified
62
+ if (response.delay) {
63
+ await new Promise((resolve) => setTimeout(resolve, response.delay));
64
+ }
65
+
66
+ res.statusCode = response.statusCode;
67
+ res.setHeader("Content-Type", "application/json");
68
+ if (response.headers) {
69
+ for (const [key, value] of Object.entries(response.headers)) {
70
+ res.setHeader(key, value);
71
+ }
72
+ }
73
+ res.end(response.body || JSON.stringify({ received: true }));
74
+ } catch (error) {
75
+ res.statusCode = 400;
76
+ res.setHeader("Content-Type", "application/json");
77
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
78
+ }
79
+
80
+ const webhookData: WebhookRequest = JSON.parse(body);
81
+ this.requests.push(webhookData);
82
+
83
+ console.log("request " + this.requests.length, webhookData);
84
+
85
+ this.emitRequest();
86
+ });
87
+ } else {
88
+ res.statusCode = 404;
89
+ res.end("Not Found");
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Starts the server on an available port
95
+ */
96
+ async start(): Promise<void> {
97
+ return new Promise<void>((resolve, reject) => {
98
+ this.server.listen(0, () => {
99
+ try {
100
+ const address = this.server.address() as AddressInfo;
101
+ this.port = address.port;
102
+ this.url = `http://localhost:${this.port}/webhook`;
103
+ resolve();
104
+ } catch (error) {
105
+ reject(error);
106
+ }
107
+ });
108
+
109
+ this.server.on("error", reject);
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Stops the server
115
+ */
116
+ async close(): Promise<void> {
117
+ return new Promise<void>((resolve) => {
118
+ this.server.close(() => {
119
+ resolve();
120
+ });
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Resets all stored requests and responses
126
+ */
127
+ reset(): void {
128
+ this.requests.length = 0;
129
+ this.responses.length = 0;
130
+ this.responseIndex = 0;
131
+ }
132
+
133
+ /**
134
+ * Sets a response for a specific request index
135
+ */
136
+ setResponse(
137
+ index: number,
138
+ statusCode: number,
139
+ body?: string,
140
+ delay?: number,
141
+ headers?: Record<string, string>,
142
+ ): void {
143
+ this.responses[index] = { statusCode, body, delay, headers };
144
+ }
145
+
146
+ /**
147
+ * Waits for a specific number of requests to be received
148
+ */
149
+ async waitForRequests(
150
+ count: number,
151
+ timeout: number = 5000,
152
+ ): Promise<WebhookRequest[]> {
153
+ return new Promise<WebhookRequest[]>((resolve, reject) => {
154
+ const timer = setTimeout(() => {
155
+ this.removeRequestListener(onRequest);
156
+ reject(
157
+ new Error(
158
+ `Timeout waiting for ${count} requests. Got ${this.requests.length}`,
159
+ ),
160
+ );
161
+ }, timeout);
162
+
163
+ const onRequest = () => {
164
+ if (this.requests.length >= count) {
165
+ clearTimeout(timer);
166
+ this.removeRequestListener(onRequest);
167
+ resolve(this.requests);
168
+ }
169
+ };
170
+
171
+ this.addRequestListener(onRequest);
172
+ onRequest();
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Gets the server port
178
+ */
179
+ getPort(): number {
180
+ return this.port;
181
+ }
182
+
183
+ /**
184
+ * Gets the webhook URL
185
+ */
186
+ getUrl(): string {
187
+ return this.url;
188
+ }
189
+
190
+ /**
191
+ * Gets the number of requests received
192
+ */
193
+ getRequestCount(): number {
194
+ return this.requests.length;
195
+ }
196
+
197
+ expectSingleRequest(): WebhookRequest {
198
+ if (this.requests.length !== 1) {
199
+ throw new Error("Expected 1 request, got " + this.requests.length);
200
+ }
201
+
202
+ return this.getLastRequest();
203
+ }
204
+
205
+ /**
206
+ * Gets the last request received
207
+ */
208
+ getLastRequest(): WebhookRequest {
209
+ const lastRequest = this.requests.at(-1);
210
+ if (!lastRequest) {
211
+ throw new Error("No requests received");
212
+ }
213
+
214
+ return lastRequest;
215
+ }
216
+
217
+ /**
218
+ * Checks if the server is running
219
+ */
220
+ isRunning(): boolean {
221
+ return this.server.listening;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Factory function to create and start a webhook test server
227
+ * @deprecated Use WebhookTestServer class directly
228
+ */
229
+ export async function createWebhookTestServer(): Promise<WebhookTestServer> {
230
+ const server = new WebhookTestServer();
231
+ await server.start();
232
+ return server;
233
+ }
@@ -0,0 +1,12 @@
1
+ import { beforeEach, afterEach } from "vitest";
2
+ import { setupJazzTestSync } from "jazz-tools/testing";
3
+
4
+ // Global test setup
5
+ beforeEach(async () => {
6
+ await setupJazzTestSync();
7
+ });
8
+
9
+ // Clean up after each test
10
+ afterEach(async () => {
11
+ // Clean up any global state if needed
12
+ });