@peerbit/indexer-sqlite3 3.0.6 → 3.0.7

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 (41) hide show
  1. package/dist/assets/sqlite3/sqlite3.worker.min.js +6 -1
  2. package/dist/benchmark/query-planner.d.ts +2 -0
  3. package/dist/benchmark/query-planner.d.ts.map +1 -0
  4. package/dist/benchmark/query-planner.js +97 -0
  5. package/dist/benchmark/query-planner.js.map +1 -0
  6. package/dist/index.min.js +535 -183
  7. package/dist/index.min.js.map +3 -3
  8. package/dist/src/engine.d.ts +15 -4
  9. package/dist/src/engine.d.ts.map +1 -1
  10. package/dist/src/engine.js +364 -179
  11. package/dist/src/engine.js.map +1 -1
  12. package/dist/src/query-planner.d.ts +20 -0
  13. package/dist/src/query-planner.d.ts.map +1 -1
  14. package/dist/src/query-planner.js +191 -21
  15. package/dist/src/query-planner.js.map +1 -1
  16. package/dist/src/schema.d.ts +6 -2
  17. package/dist/src/schema.d.ts.map +1 -1
  18. package/dist/src/schema.js +11 -8
  19. package/dist/src/schema.js.map +1 -1
  20. package/dist/src/sqlite3-messages.worker.d.ts +8 -1
  21. package/dist/src/sqlite3-messages.worker.d.ts.map +1 -1
  22. package/dist/src/sqlite3-messages.worker.js.map +1 -1
  23. package/dist/src/sqlite3.browser.d.ts.map +1 -1
  24. package/dist/src/sqlite3.browser.js +21 -0
  25. package/dist/src/sqlite3.browser.js.map +1 -1
  26. package/dist/src/sqlite3.wasm.d.ts.map +1 -1
  27. package/dist/src/sqlite3.wasm.js +4 -1
  28. package/dist/src/sqlite3.wasm.js.map +1 -1
  29. package/dist/src/sqlite3.worker.js +6 -0
  30. package/dist/src/sqlite3.worker.js.map +1 -1
  31. package/dist/src/types.d.ts +4 -0
  32. package/dist/src/types.d.ts.map +1 -1
  33. package/package.json +6 -5
  34. package/src/engine.ts +464 -235
  35. package/src/query-planner.ts +247 -22
  36. package/src/schema.ts +21 -4
  37. package/src/sqlite3-messages.worker.ts +6 -0
  38. package/src/sqlite3.browser.ts +33 -0
  39. package/src/sqlite3.wasm.ts +4 -1
  40. package/src/sqlite3.worker.ts +6 -0
  41. package/src/types.ts +3 -0
@@ -16,12 +16,18 @@ import {
16
16
  Query,
17
17
  Sort,
18
18
  StringMatch,
19
+ StringMatchMethod,
19
20
  UnsignedIntegerValue,
20
21
  } from "@peerbit/indexer-interface";
21
22
  import { hrtime } from "@peerbit/time";
22
23
  import pDefer, { type DeferredPromise } from "p-defer";
23
24
  import { escapeColumnName } from "./schema.js";
24
25
 
26
+ type IndexColumn = {
27
+ name: string;
28
+ collation?: "NOCASE";
29
+ };
30
+
25
31
  export interface QueryIndexPlanner {
26
32
  // assumes withing a query, each index can be picked independently. For example if we are to join two tables, we can pick the best index for each table
27
33
  // sorted column names key to execution time for each index that was tried
@@ -33,6 +39,7 @@ export interface QueryIndexPlanner {
33
39
  avg: number;
34
40
  times: number[];
35
41
  indexKey: string;
42
+ columns: IndexColumn[];
36
43
  created: () => boolean;
37
44
  creationPromiseDeferred: DeferredPromise<void>;
38
45
  }[];
@@ -43,12 +50,18 @@ export interface QueryIndexPlanner {
43
50
  type StmtStats = Map<string, QueryIndexPlanner>;
44
51
 
45
52
  const getSortedNameKey = (tableName: string, names: string[]) =>
46
- [tableName, ...names.sort()].join(",");
47
- const createIndexKey = (tableName: string, fields: string[]) =>
48
- `${tableName}_index_${fields.map((x) => x).join("_")}`;
53
+ [tableName, ...[...names].sort()].join(",");
54
+ const getIndexColumnKey = (field: IndexColumn) =>
55
+ `${field.name}${field.collation ? `_collate_${field.collation.toLowerCase()}` : ""}`;
56
+ const createIndexKey = (tableName: string, fields: IndexColumn[]) =>
57
+ `${tableName}_index_${fields.map((x) => getIndexColumnKey(x).replace(/[^a-zA-Z0-9_]/g, "_")).join("_")}`;
58
+ const createIndexColumnSQL = (field: IndexColumn) =>
59
+ `${escapeColumnName(field.name)}${field.collation ? ` COLLATE ${field.collation}` : ""}`;
49
60
 
50
61
  const HALF_MAX_U32 = 2147483647; // rounded down
51
62
  const HALF_MAX_U64 = 9223372036854775807n; // rounded down
63
+ const PARENT_TABLE_ID = "__parent_id";
64
+ const AMBIGUOUS_CHILD_FORCE_AFTER_USES = 6_000;
52
65
 
53
66
  export const flattenQuery = function* (props?: {
54
67
  query: Query[];
@@ -194,7 +207,16 @@ export class QueryPlanner {
194
207
  pendingIndexCreation: Map<string, Promise<void>> = new Map();
195
208
 
196
209
  constructor(
197
- readonly props: { exec: (query: string) => Promise<any> | any },
210
+ readonly props: {
211
+ exec: (query: string) => Promise<any> | any;
212
+ /**
213
+ * INDEXED BY is a hard SQLite requirement, not a hint. Keep the legacy
214
+ * forced-index behavior by default and allow callers to disable it once
215
+ * their query shapes have been verified against SQLite's own planner.
216
+ */
217
+ forceIndexes?: boolean;
218
+ optimizeAfterCreate?: boolean;
219
+ },
198
220
  ) {}
199
221
 
200
222
  async stop() {
@@ -219,24 +241,48 @@ export class QueryPlanner {
219
241
  | undefined = undefined;
220
242
  let pickedIndexKeys: Map<string, string> = new Map(); // index key to column names key
221
243
  let indexCreationPromiseToAwait: Promise<void>[] = [];
244
+ let forceIndex = this.props.forceIndexes !== false;
222
245
  return {
246
+ get forceIndex() {
247
+ return forceIndex;
248
+ },
223
249
  beforePrepare: async () => {
224
250
  // create missing indices
225
251
  if (indexCreateCommands != null) {
226
- for (const { key, cmd, deferred } of indexCreateCommands) {
252
+ const commandsToCreate: typeof indexCreateCommands = [];
253
+ for (const command of indexCreateCommands) {
254
+ if (this.pendingIndexCreation.has(command.key)) {
255
+ // TODO is this kind of debouncing needed? how do we end up here?
256
+ await this.pendingIndexCreation.get(command.key);
257
+ continue;
258
+ }
259
+ commandsToCreate.push(command);
260
+ }
261
+ if (commandsToCreate.length > 0) {
262
+ const creationPromise = Promise.resolve(
263
+ this.props.exec(
264
+ [
265
+ ...commandsToCreate.map((command) => command.cmd),
266
+ ...(this.props.optimizeAfterCreate === false
267
+ ? []
268
+ : ["PRAGMA optimize"]),
269
+ ].join(";"),
270
+ ),
271
+ );
272
+ for (const { key } of commandsToCreate) {
273
+ this.pendingIndexCreation.set(key, creationPromise);
274
+ }
227
275
  try {
228
- if (this.pendingIndexCreation.has(key)) {
229
- // TODO is this kind of debouncing needed? how do we end up here?
230
- await this.pendingIndexCreation.get(key);
276
+ await creationPromise;
277
+ for (const { key, deferred } of commandsToCreate) {
278
+ this.pendingIndexCreation.delete(key);
279
+ deferred.resolve();
231
280
  }
232
- const promise = this.props.exec(cmd);
233
- this.pendingIndexCreation.set(key, promise);
234
- await promise;
235
-
236
- this.pendingIndexCreation.delete(key);
237
- deferred.resolve();
238
281
  } catch (error) {
239
- deferred.reject(error);
282
+ for (const { key, deferred } of commandsToCreate) {
283
+ this.pendingIndexCreation.delete(key);
284
+ deferred.reject(error);
285
+ }
240
286
  }
241
287
  }
242
288
  }
@@ -250,6 +296,8 @@ export class QueryPlanner {
250
296
  await Promise.all(indexCreationPromiseToAwait);
251
297
  },
252
298
  resolveIndex: (tableName: string, columns: string[]): string => {
299
+ forceIndex = this.props.forceIndexes !== false;
300
+
253
301
  // first we figure out whether we want to reuse the fastest index or try a new one
254
302
  // only assume we either do forward or backward column order for now (not all n! permutations)
255
303
  const sortedNameKey = getSortedNameKey(tableName, columns);
@@ -262,11 +310,10 @@ export class QueryPlanner {
262
310
  }
263
311
 
264
312
  if (indexStats.results.length === 0) {
265
- // create both forward and backward permutations
266
- const permutations = generatePermutations(columns);
267
- for (const columns of permutations) {
313
+ const candidates = generateIndexCandidates(query, columns);
314
+ for (const columns of candidates) {
268
315
  const indexKey = createIndexKey(tableName, columns);
269
- const command = `create index if not exists ${indexKey} on ${tableName} (${columns.map((n) => escapeColumnName(n)).join(", ")})`;
316
+ const command = `create index if not exists ${indexKey} on ${tableName} (${columns.map((n) => createIndexColumnSQL(n)).join(", ")})`;
270
317
 
271
318
  let deferred = pDefer<void>();
272
319
  (indexCreateCommands || (indexCreateCommands = [])).push({
@@ -284,12 +331,27 @@ export class QueryPlanner {
284
331
  times: [],
285
332
  avg: -1, // setting -1 will force the first time to be the fastest (i.e. new indices are always tested once)
286
333
  indexKey,
334
+ columns,
287
335
  created: () => created,
288
336
  creationPromiseDeferred: deferred,
289
337
  });
290
338
  }
291
339
  }
292
340
 
341
+ const isAmbiguousChildPredicate =
342
+ query.sort.length === 0 &&
343
+ columns.includes(PARENT_TABLE_ID) &&
344
+ columns.length > 1;
345
+ if (isAmbiguousChildPredicate) {
346
+ const totalUses = indexStats.results.reduce(
347
+ (sum, result) => sum + result.used,
348
+ 0,
349
+ );
350
+ forceIndex =
351
+ this.props.forceIndexes !== false &&
352
+ totalUses >= AMBIGUOUS_CHILD_FORCE_AFTER_USES;
353
+ }
354
+
293
355
  // find the fastest index
294
356
  let fastestIndex = indexStats.results[0];
295
357
  fastestIndex.used++;
@@ -337,9 +399,172 @@ export class QueryPlanner {
337
399
  }
338
400
  }
339
401
 
340
- const generatePermutations = (list: string[]) => {
341
- if (list.length === 1) return [list];
342
- return [list, [...list].reverse()];
402
+ const queryKeyToColumnName = (key: string[]) => {
403
+ if (key.length > 2) {
404
+ return `${key.slice(0, -1).join("_")}__${key[key.length - 1]}`;
405
+ }
406
+ return key.join("__");
407
+ };
408
+
409
+ const pushUniqueColumn = (list: IndexColumn[], column: IndexColumn) => {
410
+ const key = getIndexColumnKey(column);
411
+ if (!list.some((x) => getIndexColumnKey(x) === key)) {
412
+ list.push(column);
413
+ }
414
+ };
415
+
416
+ const pushColumns = (target: IndexColumn[], columns: IndexColumn[]) => {
417
+ for (const column of columns) {
418
+ pushUniqueColumn(target, column);
419
+ }
420
+ };
421
+
422
+ const getIndexableQueryColumns = (
423
+ query: Query[],
424
+ availableColumns: Set<string>,
425
+ ) => {
426
+ const equality: IndexColumn[] = [];
427
+ const range: IndexColumn[] = [];
428
+
429
+ const visit = (item: Query, path: string[] = []) => {
430
+ if (item instanceof And) {
431
+ for (const condition of item.and) {
432
+ visit(condition, path);
433
+ }
434
+ return;
435
+ }
436
+ if (item instanceof Or) {
437
+ for (const condition of item.or) {
438
+ visit(condition, path);
439
+ }
440
+ return;
441
+ }
442
+ if (item instanceof Not) {
443
+ return;
444
+ }
445
+ if (item instanceof Nested) {
446
+ for (const condition of item.query) {
447
+ visit(condition, [...path, ...item.path]);
448
+ }
449
+ return;
450
+ }
451
+
452
+ let key: string[] | undefined;
453
+ let target: IndexColumn[] | undefined;
454
+ let collation: IndexColumn["collation"] | undefined;
455
+ if (item instanceof IntegerCompare) {
456
+ key = item.key;
457
+ target = item.compare === Compare.Equal ? equality : range;
458
+ } else if (item instanceof StringMatch) {
459
+ key = item.key;
460
+ if (item.method === StringMatchMethod.contains) {
461
+ return;
462
+ }
463
+ target = item.method === StringMatchMethod.exact ? equality : range;
464
+ collation = item.caseInsensitive ? "NOCASE" : undefined;
465
+ } else if (
466
+ item instanceof ByteMatchQuery ||
467
+ item instanceof BoolQuery ||
468
+ item instanceof IsNull
469
+ ) {
470
+ key = item.key;
471
+ target = equality;
472
+ }
473
+
474
+ if (!key || !target) {
475
+ return;
476
+ }
477
+ const columnName = queryKeyToColumnName([...path, ...key]);
478
+ if (availableColumns.has(columnName)) {
479
+ pushUniqueColumn(target, { name: columnName, collation });
480
+ }
481
+ };
482
+
483
+ for (const item of query) {
484
+ visit(item);
485
+ }
486
+
487
+ return { equality, range };
488
+ };
489
+
490
+ const getSortableColumns = (sort: Sort[], availableColumns: Set<string>) => {
491
+ const out: IndexColumn[] = [];
492
+ for (const item of sort) {
493
+ const columnName = queryKeyToColumnName(item.key);
494
+ if (availableColumns.has(columnName)) {
495
+ pushUniqueColumn(out, { name: columnName });
496
+ }
497
+ }
498
+ return out;
499
+ };
500
+
501
+ const normalizeCandidate = (columns: IndexColumn[]) => {
502
+ const out: IndexColumn[] = [];
503
+ pushColumns(out, columns);
504
+ return out;
505
+ };
506
+
507
+ const generateIndexCandidates = (query: PlannableQuery, columns: string[]) => {
508
+ if (columns.length === 0) {
509
+ return [];
510
+ }
511
+
512
+ const availableColumns = new Set(columns);
513
+ const { equality, range } = getIndexableQueryColumns(
514
+ query.query,
515
+ availableColumns,
516
+ );
517
+ const sort = getSortableColumns(query.sort, availableColumns);
518
+ const join = availableColumns.has(PARENT_TABLE_ID)
519
+ ? [{ name: PARENT_TABLE_ID }]
520
+ : [];
521
+ const knownColumnNames = new Set(
522
+ [...join, ...equality, ...range, ...sort].map((x) => x.name),
523
+ );
524
+ const remaining = columns
525
+ .filter((column) => !knownColumnNames.has(column))
526
+ .map((name) => ({ name }));
527
+
528
+ const candidates: IndexColumn[][] = [];
529
+ const pushCandidate = (...parts: IndexColumn[][]) => {
530
+ const candidate = normalizeCandidate(parts.flat());
531
+ if (
532
+ candidate.length > 0 &&
533
+ !candidates.some(
534
+ (existing) =>
535
+ existing.map(getIndexColumnKey).join(",") ===
536
+ candidate.map(getIndexColumnKey).join(","),
537
+ )
538
+ ) {
539
+ candidates.push(candidate);
540
+ }
541
+ };
542
+
543
+ if (sort.length > 0 && range.length > 0) {
544
+ pushCandidate(join, equality, sort, range, remaining);
545
+ pushCandidate(join, equality, range, sort, remaining);
546
+ } else if (sort.length > 0) {
547
+ pushCandidate(join, equality, sort, range, remaining);
548
+ } else {
549
+ if (join.length > 0 && (equality.length > 0 || range.length > 0)) {
550
+ pushCandidate(equality, range, join, remaining);
551
+ }
552
+ pushCandidate(join, equality, range, remaining);
553
+ }
554
+
555
+ if (join.length > 0 && (equality.length > 0 || range.length > 0)) {
556
+ if (sort.length > 0 && range.length > 0) {
557
+ pushCandidate(equality, sort, range, join, remaining);
558
+ pushCandidate(equality, range, sort, join, remaining);
559
+ } else if (sort.length > 0) {
560
+ pushCandidate(equality, range, sort, join, remaining);
561
+ }
562
+ }
563
+
564
+ pushCandidate(columns.map((name) => ({ name })));
565
+ pushCandidate([...columns].reverse().map((name) => ({ name })));
566
+
567
+ return candidates;
343
568
  };
344
569
  /* const generatePermutations = (list: string[]) => {
345
570
  const results: string[][] = [];
package/src/schema.ts CHANGED
@@ -1565,12 +1565,18 @@ export const convertDeleteRequestToQuery = (
1565
1565
  request: types.DeleteOptions,
1566
1566
  tables: Map<string, Table>,
1567
1567
  table: Table,
1568
+ options?: {
1569
+ planner?: PlanningSession;
1570
+ },
1568
1571
  ): { sql: string; bindable: any[] } => {
1569
1572
  const { query, bindable } = convertRequestToQuery(
1570
1573
  "delete",
1571
1574
  { query: coerceLocalQueries(request.query) },
1572
1575
  tables,
1573
1576
  table,
1577
+ undefined,
1578
+ [],
1579
+ options,
1574
1580
  );
1575
1581
  return {
1576
1582
  sql: `DELETE FROM ${table.name} WHERE ${table.name}.${table.primary} IN (SELECT ${table.primary} from ${table.name} ${query}) returning ${table.primary}`,
@@ -1582,12 +1588,18 @@ export const convertSumRequestToQuery = (
1582
1588
  request: types.SumOptions,
1583
1589
  tables: Map<string, Table>,
1584
1590
  table: Table,
1591
+ options?: {
1592
+ planner?: PlanningSession;
1593
+ },
1585
1594
  ): { sql: string; bindable: any[] } => {
1586
1595
  const { query, bindable } = convertRequestToQuery(
1587
1596
  "sum",
1588
1597
  { query: coerceLocalQueries(request.query), key: request.key },
1589
1598
  tables,
1590
1599
  table,
1600
+ undefined,
1601
+ [],
1602
+ options,
1591
1603
  );
1592
1604
 
1593
1605
  const inlineName = getInlineTableFieldName(request.key);
@@ -1983,10 +1995,15 @@ const _buildJoin = (
1983
1995
  table.table.primary !== false &&
1984
1996
  usedColumns.length === 1 &&
1985
1997
  usedColumns[0] === table.table.primary;
1986
- indexedBy =
1987
- options?.planner && !usesImplicitPrimaryKeyIndex
1988
- ? ` INDEXED BY ${options.planner.resolveIndex(table.table.name, usedColumns)} `
1989
- : "";
1998
+ if (options?.planner && !usesImplicitPrimaryKeyIndex) {
1999
+ const indexKey = options.planner.resolveIndex(
2000
+ table.table.name,
2001
+ usedColumns,
2002
+ );
2003
+ indexedBy = options.planner.forceIndex ? ` INDEXED BY ${indexKey} ` : "";
2004
+ } else {
2005
+ indexedBy = "";
2006
+ }
1990
2007
  }
1991
2008
 
1992
2009
  if (table.type !== "root") {
@@ -50,6 +50,11 @@ interface Prepare extends Message {
50
50
  sql: string;
51
51
  }
52
52
 
53
+ interface PrepareMany extends Message {
54
+ type: "prepare-many";
55
+ statements: { id: string; sql: string }[];
56
+ }
57
+
53
58
  type Uint8ArrayBase64Type = {
54
59
  type: "uint8array";
55
60
  encoding: "base64";
@@ -204,6 +209,7 @@ export type DatabaseMessages =
204
209
  | CreateDatabase
205
210
  | Exec
206
211
  | Prepare
212
+ | PrepareMany
207
213
  | Close
208
214
  | Drop
209
215
  | Open
@@ -311,6 +311,39 @@ class ProxyDatabase implements IDatabase {
311
311
  return statement;
312
312
  }
313
313
 
314
+ async prepareMany(statements: { sql: string; id: string }[]) {
315
+ if (statements.length === 0) {
316
+ return [];
317
+ }
318
+
319
+ const missing = statements.filter(
320
+ (statement) => !this.statements.get(statement.id),
321
+ );
322
+ if (missing.length > 0) {
323
+ const statementIds = await this.send<string[]>({
324
+ type: "prepare-many",
325
+ statements: missing,
326
+ id: uuid(),
327
+ databaseId: this.databaseId,
328
+ });
329
+
330
+ for (const [index, statementId] of statementIds.entries()) {
331
+ const definition = missing[index];
332
+ const statement = new ProxyStatement(
333
+ this.send.bind(this),
334
+ this.databaseId,
335
+ statementId,
336
+ definition.sql,
337
+ this.options,
338
+ );
339
+ this.statements.set(statementId, statement);
340
+ this.statements.set(definition.id, statement);
341
+ }
342
+ }
343
+
344
+ return statements.map((statement) => this.statements.get(statement.id)!);
345
+ }
346
+
314
347
  async open() {
315
348
  return this.send({ type: "open", id: uuid(), databaseId: this.databaseId });
316
349
  }
@@ -313,7 +313,10 @@ const create = async (
313
313
  }));
314
314
  poolUtil = activePoolUtil;
315
315
 
316
- await activePoolUtil.reserveMinimumCapacity(100);
316
+ // SAH pool capacity persists across sessions, and the default pool is
317
+ // already sized for a small number of databases plus temp files.
318
+ // Avoid preallocating a much larger pool on every cold start because it
319
+ // dominates the initial browser open latency.
317
320
  sqliteDb = new activePoolUtil.OpfsSAHPoolDb(dbFileName);
318
321
  } else {
319
322
  sqliteDb = new sqlite3.oo1.DB(":memory:");
@@ -103,6 +103,12 @@ class SqliteWorkerHandler {
103
103
  await db.prepare(message.sql, message.id);
104
104
  return statementId;
105
105
  }
106
+ if (message.type === "prepare-many") {
107
+ for (const statement of message.statements) {
108
+ await db.prepare(statement.sql, statement.id);
109
+ }
110
+ return message.statements.map((statement) => statement.id);
111
+ }
106
112
  if (message.type === "close") {
107
113
  await db.close();
108
114
  this.databases.delete(message.databaseId);
package/src/types.ts CHANGED
@@ -7,6 +7,9 @@ export type SQLite = {
7
7
  export type Database = {
8
8
  exec: (sql: string) => Promise<any> | any;
9
9
  prepare: (sql: string, id?: string) => Promise<Statement> | Statement;
10
+ prepareMany?: (
11
+ statements: { sql: string; id: string }[],
12
+ ) => Promise<Statement[]> | Statement[];
10
13
  close: () => Promise<any> | any;
11
14
  drop: () => Promise<any> | any;
12
15
  open(): Promise<any> | any;