@minnowdb/core 0.7.7 → 0.7.10
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/dist/engine/batch.d.ts +14 -1
- package/dist/engine/batch.js +23 -2
- package/dist/engine/buffered-writer.d.ts +7 -4
- package/dist/engine/client.d.ts +44 -27
- package/dist/engine/client.js +22 -10
- package/dist/engine/database.d.ts +40 -22
- package/dist/engine/database.js +489 -38
- package/dist/engine/keyed-live.js +55 -18
- package/dist/engine/live.d.ts +95 -8
- package/dist/engine/live.js +311 -148
- package/dist/engine/schema.d.ts +85 -2
- package/dist/engine/typed-live.d.ts +12 -1
- package/dist/engine/typed-live.js +89 -12
- package/dist/engine/worker-server.js +12 -5
- package/dist/worker-protocol/index.d.ts +1 -1
- package/dist/worker-protocol/index.js +1 -1
- package/package.json +1 -1
package/dist/engine/schema.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { type ColumnDefault, type SqlDomain, type TableColumnRecord, type TableRecord } from "../storage/types.js";
|
|
2
|
+
import type { ComparisonOperator } from "../plan/model.js";
|
|
3
|
+
import type { BatchRow, BatchValue, InsertBatchInput } from "./batch.js";
|
|
2
4
|
import { type Catalog, type CatalogColumn, type CatalogTable } from "./catalog.js";
|
|
5
|
+
import type { DatabaseRow, DeleteBatchInput, UpdateBatchInput, UpsertOptions } from "./database.js";
|
|
3
6
|
import { type Expression, type QueryValue } from "./query.js";
|
|
4
7
|
/**
|
|
5
8
|
* Typed schema DSL and catalog migration planning. Column builders carry compile-time value and
|
|
@@ -313,8 +316,8 @@ export declare function schema<TTables extends readonly AnyTable[], TViews exten
|
|
|
313
316
|
}): SchemaDefinition<TTables, TViews>;
|
|
314
317
|
type ColumnTypeMetadata<TColumn> = TColumn extends {
|
|
315
318
|
readonly "~types"?: infer TMetadata extends {
|
|
316
|
-
readonly select:
|
|
317
|
-
readonly input:
|
|
319
|
+
readonly select: SchemaValue;
|
|
320
|
+
readonly input: SchemaValue;
|
|
318
321
|
};
|
|
319
322
|
} ? NonNullable<TMetadata> : never;
|
|
320
323
|
type ColumnValue<TColumn extends AnyColumn> = TColumn["isNullable"] extends true ? ColumnTypeMetadata<TColumn>["select"] | null : ColumnTypeMetadata<TColumn>["select"];
|
|
@@ -359,6 +362,86 @@ export type PrimaryKeyKeys<TTable extends AnyTable> = {
|
|
|
359
362
|
export type InferUpdateChanges<TTable extends AnyTable> = {
|
|
360
363
|
[K in keyof TTable["columns"] as K extends PrimaryKeyKeys<TTable> | GeneratedKeys<TTable> ? never : K]?: ColumnInputValue<TTable["columns"][K]> | undefined;
|
|
361
364
|
};
|
|
365
|
+
/** The bound every schema type parameter carries. */
|
|
366
|
+
export type AnySchema = SchemaDefinition<readonly AnyTable[]>;
|
|
367
|
+
/**
|
|
368
|
+
* The schema type parameter's default: no declaration, so the batch API addresses tables by
|
|
369
|
+
* plain string. It is `any` rather than `AnySchema` so that a database declared against a schema
|
|
370
|
+
* remains assignable to the undeclared class — `MinnowDatabase<typeof mine>` is a
|
|
371
|
+
* `MinnowDatabase` — which no ordinary type argument can express once table names are checked.
|
|
372
|
+
* Every type below resolves to its erased branch for it, so nothing else about it is `any`.
|
|
373
|
+
*/
|
|
374
|
+
export type UntypedSchema = any;
|
|
375
|
+
type SchemaTable<TSchema extends AnySchema> = TSchema["tables"][number];
|
|
376
|
+
/** True when the schema carries no literal table names — the untyped default. */
|
|
377
|
+
type ErasedSchema<TSchema extends AnySchema> = string extends SchemaTable<TSchema>["name"] ? true : false;
|
|
378
|
+
/** The declared table names; `string` for an untyped database. */
|
|
379
|
+
export type TableName<TSchema extends AnySchema> = SchemaTable<TSchema>["name"];
|
|
380
|
+
/** The declaration behind one table name. */
|
|
381
|
+
export type TableOf<TSchema extends AnySchema, TName extends TableName<TSchema>> = Extract<SchemaTable<TSchema>, {
|
|
382
|
+
readonly name: TName;
|
|
383
|
+
}>;
|
|
384
|
+
/** A declared table's column names; `string` for an untyped database. */
|
|
385
|
+
export type BatchColumnName<TSchema extends AnySchema, TName extends TableName<TSchema>> = ErasedSchema<TSchema> extends true ? string : keyof TableOf<TSchema, TName>["columns"] & string;
|
|
386
|
+
/** The columnar form of `InferInsertRow`: one vector per column, optional where the row is. */
|
|
387
|
+
export interface ColumnarInsertBatch<TTable extends AnyTable> {
|
|
388
|
+
readonly columns: {
|
|
389
|
+
readonly [K in RequiredInsertKeys<TTable>]: ReadonlyArray<ColumnInputValue<TTable["columns"][K]>>;
|
|
390
|
+
} & {
|
|
391
|
+
readonly [K in OptionalInsertKeys<TTable>]?: ReadonlyArray<ColumnInputValue<TTable["columns"][K]>>;
|
|
392
|
+
};
|
|
393
|
+
/** Per row, whether the column was omitted (takes its default) rather than set to NULL. */
|
|
394
|
+
readonly omitted?: Partial<Readonly<Record<OptionalInsertKeys<TTable>, readonly boolean[]>>>;
|
|
395
|
+
readonly rowCount?: number;
|
|
396
|
+
}
|
|
397
|
+
/** What `insertBatch` and `upsertBatch` accept for one table: typed rows, or a typed columnar batch. */
|
|
398
|
+
export type BatchInsertInput<TSchema extends AnySchema, TName extends TableName<TSchema>> = ErasedSchema<TSchema> extends true ? InsertBatchInput : ReadonlyArray<InferInsertRow<TableOf<TSchema, TName>>> | ColumnarInsertBatch<TableOf<TSchema, TName>>;
|
|
399
|
+
/** One row for `insert`, `upsert`, or a buffered writer's `add`. */
|
|
400
|
+
export type BatchInsertRow<TSchema extends AnySchema, TName extends TableName<TSchema>> = ErasedSchema<TSchema> extends true ? BatchRow : InferInsertRow<TableOf<TSchema, TName>>;
|
|
401
|
+
/** What `readTable` returns per row. */
|
|
402
|
+
export type BatchReadRow<TSchema extends AnySchema, TName extends TableName<TSchema>> = ErasedSchema<TSchema> extends true ? DatabaseRow : InferRow<TableOf<TSchema, TName>>;
|
|
403
|
+
/**
|
|
404
|
+
* The key a batch write addresses a row by: the `.unique()` column or a one-column table-level
|
|
405
|
+
* primary key. A composite primary key is addressed through its hidden locator, which the batch
|
|
406
|
+
* API does not expose, so those tables get `never` here and are written through SQL instead.
|
|
407
|
+
*/
|
|
408
|
+
type BatchKey<TTable extends AnyTable> = TTable["primaryKey"] extends readonly [
|
|
409
|
+
unknown,
|
|
410
|
+
unknown,
|
|
411
|
+
...unknown[]
|
|
412
|
+
] ? never : TTable["primaryKey"] extends readonly [infer TOnly extends keyof TTable["columns"]] ? NonNullable<ColumnValue<TTable["columns"][TOnly]>> : NonNullable<ScalarUniqueKeyValue<TTable>>;
|
|
413
|
+
/** One key value for `update` and `delete`. */
|
|
414
|
+
export type BatchKeyValue<TSchema extends AnySchema, TName extends TableName<TSchema>> = ErasedSchema<TSchema> extends true ? Exclude<BatchValue, null> : BatchKey<TableOf<TSchema, TName>>;
|
|
415
|
+
/** The changes object `update` takes: every non-key, non-generated column, each optional. */
|
|
416
|
+
export type BatchUpdateChanges<TSchema extends AnySchema, TName extends TableName<TSchema>> = ErasedSchema<TSchema> extends true ? Readonly<Record<string, BatchValue>> : InferUpdateChanges<TableOf<TSchema, TName>>;
|
|
417
|
+
/** What `updateBatch` takes: typed keys and one vector per changed column. */
|
|
418
|
+
export type BatchUpdateInput<TSchema extends AnySchema, TName extends TableName<TSchema>> = ErasedSchema<TSchema> extends true ? UpdateBatchInput : {
|
|
419
|
+
readonly keys: ReadonlyArray<BatchKey<TableOf<TSchema, TName>>>;
|
|
420
|
+
readonly changes: {
|
|
421
|
+
readonly [K in keyof InferUpdateChanges<TableOf<TSchema, TName>>]?: ReadonlyArray<Exclude<InferUpdateChanges<TableOf<TSchema, TName>>[K], undefined>>;
|
|
422
|
+
};
|
|
423
|
+
};
|
|
424
|
+
/** What `deleteBatch` takes. */
|
|
425
|
+
export type BatchDeleteInput<TSchema extends AnySchema, TName extends TableName<TSchema>> = ErasedSchema<TSchema> extends true ? DeleteBatchInput : {
|
|
426
|
+
readonly keys: ReadonlyArray<BatchKey<TableOf<TSchema, TName>>>;
|
|
427
|
+
};
|
|
428
|
+
/** A `conflictWhere` guard whose `value` follows the column it names. */
|
|
429
|
+
export type BatchConflictWhere<TTable extends AnyTable> = {
|
|
430
|
+
[K in keyof TTable["columns"] & string]: {
|
|
431
|
+
readonly column: K;
|
|
432
|
+
readonly operator: ComparisonOperator;
|
|
433
|
+
readonly value: ColumnInputValue<TTable["columns"][K]>;
|
|
434
|
+
};
|
|
435
|
+
}[keyof TTable["columns"] & string];
|
|
436
|
+
/** `upsertBatch`/`upsert` options, with the guard typed against the table. */
|
|
437
|
+
export type BatchUpsertOptions<TSchema extends AnySchema, TName extends TableName<TSchema>> = ErasedSchema<TSchema> extends true ? UpsertOptions : {
|
|
438
|
+
readonly conflictWhere?: BatchConflictWhere<TableOf<TSchema, TName>>;
|
|
439
|
+
};
|
|
440
|
+
/** `readTable` options; naming `columns` narrows the returned rows to those columns. */
|
|
441
|
+
export interface BatchReadOptions<TSchema extends AnySchema, TName extends TableName<TSchema>, TColumns extends ReadonlyArray<BatchColumnName<TSchema, TName>> = ReadonlyArray<BatchColumnName<TSchema, TName>>> {
|
|
442
|
+
readonly version?: number;
|
|
443
|
+
readonly columns?: TColumns;
|
|
444
|
+
}
|
|
362
445
|
export type MigrationStep = {
|
|
363
446
|
kind: "create-table";
|
|
364
447
|
table: AnyTable;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import type { LiveQueryInput, LiveQueryObserveOptions } from "./live.js";
|
|
1
|
+
import type { LiveQueryInput, LiveQueryObserveOptions, LiveQuerySubscribeOptions } from "./live.js";
|
|
2
|
+
import type { QueryResult } from "./query.js";
|
|
2
3
|
/** The structural live-query surface shared by MinnowDatabase and its worker client. */
|
|
3
4
|
export interface LiveQueryBackend {
|
|
4
5
|
observe(query: LiveQueryInput, options: LiveQueryObserveOptions): Promise<LiveQuerySubscriptionLike>;
|
|
6
|
+
/** Result delivery, used when the source can decode the engine's result itself. */
|
|
7
|
+
subscribe?(query: LiveQueryInput, options: LiveQuerySubscribeOptions): Promise<LiveQuerySubscriptionLike>;
|
|
5
8
|
refresh(): Promise<void>;
|
|
6
9
|
close(): void | Promise<void>;
|
|
7
10
|
}
|
|
@@ -22,6 +25,14 @@ export interface LiveQueryDriver {
|
|
|
22
25
|
export interface LiveQuerySource<out TRow> {
|
|
23
26
|
readonly query: LiveQueryInput;
|
|
24
27
|
execute(signal?: AbortSignal): Promise<readonly TRow[]>;
|
|
28
|
+
/**
|
|
29
|
+
* Turns a result the engine delivered into the adapter's rows. With it, the query subscribes
|
|
30
|
+
* for results rather than invalidations: the engine executes or patches the statement where
|
|
31
|
+
* the data is, compares, and hands over a changed result once — over a worker channel, as
|
|
32
|
+
* one columnar transfer — and `execute` is never called after the statement is registered.
|
|
33
|
+
* Without it, an invalidation is followed by `execute`, which the engine's memo serves.
|
|
34
|
+
*/
|
|
35
|
+
decode?(result: QueryResult): readonly TRow[] | Promise<readonly TRow[]>;
|
|
25
36
|
}
|
|
26
37
|
export type LiveSnapshot<TRow> = {
|
|
27
38
|
readonly status: "loading";
|
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { sameLiveValue } from "./live-equal.js";
|
|
2
|
-
function
|
|
3
|
-
|
|
2
|
+
function reconcileRows(previous, next, retained) {
|
|
3
|
+
const rows = new Array(next.length);
|
|
4
|
+
let changed = previous.length !== next.length;
|
|
5
|
+
const provenance = retained?.length === next.length ? retained : void 0;
|
|
6
|
+
for (let index = 0; index < next.length; index += 1) {
|
|
7
|
+
const row = next[index];
|
|
8
|
+
const was = provenance?.[index] ?? -1;
|
|
9
|
+
if (was >= 0 && was < previous.length) {
|
|
10
|
+
rows[index] = previous[was];
|
|
11
|
+
if (was !== index)
|
|
12
|
+
changed = true;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const before = previous[index];
|
|
16
|
+
if (index < previous.length && sameLiveValue(before, row))
|
|
17
|
+
rows[index] = before;
|
|
18
|
+
else {
|
|
19
|
+
rows[index] = row;
|
|
20
|
+
changed = true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return changed ? Object.freeze(rows) : void 0;
|
|
4
24
|
}
|
|
5
25
|
class LiveQuery {
|
|
6
26
|
#listeners = /* @__PURE__ */ new Set();
|
|
@@ -11,6 +31,9 @@ class LiveQuery {
|
|
|
11
31
|
#subscription;
|
|
12
32
|
#observationGeneration = 0;
|
|
13
33
|
#queued;
|
|
34
|
+
#lastDelivered;
|
|
35
|
+
#deliveriesReceived = 0;
|
|
36
|
+
#rowsFromDelivery = 0;
|
|
14
37
|
#execution;
|
|
15
38
|
#executionAbort;
|
|
16
39
|
#invalidationSequence = 0;
|
|
@@ -52,10 +75,24 @@ class LiveQuery {
|
|
|
52
75
|
}
|
|
53
76
|
if (this.#invalidationSequence === sequence) {
|
|
54
77
|
const version = this.#snapshot.status === "loading" ? null : this.#snapshot.version;
|
|
55
|
-
this.#
|
|
78
|
+
if (this.#decodes()) {
|
|
79
|
+
const last = this.#lastDelivered;
|
|
80
|
+
if (this.#snapshot.status === "error" && last !== void 0)
|
|
81
|
+
this.#schedule(last);
|
|
82
|
+
} else
|
|
83
|
+
this.#schedule({ manifestVersion: version, catalogEpoch: 0, initial: false });
|
|
56
84
|
}
|
|
57
85
|
await this.#waitForIdle();
|
|
58
86
|
}
|
|
87
|
+
#decodes() {
|
|
88
|
+
return this.#source.decode !== void 0 && this.#backend.subscribe !== void 0;
|
|
89
|
+
}
|
|
90
|
+
async #decodeDelivered(result) {
|
|
91
|
+
const source = this.#source;
|
|
92
|
+
if (source.decode === void 0)
|
|
93
|
+
throw new TypeError("Live query source lost its decoder");
|
|
94
|
+
return source.decode(result);
|
|
95
|
+
}
|
|
59
96
|
close() {
|
|
60
97
|
if (this.#closed)
|
|
61
98
|
return;
|
|
@@ -75,7 +112,36 @@ class LiveQuery {
|
|
|
75
112
|
if (this.#subscription !== void 0 || this.#closed)
|
|
76
113
|
return;
|
|
77
114
|
const generation = this.#observationGeneration += 1;
|
|
115
|
+
if (this.#source.decode !== void 0 && this.#backend.subscribe !== void 0) {
|
|
116
|
+
const subscription2 = this.#backend.subscribe(this.#source.query, {
|
|
117
|
+
onChange: (result, delivery) => {
|
|
118
|
+
if (generation !== this.#observationGeneration || this.#closed)
|
|
119
|
+
return;
|
|
120
|
+
this.#deliveriesReceived += 1;
|
|
121
|
+
this.#schedule({ result, delivery, sequence: this.#deliveriesReceived });
|
|
122
|
+
},
|
|
123
|
+
onError: (error) => {
|
|
124
|
+
if (generation !== this.#observationGeneration || this.#closed)
|
|
125
|
+
return;
|
|
126
|
+
this.#setError(error, this.#currentVersion());
|
|
127
|
+
},
|
|
128
|
+
onComplete: () => {
|
|
129
|
+
if (generation !== this.#observationGeneration)
|
|
130
|
+
return;
|
|
131
|
+
this.#subscription = void 0;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
this.#subscription = subscription2;
|
|
135
|
+
subscription2.catch((error) => {
|
|
136
|
+
if (generation !== this.#observationGeneration || this.#closed)
|
|
137
|
+
return;
|
|
138
|
+
this.#subscription = void 0;
|
|
139
|
+
this.#setError(error, this.#currentVersion());
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
78
143
|
const subscription = this.#backend.observe(this.#source.query, {
|
|
144
|
+
suppressUnchanged: true,
|
|
79
145
|
onInvalidate: (invalidation) => {
|
|
80
146
|
if (generation !== this.#observationGeneration || this.#closed)
|
|
81
147
|
return;
|
|
@@ -109,9 +175,9 @@ class LiveQuery {
|
|
|
109
175
|
void subscription.then((handle) => handle.close()).catch(() => void 0);
|
|
110
176
|
}
|
|
111
177
|
}
|
|
112
|
-
#schedule(
|
|
178
|
+
#schedule(work) {
|
|
113
179
|
this.#invalidationSequence += 1;
|
|
114
|
-
this.#queued =
|
|
180
|
+
this.#queued = work;
|
|
115
181
|
if (this.#execution !== void 0)
|
|
116
182
|
return;
|
|
117
183
|
const execution = this.#drain();
|
|
@@ -125,24 +191,35 @@ class LiveQuery {
|
|
|
125
191
|
}
|
|
126
192
|
async #drain() {
|
|
127
193
|
while (this.#queued !== void 0 && !this.#closed) {
|
|
128
|
-
const
|
|
194
|
+
const work = this.#queued;
|
|
129
195
|
this.#queued = void 0;
|
|
130
196
|
const abort = new AbortController();
|
|
131
197
|
this.#executionAbort = abort;
|
|
198
|
+
const invalidation = "result" in work ? work.delivery : work;
|
|
132
199
|
try {
|
|
133
|
-
|
|
200
|
+
let executed;
|
|
201
|
+
if ("result" in work) {
|
|
202
|
+
this.#lastDelivered = work;
|
|
203
|
+
executed = await this.#decodeDelivered(work.result);
|
|
204
|
+
this.#lastDelivered = void 0;
|
|
205
|
+
} else
|
|
206
|
+
executed = await this.#source.execute(abort.signal);
|
|
134
207
|
if (this.#executionWasCancelled(abort))
|
|
135
208
|
continue;
|
|
136
209
|
if (this.#hasQueuedInvalidation())
|
|
137
210
|
continue;
|
|
138
211
|
const previous = this.#snapshot.rows;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
212
|
+
const provenance = "result" in work && this.#rowsFromDelivery === work.sequence - 1 ? work.delivery.retained : void 0;
|
|
213
|
+
const rows = reconcileRows(previous, executed, provenance);
|
|
214
|
+
if ("result" in work)
|
|
215
|
+
this.#rowsFromDelivery = work.sequence;
|
|
216
|
+
if (rows === void 0) {
|
|
217
|
+
if (this.#snapshot.status === "ready" && this.#snapshot.version === invalidation.manifestVersion) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
143
220
|
this.#snapshot = {
|
|
144
221
|
status: "ready",
|
|
145
|
-
rows: previous,
|
|
222
|
+
rows: this.#snapshot.status === "loading" ? Object.freeze([...previous]) : previous,
|
|
146
223
|
version: invalidation.manifestVersion
|
|
147
224
|
};
|
|
148
225
|
} else {
|
|
@@ -379,7 +379,8 @@ class DatabaseRpcServer {
|
|
|
379
379
|
try {
|
|
380
380
|
set = this.database.liveQueries({
|
|
381
381
|
...channelName === void 0 ? {} : { channelName },
|
|
382
|
-
...pollIntervalMs === void 0 ? {} : { pollIntervalMs }
|
|
382
|
+
...pollIntervalMs === void 0 ? {} : { pollIntervalMs },
|
|
383
|
+
sharedResults: true
|
|
383
384
|
});
|
|
384
385
|
this.#publishHandle(handleId, { type: "live-set", set, subscriptionIds: /* @__PURE__ */ new Set() });
|
|
385
386
|
} catch (error) {
|
|
@@ -667,11 +668,15 @@ class DatabaseRpcServer {
|
|
|
667
668
|
let subscription;
|
|
668
669
|
try {
|
|
669
670
|
subscription = await handle.set.subscribe(query, {
|
|
670
|
-
onChange: (result) => {
|
|
671
|
+
onChange: (result, delivery) => {
|
|
671
672
|
const encoded = encodeQueryResult(result);
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
673
|
+
const retained = delivery.retained?.slice();
|
|
674
|
+
if (retained !== void 0)
|
|
675
|
+
encoded.transfer.push(retained.buffer);
|
|
676
|
+
this.scope.postMessage(rpcEvent(subscriptionId, "change", {
|
|
677
|
+
result: encoded.payload,
|
|
678
|
+
delivery: retained === void 0 ? delivery : { ...delivery, retained }
|
|
679
|
+
}), { transfer: encoded.transfer });
|
|
675
680
|
},
|
|
676
681
|
onError: (error) => {
|
|
677
682
|
this.scope.postMessage(rpcEvent(subscriptionId, "error", serializeError(error)));
|
|
@@ -700,9 +705,11 @@ class DatabaseRpcServer {
|
|
|
700
705
|
case "observe": {
|
|
701
706
|
const subscriptionId = this.#claimHandleId(args[0]);
|
|
702
707
|
const query = args[1];
|
|
708
|
+
const { suppressUnchanged } = args[2] ?? {};
|
|
703
709
|
let subscription;
|
|
704
710
|
try {
|
|
705
711
|
subscription = await handle.set.observe(query, {
|
|
712
|
+
...suppressUnchanged === true ? { suppressUnchanged: true } : {},
|
|
706
713
|
onInvalidate: (invalidation) => {
|
|
707
714
|
this.scope.postMessage(rpcEvent(subscriptionId, "invalidate", invalidation));
|
|
708
715
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@minnowdb/core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.10",
|
|
4
4
|
"description": "A columnar SQL database for the browser: PostgreSQL-style SQL over durable IndexedDB or OPFS data, with no server or WebAssembly module.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Eric Wilhite",
|