@atscript/db-sqlite 0.1.163 → 0.1.165

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
@@ -379,8 +379,8 @@ function haversineDistanceExpr(quotedCol, point) {
379
379
  /**
380
380
  * Builds a SELECT statement with optional sort, limit, offset, projection.
381
381
  */
382
- function buildSelect(table, where, controls) {
383
- return (0, _atscript_db_sql_tools.buildSelect)(sqliteDialect, table, where, controls);
382
+ function buildSelect(table, where, controls, defaultOrder) {
383
+ return (0, _atscript_db_sql_tools.buildSelect)(sqliteDialect, table, where, controls, void 0, defaultOrder);
384
384
  }
385
385
  /**
386
386
  * Builds an UPDATE ... SET ... WHERE statement.
@@ -1011,32 +1011,42 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1011
1011
  * Wraps a write operation to catch native SQLite constraint errors
1012
1012
  * and rethrow as structured `DbError`.
1013
1013
  */
1014
- _wrapConstraintError(fn) {
1014
+ _wrapConstraintError(fn, row) {
1015
1015
  try {
1016
1016
  return fn();
1017
1017
  } catch (error) {
1018
1018
  if (error instanceof Error) {
1019
- if (error.message.includes("FOREIGN KEY constraint failed")) throw new _atscript_db.DbError("FK_VIOLATION", [{
1020
- path: "",
1021
- message: error.message
1022
- }]);
1023
- const uniqueMatch = error.message.match(/UNIQUE constraint failed:\s*\S+\.(\S+)/);
1024
- if (uniqueMatch) throw new _atscript_db.DbError("CONFLICT", [{
1025
- path: uniqueMatch[1],
1026
- message: error.message
1027
- }]);
1028
- if (error.message.includes("UNIQUE constraint failed")) throw new _atscript_db.DbError("CONFLICT", [{
1029
- path: "",
1030
- message: error.message
1031
- }]);
1019
+ if (error.message.includes("FOREIGN KEY constraint failed")) {
1020
+ const columns = row ? this._probeFkViolation(row) : void 0;
1021
+ throw (0, _atscript_db.constraintViolation)("FK_VIOLATION", columns ? { columns } : {}, error.message);
1022
+ }
1023
+ const unique = error.message.match(/UNIQUE constraint failed:\s*(.+)$/);
1024
+ if (unique) throw (0, _atscript_db.constraintViolation)("CONFLICT", { columns: unique[1].split(",").map((c) => c.trim().replace(/^.*\./, "")).filter(Boolean) }, error.message);
1032
1025
  }
1033
1026
  throw error;
1034
1027
  }
1035
1028
  }
1029
+ /**
1030
+ * The physical columns of the first foreign key whose (non-null) value in
1031
+ * `row` names no target row — what SQLite's `FOREIGN KEY constraint
1032
+ * failed` leaves out. Runs only on that error path.
1033
+ */
1034
+ _probeFkViolation(row) {
1035
+ for (const fk of this._table.foreignKeys.values()) {
1036
+ const columns = fk.physicalFields ?? fk.fields;
1037
+ const values = columns.map((c) => row[c]);
1038
+ if (values.some((v) => v === null || v === void 0)) continue;
1039
+ const where = (fk.physicalTargetFields ?? fk.targetFields).map((c) => `"${esc(c)}" = ?`).join(" AND ");
1040
+ const sql = `SELECT 1 AS hit FROM "${esc(fk.targetTable)}" WHERE ${where} LIMIT 1`;
1041
+ try {
1042
+ if (!this.driver.get(sql, values)) return [...columns];
1043
+ } catch {}
1044
+ }
1045
+ }
1036
1046
  async insertOne(data) {
1037
1047
  const { sql, params } = this._insertSql.build(this.resolveTableName(), data);
1038
1048
  this._log(sql, params);
1039
- const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
1049
+ const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params), data));
1040
1050
  return { insertedId: this._resolveInsertedId(data, result.lastInsertRowid) };
1041
1051
  }
1042
1052
  async insertMany(data) {
@@ -1054,7 +1064,7 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1054
1064
  for (const row of data) {
1055
1065
  const { sql, params } = this._insertSql.build(tableName, row);
1056
1066
  this._log(sql, params);
1057
- const result = this._wrapConstraintError(() => this.driver.run(sql, params));
1067
+ const result = this._wrapConstraintError(() => this.driver.run(sql, params), row);
1058
1068
  out.push(this._resolveInsertedId(row, result.lastInsertRowid));
1059
1069
  }
1060
1070
  return out;
@@ -1082,7 +1092,7 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1082
1092
  const sql = `${built.sql} ON CONFLICT DO NOTHING`;
1083
1093
  const params = built.params;
1084
1094
  this._log(sql, params);
1085
- const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
1095
+ const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params), row));
1086
1096
  slots.push(result.changes === 0 ? null : { insertedId: this._resolveInsertedId(row, result.lastInsertRowid) });
1087
1097
  }
1088
1098
  return slots;
@@ -1163,7 +1173,7 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1163
1173
  params: where.params
1164
1174
  }, ops, versionColumn, expectedVersion);
1165
1175
  this._log(sql, params);
1166
- const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
1176
+ const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params), data));
1167
1177
  return {
1168
1178
  matchedCount: result.changes,
1169
1179
  modifiedCount: result.changes
@@ -1191,7 +1201,7 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1191
1201
  const versionColumn = this._versionColumnFor(opts);
1192
1202
  const { sql, params } = buildUpdate(this.resolveTableName(), data, where, ops, versionColumn);
1193
1203
  this._log(sql, params);
1194
- const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
1204
+ const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params), data));
1195
1205
  return {
1196
1206
  matchedCount: result.changes,
1197
1207
  modifiedCount: result.changes
@@ -1201,12 +1211,13 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1201
1211
  const where = buildWhere(filter);
1202
1212
  const tableName = this.resolveTableName();
1203
1213
  const versionColumn = this._table.versionColumnPhysical;
1204
- const { sql, params } = buildUpdate(tableName, (0, _atscript_db_sql_tools.fillReplacePayload)(data, (0, _atscript_db_sql_tools.replaceColumnsFor)(this._table.fieldDescriptors, this.nativeDefaultFns()), versionColumn, this._table.onUpdateIncPhysical), {
1214
+ const full = (0, _atscript_db_sql_tools.fillReplacePayload)(data, (0, _atscript_db_sql_tools.replaceColumnsFor)(this._table.fieldDescriptors, this.nativeDefaultFns()), versionColumn, this._table.onUpdateIncPhysical);
1215
+ const { sql, params } = buildUpdate(tableName, full, {
1205
1216
  sql: `rowid = (SELECT rowid FROM "${esc(tableName)}" WHERE ${where.sql} LIMIT 1)`,
1206
1217
  params: where.params
1207
1218
  }, this._onUpdateIncOps(), versionColumn, expectedVersion);
1208
1219
  this._log(sql, params);
1209
- const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
1220
+ const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params), full));
1210
1221
  return {
1211
1222
  matchedCount: result.changes,
1212
1223
  modifiedCount: result.changes
@@ -1217,7 +1228,7 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1217
1228
  const versionColumn = this._table.versionColumnPhysical;
1218
1229
  const { sql, params } = buildUpdate(this.resolveTableName(), data, where, this._onUpdateIncOps(), versionColumn);
1219
1230
  this._log(sql, params);
1220
- const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
1231
+ const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params), data));
1221
1232
  return {
1222
1233
  matchedCount: result.changes,
1223
1234
  modifiedCount: result.changes
@@ -1582,19 +1593,56 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1582
1593
  async search(text, query, indexName) {
1583
1594
  if (!text.trim()) return [];
1584
1595
  const plan = this._searchPlan(text, indexName);
1585
- if (!plan.join) {
1586
- const { sql, params } = buildSelect(this.resolveTableName(), andWhere(plan.predicate, buildWhere(query.filter)), query.controls);
1596
+ const controls = query.controls || {};
1597
+ const sorted = (0, _atscript_db_sql_tools.orderByList)(sqliteDialect, controls.$sort, controls.$nulls, "t.");
1598
+ if (!plan.join && (sorted || plan.textFields === 0)) {
1599
+ const { sql, params } = buildSelect(this.resolveTableName(), andWhere(plan.predicate, buildWhere(query.filter)), query.controls, (0, _atscript_db_sql_tools.searchRankOrder)(sqliteDialect, {
1600
+ ...plan.exact,
1601
+ pkColumns: this._pkColumns()
1602
+ }));
1587
1603
  this._log(sql, params);
1588
1604
  return this._stmt(() => this.driver.all(sql, params));
1589
1605
  }
1590
- const base = this._buildFtsBase(text, query.filter, indexName);
1591
- const controls = query.controls || {};
1606
+ const fts = this._ftsTableName(plan.index.name);
1607
+ let head;
1608
+ let params;
1609
+ let textRank;
1610
+ if (plan.join) {
1611
+ const base = this._buildFtsBase(text, query.filter, plan.index);
1612
+ head = base.fromWhere;
1613
+ params = [...base.params];
1614
+ textRank = {
1615
+ sql: `${this._bm25Expr(`fts."${esc(fts)}"`, plan.index)} ASC`,
1616
+ params: []
1617
+ };
1618
+ } else {
1619
+ const where = buildPrefixedWhere("t", query.filter);
1620
+ const eqs = plan.exact.exactEqualities.map((eq) => `t.${eq}`);
1621
+ head = `FROM "${esc(this.resolveTableName())}" AS t LEFT JOIN m ON m.__atscript_r = t.rowid WHERE (m.__atscript_r IS NOT NULL OR ${eqs.join(" OR ")})` + (where.sql === "1=1" ? "" : ` AND (${where.sql})`);
1622
+ params = [
1623
+ quoteFtsTerm(text),
1624
+ ...eqs.map(() => plan.exact.n),
1625
+ ...where.params
1626
+ ];
1627
+ textRank = {
1628
+ sql: "m.__atscript_s ASC",
1629
+ params: []
1630
+ };
1631
+ }
1632
+ const order = sorted ? {
1633
+ sql: sorted,
1634
+ params: []
1635
+ } : (0, _atscript_db_sql_tools.searchRankOrder)(sqliteDialect, {
1636
+ exactEqualities: plan.join ? [] : plan.exact.exactEqualities.map((eq) => `t.${eq}`),
1637
+ n: plan.exact.n,
1638
+ textRank,
1639
+ pkColumns: this._pkColumns(),
1640
+ prefix: "t."
1641
+ });
1592
1642
  let cols = "t.*";
1593
1643
  if (controls.$select?.asArray?.length) cols = controls.$select.asArray.map((c) => `t."${esc(c)}"`).join(", ");
1594
- let sql = `SELECT ${cols} ${base.fromWhere}`;
1595
- const params = [...base.params];
1596
- const orderBy = (0, _atscript_db_sql_tools.orderByList)(sqliteDialect, controls.$sort, controls.$nulls, "t.");
1597
- if (orderBy) sql += ` ORDER BY ${orderBy}`;
1644
+ let sql = `${plan.join ? "" : `WITH m AS MATERIALIZED (SELECT rowid AS __atscript_r, ${this._bm25Expr(`"${esc(fts)}"`, plan.index)} AS __atscript_s FROM "${esc(fts)}" WHERE "${esc(fts)}" MATCH ?) `}SELECT ${cols} ${head} ORDER BY ${order.sql}`;
1645
+ params.push(...order.params);
1598
1646
  if (controls.$limit !== void 0) {
1599
1647
  sql += ` LIMIT ?`;
1600
1648
  params.push(controls.$limit);
@@ -1617,7 +1665,7 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1617
1665
  let countSql;
1618
1666
  let countParams;
1619
1667
  if (plan.join) {
1620
- const base = this._buildFtsBase(text, query.filter, indexName);
1668
+ const base = this._buildFtsBase(text, query.filter, plan.index);
1621
1669
  countSql = `SELECT COUNT(*) as cnt ${base.fromWhere}`;
1622
1670
  countParams = base.params;
1623
1671
  } else {
@@ -1681,6 +1729,7 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1681
1729
  const index = this._resolveFtsIndex(indexName);
1682
1730
  const { text: textFields, integer: integerFields } = (0, _atscript_db.splitFulltextFields)(index);
1683
1731
  const n = integerFields.length > 0 ? (0, _atscript_db.searchTermInteger)(text) : void 0;
1732
+ const equalities = n === void 0 ? [] : integerFields.map((f) => `"${esc(f.name)}" = ?`);
1684
1733
  const parts = [];
1685
1734
  const params = [];
1686
1735
  if (textFields.length > 0) {
@@ -1688,21 +1737,39 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
1688
1737
  parts.push(`rowid IN (SELECT rowid FROM ${quoted} WHERE ${quoted} MATCH ?)`);
1689
1738
  params.push(quoteFtsTerm(text));
1690
1739
  }
1691
- if (n !== void 0) for (const f of integerFields) {
1692
- parts.push(`"${esc(f.name)}" = ?`);
1740
+ for (const eq of equalities) {
1741
+ parts.push(eq);
1693
1742
  params.push(n);
1694
1743
  }
1695
1744
  return {
1745
+ index,
1696
1746
  predicate: (0, _atscript_db_sql_tools.orFragment)(parts, params),
1697
- join: textFields.length > 0 && n === void 0
1747
+ join: textFields.length > 0 && n === void 0,
1748
+ textFields: textFields.length,
1749
+ exact: {
1750
+ exactEqualities: equalities,
1751
+ n
1752
+ }
1698
1753
  };
1699
1754
  }
1700
1755
  /**
1756
+ * `bm25(<hidden column>[, w…])` over an index's text members (lower = better). The
1757
+ * per-column weights follow the FTS5 table's column order; omitted when
1758
+ * every member has the default weight.
1759
+ */
1760
+ _bm25Expr(hidden, index) {
1761
+ const weights = (0, _atscript_db.splitFulltextFields)(index).text.map((f) => f.weight ?? 1);
1762
+ return weights.every((w) => w === 1) ? `bm25(${hidden})` : `bm25(${hidden}, ${weights.map((w) => String(w)).join(", ")})`;
1763
+ }
1764
+ /** The physical primary-key columns — the relevance tie-breaker. */
1765
+ _pkColumns() {
1766
+ return this._table.primaryKeys.map((key) => this._table.physicalPath(key));
1767
+ }
1768
+ /**
1701
1769
  * Builds the shared FROM+JOIN+WHERE fragment for FTS5 queries.
1702
1770
  * Both data and count queries reuse this to avoid duplicating index resolution and filter translation.
1703
1771
  */
1704
- _buildFtsBase(text, filter, indexName) {
1705
- const ftsIndex = this._resolveFtsIndex(indexName);
1772
+ _buildFtsBase(text, filter, ftsIndex) {
1706
1773
  const ftsTable = this._ftsTableName(ftsIndex.name);
1707
1774
  const tableName = this.resolveTableName();
1708
1775
  const where = buildPrefixedWhere("t", filter);
package/dist/index.d.cts CHANGED
@@ -385,6 +385,12 @@ declare class SqliteAdapter extends BaseDbAdapter {
385
385
  * and rethrow as structured `DbError`.
386
386
  */
387
387
  private _wrapConstraintError;
388
+ /**
389
+ * The physical columns of the first foreign key whose (non-null) value in
390
+ * `row` names no target row — what SQLite's `FOREIGN KEY constraint
391
+ * failed` leaves out. Runs only on that error path.
392
+ */
393
+ private _probeFkViolation;
388
394
  insertOne(data: Record<string, unknown>): Promise<TDbInsertResult>;
389
395
  insertMany(data: Array<Record<string, unknown>>): Promise<TDbInsertManyResult>;
390
396
  supportsInsertIgnore(): boolean;
@@ -519,6 +525,14 @@ declare class SqliteAdapter extends BaseDbAdapter {
519
525
  * behavior change for text-only searches).
520
526
  */
521
527
  private _searchPlan;
528
+ /**
529
+ * `bm25(<hidden column>[, w…])` over an index's text members (lower = better). The
530
+ * per-column weights follow the FTS5 table's column order; omitted when
531
+ * every member has the default weight.
532
+ */
533
+ private _bm25Expr;
534
+ /** The physical primary-key columns — the relevance tie-breaker. */
535
+ private _pkColumns;
522
536
  /**
523
537
  * Builds the shared FROM+JOIN+WHERE fragment for FTS5 queries.
524
538
  * Both data and count queries reuse this to avoid duplicating index resolution and filter translation.
package/dist/index.d.mts CHANGED
@@ -385,6 +385,12 @@ declare class SqliteAdapter extends BaseDbAdapter {
385
385
  * and rethrow as structured `DbError`.
386
386
  */
387
387
  private _wrapConstraintError;
388
+ /**
389
+ * The physical columns of the first foreign key whose (non-null) value in
390
+ * `row` names no target row — what SQLite's `FOREIGN KEY constraint
391
+ * failed` leaves out. Runs only on that error path.
392
+ */
393
+ private _probeFkViolation;
388
394
  insertOne(data: Record<string, unknown>): Promise<TDbInsertResult>;
389
395
  insertMany(data: Array<Record<string, unknown>>): Promise<TDbInsertManyResult>;
390
396
  supportsInsertIgnore(): boolean;
@@ -519,6 +525,14 @@ declare class SqliteAdapter extends BaseDbAdapter {
519
525
  * behavior change for text-only searches).
520
526
  */
521
527
  private _searchPlan;
528
+ /**
529
+ * `bm25(<hidden column>[, w…])` over an index's text members (lower = better). The
530
+ * per-column weights follow the FTS5 table's column order; omitted when
531
+ * every member has the default weight.
532
+ */
533
+ private _bm25Expr;
534
+ /** The physical primary-key columns — the relevance tie-breaker. */
535
+ private _pkColumns;
522
536
  /**
523
537
  * Builds the shared FROM+JOIN+WHERE fragment for FTS5 queries.
524
538
  * Both data and count queries reuse this to avoid duplicating index resolution and filter translation.
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_ISOLATION_LEVELS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, createChangeRecorder, defaultFulltextIndex, describeFulltext, fkColumns, searchIndexNotFoundMessage, searchTermInteger, splitFulltextFields, vectorIndexNotFoundMessage } from "@atscript/db";
2
+ import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_ISOLATION_LEVELS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, constraintViolation, createChangeRecorder, defaultFulltextIndex, describeFulltext, fkColumns, searchIndexNotFoundMessage, searchTermInteger, splitFulltextFields, vectorIndexNotFoundMessage } from "@atscript/db";
3
3
  import { resolveAggregateSearch } from "@atscript/db/agg";
4
- import { EMPTY_AND, InsertSqlCache, SEARCH_SOURCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, fromSourceSql, geoWindowFromControls, jsonDollarPath, orFragment, orderByList, parseRegexString, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, unquoteSqlStringLiteral, viewReadSource, writeLimitWhere } from "@atscript/db-sql-tools";
4
+ import { EMPTY_AND, InsertSqlCache, SEARCH_SOURCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, fromSourceSql, geoWindowFromControls, jsonDollarPath, orFragment, orderByList, parseRegexString, refActionToSql, renameGeoDistance, replaceColumnsFor, searchRankOrder, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, unquoteSqlStringLiteral, viewReadSource, writeLimitWhere } 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
@@ -378,8 +378,8 @@ function haversineDistanceExpr(quotedCol, point) {
378
378
  /**
379
379
  * Builds a SELECT statement with optional sort, limit, offset, projection.
380
380
  */
381
- function buildSelect$1(table, where, controls) {
382
- return buildSelect(sqliteDialect, table, where, controls);
381
+ function buildSelect$1(table, where, controls, defaultOrder) {
382
+ return buildSelect(sqliteDialect, table, where, controls, void 0, defaultOrder);
383
383
  }
384
384
  /**
385
385
  * Builds an UPDATE ... SET ... WHERE statement.
@@ -1010,32 +1010,42 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1010
1010
  * Wraps a write operation to catch native SQLite constraint errors
1011
1011
  * and rethrow as structured `DbError`.
1012
1012
  */
1013
- _wrapConstraintError(fn) {
1013
+ _wrapConstraintError(fn, row) {
1014
1014
  try {
1015
1015
  return fn();
1016
1016
  } catch (error) {
1017
1017
  if (error instanceof Error) {
1018
- if (error.message.includes("FOREIGN KEY constraint failed")) throw new DbError("FK_VIOLATION", [{
1019
- path: "",
1020
- message: error.message
1021
- }]);
1022
- const uniqueMatch = error.message.match(/UNIQUE constraint failed:\s*\S+\.(\S+)/);
1023
- if (uniqueMatch) throw new DbError("CONFLICT", [{
1024
- path: uniqueMatch[1],
1025
- message: error.message
1026
- }]);
1027
- if (error.message.includes("UNIQUE constraint failed")) throw new DbError("CONFLICT", [{
1028
- path: "",
1029
- message: error.message
1030
- }]);
1018
+ if (error.message.includes("FOREIGN KEY constraint failed")) {
1019
+ const columns = row ? this._probeFkViolation(row) : void 0;
1020
+ throw constraintViolation("FK_VIOLATION", columns ? { columns } : {}, error.message);
1021
+ }
1022
+ const unique = error.message.match(/UNIQUE constraint failed:\s*(.+)$/);
1023
+ if (unique) throw constraintViolation("CONFLICT", { columns: unique[1].split(",").map((c) => c.trim().replace(/^.*\./, "")).filter(Boolean) }, error.message);
1031
1024
  }
1032
1025
  throw error;
1033
1026
  }
1034
1027
  }
1028
+ /**
1029
+ * The physical columns of the first foreign key whose (non-null) value in
1030
+ * `row` names no target row — what SQLite's `FOREIGN KEY constraint
1031
+ * failed` leaves out. Runs only on that error path.
1032
+ */
1033
+ _probeFkViolation(row) {
1034
+ for (const fk of this._table.foreignKeys.values()) {
1035
+ const columns = fk.physicalFields ?? fk.fields;
1036
+ const values = columns.map((c) => row[c]);
1037
+ if (values.some((v) => v === null || v === void 0)) continue;
1038
+ const where = (fk.physicalTargetFields ?? fk.targetFields).map((c) => `"${esc(c)}" = ?`).join(" AND ");
1039
+ const sql = `SELECT 1 AS hit FROM "${esc(fk.targetTable)}" WHERE ${where} LIMIT 1`;
1040
+ try {
1041
+ if (!this.driver.get(sql, values)) return [...columns];
1042
+ } catch {}
1043
+ }
1044
+ }
1035
1045
  async insertOne(data) {
1036
1046
  const { sql, params } = this._insertSql.build(this.resolveTableName(), data);
1037
1047
  this._log(sql, params);
1038
- const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
1048
+ const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params), data));
1039
1049
  return { insertedId: this._resolveInsertedId(data, result.lastInsertRowid) };
1040
1050
  }
1041
1051
  async insertMany(data) {
@@ -1053,7 +1063,7 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1053
1063
  for (const row of data) {
1054
1064
  const { sql, params } = this._insertSql.build(tableName, row);
1055
1065
  this._log(sql, params);
1056
- const result = this._wrapConstraintError(() => this.driver.run(sql, params));
1066
+ const result = this._wrapConstraintError(() => this.driver.run(sql, params), row);
1057
1067
  out.push(this._resolveInsertedId(row, result.lastInsertRowid));
1058
1068
  }
1059
1069
  return out;
@@ -1081,7 +1091,7 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1081
1091
  const sql = `${built.sql} ON CONFLICT DO NOTHING`;
1082
1092
  const params = built.params;
1083
1093
  this._log(sql, params);
1084
- const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
1094
+ const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params), row));
1085
1095
  slots.push(result.changes === 0 ? null : { insertedId: this._resolveInsertedId(row, result.lastInsertRowid) });
1086
1096
  }
1087
1097
  return slots;
@@ -1162,7 +1172,7 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1162
1172
  params: where.params
1163
1173
  }, ops, versionColumn, expectedVersion);
1164
1174
  this._log(sql, params);
1165
- const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
1175
+ const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params), data));
1166
1176
  return {
1167
1177
  matchedCount: result.changes,
1168
1178
  modifiedCount: result.changes
@@ -1190,7 +1200,7 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1190
1200
  const versionColumn = this._versionColumnFor(opts);
1191
1201
  const { sql, params } = buildUpdate$1(this.resolveTableName(), data, where, ops, versionColumn);
1192
1202
  this._log(sql, params);
1193
- const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
1203
+ const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params), data));
1194
1204
  return {
1195
1205
  matchedCount: result.changes,
1196
1206
  modifiedCount: result.changes
@@ -1200,12 +1210,13 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1200
1210
  const where = buildWhere(filter);
1201
1211
  const tableName = this.resolveTableName();
1202
1212
  const versionColumn = this._table.versionColumnPhysical;
1203
- const { sql, params } = buildUpdate$1(tableName, fillReplacePayload(data, replaceColumnsFor(this._table.fieldDescriptors, this.nativeDefaultFns()), versionColumn, this._table.onUpdateIncPhysical), {
1213
+ const full = fillReplacePayload(data, replaceColumnsFor(this._table.fieldDescriptors, this.nativeDefaultFns()), versionColumn, this._table.onUpdateIncPhysical);
1214
+ const { sql, params } = buildUpdate$1(tableName, full, {
1204
1215
  sql: `rowid = (SELECT rowid FROM "${esc(tableName)}" WHERE ${where.sql} LIMIT 1)`,
1205
1216
  params: where.params
1206
1217
  }, this._onUpdateIncOps(), versionColumn, expectedVersion);
1207
1218
  this._log(sql, params);
1208
- const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
1219
+ const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params), full));
1209
1220
  return {
1210
1221
  matchedCount: result.changes,
1211
1222
  modifiedCount: result.changes
@@ -1216,7 +1227,7 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1216
1227
  const versionColumn = this._table.versionColumnPhysical;
1217
1228
  const { sql, params } = buildUpdate$1(this.resolveTableName(), data, where, this._onUpdateIncOps(), versionColumn);
1218
1229
  this._log(sql, params);
1219
- const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
1230
+ const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params), data));
1220
1231
  return {
1221
1232
  matchedCount: result.changes,
1222
1233
  modifiedCount: result.changes
@@ -1581,19 +1592,56 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1581
1592
  async search(text, query, indexName) {
1582
1593
  if (!text.trim()) return [];
1583
1594
  const plan = this._searchPlan(text, indexName);
1584
- if (!plan.join) {
1585
- const { sql, params } = buildSelect$1(this.resolveTableName(), andWhere(plan.predicate, buildWhere(query.filter)), query.controls);
1595
+ const controls = query.controls || {};
1596
+ const sorted = orderByList(sqliteDialect, controls.$sort, controls.$nulls, "t.");
1597
+ if (!plan.join && (sorted || plan.textFields === 0)) {
1598
+ const { sql, params } = buildSelect$1(this.resolveTableName(), andWhere(plan.predicate, buildWhere(query.filter)), query.controls, searchRankOrder(sqliteDialect, {
1599
+ ...plan.exact,
1600
+ pkColumns: this._pkColumns()
1601
+ }));
1586
1602
  this._log(sql, params);
1587
1603
  return this._stmt(() => this.driver.all(sql, params));
1588
1604
  }
1589
- const base = this._buildFtsBase(text, query.filter, indexName);
1590
- const controls = query.controls || {};
1605
+ const fts = this._ftsTableName(plan.index.name);
1606
+ let head;
1607
+ let params;
1608
+ let textRank;
1609
+ if (plan.join) {
1610
+ const base = this._buildFtsBase(text, query.filter, plan.index);
1611
+ head = base.fromWhere;
1612
+ params = [...base.params];
1613
+ textRank = {
1614
+ sql: `${this._bm25Expr(`fts."${esc(fts)}"`, plan.index)} ASC`,
1615
+ params: []
1616
+ };
1617
+ } else {
1618
+ const where = buildPrefixedWhere("t", query.filter);
1619
+ const eqs = plan.exact.exactEqualities.map((eq) => `t.${eq}`);
1620
+ head = `FROM "${esc(this.resolveTableName())}" AS t LEFT JOIN m ON m.__atscript_r = t.rowid WHERE (m.__atscript_r IS NOT NULL OR ${eqs.join(" OR ")})` + (where.sql === "1=1" ? "" : ` AND (${where.sql})`);
1621
+ params = [
1622
+ quoteFtsTerm(text),
1623
+ ...eqs.map(() => plan.exact.n),
1624
+ ...where.params
1625
+ ];
1626
+ textRank = {
1627
+ sql: "m.__atscript_s ASC",
1628
+ params: []
1629
+ };
1630
+ }
1631
+ const order = sorted ? {
1632
+ sql: sorted,
1633
+ params: []
1634
+ } : searchRankOrder(sqliteDialect, {
1635
+ exactEqualities: plan.join ? [] : plan.exact.exactEqualities.map((eq) => `t.${eq}`),
1636
+ n: plan.exact.n,
1637
+ textRank,
1638
+ pkColumns: this._pkColumns(),
1639
+ prefix: "t."
1640
+ });
1591
1641
  let cols = "t.*";
1592
1642
  if (controls.$select?.asArray?.length) cols = controls.$select.asArray.map((c) => `t."${esc(c)}"`).join(", ");
1593
- let sql = `SELECT ${cols} ${base.fromWhere}`;
1594
- const params = [...base.params];
1595
- const orderBy = orderByList(sqliteDialect, controls.$sort, controls.$nulls, "t.");
1596
- if (orderBy) sql += ` ORDER BY ${orderBy}`;
1643
+ let sql = `${plan.join ? "" : `WITH m AS MATERIALIZED (SELECT rowid AS __atscript_r, ${this._bm25Expr(`"${esc(fts)}"`, plan.index)} AS __atscript_s FROM "${esc(fts)}" WHERE "${esc(fts)}" MATCH ?) `}SELECT ${cols} ${head} ORDER BY ${order.sql}`;
1644
+ params.push(...order.params);
1597
1645
  if (controls.$limit !== void 0) {
1598
1646
  sql += ` LIMIT ?`;
1599
1647
  params.push(controls.$limit);
@@ -1616,7 +1664,7 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1616
1664
  let countSql;
1617
1665
  let countParams;
1618
1666
  if (plan.join) {
1619
- const base = this._buildFtsBase(text, query.filter, indexName);
1667
+ const base = this._buildFtsBase(text, query.filter, plan.index);
1620
1668
  countSql = `SELECT COUNT(*) as cnt ${base.fromWhere}`;
1621
1669
  countParams = base.params;
1622
1670
  } else {
@@ -1680,6 +1728,7 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1680
1728
  const index = this._resolveFtsIndex(indexName);
1681
1729
  const { text: textFields, integer: integerFields } = splitFulltextFields(index);
1682
1730
  const n = integerFields.length > 0 ? searchTermInteger(text) : void 0;
1731
+ const equalities = n === void 0 ? [] : integerFields.map((f) => `"${esc(f.name)}" = ?`);
1683
1732
  const parts = [];
1684
1733
  const params = [];
1685
1734
  if (textFields.length > 0) {
@@ -1687,21 +1736,39 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
1687
1736
  parts.push(`rowid IN (SELECT rowid FROM ${quoted} WHERE ${quoted} MATCH ?)`);
1688
1737
  params.push(quoteFtsTerm(text));
1689
1738
  }
1690
- if (n !== void 0) for (const f of integerFields) {
1691
- parts.push(`"${esc(f.name)}" = ?`);
1739
+ for (const eq of equalities) {
1740
+ parts.push(eq);
1692
1741
  params.push(n);
1693
1742
  }
1694
1743
  return {
1744
+ index,
1695
1745
  predicate: orFragment(parts, params),
1696
- join: textFields.length > 0 && n === void 0
1746
+ join: textFields.length > 0 && n === void 0,
1747
+ textFields: textFields.length,
1748
+ exact: {
1749
+ exactEqualities: equalities,
1750
+ n
1751
+ }
1697
1752
  };
1698
1753
  }
1699
1754
  /**
1755
+ * `bm25(<hidden column>[, w…])` over an index's text members (lower = better). The
1756
+ * per-column weights follow the FTS5 table's column order; omitted when
1757
+ * every member has the default weight.
1758
+ */
1759
+ _bm25Expr(hidden, index) {
1760
+ const weights = splitFulltextFields(index).text.map((f) => f.weight ?? 1);
1761
+ return weights.every((w) => w === 1) ? `bm25(${hidden})` : `bm25(${hidden}, ${weights.map((w) => String(w)).join(", ")})`;
1762
+ }
1763
+ /** The physical primary-key columns — the relevance tie-breaker. */
1764
+ _pkColumns() {
1765
+ return this._table.primaryKeys.map((key) => this._table.physicalPath(key));
1766
+ }
1767
+ /**
1700
1768
  * Builds the shared FROM+JOIN+WHERE fragment for FTS5 queries.
1701
1769
  * Both data and count queries reuse this to avoid duplicating index resolution and filter translation.
1702
1770
  */
1703
- _buildFtsBase(text, filter, indexName) {
1704
- const ftsIndex = this._resolveFtsIndex(indexName);
1771
+ _buildFtsBase(text, filter, ftsIndex) {
1705
1772
  const ftsTable = this._ftsTableName(ftsIndex.name);
1706
1773
  const tableName = this.resolveTableName();
1707
1774
  const where = buildPrefixedWhere("t", filter);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-sqlite",
3
- "version": "0.1.163",
3
+ "version": "0.1.165",
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.109",
40
- "@atscript/typescript": "^0.1.109",
39
+ "@atscript/core": "^0.2.1",
40
+ "@atscript/typescript": "^0.2.1",
41
41
  "@types/better-sqlite3": "^7.6.13",
42
42
  "@uniqu/core": "^0.1.15",
43
43
  "better-sqlite3": "^12.6.2",
44
44
  "sqlite-vec": "^0.1.9",
45
- "unplugin-atscript": "^0.1.109"
45
+ "unplugin-atscript": "^0.2.1"
46
46
  },
47
47
  "peerDependencies": {
48
- "@atscript/core": "^0.1.109",
49
- "@atscript/typescript": "^0.1.109",
48
+ "@atscript/core": "^0.2.1",
49
+ "@atscript/typescript": "^0.2.1",
50
50
  "@uniqu/core": "^0.1.15",
51
51
  "better-sqlite3": ">=11.0.0",
52
- "@atscript/db": "^0.1.163",
53
- "@atscript/db-sql-tools": "^0.1.163"
52
+ "@atscript/db": "^0.1.165",
53
+ "@atscript/db-sql-tools": "^0.1.165"
54
54
  },
55
55
  "peerDependenciesMeta": {
56
56
  "better-sqlite3": {