move to package
parent
bbf3c6f57d
commit
a81e5427a7
@ -1,224 +0,0 @@
|
||||
import { cache } from "beth-jsx";
|
||||
import { Database } from "bun:sqlite";
|
||||
|
||||
class BethPersistCache {
|
||||
private callBackMap: Map<
|
||||
string,
|
||||
{
|
||||
callBack: () => Promise<any>;
|
||||
tags: string[];
|
||||
cache: "memory" | "json";
|
||||
}
|
||||
>;
|
||||
private pendingMap: Map<string, Promise<any>>;
|
||||
private inMemoryDataCache: Map<string, any>;
|
||||
private jsonDataCache: Database;
|
||||
private intervals: Set<NodeJS.Timeout>;
|
||||
private keys: Set<string>;
|
||||
|
||||
constructor() {
|
||||
this.callBackMap = new Map();
|
||||
this.inMemoryDataCache = new Map();
|
||||
this.jsonDataCache = new Database("beth-cache.sqlite");
|
||||
this.intervals = new Set();
|
||||
this.pendingMap = new Map();
|
||||
this.keys = new Set();
|
||||
|
||||
this.jsonDataCache.run(`
|
||||
DROP TABLE IF EXISTS cache;
|
||||
`);
|
||||
|
||||
this.jsonDataCache.run(`
|
||||
CREATE TABLE cache (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
private setJsonCache(key: string, value: any) {
|
||||
this.jsonDataCache.run(
|
||||
`
|
||||
INSERT INTO cache (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = excluded.value;
|
||||
`,
|
||||
[key, JSON.stringify(value)]
|
||||
);
|
||||
}
|
||||
|
||||
private getJsonCache(key: string) {
|
||||
console.log("JSON Cache HIT:", key);
|
||||
const result = this.jsonDataCache
|
||||
.query("SELECT value FROM cache WHERE key = ?")
|
||||
.get(key) as { value: string } | undefined;
|
||||
if (!result) throw new Error("JSON Cache Miss");
|
||||
return JSON.parse(result.value);
|
||||
}
|
||||
|
||||
public seed({
|
||||
key,
|
||||
tags,
|
||||
revalidate,
|
||||
callBack,
|
||||
cache,
|
||||
}: {
|
||||
cache: "memory" | "json";
|
||||
callBack: () => Promise<any>;
|
||||
key: string;
|
||||
tags: string[];
|
||||
revalidate: number;
|
||||
}) {
|
||||
if (this.keys.has(key)) {
|
||||
throw new Error(
|
||||
`Persistant Cache Key already exists: ${key} - these much be unqiue across your entire app`
|
||||
);
|
||||
} else {
|
||||
this.keys.add(key);
|
||||
}
|
||||
|
||||
const promise = callBack();
|
||||
this.pendingMap.set(key, promise);
|
||||
|
||||
promise.then((value) => {
|
||||
this.pendingMap.delete(key);
|
||||
console.log(`Seeding ${cache} Cache:`, key);
|
||||
if (cache === "memory") {
|
||||
this.inMemoryDataCache.set(key, value);
|
||||
} else if (cache === "json") {
|
||||
this.setJsonCache(key, value);
|
||||
}
|
||||
this.callBackMap.set(key, {
|
||||
callBack,
|
||||
tags,
|
||||
cache,
|
||||
});
|
||||
if (revalidate > 0) {
|
||||
this.setInterval(key, revalidate);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private rerunCallBack(key: string) {
|
||||
const pending = this.pendingMap.get(key);
|
||||
if (pending) {
|
||||
console.log("PENDING CACHE HIT:", key);
|
||||
return pending;
|
||||
}
|
||||
|
||||
console.log("rerunning callback:", key);
|
||||
const result = this.callBackMap.get(key);
|
||||
if (!result) {
|
||||
throw new Error("No callback found for key: " + key);
|
||||
}
|
||||
const { callBack, tags, cache } = result;
|
||||
const callBackPromise = callBack();
|
||||
this.pendingMap.set(key, callBackPromise);
|
||||
callBackPromise.then((value) => {
|
||||
if (cache === "memory") {
|
||||
this.inMemoryDataCache.set(key, value);
|
||||
} else if (cache === "json") {
|
||||
this.setJsonCache(key, value);
|
||||
}
|
||||
this.callBackMap.set(key, {
|
||||
callBack,
|
||||
tags,
|
||||
cache,
|
||||
});
|
||||
this.pendingMap.delete(key);
|
||||
});
|
||||
return callBackPromise;
|
||||
}
|
||||
|
||||
private setInterval(key: string, revalidate: number) {
|
||||
if (revalidate === Infinity) {
|
||||
console.log("No revalidate interval for:", key);
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
console.log(`Cache Revalidating (on ${revalidate}s interval):`, key);
|
||||
this.rerunCallBack(key);
|
||||
}, revalidate * 1000);
|
||||
console.log("Setting Revalidate Interval:", key, revalidate);
|
||||
this.intervals.add(interval);
|
||||
}
|
||||
|
||||
private getMemoryCache(key: string) {
|
||||
const cacheResult = this.inMemoryDataCache.get(key);
|
||||
if (cacheResult) {
|
||||
console.log("Memory Cache HIT:", key);
|
||||
return cacheResult;
|
||||
} else {
|
||||
throw new Error("Memory Cache Miss");
|
||||
}
|
||||
}
|
||||
|
||||
public revalidateTag(tag: string) {
|
||||
console.log("Revalidating tag:", tag);
|
||||
this.callBackMap.forEach((value, key) => {
|
||||
if (value.tags.includes(tag)) {
|
||||
this.rerunCallBack(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public getCachedValue(key: string, cache: "memory" | "json") {
|
||||
try {
|
||||
const pending = this.pendingMap.get(key);
|
||||
if (pending) {
|
||||
console.log("PENDING CACHE HIT:", key);
|
||||
return pending;
|
||||
}
|
||||
|
||||
if (cache === "memory") {
|
||||
return this.getMemoryCache(key);
|
||||
} else if (cache === "json") {
|
||||
return this.getJsonCache(key);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
if (e.message === "Memory Cache Miss") {
|
||||
return this.rerunCallBack(key);
|
||||
} else if (e.message === "JSON Cache Miss") {
|
||||
return this.rerunCallBack(key);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const GLOBAL_CACHE = new BethPersistCache();
|
||||
|
||||
type CacheOptions = {
|
||||
persist?: "memory" | "json";
|
||||
revalidate?: number;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export function persistedCache<T extends () => Promise<any>>(
|
||||
callBack: T,
|
||||
key: string,
|
||||
options?: CacheOptions
|
||||
): T {
|
||||
const persist = options?.persist ?? "json";
|
||||
const revalidate = options?.revalidate ?? Infinity;
|
||||
const tags = options?.tags ?? [];
|
||||
|
||||
console.log("Cache MISS: ", key);
|
||||
GLOBAL_CACHE.seed({
|
||||
callBack,
|
||||
key,
|
||||
tags,
|
||||
revalidate,
|
||||
cache: persist,
|
||||
});
|
||||
return cache(() => GLOBAL_CACHE.getCachedValue(key, persist)) as T;
|
||||
}
|
||||
|
||||
export function revalidateTag(tag: string) {
|
||||
GLOBAL_CACHE.revalidateTag(tag);
|
||||
}
|
@ -1,552 +0,0 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { persistedCache, revalidateTag } from "../cache";
|
||||
import "beth-jsx/register";
|
||||
import { renderToString } from "beth-jsx";
|
||||
|
||||
test("static json cache", async () => {
|
||||
let count = 0;
|
||||
const getCount = async () => ++count;
|
||||
const cachedGetCount = persistedCache(getCount, "getCount1");
|
||||
|
||||
const Component = async () => {
|
||||
const data = await cachedGetCount();
|
||||
return <p>number: {data}</p>;
|
||||
};
|
||||
|
||||
const html = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
// This should result in no 'cache hit' log, because the render cache is never reset from the previous render
|
||||
const html2 = await (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
);
|
||||
|
||||
expect(html2).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
// even in a new render we get the same results
|
||||
const Test = () => <Component />;
|
||||
|
||||
const html3 = await renderToString(() => (
|
||||
<>
|
||||
<Test />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html3).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
});
|
||||
|
||||
test("static memory cache", async () => {
|
||||
let count = 0;
|
||||
const getCount = async () => ++count;
|
||||
const cachedGetCount = persistedCache(getCount, "getCount2", {
|
||||
persist: "memory",
|
||||
});
|
||||
|
||||
const Component = async () => {
|
||||
const data = await cachedGetCount();
|
||||
return <p>number: {data}</p>;
|
||||
};
|
||||
|
||||
const html = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
// even in a new render we get the same results
|
||||
const Test = () => <Component />;
|
||||
|
||||
const html3 = await renderToString(() => (
|
||||
<>
|
||||
<Test />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html3).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
});
|
||||
|
||||
test("json cache revalidate interval", async () => {
|
||||
let count = 0;
|
||||
const getCount = async () => ++count;
|
||||
const cachedGetCount = persistedCache(getCount, "getCount3", {
|
||||
revalidate: 1,
|
||||
});
|
||||
|
||||
const Component = async () => {
|
||||
const data = await cachedGetCount();
|
||||
return <p>number: {data}</p>;
|
||||
};
|
||||
|
||||
const html = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
count++;
|
||||
|
||||
// should the be same right away
|
||||
|
||||
const html2 = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html2).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
// and the same until a second has passed
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(async () => {
|
||||
const html3 = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html3).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
resolve(void 0);
|
||||
}, 500)
|
||||
);
|
||||
|
||||
// but after a second it should be different
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(async () => {
|
||||
const html3 = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html3).toBe(`<p>number: 3</p><p>number: 3</p>`);
|
||||
|
||||
resolve(void 0);
|
||||
}, 1100)
|
||||
);
|
||||
});
|
||||
|
||||
test("memory cache revalidate interval", async () => {
|
||||
let count = 0;
|
||||
const getCount = async () => ++count;
|
||||
const cachedGetCount = persistedCache(getCount, "getCount4", {
|
||||
persist: "memory",
|
||||
revalidate: 1,
|
||||
});
|
||||
|
||||
const Component = async () => {
|
||||
const data = await cachedGetCount();
|
||||
return <p>number: {data}</p>;
|
||||
};
|
||||
|
||||
const html = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
count++;
|
||||
|
||||
// should the be same right away
|
||||
|
||||
const html2 = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html2).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
// and the same until a second has passed
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(async () => {
|
||||
const html3 = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html3).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
resolve(void 0);
|
||||
}, 500)
|
||||
);
|
||||
|
||||
// but after a second it should be different
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(async () => {
|
||||
const html3 = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html3).toBe(`<p>number: 3</p><p>number: 3</p>`);
|
||||
|
||||
resolve(void 0);
|
||||
}, 1100)
|
||||
);
|
||||
});
|
||||
|
||||
test("json cache revalidate tag", async () => {
|
||||
let count = 0;
|
||||
const getCount = async () => ++count;
|
||||
const cachedGetCount = persistedCache(getCount, "getCount5", {
|
||||
tags: ["tag1"],
|
||||
});
|
||||
|
||||
const Component = async () => {
|
||||
const data = await cachedGetCount();
|
||||
return <p>number: {data}</p>;
|
||||
};
|
||||
|
||||
const html = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
count++;
|
||||
|
||||
// should the be same right away
|
||||
|
||||
const html2 = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html2).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
revalidateTag("tag1");
|
||||
|
||||
// now should be different
|
||||
|
||||
const html3 = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html3).toBe(`<p>number: 3</p><p>number: 3</p>`);
|
||||
});
|
||||
|
||||
test("memory cache revalidate tag", async () => {
|
||||
let count = 0;
|
||||
const getCount = async () => ++count;
|
||||
const cachedGetCount = persistedCache(getCount, "getCount6", {
|
||||
tags: ["tag1"],
|
||||
persist: "memory",
|
||||
});
|
||||
|
||||
const Component = async () => {
|
||||
const data = await cachedGetCount();
|
||||
return <p>number: {data}</p>;
|
||||
};
|
||||
|
||||
const html = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
count++;
|
||||
|
||||
// should the be same right away
|
||||
|
||||
const html2 = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html2).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
revalidateTag("tag1");
|
||||
|
||||
// now should be different
|
||||
|
||||
const html3 = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html3).toBe(`<p>number: 3</p><p>number: 3</p>`);
|
||||
});
|
||||
|
||||
test("request during interval revalidation", async () => {
|
||||
let count = 0;
|
||||
const getCount = async () =>
|
||||
new Promise((resolve) => setTimeout(() => resolve(++count), 100));
|
||||
const cachedGetCount = persistedCache(getCount, "getCount7", {
|
||||
revalidate: 1,
|
||||
});
|
||||
|
||||
const Component = async () => {
|
||||
const data = await cachedGetCount();
|
||||
return <p>number: {data}</p>;
|
||||
};
|
||||
|
||||
const html = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
// cache request goes off during revalidation
|
||||
// should result in 'pending cache hit' log + updated data
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(async () => {
|
||||
const html3 = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html3).toBe(`<p>number: 2</p><p>number: 2</p>`);
|
||||
|
||||
resolve(void 0);
|
||||
}, 1010)
|
||||
);
|
||||
});
|
||||
|
||||
test("request during tag revalidation", async () => {
|
||||
let count = 0;
|
||||
const getCount = async () =>
|
||||
new Promise((resolve) => setTimeout(() => resolve(++count), 100));
|
||||
const cachedGetCount = persistedCache(getCount, "getCount8", {
|
||||
tags: ["tag1"],
|
||||
});
|
||||
|
||||
const Component = async () => {
|
||||
const data = await cachedGetCount();
|
||||
return <p>number: {data}</p>;
|
||||
};
|
||||
|
||||
const html = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
setTimeout(() => {
|
||||
count++;
|
||||
revalidateTag("tag1");
|
||||
}, 1000);
|
||||
|
||||
// cache request goes off during revalidation
|
||||
// should result in 'pending cache hit' log + updated data
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(async () => {
|
||||
const html3 = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html3).toBe(`<p>number: 3</p><p>number: 3</p>`);
|
||||
|
||||
resolve(void 0);
|
||||
}, 1010)
|
||||
);
|
||||
});
|
||||
|
||||
test("interval during tag revalidation", async () => {
|
||||
let count = 0;
|
||||
const getCount = async () =>
|
||||
new Promise((resolve) => setTimeout(() => resolve(++count), 300));
|
||||
const cachedGetCount = persistedCache(getCount, "getCount9", {
|
||||
tags: ["tag1"],
|
||||
revalidate: 1,
|
||||
});
|
||||
|
||||
const Component = async () => {
|
||||
const data = await cachedGetCount();
|
||||
return <p>number: {data}</p>;
|
||||
};
|
||||
|
||||
const html = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
setTimeout(() => {
|
||||
count++;
|
||||
revalidateTag("tag1");
|
||||
}, 900);
|
||||
|
||||
// should see pending cache hit for interval revalidation
|
||||
|
||||
// cache request goes off during revalidation
|
||||
// should result in 2nd 'pending cache hit' log + updated data
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(async () => {
|
||||
const html3 = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html3).toBe(`<p>number: 3</p><p>number: 3</p>`);
|
||||
|
||||
resolve(void 0);
|
||||
}, 1100)
|
||||
);
|
||||
});
|
||||
|
||||
test("interval during tag revalidation", async () => {
|
||||
let count = 0;
|
||||
const getCount = async () =>
|
||||
new Promise((resolve) => setTimeout(() => resolve(++count), 300));
|
||||
const cachedGetCount = persistedCache(getCount, "getCount10", {
|
||||
tags: ["tag1"],
|
||||
revalidate: 1,
|
||||
});
|
||||
|
||||
const Component = async () => {
|
||||
const data = await cachedGetCount();
|
||||
return <p>number: {data}</p>;
|
||||
};
|
||||
|
||||
const html = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html).toBe(`<p>number: 1</p><p>number: 1</p>`);
|
||||
|
||||
setTimeout(() => {
|
||||
count++;
|
||||
revalidateTag("tag1");
|
||||
}, 1100);
|
||||
|
||||
// should see pending cache hit for tag revalidation
|
||||
|
||||
// cache request goes off during revalidation
|
||||
// should result in 2nd 'pending cache hit' log + updated data
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(async () => {
|
||||
const html3 = await renderToString(() => (
|
||||
<>
|
||||
<Component />
|
||||
<Component />
|
||||
</>
|
||||
));
|
||||
|
||||
expect(html3).toBe(`<p>number: 3</p><p>number: 3</p>`);
|
||||
|
||||
resolve(void 0);
|
||||
}, 1150)
|
||||
);
|
||||
});
|
||||
|
||||
test("complex object storage to memory", async () => {
|
||||
const getData = async () => ({
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
d: 3,
|
||||
e: [4, 5, 6],
|
||||
},
|
||||
});
|
||||
|
||||
const cachedGetData = persistedCache(getData, "getData1", {
|
||||
persist: "memory",
|
||||
});
|
||||
|
||||
const data = await cachedGetData();
|
||||
|
||||
expect(data).toStrictEqual({
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
d: 3,
|
||||
e: [4, 5, 6],
|
||||
},
|
||||
});
|
||||
});
|
||||
test("complex object storage to json", async () => {
|
||||
const getData = async () => ({
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
d: 3,
|
||||
e: [4, 5, 6],
|
||||
},
|
||||
});
|
||||
|
||||
const cachedGetData = persistedCache(getData, "getData2", {
|
||||
persist: "json",
|
||||
});
|
||||
|
||||
const data = await cachedGetData();
|
||||
|
||||
expect(data).toStrictEqual({
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
d: 3,
|
||||
e: [4, 5, 6],
|
||||
},
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue