bundis 0.1.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/src/launch.ts ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Public launchers for using bundis from another project.
3
+ *
4
+ * Two ways to run the server (both return a ready-to-use `redis://` URL for a
5
+ * stock `Bun.RedisClient`):
6
+ *
7
+ * - {@link embedServer} — in the **current** process. Zero IPC overhead, but
8
+ * shares the event loop with the host app (bun:sqlite is synchronous).
9
+ * - {@link spawnServer} — in a **separate** Bun process. Isolates the SQLite
10
+ * writer and any blocking work from the host app; resolves once the child
11
+ * signals readiness on stdout.
12
+ */
13
+
14
+ import { startServer, type RunningServer } from "./server";
15
+ import type { ServerConfig } from "./config";
16
+
17
+ /** stdout JSON `event` value the CLI prints once it is accepting connections. */
18
+ export const READY_EVENT = "bundis:ready";
19
+
20
+ export interface LaunchOptions {
21
+ /** Bind address (default "127.0.0.1"; use "0.0.0.0" to expose externally). */
22
+ host?: string;
23
+ /** TCP port (default 6379; 0 = pick an ephemeral port). */
24
+ port?: number;
25
+ /** SQLite file path (default "./data.db"; ":memory:" for non-persistent). */
26
+ dbPath?: string;
27
+ /** When set, clients must AUTH with this password. */
28
+ password?: string;
29
+ /** Active-expiry sweep interval in ms (default 100). */
30
+ reaperIntervalMs?: number;
31
+ }
32
+
33
+ export interface EmbeddedServer {
34
+ readonly host: string;
35
+ /** Actual bound port (resolves `port: 0`). */
36
+ readonly port: number;
37
+ /** Connection URL for a stock `new RedisClient(url)`. */
38
+ readonly url: string;
39
+ /** Underlying server handle (storage, pub/sub hub, config). */
40
+ readonly server: RunningServer;
41
+ /** Stop listening, stop the reaper, close storage. */
42
+ stop(): void;
43
+ }
44
+
45
+ /** Start the server inside the current Bun process (main-process mode). */
46
+ export function embedServer(opts: LaunchOptions = {}): EmbeddedServer {
47
+ const config = resolveConfig(opts);
48
+ const running = startServer(config);
49
+ return {
50
+ host: running.hostname,
51
+ port: running.port,
52
+ url: clientUrl(running.hostname, running.port, config.password),
53
+ server: running,
54
+ stop: () => running.stop(),
55
+ };
56
+ }
57
+
58
+ export interface SpawnedServer {
59
+ readonly host: string;
60
+ /** Actual bound port (resolves `port: 0`). */
61
+ readonly port: number;
62
+ /** Connection URL for a stock `new RedisClient(url)`. */
63
+ readonly url: string;
64
+ readonly pid: number;
65
+ /** Kill the child process and wait for it to exit. */
66
+ stop(): Promise<void>;
67
+ }
68
+
69
+ export interface SpawnServerOptions extends LaunchOptions {
70
+ /** Bun executable to launch with (default: the currently running bun). */
71
+ bunPath?: string;
72
+ /** Max ms to wait for the child's ready signal (default 10_000). */
73
+ readyTimeoutMs?: number;
74
+ }
75
+
76
+ /** Start the server as a separate Bun process (sidecar mode). */
77
+ export async function spawnServer(opts: SpawnServerOptions = {}): Promise<SpawnedServer> {
78
+ const config = resolveConfig(opts);
79
+ const cliPath = Bun.fileURLToPath(new URL("./cli.ts", import.meta.url));
80
+ const proc = Bun.spawn(
81
+ [
82
+ opts.bunPath ?? process.execPath,
83
+ cliPath,
84
+ "--host", config.host,
85
+ "--port", String(config.port),
86
+ "--db", config.dbPath,
87
+ ...(config.password !== null ? ["--password", config.password] : []),
88
+ "--reaper", String(config.reaperIntervalMs),
89
+ ],
90
+ { stdout: "pipe", stderr: "inherit" },
91
+ );
92
+
93
+ let ready: { host: string; port: number };
94
+ try {
95
+ ready = await waitForReady(proc.stdout, opts.readyTimeoutMs ?? 10_000);
96
+ } catch (err) {
97
+ proc.kill();
98
+ await proc.exited;
99
+ throw err;
100
+ }
101
+
102
+ return {
103
+ host: ready.host,
104
+ port: ready.port,
105
+ url: clientUrl(ready.host, ready.port, config.password),
106
+ pid: proc.pid,
107
+ async stop() {
108
+ proc.kill();
109
+ await proc.exited;
110
+ },
111
+ };
112
+ }
113
+
114
+ // ── helpers ──────────────────────────────────────────────────────────────---
115
+
116
+ function resolveConfig(opts: LaunchOptions): ServerConfig {
117
+ return {
118
+ host: opts.host ?? "127.0.0.1",
119
+ port: opts.port ?? 6379,
120
+ dbPath: opts.dbPath ?? "./data.db",
121
+ password: opts.password ?? null,
122
+ reaperIntervalMs: opts.reaperIntervalMs ?? 100,
123
+ };
124
+ }
125
+
126
+ function clientUrl(host: string, port: number, password: string | null): string {
127
+ // A wildcard bind address is not connectable; clients use loopback.
128
+ const h = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
129
+ const auth = password === null ? "" : `:${encodeURIComponent(password)}@`;
130
+ return `redis://${auth}${h}:${port}`;
131
+ }
132
+
133
+ /** Scan child stdout line-by-line until the READY_EVENT JSON line appears. */
134
+ async function waitForReady(
135
+ stdout: ReadableStream<Uint8Array>,
136
+ timeoutMs: number,
137
+ ): Promise<{ host: string; port: number }> {
138
+ const reader = stdout.getReader();
139
+ const decoder = new TextDecoder();
140
+ const deadline = Date.now() + timeoutMs;
141
+ let buf = "";
142
+ for (;;) {
143
+ const { done, value } = await Promise.race([
144
+ reader.read(),
145
+ rejectAfter(deadline - Date.now()),
146
+ ]);
147
+ if (done) {
148
+ reader.releaseLock();
149
+ throw new Error("bundis child exited before signalling ready");
150
+ }
151
+ buf += decoder.decode(value, { stream: true });
152
+ let nl: number;
153
+ while ((nl = buf.indexOf("\n")) !== -1) {
154
+ const line = buf.slice(0, nl).trim();
155
+ buf = buf.slice(nl + 1);
156
+ if (!line.startsWith("{")) continue;
157
+ try {
158
+ const msg = JSON.parse(line) as { event?: string; host?: string; port?: number };
159
+ if (msg.event === READY_EVENT && msg.host !== undefined && msg.port !== undefined) {
160
+ void drain(reader); // keep the pipe from filling if the child logs later
161
+ return { host: msg.host, port: msg.port };
162
+ }
163
+ } catch {
164
+ // not our line
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ async function drain(reader: { read(): Promise<{ done: boolean }> }): Promise<void> {
171
+ try {
172
+ while (!(await reader.read()).done) {
173
+ // discard
174
+ }
175
+ } catch {
176
+ // stream torn down with the process
177
+ }
178
+ }
179
+
180
+ function rejectAfter(ms: number): Promise<never> {
181
+ return new Promise((_, reject) => {
182
+ const t = setTimeout(
183
+ () => reject(new Error("timed out waiting for bundis ready signal")),
184
+ Math.max(0, ms),
185
+ );
186
+ (t as unknown as { unref?: () => void }).unref?.();
187
+ });
188
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * L2 inbound RESP parser (streaming).
3
+ *
4
+ * TCP has no message boundaries: {@link ../server.ts}'s `data` callback delivers
5
+ * arbitrary chunks. This parser keeps a per-connection accumulation buffer and
6
+ * yields complete commands only, preserving arrival order so pipelined commands
7
+ * stay in order (§4.1.1, §4.1.2).
8
+ *
9
+ * The stock `Bun.RedisClient` always sends commands as a RESP Array of Bulk
10
+ * Strings (`*N\r\n($len\r\n<bytes>\r\n)*`). We parse that shape, plus inline
11
+ * commands as a tolerant fallback. Argument bytes are preserved verbatim.
12
+ */
13
+
14
+ import type { Command } from "./types";
15
+
16
+ const CR = 13;
17
+ const LF = 10;
18
+ const STAR = 42; // '*'
19
+ const DOLLAR = 36; // '$'
20
+
21
+ export class RespParser {
22
+ /** Pending bytes not yet consumed into a full command. */
23
+ #buf: Uint8Array = new Uint8Array(0);
24
+
25
+ /** Append a freshly received chunk to the internal buffer. */
26
+ push(chunk: Uint8Array): void {
27
+ if (this.#buf.length === 0) {
28
+ this.#buf = chunk;
29
+ return;
30
+ }
31
+ const merged = new Uint8Array(this.#buf.length + chunk.length);
32
+ merged.set(this.#buf, 0);
33
+ merged.set(chunk, this.#buf.length);
34
+ this.#buf = merged;
35
+ }
36
+
37
+ /**
38
+ * Drain all complete commands currently buffered, in order.
39
+ * Incomplete trailing bytes are retained for the next {@link push}.
40
+ * Throws on malformed protocol (the caller should reply with an error and
41
+ * close the connection).
42
+ */
43
+ drain(): Command[] {
44
+ const out: Command[] = [];
45
+ let offset = 0;
46
+ for (;;) {
47
+ const res = this.#parseOne(offset);
48
+ if (res === null) break; // need more bytes
49
+ out.push(res.command);
50
+ offset = res.next;
51
+ }
52
+ if (offset > 0) {
53
+ this.#buf = this.#buf.subarray(offset);
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** Parse a single command starting at `start`, or null if incomplete. */
59
+ #parseOne(start: number): { command: Command; next: number } | null {
60
+ const buf = this.#buf;
61
+ if (start >= buf.length) return null;
62
+ const first = buf[start]!;
63
+ if (first === STAR) return this.#parseArray(start);
64
+ // Inline command fallback: a CRLF- or LF-terminated whitespace-split line.
65
+ return this.#parseInline(start);
66
+ }
67
+
68
+ #parseArray(start: number): { command: Command; next: number } | null {
69
+ const buf = this.#buf;
70
+ const header = this.#readLine(start);
71
+ if (header === null) return null;
72
+ const count = parseInt(decodeAscii(buf.subarray(start + 1, header.end)), 10);
73
+ if (Number.isNaN(count)) throw new ProtocolError("invalid multibulk length");
74
+ if (count <= 0) {
75
+ // Empty/negative multibulk: skip, no command produced. Represent as empty.
76
+ return { command: { name: "", args: [] }, next: header.next };
77
+ }
78
+ const args: Uint8Array[] = [];
79
+ let offset = header.next;
80
+ for (let i = 0; i < count; i++) {
81
+ if (offset >= buf.length) return null;
82
+ if (buf[offset] !== DOLLAR) throw new ProtocolError("expected bulk string");
83
+ const lenLine = this.#readLine(offset);
84
+ if (lenLine === null) return null;
85
+ const len = parseInt(decodeAscii(buf.subarray(offset + 1, lenLine.end)), 10);
86
+ if (Number.isNaN(len) || len < 0) throw new ProtocolError("invalid bulk length");
87
+ const dataStart = lenLine.next;
88
+ const dataEnd = dataStart + len;
89
+ if (dataEnd + 2 > buf.length) return null; // data + trailing CRLF not all here yet
90
+ args.push(buf.slice(dataStart, dataEnd)); // copy: detach from the shared buffer
91
+ offset = dataEnd + 2; // skip trailing CRLF
92
+ }
93
+ const name = decodeAscii(args[0]!).toUpperCase();
94
+ return { command: { name, args }, next: offset };
95
+ }
96
+
97
+ #parseInline(start: number): { command: Command; next: number } | null {
98
+ const line = this.#readLine(start);
99
+ if (line === null) return null;
100
+ const raw = this.#buf.subarray(start, line.end);
101
+ const text = decodeAscii(raw).trim();
102
+ if (text.length === 0) {
103
+ return { command: { name: "", args: [] }, next: line.next };
104
+ }
105
+ const tokens = text.split(/\s+/);
106
+ const args = tokens.map((t) => new TextEncoder().encode(t));
107
+ return { command: { name: tokens[0]!.toUpperCase(), args }, next: line.next };
108
+ }
109
+
110
+ /**
111
+ * Locate the next CRLF (or bare LF) at/after `from`.
112
+ * Returns `end` (index of CR/LF, exclusive of the terminator) and `next`
113
+ * (index just past the terminator), or null if no line terminator yet.
114
+ */
115
+ #readLine(from: number): { end: number; next: number } | null {
116
+ const buf = this.#buf;
117
+ for (let i = from; i < buf.length; i++) {
118
+ if (buf[i] === LF) {
119
+ const end = i > from && buf[i - 1] === CR ? i - 1 : i;
120
+ return { end, next: i + 1 };
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+ }
126
+
127
+ export class ProtocolError extends Error {}
128
+
129
+ function decodeAscii(bytes: Uint8Array): string {
130
+ let s = "";
131
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!);
132
+ return s;
133
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * L2 outbound RESP3 serializer.
3
+ *
4
+ * Walks a {@link Reply} tree and produces wire bytes. Bulk/verbatim lengths are
5
+ * computed in **bytes** (not characters) so binary-safe values and multi-byte
6
+ * UTF-8 are framed correctly. This is the only place RESP3 type bytes are chosen.
7
+ */
8
+
9
+ import type { Reply } from "./types";
10
+
11
+ const encoder = new TextEncoder();
12
+
13
+ /** Serialize one reply to a single Uint8Array. */
14
+ export function serialize(reply: Reply): Uint8Array {
15
+ const parts: Uint8Array[] = [];
16
+ write(reply, parts);
17
+ return concat(parts);
18
+ }
19
+
20
+ /** Serialize many replies back-to-back (one pipelined write). */
21
+ export function serializeAll(replies: readonly Reply[]): Uint8Array {
22
+ const parts: Uint8Array[] = [];
23
+ for (const r of replies) write(r, parts);
24
+ return concat(parts);
25
+ }
26
+
27
+ function write(reply: Reply, out: Uint8Array[]): void {
28
+ switch (reply.t) {
29
+ case "simple":
30
+ out.push(encoder.encode(`+${reply.v}\r\n`));
31
+ return;
32
+ case "error":
33
+ out.push(encoder.encode(`-${reply.code} ${reply.msg}\r\n`));
34
+ return;
35
+ case "int":
36
+ out.push(encoder.encode(`:${reply.v}\r\n`));
37
+ return;
38
+ case "bignum":
39
+ out.push(encoder.encode(`(${reply.v}\r\n`));
40
+ return;
41
+ case "bool":
42
+ out.push(encoder.encode(reply.v ? "#t\r\n" : "#f\r\n"));
43
+ return;
44
+ case "double":
45
+ out.push(encoder.encode(`,${formatDouble(reply.v)}\r\n`));
46
+ return;
47
+ case "null":
48
+ out.push(encoder.encode("_\r\n"));
49
+ return;
50
+ case "bulk": {
51
+ if (reply.v === null) {
52
+ out.push(encoder.encode("_\r\n")); // RESP3 null
53
+ return;
54
+ }
55
+ const body = typeof reply.v === "string" ? encoder.encode(reply.v) : reply.v;
56
+ out.push(encoder.encode(`$${body.length}\r\n`));
57
+ out.push(body);
58
+ out.push(CRLF);
59
+ return;
60
+ }
61
+ case "verbatim": {
62
+ const body = encoder.encode(`${reply.fmt}:${reply.v}`);
63
+ out.push(encoder.encode(`=${body.length}\r\n`));
64
+ out.push(body);
65
+ out.push(CRLF);
66
+ return;
67
+ }
68
+ case "array": {
69
+ if (reply.v === null) {
70
+ out.push(encoder.encode("*-1\r\n"));
71
+ return;
72
+ }
73
+ out.push(encoder.encode(`*${reply.v.length}\r\n`));
74
+ for (const el of reply.v) write(el, out);
75
+ return;
76
+ }
77
+ case "set":
78
+ out.push(encoder.encode(`~${reply.v.length}\r\n`));
79
+ for (const el of reply.v) write(el, out);
80
+ return;
81
+ case "push":
82
+ out.push(encoder.encode(`>${reply.v.length}\r\n`));
83
+ for (const el of reply.v) write(el, out);
84
+ return;
85
+ case "map":
86
+ out.push(encoder.encode(`%${reply.v.length}\r\n`));
87
+ for (const [k, val] of reply.v) {
88
+ write(k, out);
89
+ write(val, out);
90
+ }
91
+ return;
92
+ }
93
+ }
94
+
95
+ const CRLF = encoder.encode("\r\n");
96
+
97
+ function formatDouble(v: number): string {
98
+ if (Number.isNaN(v)) return "nan";
99
+ if (v === Infinity) return "inf";
100
+ if (v === -Infinity) return "-inf";
101
+ return String(v);
102
+ }
103
+
104
+ function concat(parts: Uint8Array[]): Uint8Array {
105
+ let total = 0;
106
+ for (const p of parts) total += p.length;
107
+ const out = new Uint8Array(total);
108
+ let offset = 0;
109
+ for (const p of parts) {
110
+ out.set(p, offset);
111
+ offset += p.length;
112
+ }
113
+ return out;
114
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * L2 RESP3 reply model.
3
+ *
4
+ * A {@link Reply} is a tagged union describing exactly which RESP3 type the
5
+ * server must emit. The serializer ({@link ./serializer.ts}) walks this tree and
6
+ * produces bytes. Keeping the wire type explicit (rather than inferring it from a
7
+ * JS value) is what lets us honor the §2.4 type-conversion contract precisely.
8
+ */
9
+
10
+ export type Reply =
11
+ | { readonly t: "simple"; readonly v: string } // +OK\r\n
12
+ | { readonly t: "error"; readonly code: string; readonly msg: string } // -CODE msg\r\n
13
+ | { readonly t: "int"; readonly v: number | bigint } // :1\r\n
14
+ | { readonly t: "bulk"; readonly v: Uint8Array | string | null } // $len\r\n..\r\n (null => RESP3 null)
15
+ | { readonly t: "null" } // _\r\n
16
+ | { readonly t: "array"; readonly v: readonly Reply[] | null } // *N\r\n.. (null => *-1)
17
+ | { readonly t: "map"; readonly v: ReadonlyArray<readonly [Reply, Reply]> } // %N\r\n..
18
+ | { readonly t: "set"; readonly v: readonly Reply[] } // ~N\r\n..
19
+ | { readonly t: "bool"; readonly v: boolean } // #t\r\n / #f\r\n
20
+ | { readonly t: "double"; readonly v: number } // ,3.14\r\n
21
+ | { readonly t: "bignum"; readonly v: bigint } // (123\r\n
22
+ | { readonly t: "verbatim"; readonly fmt: string; readonly v: string } // =len\r\ntxt:..\r\n
23
+ | { readonly t: "push"; readonly v: readonly Reply[] }; // >N\r\n..
24
+
25
+ /** Reply constructors. Use these instead of building object literals by hand. */
26
+ export const R = {
27
+ simple: (v: string): Reply => ({ t: "simple", v }),
28
+ ok: (): Reply => ({ t: "simple", v: "OK" }),
29
+ error: (code: string, msg: string): Reply => ({ t: "error", code, msg }),
30
+ int: (v: number | bigint): Reply => ({ t: "int", v }),
31
+ bulk: (v: Uint8Array | string | null): Reply => ({ t: "bulk", v }),
32
+ nullReply: (): Reply => ({ t: "null" }),
33
+ array: (v: readonly Reply[] | null): Reply => ({ t: "array", v }),
34
+ map: (v: ReadonlyArray<readonly [Reply, Reply]>): Reply => ({ t: "map", v }),
35
+ set: (v: readonly Reply[]): Reply => ({ t: "set", v }),
36
+ bool: (v: boolean): Reply => ({ t: "bool", v }),
37
+ double: (v: number): Reply => ({ t: "double", v }),
38
+ bignum: (v: bigint): Reply => ({ t: "bignum", v }),
39
+ verbatim: (fmt: string, v: string): Reply => ({ t: "verbatim", fmt, v }),
40
+ push: (v: readonly Reply[]): Reply => ({ t: "push", v }),
41
+ } as const;
42
+
43
+ /** A fully parsed inbound command: name is upper-cased ASCII, args keep raw bytes. */
44
+ export interface Command {
45
+ /** Upper-cased command name, e.g. "SET". */
46
+ readonly name: string;
47
+ /** All tokens including the command name, as raw bytes (binary-safe). */
48
+ readonly args: Uint8Array[];
49
+ }
package/src/server.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * L1 Transport — Bun.listen TCP server and socket lifecycle.
3
+ *
4
+ * Per connection (§4.1): accumulate bytes in the parser, drain complete commands
5
+ * in arrival order, dispatch each serially (handlers are synchronous over
6
+ * bun:sqlite), and write replies in order. Connection state lives on
7
+ * `socket.data`. Backpressure is handled via the `drain` callback.
8
+ */
9
+
10
+ import type { Socket, TCPSocketListener } from "bun";
11
+ import { Connection } from "./connection";
12
+ import { dispatch } from "./dispatcher";
13
+ import { ProtocolError } from "./resp/parser";
14
+ import { R } from "./resp/types";
15
+ import { SqliteStorage } from "./storage/sqlite";
16
+ import { PubSubHub } from "./sidecar/pubsub";
17
+ import { WatchRegistry } from "./sidecar/watch";
18
+ import { ExpiryReaper } from "./sidecar/reaper";
19
+ import type { ServerConfig } from "./config";
20
+ import type { ServerContext } from "./engine/context";
21
+
22
+ export interface RunningServer {
23
+ /** Actual bound port (resolves `port: 0` used by tests). */
24
+ readonly port: number;
25
+ readonly hostname: string;
26
+ readonly server: ServerContext;
27
+ /** Stop accepting connections, stop the reaper, and close storage. */
28
+ stop(): void;
29
+ }
30
+
31
+ export function startServer(config: ServerConfig): RunningServer {
32
+ const watch = new WatchRegistry();
33
+ const storage = new SqliteStorage(config.dbPath, { onWrite: watch.bump });
34
+ const hub = new PubSubHub();
35
+ const ctx: ServerContext = { storage, hub, watch, config };
36
+
37
+ const reaper = new ExpiryReaper(storage, config.reaperIntervalMs);
38
+ reaper.start();
39
+
40
+ const listener: TCPSocketListener<Connection> = Bun.listen<Connection>({
41
+ hostname: config.host,
42
+ port: config.port,
43
+ socket: {
44
+ open(socket: Socket<Connection>) {
45
+ socket.data = new Connection(socket);
46
+ },
47
+ data(socket: Socket<Connection>, chunk: Buffer) {
48
+ const conn = socket.data;
49
+ conn.parser.push(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
50
+ let commands;
51
+ try {
52
+ commands = conn.parser.drain();
53
+ } catch (err) {
54
+ if (err instanceof ProtocolError) {
55
+ conn.send(R.error("ERR", `Protocol error: ${err.message}`));
56
+ socket.end();
57
+ return;
58
+ }
59
+ throw err;
60
+ }
61
+ const now = Date.now();
62
+ for (const command of commands) {
63
+ dispatch(conn, command, ctx, now);
64
+ }
65
+ },
66
+ drain(socket: Socket<Connection>) {
67
+ socket.data?.flush();
68
+ },
69
+ close(socket: Socket<Connection>) {
70
+ const conn = socket.data;
71
+ if (conn) {
72
+ conn.markClosed();
73
+ hub.drop(conn);
74
+ }
75
+ },
76
+ error(socket: Socket<Connection>) {
77
+ socket.data?.markClosed();
78
+ },
79
+ },
80
+ });
81
+
82
+ return {
83
+ port: listener.port,
84
+ hostname: listener.hostname,
85
+ server: ctx,
86
+ stop() {
87
+ reaper.stop();
88
+ listener.stop(true);
89
+ storage.close();
90
+ },
91
+ };
92
+ }