@atscript/db-sqlite 0.1.148 → 0.1.150

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/index.cjs CHANGED
@@ -281,6 +281,7 @@ const sqliteDialect = {
281
281
  params: [likePattern]
282
282
  };
283
283
  },
284
+ integerText: (expr) => `CAST(CAST(${expr} AS INTEGER) AS TEXT)`,
284
285
  geoWithin(quotedCol, circle) {
285
286
  const dist = haversineDistanceExpr(quotedCol, circle.center);
286
287
  return {
@@ -687,6 +688,42 @@ function getSqliteTxGate(driver) {
687
688
  return gate;
688
689
  }
689
690
  //#endregion
691
+ //#region src/fts-term.ts
692
+ const BAREWORD = /^[\p{L}\p{N}_]+$/u;
693
+ const KEYWORD = /^(?:AND|OR|NOT|NEAR)$/i;
694
+ /**
695
+ * Turns a user's `$search` text into a safe FTS5 MATCH expression (since
696
+ * 0.1.150). FTS5 reads the raw text as a query language — `-2946`, `a AND`,
697
+ * `title:x`, `(`, a stray `"` — and throws a syntax error on most of it.
698
+ * Every term is therefore quoted (plain words are already safe barewords)
699
+ * so no user text can raise one:
700
+ *
701
+ * - a `"quoted phrase"` stays a phrase (words in order);
702
+ * - a bare word stays a word, and a trailing `*` keeps its prefix meaning
703
+ * (`quok*` finds `quokka`);
704
+ * - words are AND-ed, as before;
705
+ * - everything else — `AND` / `OR` / `NOT`, `-`, `+`, `^`, `NEAR(…)`,
706
+ * `column:`, parentheses — is plain text (the tokenizer drops punctuation).
707
+ *
708
+ * Quoted strings never contain a `"`: an inner `"` is doubled (FTS5 escape).
709
+ */
710
+ function quoteFtsTerm(text) {
711
+ const parts = [];
712
+ for (const match of text.matchAll(/"([^"]*)"|(\S+)/g)) {
713
+ const phrase = match[1];
714
+ if (phrase !== void 0) {
715
+ if (phrase.trim()) parts.push(`"${phrase.replace(/"/g, "\"\"")}"`);
716
+ continue;
717
+ }
718
+ const word = match[2];
719
+ const core = word.replace(/\*+$/, "");
720
+ if (!core) continue;
721
+ const quoted = BAREWORD.test(core) && !KEYWORD.test(core) ? core : `"${core.replace(/"/g, "\"\"")}"`;
722
+ parts.push(core.length < word.length ? `${quoted}*` : quoted);
723
+ }
724
+ return parts.length > 0 ? parts.join(" ") : "\"\"";
725
+ }
726
+ //#endregion
690
727
  //#region src/sqlite-adapter.ts
691
728
  /**
692
729
  * SQLite adapter for {@link AtscriptDbTable}.
@@ -1004,7 +1041,7 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1004
1041
  async aggregate(query) {
1005
1042
  const tableName = this.resolveTableName();
1006
1043
  const search = (0, _atscript_db_agg.resolveAggregateSearch)(query.controls);
1007
- const where = search ? andWhere(this._buildFtsMatchWhere(search.text, search.indexName), buildWhere(query.filter)) : buildWhere(query.filter);
1044
+ const where = search ? andWhere(this._buildSearchPredicate(search.text, search.indexName), buildWhere(query.filter)) : buildWhere(query.filter);
1008
1045
  if (query.controls.$count) {
1009
1046
  const { sql, params } = buildAggregateCount(tableName, where, query.controls);
1010
1047
  this._log(sql, params);
@@ -1014,10 +1051,10 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1014
1051
  this._log(sql, params);
1015
1052
  return this._stmt(() => this.driver.all(sql, params));
1016
1053
  }
1017
- async updateOne(filter, data, ops, expectedVersion) {
1054
+ async updateOne(filter, data, ops, expectedVersion, opts) {
1018
1055
  const where = buildWhere(filter);
1019
1056
  const tableName = this.resolveTableName();
1020
- const versionColumn = this._table.versionColumnPhysical;
1057
+ const versionColumn = this._versionColumnFor(opts, expectedVersion);
1021
1058
  const { sql, params } = buildUpdate(tableName, data, {
1022
1059
  sql: `rowid = (SELECT rowid FROM "${esc(tableName)}" WHERE ${where.sql} LIMIT 1)`,
1023
1060
  params: where.params
@@ -1029,9 +1066,9 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1029
1066
  modifiedCount: result.changes
1030
1067
  };
1031
1068
  }
1032
- async updateMany(filter, data, ops) {
1069
+ async updateMany(filter, data, ops, opts) {
1033
1070
  const where = buildWhere(filter);
1034
- const versionColumn = this._table.versionColumnPhysical;
1071
+ const versionColumn = this._versionColumnFor(opts);
1035
1072
  const { sql, params } = buildUpdate(this.resolveTableName(), data, where, ops, versionColumn);
1036
1073
  this._log(sql, params);
1037
1074
  const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
@@ -1376,12 +1413,14 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1376
1413
  }
1377
1414
  getSearchIndexes() {
1378
1415
  const indexes = [];
1379
- for (const idx of this._getFulltextIndexes()) indexes.push({
1416
+ const ftAll = this._getFulltextIndexes();
1417
+ const ftDefault = (0, _atscript_db.defaultFulltextIndex)(ftAll);
1418
+ for (const idx of ftAll) indexes.push({
1380
1419
  name: idx.name,
1381
- description: `FTS5 index (${idx.fields.map((f) => f.name).join(", ")})`,
1420
+ description: (0, _atscript_db.describeFulltext)(idx, (names) => `FTS5 index (${names})`),
1382
1421
  type: "text",
1383
1422
  fields: this._indexLogicalPaths(idx),
1384
- isDefault: indexes.length === 0
1423
+ isDefault: idx === ftDefault
1385
1424
  });
1386
1425
  let firstVector = true;
1387
1426
  for (const [field, vec] of this._vectorFields) {
@@ -1398,6 +1437,12 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1398
1437
  }
1399
1438
  async search(text, query, indexName) {
1400
1439
  if (!text.trim()) return [];
1440
+ const plan = this._searchPlan(text, indexName);
1441
+ if (!plan.join) {
1442
+ const { sql, params } = buildSelect(this.resolveTableName(), andWhere(plan.predicate, buildWhere(query.filter)), query.controls);
1443
+ this._log(sql, params);
1444
+ return this._stmt(() => this.driver.all(sql, params));
1445
+ }
1401
1446
  const base = this._buildFtsBase(text, query.filter, indexName);
1402
1447
  const controls = query.controls || {};
1403
1448
  let cols = "t.*";
@@ -1427,12 +1472,22 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1427
1472
  count: 0
1428
1473
  };
1429
1474
  const data = await this.search(text, query, indexName);
1430
- const base = this._buildFtsBase(text, query.filter, indexName);
1431
- const countSql = `SELECT COUNT(*) as cnt ${base.fromWhere}`;
1432
- this._log(countSql, base.params);
1475
+ const plan = this._searchPlan(text, indexName);
1476
+ let countSql;
1477
+ let countParams;
1478
+ if (plan.join) {
1479
+ const base = this._buildFtsBase(text, query.filter, indexName);
1480
+ countSql = `SELECT COUNT(*) as cnt ${base.fromWhere}`;
1481
+ countParams = base.params;
1482
+ } else {
1483
+ const where = andWhere(plan.predicate, buildWhere(query.filter));
1484
+ countSql = `SELECT COUNT(*) as cnt FROM "${esc(this.resolveTableName())}" WHERE ${where.sql}`;
1485
+ countParams = where.params;
1486
+ }
1487
+ this._log(countSql, countParams);
1433
1488
  return {
1434
1489
  data,
1435
- count: (await this._stmt(() => this.driver.get(countSql, base.params)))?.cnt ?? 0
1490
+ count: (await this._stmt(() => this.driver.get(countSql, countParams)))?.cnt ?? 0
1436
1491
  };
1437
1492
  }
1438
1493
  /** Builds FTS table name from index name: `<table>__fts__<indexName>`. */
@@ -1454,26 +1509,51 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1454
1509
  if (!found) throw new Error((0, _atscript_db.searchIndexNotFoundMessage)(indexName));
1455
1510
  return found;
1456
1511
  }
1457
- return ftIndexes[0];
1512
+ return (0, _atscript_db.defaultFulltextIndex)(ftIndexes);
1458
1513
  }
1459
1514
  /**
1460
- * The FTS5 match restated as a standalone WHERE fragment:
1461
- * `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)`.
1515
+ * The search restated as a standalone WHERE fragment: the FTS5 match
1516
+ * `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)`
1517
+ * OR exact equality on each integer member when the whole term is a whole
1518
+ * number (since 0.1.150).
1462
1519
  *
1463
1520
  * The external-content FTS5 tables this adapter creates carry
1464
1521
  * `content_rowid='rowid'`, so the virtual table's `rowid` IS the content
1465
1522
  * table's rowid, and the unqualified `rowid` outside the subquery resolves
1466
1523
  * against the aggregate's single FROM table. {@link _buildFtsBase} is the
1467
1524
  * leaf path's JOIN form, which the unaliased aggregate builders cannot take.
1525
+ * SQLite's multi-index OR optimization serves each arm from its own index
1526
+ * (the FTS5 rowid lookup and the member's btree).
1468
1527
  *
1469
1528
  * Index resolution goes through {@link _resolveFtsIndex}, as the leaf path
1470
1529
  * does, so a named `$index` and its "not found" error behave identically.
1471
1530
  */
1472
- _buildFtsMatchWhere(text, indexName) {
1473
- const quoted = `"${esc(this._ftsTableName(this._resolveFtsIndex(indexName).name))}"`;
1531
+ _buildSearchPredicate(text, indexName) {
1532
+ return this._searchPlan(text, indexName).predicate;
1533
+ }
1534
+ /**
1535
+ * How a search runs: the standalone predicate, and whether the leaf path
1536
+ * may keep its FTS5 JOIN form (text members and no numeric branch — zero
1537
+ * behavior change for text-only searches).
1538
+ */
1539
+ _searchPlan(text, indexName) {
1540
+ const index = this._resolveFtsIndex(indexName);
1541
+ const { text: textFields, integer: integerFields } = (0, _atscript_db.splitFulltextFields)(index);
1542
+ const n = integerFields.length > 0 ? (0, _atscript_db.searchTermInteger)(text) : void 0;
1543
+ const parts = [];
1544
+ const params = [];
1545
+ if (textFields.length > 0) {
1546
+ const quoted = `"${esc(this._ftsTableName(index.name))}"`;
1547
+ parts.push(`rowid IN (SELECT rowid FROM ${quoted} WHERE ${quoted} MATCH ?)`);
1548
+ params.push(quoteFtsTerm(text));
1549
+ }
1550
+ if (n !== void 0) for (const f of integerFields) {
1551
+ parts.push(`"${esc(f.name)}" = ?`);
1552
+ params.push(n);
1553
+ }
1474
1554
  return {
1475
- sql: `rowid IN (SELECT rowid FROM ${quoted} WHERE ${quoted} MATCH ?)`,
1476
- params: [text]
1555
+ predicate: (0, _atscript_db_sql_tools.orFragment)(parts, params),
1556
+ join: textFields.length > 0 && n === void 0
1477
1557
  };
1478
1558
  }
1479
1559
  /**
@@ -1488,7 +1568,7 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1488
1568
  let fromWhere = `FROM "${esc(tableName)}" AS t`;
1489
1569
  fromWhere += ` JOIN "${esc(ftsTable)}" AS fts ON t.rowid = fts.rowid`;
1490
1570
  fromWhere += ` WHERE fts."${esc(ftsTable)}" MATCH ?`;
1491
- const params = [text];
1571
+ const params = [quoteFtsTerm(text)];
1492
1572
  if (where.sql !== "1=1") {
1493
1573
  fromWhere += ` AND (${where.sql})`;
1494
1574
  params.push(...where.params);
@@ -1502,7 +1582,7 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1502
1582
  * Creates/drops FTS5 virtual tables and sync triggers to match desired fulltext indexes.
1503
1583
  */
1504
1584
  _syncFtsIndexes(tableName) {
1505
- const ftIndexes = this._getFulltextIndexes();
1585
+ const ftIndexes = this._getFulltextIndexes().filter((idx) => (0, _atscript_db.splitFulltextFields)(idx).text.length > 0);
1506
1586
  const desiredFtsTables = new Set(ftIndexes.map((idx) => this._ftsTableName(idx.name)));
1507
1587
  const existingFts = this.driver.all(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name LIKE ?`, [`${tableName}__fts__%`]).filter((r) => r.sql.startsWith("CREATE VIRTUAL TABLE")).map((r) => r.name);
1508
1588
  for (const name of existingFts) if (!desiredFtsTables.has(name)) this._dropFtsTable(name);
@@ -1510,16 +1590,27 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1510
1590
  for (const index of ftIndexes) {
1511
1591
  const ftsTable = this._ftsTableName(index.name);
1512
1592
  if (!existingSet.has(ftsTable)) this._createFtsTable(tableName, ftsTable, index);
1593
+ else if (!this._ftsColumnsMatch(ftsTable, index)) {
1594
+ this._dropFtsTable(ftsTable);
1595
+ this._createFtsTable(tableName, ftsTable, index);
1596
+ }
1513
1597
  }
1514
1598
  }
1599
+ /** Whether an existing FTS5 table has exactly the index's text columns, in order. */
1600
+ _ftsColumnsMatch(ftsTable, index) {
1601
+ const live = this.driver.all(`PRAGMA table_info("${esc(ftsTable)}")`).map((c) => c.name);
1602
+ const desired = (0, _atscript_db.splitFulltextFields)(index).text.map((f) => f.name);
1603
+ return live.length === desired.length && live.every((name, i) => name === desired[i]);
1604
+ }
1515
1605
  /** Creates an FTS5 virtual table with sync triggers and rebuilds the index. */
1516
1606
  _createFtsTable(tableName, ftsTable, index) {
1517
- const fieldList = index.fields.map((f) => `"${esc(f.name)}"`).join(", ");
1607
+ const fields = (0, _atscript_db.splitFulltextFields)(index).text;
1608
+ const fieldList = fields.map((f) => `"${esc(f.name)}"`).join(", ");
1518
1609
  const createSql = `CREATE VIRTUAL TABLE IF NOT EXISTS "${esc(ftsTable)}" USING fts5(${fieldList}, content='${tableName.replace(/'/g, "''")}', content_rowid='rowid')`;
1519
1610
  this._log(createSql);
1520
1611
  this.driver.exec(createSql);
1521
- const newFields = index.fields.map((f) => `new."${esc(f.name)}"`).join(", ");
1522
- const oldFields = index.fields.map((f) => `old."${esc(f.name)}"`).join(", ");
1612
+ const newFields = fields.map((f) => `new."${esc(f.name)}"`).join(", ");
1613
+ const oldFields = fields.map((f) => `old."${esc(f.name)}"`).join(", ");
1523
1614
  const ef = esc(ftsTable);
1524
1615
  const aiSql = `CREATE TRIGGER IF NOT EXISTS "${esc(ftsTable + "__ai")}" AFTER INSERT ON "${esc(tableName)}" BEGIN INSERT INTO "${ef}"(rowid, ${fieldList}) VALUES (new.rowid, ${newFields}); END`;
1525
1616
  this._log(aiSql);
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
1
+ import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
2
2
  import { TMetadataMap } from "@atscript/typescript/utils";
3
3
  import { FilterExpr as FilterExpr$1 } from "@uniqu/core";
4
4
  import { TFilterVisitorOptions, TSqlFragment, TSqlFragment as TSqlFragment$1 } from "@atscript/db-sql-tools";
@@ -357,8 +357,8 @@ declare class SqliteAdapter extends BaseDbAdapter {
357
357
  findManyPerPartition(query: DbQuery, partitionBy: readonly string[]): Promise<Array<Record<string, unknown>>>;
358
358
  count(query: DbQuery): Promise<number>;
359
359
  aggregate(query: DbQuery): Promise<Array<Record<string, unknown>>>;
360
- updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number): Promise<TDbUpdateResult>;
361
- updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps): Promise<TDbUpdateResult>;
360
+ updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
361
+ updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
362
362
  replaceOne(filter: FilterExpr, data: Record<string, unknown>, expectedVersion?: number): Promise<TDbUpdateResult>;
363
363
  replaceMany(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult>;
364
364
  deleteOne(filter: FilterExpr): Promise<TDbDeleteResult>;
@@ -425,19 +425,29 @@ declare class SqliteAdapter extends BaseDbAdapter {
425
425
  /** Resolves a fulltext index by name, or returns the first available. */
426
426
  private _resolveFtsIndex;
427
427
  /**
428
- * The FTS5 match restated as a standalone WHERE fragment:
429
- * `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)`.
428
+ * The search restated as a standalone WHERE fragment: the FTS5 match
429
+ * `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)`
430
+ * OR exact equality on each integer member when the whole term is a whole
431
+ * number (since 0.1.150).
430
432
  *
431
433
  * The external-content FTS5 tables this adapter creates carry
432
434
  * `content_rowid='rowid'`, so the virtual table's `rowid` IS the content
433
435
  * table's rowid, and the unqualified `rowid` outside the subquery resolves
434
436
  * against the aggregate's single FROM table. {@link _buildFtsBase} is the
435
437
  * leaf path's JOIN form, which the unaliased aggregate builders cannot take.
438
+ * SQLite's multi-index OR optimization serves each arm from its own index
439
+ * (the FTS5 rowid lookup and the member's btree).
436
440
  *
437
441
  * Index resolution goes through {@link _resolveFtsIndex}, as the leaf path
438
442
  * does, so a named `$index` and its "not found" error behave identically.
439
443
  */
440
- private _buildFtsMatchWhere;
444
+ private _buildSearchPredicate;
445
+ /**
446
+ * How a search runs: the standalone predicate, and whether the leaf path
447
+ * may keep its FTS5 JOIN form (text members and no numeric branch — zero
448
+ * behavior change for text-only searches).
449
+ */
450
+ private _searchPlan;
441
451
  /**
442
452
  * Builds the shared FROM+JOIN+WHERE fragment for FTS5 queries.
443
453
  * Both data and count queries reuse this to avoid duplicating index resolution and filter translation.
@@ -447,6 +457,8 @@ declare class SqliteAdapter extends BaseDbAdapter {
447
457
  * Creates/drops FTS5 virtual tables and sync triggers to match desired fulltext indexes.
448
458
  */
449
459
  private _syncFtsIndexes;
460
+ /** Whether an existing FTS5 table has exactly the index's text columns, in order. */
461
+ private _ftsColumnsMatch;
450
462
  /** Creates an FTS5 virtual table with sync triggers and rebuilds the index. */
451
463
  private _createFtsTable;
452
464
  /** Drops an FTS5 virtual table and its sync triggers. */
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
1
+ import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
2
2
  import { TFilterVisitorOptions, TSqlFragment, TSqlFragment as TSqlFragment$1 } from "@atscript/db-sql-tools";
3
3
  import { FilterExpr as FilterExpr$1 } from "@uniqu/core";
4
4
  import { TMetadataMap } from "@atscript/typescript/utils";
@@ -357,8 +357,8 @@ declare class SqliteAdapter extends BaseDbAdapter {
357
357
  findManyPerPartition(query: DbQuery, partitionBy: readonly string[]): Promise<Array<Record<string, unknown>>>;
358
358
  count(query: DbQuery): Promise<number>;
359
359
  aggregate(query: DbQuery): Promise<Array<Record<string, unknown>>>;
360
- updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number): Promise<TDbUpdateResult>;
361
- updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps): Promise<TDbUpdateResult>;
360
+ updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
361
+ updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
362
362
  replaceOne(filter: FilterExpr, data: Record<string, unknown>, expectedVersion?: number): Promise<TDbUpdateResult>;
363
363
  replaceMany(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult>;
364
364
  deleteOne(filter: FilterExpr): Promise<TDbDeleteResult>;
@@ -425,19 +425,29 @@ declare class SqliteAdapter extends BaseDbAdapter {
425
425
  /** Resolves a fulltext index by name, or returns the first available. */
426
426
  private _resolveFtsIndex;
427
427
  /**
428
- * The FTS5 match restated as a standalone WHERE fragment:
429
- * `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)`.
428
+ * The search restated as a standalone WHERE fragment: the FTS5 match
429
+ * `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)`
430
+ * OR exact equality on each integer member when the whole term is a whole
431
+ * number (since 0.1.150).
430
432
  *
431
433
  * The external-content FTS5 tables this adapter creates carry
432
434
  * `content_rowid='rowid'`, so the virtual table's `rowid` IS the content
433
435
  * table's rowid, and the unqualified `rowid` outside the subquery resolves
434
436
  * against the aggregate's single FROM table. {@link _buildFtsBase} is the
435
437
  * leaf path's JOIN form, which the unaliased aggregate builders cannot take.
438
+ * SQLite's multi-index OR optimization serves each arm from its own index
439
+ * (the FTS5 rowid lookup and the member's btree).
436
440
  *
437
441
  * Index resolution goes through {@link _resolveFtsIndex}, as the leaf path
438
442
  * does, so a named `$index` and its "not found" error behave identically.
439
443
  */
440
- private _buildFtsMatchWhere;
444
+ private _buildSearchPredicate;
445
+ /**
446
+ * How a search runs: the standalone predicate, and whether the leaf path
447
+ * may keep its FTS5 JOIN form (text members and no numeric branch — zero
448
+ * behavior change for text-only searches).
449
+ */
450
+ private _searchPlan;
441
451
  /**
442
452
  * Builds the shared FROM+JOIN+WHERE fragment for FTS5 queries.
443
453
  * Both data and count queries reuse this to avoid duplicating index resolution and filter translation.
@@ -447,6 +457,8 @@ declare class SqliteAdapter extends BaseDbAdapter {
447
457
  * Creates/drops FTS5 virtual tables and sync triggers to match desired fulltext indexes.
448
458
  */
449
459
  private _syncFtsIndexes;
460
+ /** Whether an existing FTS5 table has exactly the index's text columns, in order. */
461
+ private _ftsColumnsMatch;
450
462
  /** Creates an FTS5 virtual table with sync triggers and rebuilds the index. */
451
463
  private _createFtsTable;
452
464
  /** Drops an FTS5 virtual table and its sync triggers. */
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
- import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, fkColumns, searchIndexNotFoundMessage, vectorIndexNotFoundMessage } from "@atscript/db";
2
+ import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, defaultFulltextIndex, describeFulltext, fkColumns, searchIndexNotFoundMessage, searchTermInteger, splitFulltextFields, vectorIndexNotFoundMessage } from "@atscript/db";
3
3
  import { resolveAggregateSearch } from "@atscript/db/agg";
4
- import { EMPTY_AND, SEARCH_SOURCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, geoWindowFromControls, jsonDollarPath, parseRegexString, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue } from "@atscript/db-sql-tools";
4
+ import { EMPTY_AND, SEARCH_SOURCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, geoWindowFromControls, jsonDollarPath, orFragment, parseRegexString, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue } from "@atscript/db-sql-tools";
5
5
  import { bucketer } from "@uniqu/core";
6
6
  import { AsyncLocalStorage } from "node:async_hooks";
7
7
  //#region src/better-sqlite3-driver.ts
@@ -280,6 +280,7 @@ const sqliteDialect = {
280
280
  params: [likePattern]
281
281
  };
282
282
  },
283
+ integerText: (expr) => `CAST(CAST(${expr} AS INTEGER) AS TEXT)`,
283
284
  geoWithin(quotedCol, circle) {
284
285
  const dist = haversineDistanceExpr(quotedCol, circle.center);
285
286
  return {
@@ -686,6 +687,42 @@ function getSqliteTxGate(driver) {
686
687
  return gate;
687
688
  }
688
689
  //#endregion
690
+ //#region src/fts-term.ts
691
+ const BAREWORD = /^[\p{L}\p{N}_]+$/u;
692
+ const KEYWORD = /^(?:AND|OR|NOT|NEAR)$/i;
693
+ /**
694
+ * Turns a user's `$search` text into a safe FTS5 MATCH expression (since
695
+ * 0.1.150). FTS5 reads the raw text as a query language — `-2946`, `a AND`,
696
+ * `title:x`, `(`, a stray `"` — and throws a syntax error on most of it.
697
+ * Every term is therefore quoted (plain words are already safe barewords)
698
+ * so no user text can raise one:
699
+ *
700
+ * - a `"quoted phrase"` stays a phrase (words in order);
701
+ * - a bare word stays a word, and a trailing `*` keeps its prefix meaning
702
+ * (`quok*` finds `quokka`);
703
+ * - words are AND-ed, as before;
704
+ * - everything else — `AND` / `OR` / `NOT`, `-`, `+`, `^`, `NEAR(…)`,
705
+ * `column:`, parentheses — is plain text (the tokenizer drops punctuation).
706
+ *
707
+ * Quoted strings never contain a `"`: an inner `"` is doubled (FTS5 escape).
708
+ */
709
+ function quoteFtsTerm(text) {
710
+ const parts = [];
711
+ for (const match of text.matchAll(/"([^"]*)"|(\S+)/g)) {
712
+ const phrase = match[1];
713
+ if (phrase !== void 0) {
714
+ if (phrase.trim()) parts.push(`"${phrase.replace(/"/g, "\"\"")}"`);
715
+ continue;
716
+ }
717
+ const word = match[2];
718
+ const core = word.replace(/\*+$/, "");
719
+ if (!core) continue;
720
+ const quoted = BAREWORD.test(core) && !KEYWORD.test(core) ? core : `"${core.replace(/"/g, "\"\"")}"`;
721
+ parts.push(core.length < word.length ? `${quoted}*` : quoted);
722
+ }
723
+ return parts.length > 0 ? parts.join(" ") : "\"\"";
724
+ }
725
+ //#endregion
689
726
  //#region src/sqlite-adapter.ts
690
727
  /**
691
728
  * SQLite adapter for {@link AtscriptDbTable}.
@@ -1003,7 +1040,7 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1003
1040
  async aggregate(query) {
1004
1041
  const tableName = this.resolveTableName();
1005
1042
  const search = resolveAggregateSearch(query.controls);
1006
- const where = search ? andWhere(this._buildFtsMatchWhere(search.text, search.indexName), buildWhere(query.filter)) : buildWhere(query.filter);
1043
+ const where = search ? andWhere(this._buildSearchPredicate(search.text, search.indexName), buildWhere(query.filter)) : buildWhere(query.filter);
1007
1044
  if (query.controls.$count) {
1008
1045
  const { sql, params } = buildAggregateCount$1(tableName, where, query.controls);
1009
1046
  this._log(sql, params);
@@ -1013,10 +1050,10 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1013
1050
  this._log(sql, params);
1014
1051
  return this._stmt(() => this.driver.all(sql, params));
1015
1052
  }
1016
- async updateOne(filter, data, ops, expectedVersion) {
1053
+ async updateOne(filter, data, ops, expectedVersion, opts) {
1017
1054
  const where = buildWhere(filter);
1018
1055
  const tableName = this.resolveTableName();
1019
- const versionColumn = this._table.versionColumnPhysical;
1056
+ const versionColumn = this._versionColumnFor(opts, expectedVersion);
1020
1057
  const { sql, params } = buildUpdate$1(tableName, data, {
1021
1058
  sql: `rowid = (SELECT rowid FROM "${esc(tableName)}" WHERE ${where.sql} LIMIT 1)`,
1022
1059
  params: where.params
@@ -1028,9 +1065,9 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1028
1065
  modifiedCount: result.changes
1029
1066
  };
1030
1067
  }
1031
- async updateMany(filter, data, ops) {
1068
+ async updateMany(filter, data, ops, opts) {
1032
1069
  const where = buildWhere(filter);
1033
- const versionColumn = this._table.versionColumnPhysical;
1070
+ const versionColumn = this._versionColumnFor(opts);
1034
1071
  const { sql, params } = buildUpdate$1(this.resolveTableName(), data, where, ops, versionColumn);
1035
1072
  this._log(sql, params);
1036
1073
  const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
@@ -1375,12 +1412,14 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1375
1412
  }
1376
1413
  getSearchIndexes() {
1377
1414
  const indexes = [];
1378
- for (const idx of this._getFulltextIndexes()) indexes.push({
1415
+ const ftAll = this._getFulltextIndexes();
1416
+ const ftDefault = defaultFulltextIndex(ftAll);
1417
+ for (const idx of ftAll) indexes.push({
1379
1418
  name: idx.name,
1380
- description: `FTS5 index (${idx.fields.map((f) => f.name).join(", ")})`,
1419
+ description: describeFulltext(idx, (names) => `FTS5 index (${names})`),
1381
1420
  type: "text",
1382
1421
  fields: this._indexLogicalPaths(idx),
1383
- isDefault: indexes.length === 0
1422
+ isDefault: idx === ftDefault
1384
1423
  });
1385
1424
  let firstVector = true;
1386
1425
  for (const [field, vec] of this._vectorFields) {
@@ -1397,6 +1436,12 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1397
1436
  }
1398
1437
  async search(text, query, indexName) {
1399
1438
  if (!text.trim()) return [];
1439
+ const plan = this._searchPlan(text, indexName);
1440
+ if (!plan.join) {
1441
+ const { sql, params } = buildSelect$1(this.resolveTableName(), andWhere(plan.predicate, buildWhere(query.filter)), query.controls);
1442
+ this._log(sql, params);
1443
+ return this._stmt(() => this.driver.all(sql, params));
1444
+ }
1400
1445
  const base = this._buildFtsBase(text, query.filter, indexName);
1401
1446
  const controls = query.controls || {};
1402
1447
  let cols = "t.*";
@@ -1426,12 +1471,22 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1426
1471
  count: 0
1427
1472
  };
1428
1473
  const data = await this.search(text, query, indexName);
1429
- const base = this._buildFtsBase(text, query.filter, indexName);
1430
- const countSql = `SELECT COUNT(*) as cnt ${base.fromWhere}`;
1431
- this._log(countSql, base.params);
1474
+ const plan = this._searchPlan(text, indexName);
1475
+ let countSql;
1476
+ let countParams;
1477
+ if (plan.join) {
1478
+ const base = this._buildFtsBase(text, query.filter, indexName);
1479
+ countSql = `SELECT COUNT(*) as cnt ${base.fromWhere}`;
1480
+ countParams = base.params;
1481
+ } else {
1482
+ const where = andWhere(plan.predicate, buildWhere(query.filter));
1483
+ countSql = `SELECT COUNT(*) as cnt FROM "${esc(this.resolveTableName())}" WHERE ${where.sql}`;
1484
+ countParams = where.params;
1485
+ }
1486
+ this._log(countSql, countParams);
1432
1487
  return {
1433
1488
  data,
1434
- count: (await this._stmt(() => this.driver.get(countSql, base.params)))?.cnt ?? 0
1489
+ count: (await this._stmt(() => this.driver.get(countSql, countParams)))?.cnt ?? 0
1435
1490
  };
1436
1491
  }
1437
1492
  /** Builds FTS table name from index name: `<table>__fts__<indexName>`. */
@@ -1453,26 +1508,51 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1453
1508
  if (!found) throw new Error(searchIndexNotFoundMessage(indexName));
1454
1509
  return found;
1455
1510
  }
1456
- return ftIndexes[0];
1511
+ return defaultFulltextIndex(ftIndexes);
1457
1512
  }
1458
1513
  /**
1459
- * The FTS5 match restated as a standalone WHERE fragment:
1460
- * `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)`.
1514
+ * The search restated as a standalone WHERE fragment: the FTS5 match
1515
+ * `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)`
1516
+ * OR exact equality on each integer member when the whole term is a whole
1517
+ * number (since 0.1.150).
1461
1518
  *
1462
1519
  * The external-content FTS5 tables this adapter creates carry
1463
1520
  * `content_rowid='rowid'`, so the virtual table's `rowid` IS the content
1464
1521
  * table's rowid, and the unqualified `rowid` outside the subquery resolves
1465
1522
  * against the aggregate's single FROM table. {@link _buildFtsBase} is the
1466
1523
  * leaf path's JOIN form, which the unaliased aggregate builders cannot take.
1524
+ * SQLite's multi-index OR optimization serves each arm from its own index
1525
+ * (the FTS5 rowid lookup and the member's btree).
1467
1526
  *
1468
1527
  * Index resolution goes through {@link _resolveFtsIndex}, as the leaf path
1469
1528
  * does, so a named `$index` and its "not found" error behave identically.
1470
1529
  */
1471
- _buildFtsMatchWhere(text, indexName) {
1472
- const quoted = `"${esc(this._ftsTableName(this._resolveFtsIndex(indexName).name))}"`;
1530
+ _buildSearchPredicate(text, indexName) {
1531
+ return this._searchPlan(text, indexName).predicate;
1532
+ }
1533
+ /**
1534
+ * How a search runs: the standalone predicate, and whether the leaf path
1535
+ * may keep its FTS5 JOIN form (text members and no numeric branch — zero
1536
+ * behavior change for text-only searches).
1537
+ */
1538
+ _searchPlan(text, indexName) {
1539
+ const index = this._resolveFtsIndex(indexName);
1540
+ const { text: textFields, integer: integerFields } = splitFulltextFields(index);
1541
+ const n = integerFields.length > 0 ? searchTermInteger(text) : void 0;
1542
+ const parts = [];
1543
+ const params = [];
1544
+ if (textFields.length > 0) {
1545
+ const quoted = `"${esc(this._ftsTableName(index.name))}"`;
1546
+ parts.push(`rowid IN (SELECT rowid FROM ${quoted} WHERE ${quoted} MATCH ?)`);
1547
+ params.push(quoteFtsTerm(text));
1548
+ }
1549
+ if (n !== void 0) for (const f of integerFields) {
1550
+ parts.push(`"${esc(f.name)}" = ?`);
1551
+ params.push(n);
1552
+ }
1473
1553
  return {
1474
- sql: `rowid IN (SELECT rowid FROM ${quoted} WHERE ${quoted} MATCH ?)`,
1475
- params: [text]
1554
+ predicate: orFragment(parts, params),
1555
+ join: textFields.length > 0 && n === void 0
1476
1556
  };
1477
1557
  }
1478
1558
  /**
@@ -1487,7 +1567,7 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1487
1567
  let fromWhere = `FROM "${esc(tableName)}" AS t`;
1488
1568
  fromWhere += ` JOIN "${esc(ftsTable)}" AS fts ON t.rowid = fts.rowid`;
1489
1569
  fromWhere += ` WHERE fts."${esc(ftsTable)}" MATCH ?`;
1490
- const params = [text];
1570
+ const params = [quoteFtsTerm(text)];
1491
1571
  if (where.sql !== "1=1") {
1492
1572
  fromWhere += ` AND (${where.sql})`;
1493
1573
  params.push(...where.params);
@@ -1501,7 +1581,7 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1501
1581
  * Creates/drops FTS5 virtual tables and sync triggers to match desired fulltext indexes.
1502
1582
  */
1503
1583
  _syncFtsIndexes(tableName) {
1504
- const ftIndexes = this._getFulltextIndexes();
1584
+ const ftIndexes = this._getFulltextIndexes().filter((idx) => splitFulltextFields(idx).text.length > 0);
1505
1585
  const desiredFtsTables = new Set(ftIndexes.map((idx) => this._ftsTableName(idx.name)));
1506
1586
  const existingFts = this.driver.all(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name LIKE ?`, [`${tableName}__fts__%`]).filter((r) => r.sql.startsWith("CREATE VIRTUAL TABLE")).map((r) => r.name);
1507
1587
  for (const name of existingFts) if (!desiredFtsTables.has(name)) this._dropFtsTable(name);
@@ -1509,16 +1589,27 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1509
1589
  for (const index of ftIndexes) {
1510
1590
  const ftsTable = this._ftsTableName(index.name);
1511
1591
  if (!existingSet.has(ftsTable)) this._createFtsTable(tableName, ftsTable, index);
1592
+ else if (!this._ftsColumnsMatch(ftsTable, index)) {
1593
+ this._dropFtsTable(ftsTable);
1594
+ this._createFtsTable(tableName, ftsTable, index);
1595
+ }
1512
1596
  }
1513
1597
  }
1598
+ /** Whether an existing FTS5 table has exactly the index's text columns, in order. */
1599
+ _ftsColumnsMatch(ftsTable, index) {
1600
+ const live = this.driver.all(`PRAGMA table_info("${esc(ftsTable)}")`).map((c) => c.name);
1601
+ const desired = splitFulltextFields(index).text.map((f) => f.name);
1602
+ return live.length === desired.length && live.every((name, i) => name === desired[i]);
1603
+ }
1514
1604
  /** Creates an FTS5 virtual table with sync triggers and rebuilds the index. */
1515
1605
  _createFtsTable(tableName, ftsTable, index) {
1516
- const fieldList = index.fields.map((f) => `"${esc(f.name)}"`).join(", ");
1606
+ const fields = splitFulltextFields(index).text;
1607
+ const fieldList = fields.map((f) => `"${esc(f.name)}"`).join(", ");
1517
1608
  const createSql = `CREATE VIRTUAL TABLE IF NOT EXISTS "${esc(ftsTable)}" USING fts5(${fieldList}, content='${tableName.replace(/'/g, "''")}', content_rowid='rowid')`;
1518
1609
  this._log(createSql);
1519
1610
  this.driver.exec(createSql);
1520
- const newFields = index.fields.map((f) => `new."${esc(f.name)}"`).join(", ");
1521
- const oldFields = index.fields.map((f) => `old."${esc(f.name)}"`).join(", ");
1611
+ const newFields = fields.map((f) => `new."${esc(f.name)}"`).join(", ");
1612
+ const oldFields = fields.map((f) => `old."${esc(f.name)}"`).join(", ");
1522
1613
  const ef = esc(ftsTable);
1523
1614
  const aiSql = `CREATE TRIGGER IF NOT EXISTS "${esc(ftsTable + "__ai")}" AFTER INSERT ON "${esc(tableName)}" BEGIN INSERT INTO "${ef}"(rowid, ${fieldList}) VALUES (new.rowid, ${newFields}); END`;
1524
1615
  this._log(aiSql);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-sqlite",
3
- "version": "0.1.148",
3
+ "version": "0.1.150",
4
4
  "description": "SQLite adapter for @atscript/db with swappable driver support.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -36,21 +36,21 @@
36
36
  "access": "public"
37
37
  },
38
38
  "devDependencies": {
39
- "@atscript/core": "^0.1.100",
40
- "@atscript/typescript": "^0.1.100",
39
+ "@atscript/core": "^0.1.101",
40
+ "@atscript/typescript": "^0.1.101",
41
41
  "@types/better-sqlite3": "^7.6.13",
42
42
  "@uniqu/core": "^0.1.13",
43
43
  "better-sqlite3": "^12.6.2",
44
44
  "sqlite-vec": "^0.1.9",
45
- "unplugin-atscript": "^0.1.100"
45
+ "unplugin-atscript": "^0.1.101"
46
46
  },
47
47
  "peerDependencies": {
48
- "@atscript/core": "^0.1.100",
49
- "@atscript/typescript": "^0.1.100",
48
+ "@atscript/core": "^0.1.101",
49
+ "@atscript/typescript": "^0.1.101",
50
50
  "@uniqu/core": "^0.1.13",
51
51
  "better-sqlite3": ">=11.0.0",
52
- "@atscript/db": "^0.1.148",
53
- "@atscript/db-sql-tools": "^0.1.148"
52
+ "@atscript/db": "^0.1.150",
53
+ "@atscript/db-sql-tools": "^0.1.150"
54
54
  },
55
55
  "peerDependenciesMeta": {
56
56
  "better-sqlite3": {