@nicia-ai/typegraph 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/{ast-Bh2NDeaK.d.cts → ast-CSLbPIOC.d.ts} +56 -3
  2. package/dist/{ast-COMyNeMn.d.ts → ast-DogwbBqA.d.cts} +56 -3
  3. package/dist/backend/postgres/index.cjs +123 -45
  4. package/dist/backend/postgres/index.cjs.map +1 -1
  5. package/dist/backend/postgres/index.d.cts +15 -7
  6. package/dist/backend/postgres/index.d.ts +15 -7
  7. package/dist/backend/postgres/index.js +101 -23
  8. package/dist/backend/postgres/index.js.map +1 -1
  9. package/dist/backend/sqlite/index.cjs +15 -15
  10. package/dist/backend/sqlite/index.d.cts +11 -7
  11. package/dist/backend/sqlite/index.d.ts +11 -7
  12. package/dist/backend/sqlite/index.js +5 -5
  13. package/dist/backend/sqlite/libsql.cjs +8 -8
  14. package/dist/backend/sqlite/libsql.d.cts +4 -5
  15. package/dist/backend/sqlite/libsql.d.ts +4 -5
  16. package/dist/backend/sqlite/libsql.js +5 -5
  17. package/dist/backend/sqlite/local.cjs +9 -9
  18. package/dist/backend/sqlite/local.d.cts +4 -5
  19. package/dist/backend/sqlite/local.d.ts +4 -5
  20. package/dist/backend/sqlite/local.js +5 -5
  21. package/dist/{chunk-LELLOHJK.cjs → chunk-2X7P7AD5.cjs} +35 -35
  22. package/dist/{chunk-LELLOHJK.cjs.map → chunk-2X7P7AD5.cjs.map} +1 -1
  23. package/dist/{chunk-GNIYZKBI.js → chunk-4XNE2JG3.js} +376 -8
  24. package/dist/chunk-4XNE2JG3.js.map +1 -0
  25. package/dist/{chunk-HXJXTOXR.cjs → chunk-CQL6FOTT.cjs} +82 -21
  26. package/dist/chunk-CQL6FOTT.cjs.map +1 -0
  27. package/dist/{chunk-K7SQ3SWP.js → chunk-EYELZHKY.js} +120 -12
  28. package/dist/chunk-EYELZHKY.js.map +1 -0
  29. package/dist/{chunk-MME3H4ZF.cjs → chunk-FQCQM3KG.cjs} +385 -9
  30. package/dist/chunk-FQCQM3KG.cjs.map +1 -0
  31. package/dist/{chunk-X33WV2UF.cjs → chunk-HRNQ3DVJ.cjs} +24 -21
  32. package/dist/chunk-HRNQ3DVJ.cjs.map +1 -0
  33. package/dist/{chunk-W6M7XWKX.js → chunk-JYUQA77I.js} +21 -18
  34. package/dist/chunk-JYUQA77I.js.map +1 -0
  35. package/dist/{chunk-LLHFS53Q.js → chunk-NRNJ52AA.js} +72 -11
  36. package/dist/chunk-NRNJ52AA.js.map +1 -0
  37. package/dist/{chunk-IYWWXOVK.cjs → chunk-Q73OWTJY.cjs} +204 -89
  38. package/dist/chunk-Q73OWTJY.cjs.map +1 -0
  39. package/dist/{chunk-G5CYWKRF.js → chunk-RS5OLGEW.js} +2 -2
  40. package/dist/{chunk-G5CYWKRF.js.map → chunk-RS5OLGEW.js.map} +1 -1
  41. package/dist/{chunk-6GWJH6AR.js → chunk-SMMOAVJ5.js} +4 -4
  42. package/dist/{chunk-6GWJH6AR.js.map → chunk-SMMOAVJ5.js.map} +1 -1
  43. package/dist/{chunk-QFZQGEQ3.js → chunk-UQVBVBVG.js} +151 -36
  44. package/dist/chunk-UQVBVBVG.js.map +1 -0
  45. package/dist/{chunk-P5CNM325.cjs → chunk-UVZHTBZ5.cjs} +123 -11
  46. package/dist/chunk-UVZHTBZ5.cjs.map +1 -0
  47. package/dist/{chunk-5GZMPJOP.cjs → chunk-WRWVWKQL.cjs} +2 -2
  48. package/dist/{chunk-5GZMPJOP.cjs.map → chunk-WRWVWKQL.cjs.map} +1 -1
  49. package/dist/{ddl-BBo2EcFc.d.cts → ddl-BqkTShcR.d.ts} +28 -6
  50. package/dist/{ddl-D4jlcOH_.d.ts → ddl-Dk1NDN3M.d.cts} +28 -6
  51. package/dist/index.cjs +1705 -490
  52. package/dist/index.cjs.map +1 -1
  53. package/dist/index.d.cts +8 -10
  54. package/dist/index.d.ts +8 -10
  55. package/dist/index.js +1552 -361
  56. package/dist/index.js.map +1 -1
  57. package/dist/indexes/index.cjs +17 -17
  58. package/dist/indexes/index.d.cts +5 -5
  59. package/dist/indexes/index.d.ts +5 -5
  60. package/dist/indexes/index.js +3 -3
  61. package/dist/interchange/index.cjs +6 -6
  62. package/dist/interchange/index.d.cts +4 -5
  63. package/dist/interchange/index.d.ts +4 -5
  64. package/dist/interchange/index.js +1 -1
  65. package/dist/{manager-Chhrq1vl.d.ts → manager-0jVZtiE8.d.ts} +1 -2
  66. package/dist/{manager-Bj1UEnhE.d.cts → manager-BPK-2PUI.d.cts} +1 -2
  67. package/dist/profiler/index.cjs +15 -8
  68. package/dist/profiler/index.cjs.map +1 -1
  69. package/dist/profiler/index.d.cts +7 -8
  70. package/dist/profiler/index.d.ts +7 -8
  71. package/dist/profiler/index.js +8 -1
  72. package/dist/profiler/index.js.map +1 -1
  73. package/dist/schema/index.cjs +21 -21
  74. package/dist/schema/index.d.cts +4 -5
  75. package/dist/schema/index.d.ts +4 -5
  76. package/dist/schema/index.js +3 -3
  77. package/dist/{sqlite-DE-6NWtC.d.cts → sqlite-D1rrwcir.d.cts} +14 -1
  78. package/dist/{sqlite-Blq-AhmK.d.ts → sqlite-W3rmJMlc.d.ts} +14 -1
  79. package/dist/{store-BMrRi_mY.d.ts → store-2QW2SIRk.d.cts} +404 -5
  80. package/dist/{store-DRRvALu8.d.cts → store-C0q13mtJ.d.ts} +404 -5
  81. package/dist/{types-B3mmOMJV.d.cts → types-CQF88McG.d.cts} +475 -2
  82. package/dist/{types-ThB4cFLp.d.ts → types-CQF88McG.d.ts} +475 -2
  83. package/dist/{types-COPePE8_.d.ts → types-CglvhPKz.d.ts} +1 -1
  84. package/dist/{types-CVtGFpB9.d.cts → types-D23slCOA.d.cts} +1 -1
  85. package/dist/{types-Ceb01czq.d.ts → types-IMQCuser.d.ts} +2 -2
  86. package/dist/{types-CZd2PEOc.d.cts → types-vgjar7Ch.d.cts} +2 -2
  87. package/package.json +9 -9
  88. package/dist/chunk-GNIYZKBI.js.map +0 -1
  89. package/dist/chunk-HXJXTOXR.cjs.map +0 -1
  90. package/dist/chunk-IYWWXOVK.cjs.map +0 -1
  91. package/dist/chunk-K7SQ3SWP.js.map +0 -1
  92. package/dist/chunk-LLHFS53Q.js.map +0 -1
  93. package/dist/chunk-MME3H4ZF.cjs.map +0 -1
  94. package/dist/chunk-P5CNM325.cjs.map +0 -1
  95. package/dist/chunk-QFZQGEQ3.js.map +0 -1
  96. package/dist/chunk-W6M7XWKX.js.map +0 -1
  97. package/dist/chunk-X33WV2UF.cjs.map +0 -1
  98. package/dist/types-DMzKq0d5.d.cts +0 -181
  99. package/dist/types-DMzKq0d5.d.ts +0 -181
package/dist/index.js CHANGED
@@ -1,12 +1,13 @@
1
- import { META_EDGE_BRAND, META_EDGE_SUB_CLASS_OF, META_EDGE_NARROWER, META_EDGE_BROADER, META_EDGE_RELATED_TO, META_EDGE_EQUIVALENT_TO, META_EDGE_SAME_AS, META_EDGE_DIFFERENT_FROM, META_EDGE_DISJOINT_WITH, META_EDGE_HAS_PART, META_EDGE_PART_OF, META_EDGE_INVERSE_OF, META_EDGE_IMPLIES, ensureSchema, computeClosuresFromOntology, createEmptyClosures, KindRegistry, validateNodeProps, validateEdgeProps } from './chunk-W6M7XWKX.js';
2
- export { isMetaEdge } from './chunk-W6M7XWKX.js';
3
- import { NODE_TYPE_BRAND, EDGE_TYPE_BRAND, nowIso, validateOptionalIsoDate, getNodeKinds, getEdgeKinds } from './chunk-G5CYWKRF.js';
4
- export { defineGraph, getEdgeKinds, getNodeKinds, isEdgeType, isEdgeTypeWithEndpoints, isGraphDef, isNodeType } from './chunk-G5CYWKRF.js';
1
+ import { META_EDGE_BRAND, META_EDGE_SUB_CLASS_OF, META_EDGE_NARROWER, META_EDGE_BROADER, META_EDGE_RELATED_TO, META_EDGE_EQUIVALENT_TO, META_EDGE_SAME_AS, META_EDGE_DIFFERENT_FROM, META_EDGE_DISJOINT_WITH, META_EDGE_HAS_PART, META_EDGE_PART_OF, META_EDGE_INVERSE_OF, META_EDGE_IMPLIES, ensureSchema, computeClosuresFromOntology, createEmptyClosures, KindRegistry, validateNodeProps, validateEdgeProps } from './chunk-JYUQA77I.js';
2
+ export { isMetaEdge } from './chunk-JYUQA77I.js';
3
+ import { NODE_TYPE_BRAND, EDGE_TYPE_BRAND, nowIso, validateOptionalIsoDate, getNodeKinds, getEdgeKinds } from './chunk-RS5OLGEW.js';
4
+ export { defineGraph, getEdgeKinds, getNodeKinds, isEdgeType, isEdgeTypeWithEndpoints, isGraphDef, isNodeType } from './chunk-RS5OLGEW.js';
5
5
  import { ConfigurationError, UnsupportedPredicateError, ValidationError, CompilerInvariantError, KindNotFoundError, DatabaseOperationError, EdgeNotFoundError, NodeConstraintNotFoundError, RestrictedDeleteError, NodeNotFoundError, EndpointNotFoundError, UniquenessError, CardinalityError, EndpointError, DisjointError } from './chunk-WFMTAEEN.js';
6
6
  export { BackendDisposedError, CardinalityError, CompilerInvariantError, ConfigurationError, DatabaseOperationError, DisjointError, EdgeNotFoundError, EndpointError, EndpointNotFoundError, KindNotFoundError, MigrationError, NodeConstraintNotFoundError, NodeNotFoundError, RestrictedDeleteError, SchemaMismatchError, TypeGraphError, UniquenessError, UnsupportedPredicateError, ValidationError, VersionConflictError, getErrorSuggestion, isConstraintError, isSystemError, isTypeGraphError, isUserRecoverable } from './chunk-WFMTAEEN.js';
7
- import { MAX_PG_IDENTIFIER_LENGTH, DEFAULT_PAGINATION_LIMIT, getDialect, DEFAULT_STREAM_BATCH_SIZE, NODE_META_KEYS, EDGE_META_KEYS, SQLITE_PATH_DELIMITER, PG_ARRAY_START, PG_ARRAY_END, PG_PATH_ELEMENT_SEPARATOR, fnv1aBase36 } from './chunk-GNIYZKBI.js';
8
- import { jsonPointer, parseJsonPointer, createSchemaIntrospector, normalizeJsonPointer, joinJsonPointers, resolveFieldTypeInfoAtJsonPointer, getEmbeddingDimensions, isEmbeddingSchema } from './chunk-K7SQ3SWP.js';
9
- export { MAX_JSON_POINTER_DEPTH, embedding, getEmbeddingDimensions, isEmbeddingSchema, joinJsonPointers, jsonPointer, normalizeJsonPointer, parseJsonPointer } from './chunk-K7SQ3SWP.js';
7
+ import { MAX_PG_IDENTIFIER_LENGTH, DEFAULT_PAGINATION_LIMIT, getDialect, DEFAULT_STREAM_BATCH_SIZE, NODE_META_KEYS, EDGE_META_KEYS, SQLITE_PATH_DELIMITER, PG_ARRAY_START, PG_ARRAY_END, PG_PATH_ELEMENT_SEPARATOR, fnv1aBase36 } from './chunk-4XNE2JG3.js';
8
+ export { fts5Strategy, tsvectorStrategy } from './chunk-4XNE2JG3.js';
9
+ import { jsonPointer, parseJsonPointer, createSchemaIntrospector, normalizeJsonPointer, joinJsonPointers, resolveFieldTypeInfoAtJsonPointer, getSearchableMetadata, DEFAULT_SEARCHABLE_LANGUAGE, getEmbeddingDimensions, isEmbeddingSchema } from './chunk-EYELZHKY.js';
10
+ export { DEFAULT_SEARCHABLE_LANGUAGE, MAX_JSON_POINTER_DEPTH, embedding, getEmbeddingDimensions, getSearchableMetadata, isEmbeddingSchema, isSearchableSchema, joinJsonPointers, jsonPointer, normalizeJsonPointer, parseJsonPointer, searchable } from './chunk-EYELZHKY.js';
10
11
  import { z } from 'zod';
11
12
  import { sql, Placeholder } from 'drizzle-orm';
12
13
  import { nanoid } from 'nanoid';
@@ -31,6 +32,44 @@ var PROTOTYPE_POLLUTION_KEYS = /* @__PURE__ */ new Set([
31
32
  "constructor",
32
33
  "prototype"
33
34
  ]);
35
+ var RESERVED_PROPERTY_PREFIX = "$";
36
+ function isReservedPropertyName(name) {
37
+ return name.startsWith(RESERVED_PROPERTY_PREFIX);
38
+ }
39
+ function assertSchemaKeysAreFree(entityKind, name, keys, reservedStructuralKeys) {
40
+ const structuralConflicts = keys.filter(
41
+ (key) => reservedStructuralKeys.has(key)
42
+ );
43
+ if (structuralConflicts.length > 0) {
44
+ const label = entityKind.toLowerCase();
45
+ throw new ConfigurationError(
46
+ `${entityKind} "${name}" schema contains reserved property names: ${structuralConflicts.join(", ")}`,
47
+ {
48
+ [`${label}Type`]: name,
49
+ conflicts: structuralConflicts,
50
+ reservedKeys: [...reservedStructuralKeys]
51
+ },
52
+ {
53
+ suggestion: `Rename the conflicting properties. Reserved names (${[...reservedStructuralKeys].join(", ")}) are added automatically to all ${label}s.`
54
+ }
55
+ );
56
+ }
57
+ const prefixConflicts = keys.filter((key) => isReservedPropertyName(key));
58
+ if (prefixConflicts.length > 0) {
59
+ const label = entityKind.toLowerCase();
60
+ throw new ConfigurationError(
61
+ `${entityKind} "${name}" schema uses the reserved "${RESERVED_PROPERTY_PREFIX}" prefix on: ${prefixConflicts.join(", ")}`,
62
+ {
63
+ [`${label}Type`]: name,
64
+ conflicts: prefixConflicts,
65
+ reservedPrefix: RESERVED_PROPERTY_PREFIX
66
+ },
67
+ {
68
+ suggestion: `Property names starting with "${RESERVED_PROPERTY_PREFIX}" are reserved for TypeGraph accessors (e.g. $fulltext). Rename each field.`
69
+ }
70
+ );
71
+ }
72
+ }
34
73
  function validateProjectionField(field2, entityType, kind) {
35
74
  const reserved = entityType === "node" ? RESERVED_NODE_KEYS : RESERVED_EDGE_KEYS;
36
75
  if (reserved.has(field2)) {
@@ -64,19 +103,12 @@ function filterReservedKeys(props, reservedKeys) {
64
103
 
65
104
  // src/core/node.ts
66
105
  function validateSchemaKeys(schema, name) {
67
- const shape = schema.shape;
68
- const conflicts = Object.keys(shape).filter(
69
- (key) => RESERVED_NODE_KEYS.has(key)
106
+ assertSchemaKeysAreFree(
107
+ "Node",
108
+ name,
109
+ Object.keys(schema.shape),
110
+ RESERVED_NODE_KEYS
70
111
  );
71
- if (conflicts.length > 0) {
72
- throw new ConfigurationError(
73
- `Node "${name}" schema contains reserved property names: ${conflicts.join(", ")}`,
74
- { nodeType: name, conflicts, reservedKeys: [...RESERVED_NODE_KEYS] },
75
- {
76
- suggestion: `Rename the conflicting properties. Reserved names (id, kind, meta) are added automatically to all nodes.`
77
- }
78
- );
79
- }
80
112
  }
81
113
  function defineNode(name, options) {
82
114
  validateSchemaKeys(options.schema, name);
@@ -89,19 +121,12 @@ function defineNode(name, options) {
89
121
  }
90
122
  var EMPTY_SCHEMA = z.object({});
91
123
  function validateSchemaKeys2(schema, name) {
92
- const shape = schema.shape;
93
- const conflicts = Object.keys(shape).filter(
94
- (key) => RESERVED_EDGE_KEYS.has(key)
124
+ assertSchemaKeysAreFree(
125
+ "Edge",
126
+ name,
127
+ Object.keys(schema.shape),
128
+ RESERVED_EDGE_KEYS
95
129
  );
96
- if (conflicts.length > 0) {
97
- throw new ConfigurationError(
98
- `Edge "${name}" schema contains reserved property names: ${conflicts.join(", ")}`,
99
- { edgeType: name, conflicts, reservedKeys: [...RESERVED_EDGE_KEYS] },
100
- {
101
- suggestion: `Rename the conflicting properties. Reserved names are added automatically to all edges.`
102
- }
103
- );
104
- }
105
130
  }
106
131
  function defineEdge(name, options) {
107
132
  const schema = options?.schema ?? EMPTY_SCHEMA;
@@ -567,6 +592,41 @@ function stringField(field2) {
567
592
  ilike: (pattern) => stringOp("ilike", field2, pattern)
568
593
  };
569
594
  }
595
+ var DEFAULT_FULLTEXT_MATCH_K = 50;
596
+ function createFulltextAccessor(alias, hasSearchableField) {
597
+ return {
598
+ matches: (query, k, options) => {
599
+ if (!hasSearchableField()) {
600
+ throw new UnsupportedPredicateError(
601
+ `Cannot call .$fulltext.matches() on alias "${alias}" \u2014 its node kind has no fields declared with searchable(). Add at least one: \`title: searchable({ language: "english" })\`.`
602
+ );
603
+ }
604
+ return fulltextMatch(
605
+ fieldRef(alias, ["$fulltext"]),
606
+ query,
607
+ k ?? DEFAULT_FULLTEXT_MATCH_K,
608
+ options
609
+ );
610
+ }
611
+ };
612
+ }
613
+ function fulltextMatch(field2, query, limit, options) {
614
+ if (!Number.isFinite(limit) || limit <= 0) {
615
+ throw new UnsupportedPredicateError(
616
+ `matches() limit must be a positive finite number, got ${String(limit)}`
617
+ );
618
+ }
619
+ const expr = {
620
+ __type: "fulltext_match",
621
+ field: field2,
622
+ query,
623
+ mode: options?.mode ?? "websearch",
624
+ limit,
625
+ ...options?.language === void 0 ? {} : { language: options.language },
626
+ ...options?.minScore === void 0 ? {} : { minScore: options.minScore }
627
+ };
628
+ return predicate(expr);
629
+ }
570
630
  function numberField(field2) {
571
631
  return {
572
632
  ...baseFieldBuilder(field2),
@@ -1142,11 +1202,13 @@ function compilePredicateExpression(expr, ctx) {
1142
1202
  case "in_subquery": {
1143
1203
  return compileInSubquery(expr, ctx);
1144
1204
  }
1145
- case "vector_similarity": {
1146
- return compileVectorSimilarityPredicate();
1205
+ case "vector_similarity":
1206
+ case "fulltext_match": {
1207
+ return STRUCTURAL_PREDICATE_NOOP;
1147
1208
  }
1148
1209
  }
1149
1210
  }
1211
+ var STRUCTURAL_PREDICATE_NOOP = sql.raw("1=1");
1150
1212
  function compileComparisonPredicate(expr, dialect, cteColumnPrefix) {
1151
1213
  if (isParameterRef(expr.right)) {
1152
1214
  const parameterValueType = normalizeValueType2(expr.right.valueType) ?? normalizeValueType2(expr.left.valueType);
@@ -1400,45 +1462,61 @@ function compileInSubquery(expr, ctx) {
1400
1462
  const subSql = ctx.compileQuery(expr.subquery, expr.subquery.graphId);
1401
1463
  return expr.negated ? sql`${fieldSql} NOT IN (${subSql})` : sql`${fieldSql} IN (${subSql})`;
1402
1464
  }
1403
- function compileVectorSimilarityPredicate(_expr, _ctx) {
1404
- return sql.raw("1=1");
1405
- }
1406
- function extractVectorSimilarityPredicates(predicates) {
1465
+ function extractStructuralPredicates(predicates, isMatch, rejectionMessage) {
1407
1466
  const results = [];
1408
1467
  function visit(expr, inDisallowedBranch) {
1409
- switch (expr.__type) {
1410
- case "vector_similarity": {
1411
- if (inDisallowedBranch) {
1412
- throw new UnsupportedPredicateError(
1413
- "Vector similarity predicates cannot be nested under OR or NOT. Use top-level AND combinations instead."
1414
- );
1415
- }
1416
- results.push(expr);
1417
- break;
1468
+ if (isMatch(expr)) {
1469
+ if (inDisallowedBranch) {
1470
+ throw new UnsupportedPredicateError(rejectionMessage);
1418
1471
  }
1472
+ results.push(expr);
1473
+ return;
1474
+ }
1475
+ switch (expr.__type) {
1419
1476
  case "and": {
1420
- for (const predicate2 of expr.predicates) {
1421
- visit(predicate2, inDisallowedBranch);
1422
- }
1423
- break;
1477
+ for (const child of expr.predicates) visit(child, inDisallowedBranch);
1478
+ return;
1424
1479
  }
1425
1480
  case "or": {
1426
- for (const predicate2 of expr.predicates) {
1427
- visit(predicate2, true);
1428
- }
1429
- break;
1481
+ for (const child of expr.predicates) visit(child, true);
1482
+ return;
1430
1483
  }
1431
1484
  case "not": {
1432
1485
  visit(expr.predicate, true);
1433
- break;
1486
+ return;
1487
+ }
1488
+ case "comparison":
1489
+ case "string_op":
1490
+ case "null_check":
1491
+ case "between":
1492
+ case "array_op":
1493
+ case "object_op":
1494
+ case "aggregate_comparison":
1495
+ case "exists":
1496
+ case "in_subquery":
1497
+ case "vector_similarity":
1498
+ case "fulltext_match": {
1499
+ return;
1434
1500
  }
1435
1501
  }
1436
1502
  }
1437
- for (const pred of predicates) {
1438
- visit(pred.expression, false);
1439
- }
1503
+ for (const pred of predicates) visit(pred.expression, false);
1440
1504
  return results;
1441
1505
  }
1506
+ function extractVectorSimilarityPredicates(predicates) {
1507
+ return extractStructuralPredicates(
1508
+ predicates,
1509
+ (expr) => expr.__type === "vector_similarity",
1510
+ "Vector similarity predicates cannot be nested under OR or NOT. Use top-level AND combinations instead."
1511
+ );
1512
+ }
1513
+ function extractFulltextMatchPredicates(predicates) {
1514
+ return extractStructuralPredicates(
1515
+ predicates,
1516
+ (expr) => expr.__type === "fulltext_match",
1517
+ "Fulltext match predicates cannot be nested under OR or NOT. Use top-level AND combinations instead."
1518
+ );
1519
+ }
1442
1520
 
1443
1521
  // src/query/compiler/predicate-utils.ts
1444
1522
  var EMPTY_PREDICATES = [];
@@ -1497,6 +1575,10 @@ function getNodeKindsForAlias(ast, alias) {
1497
1575
  }
1498
1576
  throw new CompilerInvariantError(`Unknown traversal source alias: ${alias}`);
1499
1577
  }
1578
+ function getHybridTargetAlias(vectorPredicate, fulltextPredicate) {
1579
+ if (!vectorPredicate || !fulltextPredicate) return void 0;
1580
+ return vectorPredicate.field.alias === fulltextPredicate.field.alias ? vectorPredicate.field.alias : void 0;
1581
+ }
1500
1582
 
1501
1583
  // src/query/compiler/emitter/plan-inspector.ts
1502
1584
  function collectPlanOperations(node, ops) {
@@ -1504,6 +1586,7 @@ function collectPlanOperations(node, ops) {
1504
1586
  switch (node.op) {
1505
1587
  case "aggregate":
1506
1588
  case "filter":
1589
+ case "fulltext_match":
1507
1590
  case "join":
1508
1591
  case "limit_offset":
1509
1592
  case "project":
@@ -1532,6 +1615,7 @@ function findUnaryNodeInProjectChain(rootNode, op) {
1532
1615
  switch (currentNode.op) {
1533
1616
  case "aggregate":
1534
1617
  case "filter":
1618
+ case "fulltext_match":
1535
1619
  case "join":
1536
1620
  case "limit_offset":
1537
1621
  case "recursive_expand":
@@ -1567,6 +1651,7 @@ function inspectProjectPlan(logicalPlan) {
1567
1651
  );
1568
1652
  return {
1569
1653
  hasAggregate: operations.has("aggregate"),
1654
+ hasFulltextMatch: operations.has("fulltext_match"),
1570
1655
  hasLimitOffset: operations.has("limit_offset"),
1571
1656
  hasRecursiveExpand: operations.has("recursive_expand"),
1572
1657
  hasSetOperation: operations.has("set_op"),
@@ -1618,6 +1703,7 @@ function findTopLevelLimitOffsetNode(rootNode) {
1618
1703
  switch (currentNode.op) {
1619
1704
  case "aggregate":
1620
1705
  case "filter":
1706
+ case "fulltext_match":
1621
1707
  case "join":
1622
1708
  case "project":
1623
1709
  case "recursive_expand":
@@ -1642,6 +1728,7 @@ function findTopLevelSortNode(rootNode) {
1642
1728
  switch (currentNode.op) {
1643
1729
  case "aggregate":
1644
1730
  case "filter":
1731
+ case "fulltext_match":
1645
1732
  case "join":
1646
1733
  case "limit_offset":
1647
1734
  case "project":
@@ -1776,16 +1863,16 @@ function assertStandardEmitterClauseAlignment(logicalPlan, input) {
1776
1863
  { component: "standard-emitter" }
1777
1864
  );
1778
1865
  }
1779
- const expectsOrderBy = planShape.hasSort || planShape.hasVectorKnn;
1866
+ const expectsOrderBy = planShape.hasSort || planShape.hasVectorKnn || planShape.hasFulltextMatch;
1780
1867
  if (expectsOrderBy && input.orderBy === void 0) {
1781
1868
  throw new CompilerInvariantError(
1782
- "Standard SQL emitter expected ORDER BY clause for plan containing a sort or vector_knn node",
1869
+ "Standard SQL emitter expected ORDER BY clause for plan containing a sort, vector_knn, or fulltext_match node",
1783
1870
  { component: "standard-emitter" }
1784
1871
  );
1785
1872
  }
1786
1873
  if (!expectsOrderBy && input.orderBy !== void 0) {
1787
1874
  throw new CompilerInvariantError(
1788
- "Standard SQL emitter received ORDER BY clause for a plan without sort or vector_knn nodes",
1875
+ "Standard SQL emitter received ORDER BY clause for a plan without sort, vector_knn, or fulltext_match nodes",
1789
1876
  { component: "standard-emitter" }
1790
1877
  );
1791
1878
  }
@@ -1824,6 +1911,62 @@ function emitStandardQuerySql(input) {
1824
1911
  return sql.join(parts, sql` `);
1825
1912
  }
1826
1913
 
1914
+ // src/query/ast.ts
1915
+ var DEFAULT_RRF_K = 60;
1916
+ var DEFAULT_RRF_WEIGHT = 1;
1917
+ function mergeEdgeKinds(traversal) {
1918
+ const inverse = traversal.inverseEdgeKinds;
1919
+ if (inverse === void 0 || inverse.length === 0) return traversal.edgeKinds;
1920
+ return [
1921
+ ...traversal.edgeKinds,
1922
+ ...inverse.filter((kind) => !traversal.edgeKinds.includes(kind))
1923
+ ];
1924
+ }
1925
+ var DEFAULT_TABLE_NAMES = {
1926
+ nodes: "typegraph_nodes",
1927
+ edges: "typegraph_edges",
1928
+ embeddings: "typegraph_node_embeddings",
1929
+ fulltext: "typegraph_node_fulltext"
1930
+ };
1931
+ var VALID_IDENTIFIER_PATTERN = /^[a-z_][a-z0-9_$]*$/i;
1932
+ function validateTableName(name, label) {
1933
+ if (!name || name.length === 0) {
1934
+ throw new ConfigurationError(`${label} table name cannot be empty`);
1935
+ }
1936
+ if (name.length > MAX_PG_IDENTIFIER_LENGTH) {
1937
+ throw new ConfigurationError(
1938
+ `${label} table name exceeds maximum length of ${MAX_PG_IDENTIFIER_LENGTH} characters`
1939
+ );
1940
+ }
1941
+ if (!VALID_IDENTIFIER_PATTERN.test(name)) {
1942
+ throw new ConfigurationError(
1943
+ `${label} table name "${name}" is not a valid SQL identifier. Table names must start with a letter or underscore and contain only letters, digits, underscores, or dollar signs.`
1944
+ );
1945
+ }
1946
+ }
1947
+ function quoteIdentifier(name) {
1948
+ return `"${name.replaceAll('"', '""')}"`;
1949
+ }
1950
+ function createSqlSchema(names = {}) {
1951
+ const tables = { ...DEFAULT_TABLE_NAMES, ...names };
1952
+ validateTableName(tables.nodes, "nodes");
1953
+ validateTableName(tables.edges, "edges");
1954
+ validateTableName(tables.embeddings, "embeddings");
1955
+ validateTableName(tables.fulltext, "fulltext");
1956
+ return {
1957
+ tables,
1958
+ nodesTable: sql.raw(quoteIdentifier(tables.nodes)),
1959
+ edgesTable: sql.raw(quoteIdentifier(tables.edges)),
1960
+ embeddingsTable: sql.raw(quoteIdentifier(tables.embeddings)),
1961
+ fulltextTable: sql.raw(quoteIdentifier(tables.fulltext))
1962
+ };
1963
+ }
1964
+ var DEFAULT_SQL_SCHEMA = createSqlSchema();
1965
+ var ALIAS_CTE_PREFIX = "cte_";
1966
+ var EMBEDDINGS_CTE_ALIAS = "cte_embeddings";
1967
+ var FULLTEXT_CTE_ALIAS = "cte_fulltext";
1968
+ var HYBRID_CANDIDATES_CTE_ALIAS = "cte_relevance_candidates";
1969
+
1827
1970
  // src/query/compiler/typed-json-extract.ts
1828
1971
  function compileTypedJsonExtract(input) {
1829
1972
  const { column, dialect, pointer, valueType } = input;
@@ -1877,7 +2020,7 @@ var EDGE_COLUMNS = [
1877
2020
  "deleted_at"
1878
2021
  ];
1879
2022
  var EMPTY_REQUIRED_COLUMNS = /* @__PURE__ */ new Set();
1880
- function quoteIdentifier(identifier) {
2023
+ function quoteIdentifier2(identifier) {
1881
2024
  return sql.raw(`"${identifier.replaceAll('"', '""')}"`);
1882
2025
  }
1883
2026
  function shouldProjectColumn(requiredColumns, column, alwaysRequiredColumns) {
@@ -1931,6 +2074,21 @@ function compileColumnReference(tableAlias, column) {
1931
2074
  }
1932
2075
  return sql`${sql.raw(tableAlias)}.${sql.raw(column)}`;
1933
2076
  }
2077
+ function qualifyColumn(owner, name) {
2078
+ return sql`${quoteIdentifier2(owner)}.${quoteIdentifier2(name)}`;
2079
+ }
2080
+ var SCOPED_RELEVANCE_NODES_ALIAS = "scoped_relevance_nodes";
2081
+ function buildScopedNodeIdsSubquery(nodeAlias) {
2082
+ const cteAlias = `${ALIAS_CTE_PREFIX}${nodeAlias}`;
2083
+ return sql`
2084
+ (
2085
+ SELECT DISTINCT
2086
+ ${qualifyColumn(cteAlias, `${nodeAlias}_id`)} AS node_id,
2087
+ ${qualifyColumn(cteAlias, `${nodeAlias}_kind`)} AS node_kind
2088
+ FROM ${quoteIdentifier2(cteAlias)}
2089
+ ) AS ${sql.raw(SCOPED_RELEVANCE_NODES_ALIAS)}
2090
+ `;
2091
+ }
1934
2092
  function compileNodeSelectColumns(tableAlias, alias, requiredColumns) {
1935
2093
  return NODE_COLUMNS.filter(
1936
2094
  (column) => column === "id" || column === "kind" || shouldProjectColumn(requiredColumns, column)
@@ -2183,7 +2341,7 @@ function buildStandardProjection(input) {
2183
2341
  }
2184
2342
  const projectedFields = fields.map((field2) => {
2185
2343
  const source = compileProjectedSource(field2, dialect);
2186
- return sql`${source} AS ${quoteIdentifier(field2.outputName)}`;
2344
+ return sql`${source} AS ${quoteIdentifier2(field2.outputName)}`;
2187
2345
  });
2188
2346
  return sql.join(projectedFields, sql`, `);
2189
2347
  }
@@ -2203,7 +2361,7 @@ function compileSelectiveProjection(fields, dialect, ast, collapsedTraversalCteA
2203
2361
  const cteAlias = collapsedTraversalCteAlias ?? aliasToCte.get(field2.alias) ?? `cte_${field2.alias}`;
2204
2362
  if (field2.isSystemField) {
2205
2363
  const dbColumn = mapSelectiveSystemFieldToColumn(field2.field);
2206
- return sql`${sql.raw(cteAlias)}.${sql.raw(`${field2.alias}_${dbColumn}`)} AS ${quoteIdentifier(field2.outputName)}`;
2364
+ return sql`${sql.raw(cteAlias)}.${sql.raw(`${field2.alias}_${dbColumn}`)} AS ${quoteIdentifier2(field2.outputName)}`;
2207
2365
  }
2208
2366
  const propsColumn = `${field2.alias}_props`;
2209
2367
  const column = sql`${sql.raw(cteAlias)}.${sql.raw(propsColumn)}`;
@@ -2214,12 +2372,56 @@ function compileSelectiveProjection(fields, dialect, ast, collapsedTraversalCteA
2214
2372
  pointer,
2215
2373
  valueType: field2.valueType
2216
2374
  });
2217
- return sql`${extracted} AS ${quoteIdentifier(field2.outputName)}`;
2375
+ return sql`${extracted} AS ${quoteIdentifier2(field2.outputName)}`;
2218
2376
  });
2219
2377
  return sql.join(columns, sql`, `);
2220
2378
  }
2379
+ function buildRelevanceJoins(vectorPredicate, fulltextPredicate) {
2380
+ const hybridTargetAlias = getHybridTargetAlias(
2381
+ vectorPredicate,
2382
+ fulltextPredicate
2383
+ );
2384
+ if (hybridTargetAlias !== void 0) {
2385
+ return [
2386
+ buildHybridCandidateJoin(hybridTargetAlias),
2387
+ buildRelevanceJoin(EMBEDDINGS_CTE_ALIAS, hybridTargetAlias, "LEFT JOIN"),
2388
+ buildRelevanceJoin(FULLTEXT_CTE_ALIAS, hybridTargetAlias, "LEFT JOIN")
2389
+ ];
2390
+ }
2391
+ const joins = [];
2392
+ if (vectorPredicate) {
2393
+ joins.push(
2394
+ buildRelevanceJoin(EMBEDDINGS_CTE_ALIAS, vectorPredicate.field.alias)
2395
+ );
2396
+ }
2397
+ if (fulltextPredicate) {
2398
+ joins.push(
2399
+ buildRelevanceJoin(FULLTEXT_CTE_ALIAS, fulltextPredicate.field.alias)
2400
+ );
2401
+ }
2402
+ return joins;
2403
+ }
2404
+ function buildHybridCandidateJoin(nodeAlias) {
2405
+ const candidateCte = sql.raw(HYBRID_CANDIDATES_CTE_ALIAS);
2406
+ const node = sql.raw(`${ALIAS_CTE_PREFIX}${nodeAlias}`);
2407
+ const idColumn = sql.raw(`${nodeAlias}_id`);
2408
+ const kindColumn = sql.raw(`${nodeAlias}_kind`);
2409
+ return sql`INNER JOIN ${candidateCte} ON ${candidateCte}.node_id = ${node}.${idColumn} AND ${candidateCte}.node_kind = ${node}.${kindColumn}`;
2410
+ }
2411
+ function buildRelevanceJoin(cteAlias, nodeAlias, joinType = "INNER JOIN") {
2412
+ const cte = sql.raw(cteAlias);
2413
+ const node = sql.raw(`${ALIAS_CTE_PREFIX}${nodeAlias}`);
2414
+ const idColumn = sql.raw(`${nodeAlias}_id`);
2415
+ const kindColumn = sql.raw(`${nodeAlias}_kind`);
2416
+ return sql`${sql.raw(joinType)} ${cte} ON ${cte}.node_id = ${node}.${idColumn} AND ${cte}.node_kind = ${node}.${kindColumn}`;
2417
+ }
2221
2418
  function buildStandardFromClause(input) {
2222
- const { ast, collapsedTraversalCteAlias, vectorPredicate } = input;
2419
+ const {
2420
+ ast,
2421
+ collapsedTraversalCteAlias,
2422
+ fulltextPredicate,
2423
+ vectorPredicate
2424
+ } = input;
2223
2425
  if (collapsedTraversalCteAlias !== void 0) {
2224
2426
  return sql`FROM ${sql.raw(collapsedTraversalCteAlias)}`;
2225
2427
  }
@@ -2234,11 +2436,8 @@ function buildStandardFromClause(input) {
2234
2436
  sql`${sql.raw(joinType)} ${sql.raw(cteAlias)} ON ${sql.raw(cteAlias)}.${sql.raw(previousAlias)}_id = cte_${sql.raw(previousAlias)}.${sql.raw(previousAlias)}_id AND ${sql.raw(cteAlias)}.${sql.raw(previousAlias)}_kind = cte_${sql.raw(previousAlias)}.${sql.raw(previousAlias)}_kind`
2235
2437
  );
2236
2438
  }
2237
- if (vectorPredicate) {
2238
- const nodeAlias = vectorPredicate.field.alias;
2239
- joins.push(
2240
- sql`INNER JOIN cte_embeddings ON cte_embeddings.node_id = cte_${sql.raw(nodeAlias)}.${sql.raw(nodeAlias)}_id`
2241
- );
2439
+ for (const join of buildRelevanceJoins(vectorPredicate, fulltextPredicate)) {
2440
+ joins.push(join);
2242
2441
  }
2243
2442
  return joins.length === 0 ? fromClause : sql`${fromClause} ${sql.join(joins, sql` `)}`;
2244
2443
  }
@@ -2277,6 +2476,15 @@ function fieldRefKey(field2) {
2277
2476
  const pointer = field2.jsonPointer ?? "";
2278
2477
  return `${field2.alias}:${field2.path.join(".")}:${pointer}`;
2279
2478
  }
2479
+ function resolveEmbeddingFieldPath(field2) {
2480
+ if (field2.jsonPointer !== void 0 && field2.jsonPointer !== "" && field2.jsonPointer !== "/") {
2481
+ return field2.jsonPointer.replace(/^\//u, "").replaceAll("/", ".");
2482
+ }
2483
+ if (field2.path[0] === "props") {
2484
+ return field2.path.slice(1).join(".");
2485
+ }
2486
+ return field2.path.join(".");
2487
+ }
2280
2488
  function buildStandardGroupBy(input) {
2281
2489
  const { ast, dialect } = input;
2282
2490
  if (!ast.groupBy || ast.groupBy.fields.length === 0) {
@@ -2323,18 +2531,29 @@ function buildStandardHaving(input) {
2323
2531
  return sql`HAVING ${condition}`;
2324
2532
  }
2325
2533
  function buildStandardEmbeddingsCte(input) {
2326
- const { ctx, graphId, vectorPredicate } = input;
2534
+ const { ctx, graphId, nodeKinds, vectorPredicate } = input;
2327
2535
  const { dialect } = ctx;
2328
2536
  const { field: field2, metric, minScore, queryEmbedding } = vectorPredicate;
2329
- const fieldPath = field2.jsonPointer ?? (field2.path.length > 1 && field2.path[0] === "props" ? `/${field2.path.slice(1).join("/")}` : `/${field2.path.join("/")}`);
2537
+ const embeddingsTableName = ctx.schema.tables.embeddings;
2538
+ if (nodeKinds.length === 0) {
2539
+ throw new UnsupportedPredicateError(
2540
+ "Vector predicate must resolve to at least one node kind"
2541
+ );
2542
+ }
2543
+ const fieldPath = resolveEmbeddingFieldPath(field2);
2330
2544
  const distanceExpr = dialect.vectorDistance(
2331
- sql.raw("embedding"),
2545
+ qualifyColumn(embeddingsTableName, "embedding"),
2332
2546
  queryEmbedding,
2333
2547
  metric
2334
2548
  );
2549
+ const scopedNodes = buildScopedNodeIdsSubquery(field2.alias);
2335
2550
  const conditions = [
2336
- sql`graph_id = ${graphId}`,
2337
- sql`field_path = ${fieldPath}`
2551
+ sql`${qualifyColumn(embeddingsTableName, "graph_id")} = ${graphId}`,
2552
+ compileKindFilter(
2553
+ qualifyColumn(embeddingsTableName, "node_kind"),
2554
+ nodeKinds
2555
+ ),
2556
+ sql`${qualifyColumn(embeddingsTableName, "field_path")} = ${fieldPath}`
2338
2557
  ];
2339
2558
  if (minScore !== void 0) {
2340
2559
  conditions.push(
@@ -2343,14 +2562,29 @@ function buildStandardEmbeddingsCte(input) {
2343
2562
  }
2344
2563
  const scoreExpr = compileVectorScoreExpression(distanceExpr, metric);
2345
2564
  return sql`
2346
- cte_embeddings AS (
2565
+ ${sql.raw(EMBEDDINGS_CTE_ALIAS)} AS (
2347
2566
  SELECT
2348
2567
  node_id,
2349
- ${distanceExpr} AS distance,
2350
- ${scoreExpr} AS score
2351
- FROM ${ctx.schema.embeddingsTable}
2352
- WHERE ${sql.join(conditions, sql` AND `)}
2353
- ORDER BY ${distanceExpr} ASC
2568
+ node_kind,
2569
+ distance,
2570
+ score,
2571
+ ROW_NUMBER() OVER (ORDER BY distance ASC) AS ord
2572
+ FROM (
2573
+ SELECT
2574
+ ${qualifyColumn(embeddingsTableName, "node_id")} AS node_id,
2575
+ ${qualifyColumn(embeddingsTableName, "node_kind")} AS node_kind,
2576
+ ${distanceExpr} AS distance,
2577
+ ${scoreExpr} AS score
2578
+ FROM ${ctx.schema.embeddingsTable}
2579
+ INNER JOIN ${scopedNodes}
2580
+ ON ${sql.raw(`${SCOPED_RELEVANCE_NODES_ALIAS}.node_id`)} =
2581
+ ${qualifyColumn(embeddingsTableName, "node_id")}
2582
+ AND ${sql.raw(`${SCOPED_RELEVANCE_NODES_ALIAS}.node_kind`)} =
2583
+ ${qualifyColumn(embeddingsTableName, "node_kind")}
2584
+ WHERE ${sql.join(conditions, sql` AND `)}
2585
+ ORDER BY distance ASC
2586
+ LIMIT ${vectorPredicate.limit}
2587
+ ) AS vec_inner
2354
2588
  )
2355
2589
  `;
2356
2590
  }
@@ -2380,37 +2614,130 @@ function compileVectorMinScoreCondition(distanceExpr, metric, minScore) {
2380
2614
  }
2381
2615
  }
2382
2616
  }
2383
- function buildStandardVectorOrderBy(input) {
2384
- const { ast, dialect } = input;
2385
- const distanceOrder = sql`cte_embeddings.distance ASC`;
2386
- const additionalOrders = [];
2387
- if (ast.orderBy && ast.orderBy.length > 0) {
2388
- const aliasToCte = buildAliasToCteMap(ast);
2389
- for (const orderSpec of ast.orderBy) {
2390
- const valueType = orderSpec.field.valueType;
2391
- if (valueType === "array" || valueType === "object") {
2392
- throw new UnsupportedPredicateError(
2393
- "Ordering by JSON arrays or objects is not supported"
2394
- );
2395
- }
2396
- const cteAlias = aliasToCte.get(orderSpec.field.alias) ?? `cte_${orderSpec.field.alias}`;
2397
- const field2 = compileFieldValue(
2398
- orderSpec.field,
2399
- dialect,
2400
- valueType,
2401
- cteAlias
2402
- );
2403
- const direction = sql.raw(orderSpec.direction.toUpperCase());
2404
- const nulls = orderSpec.nulls ?? (orderSpec.direction === "asc" ? "last" : "first");
2405
- const nullsDirection = sql.raw(nulls === "first" ? "DESC" : "ASC");
2406
- additionalOrders.push(
2407
- sql`(${field2} IS NULL) ${nullsDirection}`,
2408
- sql`${field2} ${direction}`
2617
+ function buildStandardFulltextCte(input) {
2618
+ const { ctx, fulltextPredicate, graphId, nodeKinds } = input;
2619
+ const { dialect, schema } = ctx;
2620
+ const { query, mode, language, limit, minScore } = fulltextPredicate;
2621
+ if (nodeKinds.length === 0) {
2622
+ throw new UnsupportedPredicateError(
2623
+ "Fulltext predicate must resolve to at least one node kind"
2624
+ );
2625
+ }
2626
+ const tableName = schema.tables.fulltext;
2627
+ const scopedNodes = buildScopedNodeIdsSubquery(fulltextPredicate.field.alias);
2628
+ const fulltextStrategy = dialect.fulltext;
2629
+ if (fulltextStrategy === void 0) {
2630
+ throw new UnsupportedPredicateError(
2631
+ `Fulltext match predicates are not supported for dialect "${dialect.name}"`
2632
+ );
2633
+ }
2634
+ const matchCondition = fulltextStrategy.matchCondition(
2635
+ tableName,
2636
+ query,
2637
+ mode,
2638
+ language
2639
+ );
2640
+ const rankExpression = fulltextStrategy.rankExpression(
2641
+ tableName,
2642
+ query,
2643
+ mode,
2644
+ language
2645
+ );
2646
+ const conditions = [
2647
+ sql`${qualifyColumn(tableName, "graph_id")} = ${graphId}`,
2648
+ compileKindFilter(qualifyColumn(tableName, "node_kind"), nodeKinds),
2649
+ matchCondition
2650
+ ];
2651
+ if (minScore !== void 0) {
2652
+ conditions.push(sql`${rankExpression} >= ${minScore}`);
2653
+ }
2654
+ return sql`
2655
+ ${sql.raw(FULLTEXT_CTE_ALIAS)} AS (
2656
+ SELECT
2657
+ node_id,
2658
+ node_kind,
2659
+ rank,
2660
+ ROW_NUMBER() OVER (ORDER BY rank DESC, node_id ASC) AS ord
2661
+ FROM (
2662
+ SELECT
2663
+ ${qualifyColumn(tableName, "node_id")} AS node_id,
2664
+ ${qualifyColumn(tableName, "node_kind")} AS node_kind,
2665
+ ${rankExpression} AS rank
2666
+ FROM ${schema.fulltextTable}
2667
+ INNER JOIN ${scopedNodes}
2668
+ ON ${sql.raw(`${SCOPED_RELEVANCE_NODES_ALIAS}.node_id`)} =
2669
+ ${qualifyColumn(tableName, "node_id")}
2670
+ AND ${sql.raw(`${SCOPED_RELEVANCE_NODES_ALIAS}.node_kind`)} =
2671
+ ${qualifyColumn(tableName, "node_kind")}
2672
+ WHERE ${sql.join(conditions, sql` AND `)}
2673
+ ORDER BY rank DESC, ${qualifyColumn(tableName, "node_id")} ASC
2674
+ LIMIT ${limit}
2675
+ ) AS fts_inner
2676
+ )
2677
+ `;
2678
+ }
2679
+ function buildStandardHybridCandidateCte() {
2680
+ return sql`
2681
+ ${sql.raw(HYBRID_CANDIDATES_CTE_ALIAS)} AS (
2682
+ SELECT node_id, node_kind FROM ${sql.raw(EMBEDDINGS_CTE_ALIAS)}
2683
+ UNION
2684
+ SELECT node_id, node_kind FROM ${sql.raw(FULLTEXT_CTE_ALIAS)}
2685
+ )
2686
+ `;
2687
+ }
2688
+ function compileUserOrderBy(ast, dialect) {
2689
+ if (!ast.orderBy || ast.orderBy.length === 0) return [];
2690
+ const aliasToCte = buildAliasToCteMap(ast);
2691
+ const fragments = [];
2692
+ for (const orderSpec of ast.orderBy) {
2693
+ const valueType = orderSpec.field.valueType;
2694
+ if (valueType === "array" || valueType === "object") {
2695
+ throw new UnsupportedPredicateError(
2696
+ "Ordering by JSON arrays or objects is not supported"
2409
2697
  );
2410
2698
  }
2699
+ const cteAlias = aliasToCte.get(orderSpec.field.alias) ?? `${ALIAS_CTE_PREFIX}${orderSpec.field.alias}`;
2700
+ const field2 = compileFieldValue(
2701
+ orderSpec.field,
2702
+ dialect,
2703
+ valueType,
2704
+ cteAlias
2705
+ );
2706
+ const direction = sql.raw(orderSpec.direction.toUpperCase());
2707
+ const nulls = orderSpec.nulls ?? (orderSpec.direction === "asc" ? "last" : "first");
2708
+ const nullsDirection = sql.raw(nulls === "first" ? "DESC" : "ASC");
2709
+ fragments.push(
2710
+ sql`(${field2} IS NULL) ${nullsDirection}`,
2711
+ sql`${field2} ${direction}`
2712
+ );
2411
2713
  }
2412
- const allOrders = [distanceOrder, ...additionalOrders];
2413
- return sql`ORDER BY ${sql.join(allOrders, sql`, `)}`;
2714
+ return fragments;
2715
+ }
2716
+ function buildStandardFulltextOrderBy(input) {
2717
+ const { ast, dialect } = input;
2718
+ const rankOrder = sql.raw(`${FULLTEXT_CTE_ALIAS}.rank DESC`);
2719
+ const userOrders = compileUserOrderBy(ast, dialect);
2720
+ return sql`ORDER BY ${sql.join([rankOrder, ...userOrders], sql`, `)}`;
2721
+ }
2722
+ function buildStandardHybridRrfOrderBy(input) {
2723
+ const { ast, dialect, fusion } = input;
2724
+ const k = fusion?.k ?? DEFAULT_RRF_K;
2725
+ const vectorWeight = fusion?.weights?.vector ?? DEFAULT_RRF_WEIGHT;
2726
+ const fulltextWeight = fusion?.weights?.fulltext ?? DEFAULT_RRF_WEIGHT;
2727
+ const rrfOrder = sql.raw(
2728
+ `(COALESCE(${vectorWeight} / (${k} + ${EMBEDDINGS_CTE_ALIAS}.ord), 0) + COALESCE(${fulltextWeight} / (${k} + ${FULLTEXT_CTE_ALIAS}.ord), 0)) DESC`
2729
+ );
2730
+ const idTiebreak = sql.raw(
2731
+ `COALESCE(${FULLTEXT_CTE_ALIAS}.node_id, ${EMBEDDINGS_CTE_ALIAS}.node_id) ASC`
2732
+ );
2733
+ const userOrders = compileUserOrderBy(ast, dialect);
2734
+ return sql`ORDER BY ${sql.join([rrfOrder, ...userOrders, idTiebreak], sql`, `)}`;
2735
+ }
2736
+ function buildStandardVectorOrderBy(input) {
2737
+ const { ast, dialect } = input;
2738
+ const distanceOrder = sql.raw(`${EMBEDDINGS_CTE_ALIAS}.distance ASC`);
2739
+ const userOrders = compileUserOrderBy(ast, dialect);
2740
+ return sql`ORDER BY ${sql.join([distanceOrder, ...userOrders], sql`, `)}`;
2414
2741
  }
2415
2742
  function buildLimitOffsetClause(input) {
2416
2743
  const { limit, offset } = input;
@@ -2424,6 +2751,74 @@ function buildLimitOffsetClause(input) {
2424
2751
  return parts.length > 0 ? sql.join(parts, sql` `) : void 0;
2425
2752
  }
2426
2753
 
2754
+ // src/query/compiler/passes/fulltext.ts
2755
+ function runFulltextPredicatePass(ast, dialect) {
2756
+ const fulltextPredicates = extractFulltextMatchPredicates(ast.predicates);
2757
+ if (fulltextPredicates.length > 1) {
2758
+ throw new UnsupportedPredicateError(
2759
+ "Multiple fulltext match predicates in a single query are not supported"
2760
+ );
2761
+ }
2762
+ const fulltextPredicate = fulltextPredicates[0];
2763
+ if (fulltextPredicate === void 0) {
2764
+ return { fulltextPredicate: void 0 };
2765
+ }
2766
+ if (!dialect.capabilities.supportsFulltext) {
2767
+ throw new UnsupportedPredicateError(
2768
+ `Fulltext match predicates are not supported for dialect "${dialect.name}"`
2769
+ );
2770
+ }
2771
+ const strategy = dialect.fulltext;
2772
+ if (strategy === void 0) {
2773
+ throw new UnsupportedPredicateError(
2774
+ `Dialect "${dialect.name}" advertises fulltext support but has no strategy configured`
2775
+ );
2776
+ }
2777
+ if (!strategy.supportedModes.includes(fulltextPredicate.mode)) {
2778
+ throw new UnsupportedPredicateError(
2779
+ `Fulltext query mode "${fulltextPredicate.mode}" is not supported by the "${strategy.name}" strategy. Supported modes: ${strategy.supportedModes.join(", ")}.`
2780
+ );
2781
+ }
2782
+ if (!Number.isFinite(fulltextPredicate.limit) || fulltextPredicate.limit <= 0) {
2783
+ throw new UnsupportedPredicateError(
2784
+ `Fulltext match limit must be a positive finite number, got ${String(fulltextPredicate.limit)}`
2785
+ );
2786
+ }
2787
+ const { minScore } = fulltextPredicate;
2788
+ if (minScore !== void 0 && !Number.isFinite(minScore)) {
2789
+ throw new UnsupportedPredicateError(
2790
+ `Fulltext minScore must be a finite number, got ${String(minScore)}`
2791
+ );
2792
+ }
2793
+ return { fulltextPredicate };
2794
+ }
2795
+ function resolveFulltextAwareLimit(astLimit, fulltextPredicate) {
2796
+ if (fulltextPredicate === void 0) {
2797
+ return astLimit;
2798
+ }
2799
+ if (astLimit === void 0) {
2800
+ return fulltextPredicate.limit;
2801
+ }
2802
+ return Math.min(astLimit, fulltextPredicate.limit);
2803
+ }
2804
+
2805
+ // src/query/compiler/passes/fusion.ts
2806
+ function runFusionConfigPass(ast, vectorPredicate, fulltextPredicate) {
2807
+ const fusion = ast.fusion;
2808
+ if (fusion === void 0) return { fusion: void 0 };
2809
+ if (vectorPredicate === void 0 || fulltextPredicate === void 0) {
2810
+ throw new UnsupportedPredicateError(
2811
+ `.fuseWith() requires both a .similarTo() and a .$fulltext.matches() predicate in the same query. Remove .fuseWith(), or add the missing predicate.`
2812
+ );
2813
+ }
2814
+ if (vectorPredicate.field.alias !== fulltextPredicate.field.alias) {
2815
+ throw new UnsupportedPredicateError(
2816
+ `.fuseWith() requires .similarTo() and .$fulltext.matches() to target the same node alias. Got vector=${vectorPredicate.field.alias}, fulltext=${fulltextPredicate.field.alias}.`
2817
+ );
2818
+ }
2819
+ return { fusion };
2820
+ }
2821
+
2427
2822
  // src/query/compiler/passes/recursive.ts
2428
2823
  function runRecursiveTraversalSelectionPass(ast) {
2429
2824
  const variableLengthTraversal = ast.traversals.find(
@@ -2693,6 +3088,14 @@ function lowerStandardQueryToLogicalPlanNode(input) {
2693
3088
  predicate: input.vectorPredicate
2694
3089
  };
2695
3090
  }
3091
+ if (input.fulltextPredicate !== void 0) {
3092
+ currentNode = {
3093
+ id: nextPlanNodeId(),
3094
+ input: currentNode,
3095
+ op: "fulltext_match",
3096
+ predicate: input.fulltextPredicate
3097
+ };
3098
+ }
2696
3099
  return appendAggregateSortLimitAndProjectNodes(
2697
3100
  currentNode,
2698
3101
  ast,
@@ -2770,17 +3173,30 @@ function lowerComposableQueryToLogicalPlanNode(query, dialect, graphId, nextPlan
2770
3173
  nextPlanNodeId
2771
3174
  });
2772
3175
  }
3176
+ const dialectAdapter = getDialect(dialect);
2773
3177
  const vectorPredicate = runVectorPredicatePass(
2774
3178
  query,
2775
- getDialect(dialect)
3179
+ dialectAdapter
2776
3180
  ).vectorPredicate;
2777
- const effectiveLimit = resolveVectorAwareLimit(query.limit, vectorPredicate);
3181
+ const fulltextPredicate = runFulltextPredicatePass(
3182
+ query,
3183
+ dialectAdapter
3184
+ ).fulltextPredicate;
3185
+ const limitAfterVector = resolveVectorAwareLimit(
3186
+ query.limit,
3187
+ vectorPredicate
3188
+ );
3189
+ const effectiveLimit = resolveFulltextAwareLimit(
3190
+ limitAfterVector,
3191
+ fulltextPredicate
3192
+ );
2778
3193
  const loweringInput = {
2779
3194
  ast: query,
2780
3195
  graphId,
2781
3196
  nextPlanNodeId,
2782
3197
  ...effectiveLimit === void 0 ? {} : { effectiveLimit },
2783
- ...vectorPredicate === void 0 ? {} : { vectorPredicate }
3198
+ ...vectorPredicate === void 0 ? {} : { vectorPredicate },
3199
+ ...fulltextPredicate === void 0 ? {} : { fulltextPredicate }
2784
3200
  };
2785
3201
  return lowerStandardQueryToLogicalPlanNode(loweringInput);
2786
3202
  }
@@ -3287,10 +3703,10 @@ function compileRecursiveProjection(ast, traversal, dialect) {
3287
3703
  sql`${sql.raw(nodeAlias)}_deleted_at`
3288
3704
  ];
3289
3705
  if (vl.depthAlias !== void 0) {
3290
- fields.push(sql`depth AS ${quoteIdentifier(vl.depthAlias)}`);
3706
+ fields.push(sql`depth AS ${quoteIdentifier2(vl.depthAlias)}`);
3291
3707
  }
3292
3708
  if (vl.pathAlias !== void 0) {
3293
- fields.push(sql`path AS ${quoteIdentifier(vl.pathAlias)}`);
3709
+ fields.push(sql`path AS ${quoteIdentifier2(vl.pathAlias)}`);
3294
3710
  }
3295
3711
  return sql.join(fields, sql`, `);
3296
3712
  }
@@ -3304,7 +3720,7 @@ function compileRecursiveSelectiveProjection(fields, ast, traversal, dialect) {
3304
3720
  }
3305
3721
  if (field2.isSystemField) {
3306
3722
  const dbColumn = mapSelectiveSystemFieldToColumn(field2.field);
3307
- return sql`${sql.raw(`${field2.alias}_${dbColumn}`)} AS ${quoteIdentifier(field2.outputName)}`;
3723
+ return sql`${sql.raw(`${field2.alias}_${dbColumn}`)} AS ${quoteIdentifier2(field2.outputName)}`;
3308
3724
  }
3309
3725
  const column = sql.raw(`${field2.alias}_props`);
3310
3726
  const extracted = compileTypedJsonExtract({
@@ -3313,14 +3729,14 @@ function compileRecursiveSelectiveProjection(fields, ast, traversal, dialect) {
3313
3729
  pointer: jsonPointer([field2.field]),
3314
3730
  valueType: field2.valueType
3315
3731
  });
3316
- return sql`${extracted} AS ${quoteIdentifier(field2.outputName)}`;
3732
+ return sql`${extracted} AS ${quoteIdentifier2(field2.outputName)}`;
3317
3733
  });
3318
3734
  const vl = traversal.variableLength;
3319
3735
  if (vl.depthAlias !== void 0) {
3320
- columns.push(sql`depth AS ${quoteIdentifier(vl.depthAlias)}`);
3736
+ columns.push(sql`depth AS ${quoteIdentifier2(vl.depthAlias)}`);
3321
3737
  }
3322
3738
  if (vl.pathAlias !== void 0) {
3323
- columns.push(sql`path AS ${quoteIdentifier(vl.pathAlias)}`);
3739
+ columns.push(sql`path AS ${quoteIdentifier2(vl.pathAlias)}`);
3324
3740
  }
3325
3741
  return sql.join(columns, sql`, `);
3326
3742
  }
@@ -3357,43 +3773,6 @@ function compileLimitOffset(ast) {
3357
3773
  }
3358
3774
  return parts.length > 0 ? sql.join(parts, sql` `) : void 0;
3359
3775
  }
3360
- var DEFAULT_TABLE_NAMES = {
3361
- nodes: "typegraph_nodes",
3362
- edges: "typegraph_edges",
3363
- embeddings: "typegraph_node_embeddings"
3364
- };
3365
- var VALID_IDENTIFIER_PATTERN = /^[a-z_][a-z0-9_$]*$/i;
3366
- function validateTableName(name, label) {
3367
- if (!name || name.length === 0) {
3368
- throw new ConfigurationError(`${label} table name cannot be empty`);
3369
- }
3370
- if (name.length > MAX_PG_IDENTIFIER_LENGTH) {
3371
- throw new ConfigurationError(
3372
- `${label} table name exceeds maximum length of ${MAX_PG_IDENTIFIER_LENGTH} characters`
3373
- );
3374
- }
3375
- if (!VALID_IDENTIFIER_PATTERN.test(name)) {
3376
- throw new ConfigurationError(
3377
- `${label} table name "${name}" is not a valid SQL identifier. Table names must start with a letter or underscore and contain only letters, digits, underscores, or dollar signs.`
3378
- );
3379
- }
3380
- }
3381
- function quoteIdentifier2(name) {
3382
- return `"${name.replaceAll('"', '""')}"`;
3383
- }
3384
- function createSqlSchema(names = {}) {
3385
- const tables = { ...DEFAULT_TABLE_NAMES, ...names };
3386
- validateTableName(tables.nodes, "nodes");
3387
- validateTableName(tables.edges, "edges");
3388
- validateTableName(tables.embeddings, "embeddings");
3389
- return {
3390
- tables,
3391
- nodesTable: sql.raw(quoteIdentifier2(tables.nodes)),
3392
- edgesTable: sql.raw(quoteIdentifier2(tables.edges)),
3393
- embeddingsTable: sql.raw(quoteIdentifier2(tables.embeddings))
3394
- };
3395
- }
3396
- var DEFAULT_SQL_SCHEMA = createSqlSchema();
3397
3776
 
3398
3777
  // src/query/execution/value-decoder.ts
3399
3778
  function nullToUndefined(value) {
@@ -3698,12 +4077,7 @@ async function executeSubgraph(params) {
3698
4077
  for (const row of edgeRows) {
3699
4078
  const edge = mapSubgraphEdgeRow(row, edgeProjectionPlan);
3700
4079
  insertAdjacencyEntry(adjacency, edge.fromId, edge.kind, edge);
3701
- insertAdjacencyEntry(
3702
- reverseAdjacency,
3703
- edge.toId,
3704
- edge.kind,
3705
- edge
3706
- );
4080
+ insertAdjacencyEntry(reverseAdjacency, edge.toId, edge.kind, edge);
3707
4081
  }
3708
4082
  const root = nodesMap.get(ctx.rootId);
3709
4083
  return {
@@ -3877,7 +4251,7 @@ function buildProjectedPropertyColumns(alias, plan, dialect) {
3877
4251
  valueType: fieldPlan.typeInfo?.valueType
3878
4252
  });
3879
4253
  columns.push(
3880
- sql`CASE WHEN ${sql.raw(alias)}.kind = ${kind} THEN ${extracted} ELSE NULL END AS ${quoteIdentifier(fieldPlan.outputName)}`
4254
+ sql`CASE WHEN ${sql.raw(alias)}.kind = ${kind} THEN ${extracted} ELSE NULL END AS ${quoteIdentifier2(fieldPlan.outputName)}`
3881
4255
  );
3882
4256
  }
3883
4257
  }
@@ -3944,16 +4318,6 @@ function mapSubgraphEdgeRow(row, projectionPlan) {
3944
4318
  applyProjectedFields(projectedEdge, row, kindPlan);
3945
4319
  return projectedEdge;
3946
4320
  }
3947
-
3948
- // src/query/ast.ts
3949
- function mergeEdgeKinds(traversal) {
3950
- const inverse = traversal.inverseEdgeKinds;
3951
- if (inverse === void 0 || inverse.length === 0) return traversal.edgeKinds;
3952
- return [
3953
- ...traversal.edgeKinds,
3954
- ...inverse.filter((kind) => !traversal.edgeKinds.includes(kind))
3955
- ];
3956
- }
3957
4321
  var OPERATOR_MAP = {
3958
4322
  union: "UNION",
3959
4323
  unionAll: "UNION ALL",
@@ -4110,6 +4474,9 @@ function validateSqliteSetOpLeaf(ast) {
4110
4474
  if (hasVectorSimilarityPredicates(ast)) {
4111
4475
  unsupported.push("vector similarity predicates");
4112
4476
  }
4477
+ if (hasFulltextMatchPredicates(ast)) {
4478
+ unsupported.push("fulltext match predicates");
4479
+ }
4113
4480
  if (ast.groupBy !== void 0) {
4114
4481
  unsupported.push("GROUP BY");
4115
4482
  }
@@ -4159,7 +4526,42 @@ function hasVectorSimilarityInExpression(expr) {
4159
4526
  case "between":
4160
4527
  case "array_op":
4161
4528
  case "object_op":
4162
- case "aggregate_comparison": {
4529
+ case "aggregate_comparison":
4530
+ case "fulltext_match": {
4531
+ return false;
4532
+ }
4533
+ }
4534
+ }
4535
+ return false;
4536
+ }
4537
+ function hasFulltextMatchPredicates(ast) {
4538
+ return ast.predicates.some(
4539
+ (predicate2) => hasFulltextMatchInExpression(predicate2.expression)
4540
+ );
4541
+ }
4542
+ function hasFulltextMatchInExpression(expr) {
4543
+ if ("__type" in expr) {
4544
+ switch (expr.__type) {
4545
+ case "and":
4546
+ case "or": {
4547
+ return expr.predicates.some((p) => hasFulltextMatchInExpression(p));
4548
+ }
4549
+ case "not": {
4550
+ return hasFulltextMatchInExpression(expr.predicate);
4551
+ }
4552
+ case "fulltext_match": {
4553
+ return true;
4554
+ }
4555
+ case "exists":
4556
+ case "in_subquery":
4557
+ case "comparison":
4558
+ case "string_op":
4559
+ case "null_check":
4560
+ case "between":
4561
+ case "array_op":
4562
+ case "object_op":
4563
+ case "aggregate_comparison":
4564
+ case "vector_similarity": {
4163
4565
  return false;
4164
4566
  }
4165
4567
  }
@@ -4195,7 +4597,8 @@ function hasSubqueryInExpression(expr) {
4195
4597
  case "array_op":
4196
4598
  case "object_op":
4197
4599
  case "aggregate_comparison":
4198
- case "vector_similarity": {
4600
+ case "vector_similarity":
4601
+ case "fulltext_match": {
4199
4602
  return false;
4200
4603
  }
4201
4604
  }
@@ -4473,6 +4876,10 @@ function markPredicateFieldsAsRequired(requiredColumnsByAlias, expression) {
4473
4876
  markFieldRefAsRequired(requiredColumnsByAlias, expression.field);
4474
4877
  return;
4475
4878
  }
4879
+ case "fulltext_match": {
4880
+ markFieldRefAsRequired(requiredColumnsByAlias, expression.field);
4881
+ return;
4882
+ }
4476
4883
  case "exists": {
4477
4884
  return;
4478
4885
  }
@@ -4537,7 +4944,8 @@ function hasIdEqualityPredicate(expression, alias) {
4537
4944
  case "aggregate_comparison":
4538
4945
  case "exists":
4539
4946
  case "in_subquery":
4540
- case "vector_similarity": {
4947
+ case "vector_similarity":
4948
+ case "fulltext_match": {
4541
4949
  return false;
4542
4950
  }
4543
4951
  }
@@ -4578,10 +4986,13 @@ function resolveTraversalCteLimit(ast, predicateIndex) {
4578
4986
  );
4579
4987
  return Math.max(ast.limit, pushdownLimit);
4580
4988
  }
4581
- function canCollapseSelectiveTraversalRowset(ast, vectorPredicate) {
4989
+ function canCollapseSelectiveTraversalRowset(ast, vectorPredicate, fulltextPredicate) {
4582
4990
  if (vectorPredicate !== void 0) {
4583
4991
  return false;
4584
4992
  }
4993
+ if (fulltextPredicate !== void 0) {
4994
+ return false;
4995
+ }
4585
4996
  if (ast.traversals.length === 0) {
4586
4997
  return false;
4587
4998
  }
@@ -4621,6 +5032,8 @@ function runStandardQueryPassPipeline(ast, graphId, ctx) {
4621
5032
  collapsedTraversalCteAlias: void 0,
4622
5033
  ctx,
4623
5034
  effectiveLimit: void 0,
5035
+ fulltextPredicate: void 0,
5036
+ fusion: void 0,
4624
5037
  logicalPlan: void 0,
4625
5038
  predicateIndex: buildPredicateIndex(ast),
4626
5039
  requiredColumnsByAlias: void 0,
@@ -4642,6 +5055,39 @@ function runStandardQueryPassPipeline(ast, graphId, ctx) {
4642
5055
  }
4643
5056
  });
4644
5057
  state = vectorPass.state;
5058
+ const fulltextPass = runCompilerPass(state, {
5059
+ name: "fulltext_predicate",
5060
+ execute(currentState) {
5061
+ return runFulltextPredicatePass(
5062
+ currentState.ast,
5063
+ currentState.ctx.dialect
5064
+ ).fulltextPredicate;
5065
+ },
5066
+ update(currentState, fulltextPredicate) {
5067
+ return {
5068
+ ...currentState,
5069
+ fulltextPredicate
5070
+ };
5071
+ }
5072
+ });
5073
+ state = fulltextPass.state;
5074
+ const fusionPass = runCompilerPass(state, {
5075
+ name: "fusion_config",
5076
+ execute(currentState) {
5077
+ return runFusionConfigPass(
5078
+ currentState.ast,
5079
+ currentState.vectorPredicate,
5080
+ currentState.fulltextPredicate
5081
+ ).fusion;
5082
+ },
5083
+ update(currentState, fusion) {
5084
+ return {
5085
+ ...currentState,
5086
+ fusion
5087
+ };
5088
+ }
5089
+ });
5090
+ state = fusionPass.state;
4645
5091
  const temporalPass = runCompilerPass(state, {
4646
5092
  name: "temporal_filters",
4647
5093
  execute(currentState) {
@@ -4676,7 +5122,8 @@ function runStandardQueryPassPipeline(ast, graphId, ctx) {
4676
5122
  execute(currentState) {
4677
5123
  const shouldCollapseSelectiveTraversalRowset = canCollapseSelectiveTraversalRowset(
4678
5124
  currentState.ast,
4679
- currentState.vectorPredicate
5125
+ currentState.vectorPredicate,
5126
+ currentState.fulltextPredicate
4680
5127
  );
4681
5128
  const lastTraversal = currentState.ast.traversals.at(-1);
4682
5129
  const collapsedTraversalCteAlias = shouldCollapseSelectiveTraversalRowset && lastTraversal !== void 0 ? `cte_${lastTraversal.nodeAlias}` : void 0;
@@ -4712,9 +5159,9 @@ function runStandardQueryPassPipeline(ast, graphId, ctx) {
4712
5159
  state = traversalLimitPass.state;
4713
5160
  state = {
4714
5161
  ...state,
4715
- effectiveLimit: resolveVectorAwareLimit(
4716
- state.ast.limit,
4717
- state.vectorPredicate
5162
+ effectiveLimit: resolveFulltextAwareLimit(
5163
+ resolveVectorAwareLimit(state.ast.limit, state.vectorPredicate),
5164
+ state.fulltextPredicate
4718
5165
  )
4719
5166
  };
4720
5167
  const logicalPlanPass = runCompilerPass(state, {
@@ -4731,7 +5178,8 @@ function runStandardQueryPassPipeline(ast, graphId, ctx) {
4731
5178
  collapsedTraversalCteAlias: currentState.collapsedTraversalCteAlias
4732
5179
  },
4733
5180
  ...currentState.effectiveLimit === void 0 ? {} : { effectiveLimit: currentState.effectiveLimit },
4734
- ...currentState.vectorPredicate === void 0 ? {} : { vectorPredicate: currentState.vectorPredicate }
5181
+ ...currentState.vectorPredicate === void 0 ? {} : { vectorPredicate: currentState.vectorPredicate },
5182
+ ...currentState.fulltextPredicate === void 0 ? {} : { fulltextPredicate: currentState.fulltextPredicate }
4735
5183
  });
4736
5184
  },
4737
5185
  update(currentState, logicalPlan) {
@@ -4750,11 +5198,11 @@ function compileQuery(ast, graphId, options = "sqlite") {
4750
5198
  const options_ = typeof options === "string" ? { dialect: options } : options;
4751
5199
  const dialect = options_.dialect ?? "sqlite";
4752
5200
  const schema = options_.schema ?? DEFAULT_SQL_SCHEMA;
4753
- const adapter = getDialect(dialect);
5201
+ const adapter = resolveDialectAdapter(dialect, options_.fulltextStrategy);
4754
5202
  const ctx = {
4755
5203
  dialect: adapter,
4756
5204
  schema,
4757
- compileQuery: (subAst, subGraphId) => compileQuery(subAst, subGraphId, { dialect, schema })
5205
+ compileQuery: (subAst, subGraphId) => compileQuery(subAst, subGraphId, propagateOptions(options_))
4758
5206
  };
4759
5207
  if (hasVariableLengthTraversal(ast)) {
4760
5208
  const lowered = tryLowerSingleHopRecursiveTraversal(ast);
@@ -4790,15 +5238,29 @@ function compileSetOperation2(op, graphId, options = "sqlite") {
4790
5238
  const options_ = typeof options === "string" ? { dialect: options } : options;
4791
5239
  const dialect = options_.dialect ?? "sqlite";
4792
5240
  const schema = options_.schema ?? DEFAULT_SQL_SCHEMA;
4793
- const adapter = getDialect(dialect);
5241
+ const adapter = resolveDialectAdapter(dialect, options_.fulltextStrategy);
4794
5242
  return compileSetOperation(
4795
5243
  op,
4796
5244
  graphId,
4797
5245
  adapter,
4798
5246
  schema,
4799
- (ast, gid) => compileQuery(ast, gid, { dialect, schema })
5247
+ (ast, gid) => compileQuery(ast, gid, propagateOptions(options_))
4800
5248
  );
4801
5249
  }
5250
+ function resolveDialectAdapter(dialect, fulltextStrategy) {
5251
+ const baseAdapter = getDialect(dialect);
5252
+ if (fulltextStrategy === void 0 || fulltextStrategy === baseAdapter.fulltext) {
5253
+ return baseAdapter;
5254
+ }
5255
+ return { ...baseAdapter, fulltext: fulltextStrategy };
5256
+ }
5257
+ function propagateOptions(options_) {
5258
+ return {
5259
+ dialect: options_.dialect ?? "sqlite",
5260
+ ...options_.schema === void 0 ? {} : { schema: options_.schema },
5261
+ ...options_.fulltextStrategy === void 0 ? {} : { fulltextStrategy: options_.fulltextStrategy }
5262
+ };
5263
+ }
4802
5264
  function resolveCountAggregateFastPath(ast) {
4803
5265
  if (ast.traversals.length !== 1) {
4804
5266
  return void 0;
@@ -4947,12 +5409,12 @@ function compileCountAggregateFastPath(ast, graphId, ctx, logicalPlan, requiredC
4947
5409
  source.valueType,
4948
5410
  `cte_${source.alias}`
4949
5411
  );
4950
- return sql`${value} AS ${quoteIdentifier(projectedField.outputName)}`;
5412
+ return sql`${value} AS ${quoteIdentifier2(projectedField.outputName)}`;
4951
5413
  }
4952
5414
  const projectedCountColumn = source.function === "countDistinct" ? countDistinctColumn : countColumn;
4953
5415
  const countValue = sql`${sql.raw(countCteAlias)}.${sql.raw(projectedCountColumn)}`;
4954
5416
  const aggregateValue = traversal.optional ? sql`COALESCE(${countValue}, 0)` : countValue;
4955
- return sql`${aggregateValue} AS ${quoteIdentifier(projectedField.outputName)}`;
5417
+ return sql`${aggregateValue} AS ${quoteIdentifier2(projectedField.outputName)}`;
4956
5418
  }),
4957
5419
  sql`, `
4958
5420
  );
@@ -4985,11 +5447,37 @@ function compileStandardQuery(ast, graphId, ctx) {
4985
5447
  const handler = STANDARD_QUERY_STRATEGY_HANDLERS[strategy];
4986
5448
  return handler(ast, graphId, ctx);
4987
5449
  }
5450
+ function selectStandardOrderBy(input) {
5451
+ const {
5452
+ ast,
5453
+ collapsedTraversalCteAlias,
5454
+ dialect,
5455
+ fulltextPredicate,
5456
+ fusion,
5457
+ vectorPredicate
5458
+ } = input;
5459
+ if (vectorPredicate && fulltextPredicate) {
5460
+ return buildStandardHybridRrfOrderBy({ ast, dialect, fusion });
5461
+ }
5462
+ if (vectorPredicate) {
5463
+ return buildStandardVectorOrderBy({ ast, dialect });
5464
+ }
5465
+ if (fulltextPredicate) {
5466
+ return buildStandardFulltextOrderBy({ ast, dialect });
5467
+ }
5468
+ return buildStandardOrderBy({
5469
+ ast,
5470
+ ...collapsedTraversalCteAlias === void 0 ? {} : { collapsedTraversalCteAlias },
5471
+ dialect
5472
+ });
5473
+ }
4988
5474
  function compileStandardQueryWithCteStrategy(ast, graphId, ctx) {
4989
5475
  const { dialect } = ctx;
4990
5476
  const {
4991
5477
  collapsedTraversalCteAlias,
4992
5478
  effectiveLimit,
5479
+ fulltextPredicate,
5480
+ fusion,
4993
5481
  logicalPlan,
4994
5482
  predicateIndex,
4995
5483
  requiredColumnsByAlias,
@@ -5010,7 +5498,7 @@ function compileStandardQueryWithCteStrategy(ast, graphId, ctx) {
5010
5498
  { phase: "standard-pass-pipeline" }
5011
5499
  );
5012
5500
  }
5013
- if (!vectorPredicate) {
5501
+ if (!vectorPredicate && !fulltextPredicate) {
5014
5502
  const fastPathSql = compileCountAggregateFastPath(
5015
5503
  ast,
5016
5504
  graphId,
@@ -5056,7 +5544,29 @@ function compileStandardQueryWithCteStrategy(ast, graphId, ctx) {
5056
5544
  );
5057
5545
  }
5058
5546
  if (vectorPredicate) {
5059
- ctes.push(buildStandardEmbeddingsCte({ ctx, graphId, vectorPredicate }));
5547
+ const nodeKinds = getNodeKindsForAlias(ast, vectorPredicate.field.alias);
5548
+ ctes.push(
5549
+ buildStandardEmbeddingsCte({
5550
+ ctx,
5551
+ graphId,
5552
+ nodeKinds,
5553
+ vectorPredicate
5554
+ })
5555
+ );
5556
+ }
5557
+ if (fulltextPredicate) {
5558
+ const nodeKinds = getNodeKindsForAlias(ast, fulltextPredicate.field.alias);
5559
+ ctes.push(
5560
+ buildStandardFulltextCte({
5561
+ ctx,
5562
+ fulltextPredicate,
5563
+ graphId,
5564
+ nodeKinds
5565
+ })
5566
+ );
5567
+ }
5568
+ if (getHybridTargetAlias(vectorPredicate, fulltextPredicate) !== void 0) {
5569
+ ctes.push(buildStandardHybridCandidateCte());
5060
5570
  }
5061
5571
  const projection = buildStandardProjection({
5062
5572
  ast,
@@ -5066,14 +5576,18 @@ function compileStandardQueryWithCteStrategy(ast, graphId, ctx) {
5066
5576
  const fromClause = buildStandardFromClause({
5067
5577
  ast,
5068
5578
  ...collapsedTraversalCteAlias === void 0 ? {} : { collapsedTraversalCteAlias },
5069
- ...vectorPredicate === void 0 ? {} : { vectorPredicate }
5579
+ ...vectorPredicate === void 0 ? {} : { vectorPredicate },
5580
+ ...fulltextPredicate === void 0 ? {} : { fulltextPredicate }
5070
5581
  });
5071
5582
  const groupBy = buildStandardGroupBy({ ast, dialect });
5072
5583
  const having2 = buildStandardHaving({ ast, ctx });
5073
- const orderBy = vectorPredicate ? buildStandardVectorOrderBy({ ast, dialect }) : buildStandardOrderBy({
5584
+ const orderBy = selectStandardOrderBy({
5074
5585
  ast,
5075
- ...collapsedTraversalCteAlias === void 0 ? {} : { collapsedTraversalCteAlias },
5076
- dialect
5586
+ collapsedTraversalCteAlias,
5587
+ dialect,
5588
+ fulltextPredicate,
5589
+ fusion,
5590
+ vectorPredicate
5077
5591
  });
5078
5592
  const limitOffset = buildLimitOffsetClause({
5079
5593
  limit: effectiveLimit,
@@ -5494,11 +6008,7 @@ function buildComparisonPredicate(field2, op, value) {
5494
6008
  };
5495
6009
  }
5496
6010
  function buildCursorFromContext(context, orderBy, direction) {
5497
- return buildCursorFromRow(
5498
- context,
5499
- orderBy,
5500
- direction
5501
- );
6011
+ return buildCursorFromRow(context, orderBy, direction);
5502
6012
  }
5503
6013
  function buildPaginatedResult(data, orderedRows, orderBy, limit, hasMore, isBackward, cursor, buildContext) {
5504
6014
  let nextCursor;
@@ -6091,59 +6601,41 @@ function validateSqlIdentifier(alias) {
6091
6601
  );
6092
6602
  }
6093
6603
  }
6094
- function throwInvalidVectorPredicatePlacement(path) {
6095
- throw new ValidationError(
6096
- "Vector similarity predicates cannot be nested under OR or NOT. Use top-level AND combinations instead.",
6097
- {
6098
- issues: [
6099
- {
6100
- path,
6101
- message: "Vector similarity predicates are only supported at top-level or inside AND groups."
6102
- }
6103
- ]
6104
- },
6105
- {
6106
- suggestion: "Rewrite the predicate to keep vector similarity at top-level or combine with additional filters using AND."
6107
- }
6108
- );
6109
- }
6110
- function validateVectorPredicateExpression(expression, inDisallowedBranch, path) {
6604
+ function validateStructuralPredicatePlacement(expression, matchType, buildError, inDisallowedBranch, path) {
6605
+ if (expression.__type === matchType) {
6606
+ if (inDisallowedBranch) buildError(path);
6607
+ return;
6608
+ }
6111
6609
  switch (expression.__type) {
6112
- case "vector_similarity": {
6113
- if (inDisallowedBranch) {
6114
- throwInvalidVectorPredicatePlacement(path);
6115
- }
6116
- return;
6117
- }
6118
6610
  case "and": {
6119
- for (const [
6120
- predicateIndex,
6121
- predicate2
6122
- ] of expression.predicates.entries()) {
6123
- validateVectorPredicateExpression(
6124
- predicate2,
6611
+ for (const [index, child] of expression.predicates.entries()) {
6612
+ validateStructuralPredicatePlacement(
6613
+ child,
6614
+ matchType,
6615
+ buildError,
6125
6616
  inDisallowedBranch,
6126
- `${path}.predicates[${predicateIndex}]`
6617
+ `${path}.predicates[${index}]`
6127
6618
  );
6128
6619
  }
6129
6620
  return;
6130
6621
  }
6131
6622
  case "or": {
6132
- for (const [
6133
- predicateIndex,
6134
- predicate2
6135
- ] of expression.predicates.entries()) {
6136
- validateVectorPredicateExpression(
6137
- predicate2,
6623
+ for (const [index, child] of expression.predicates.entries()) {
6624
+ validateStructuralPredicatePlacement(
6625
+ child,
6626
+ matchType,
6627
+ buildError,
6138
6628
  true,
6139
- `${path}.predicates[${predicateIndex}]`
6629
+ `${path}.predicates[${index}]`
6140
6630
  );
6141
6631
  }
6142
6632
  return;
6143
6633
  }
6144
6634
  case "not": {
6145
- validateVectorPredicateExpression(
6635
+ validateStructuralPredicatePlacement(
6146
6636
  expression.predicate,
6637
+ matchType,
6638
+ buildError,
6147
6639
  true,
6148
6640
  `${path}.predicate`
6149
6641
  );
@@ -6157,24 +6649,108 @@ function validateVectorPredicateExpression(expression, inDisallowedBranch, path)
6157
6649
  case "object_op":
6158
6650
  case "aggregate_comparison":
6159
6651
  case "exists":
6160
- case "in_subquery": {
6652
+ case "in_subquery":
6653
+ case "vector_similarity":
6654
+ case "fulltext_match": {
6161
6655
  return;
6162
6656
  }
6163
6657
  }
6164
6658
  }
6659
+ function throwInvalidVectorPredicatePlacement(path) {
6660
+ throw new ValidationError(
6661
+ "Vector similarity predicates cannot be nested under OR or NOT. Use top-level AND combinations instead.",
6662
+ {
6663
+ issues: [
6664
+ {
6665
+ path,
6666
+ message: "Vector similarity predicates are only supported at top-level or inside AND groups."
6667
+ }
6668
+ ]
6669
+ },
6670
+ {
6671
+ suggestion: "Rewrite the predicate to keep vector similarity at top-level or combine with additional filters using AND."
6672
+ }
6673
+ );
6674
+ }
6675
+ function throwInvalidFulltextPredicatePlacement(path) {
6676
+ throw new ValidationError(
6677
+ "Fulltext match predicates (.matches()) cannot be nested under OR or NOT. Use top-level AND combinations instead.",
6678
+ {
6679
+ issues: [
6680
+ {
6681
+ path,
6682
+ message: "Fulltext match predicates are only supported at top-level or inside AND groups."
6683
+ }
6684
+ ]
6685
+ },
6686
+ {
6687
+ suggestion: "Restructure your query so the fulltext match appears at the top level of a whereNode() or inside an .and() combination."
6688
+ }
6689
+ );
6690
+ }
6165
6691
  function validateVectorPredicatePlacement(predicates) {
6166
- for (const [predicateIndex, predicate2] of predicates.entries()) {
6167
- validateVectorPredicateExpression(
6692
+ for (const [index, predicate2] of predicates.entries()) {
6693
+ validateStructuralPredicatePlacement(
6694
+ predicate2.expression,
6695
+ "vector_similarity",
6696
+ throwInvalidVectorPredicatePlacement,
6697
+ false,
6698
+ `predicates[${index}].expression`
6699
+ );
6700
+ }
6701
+ }
6702
+ function validateFulltextPredicatePlacement(predicates) {
6703
+ for (const [index, predicate2] of predicates.entries()) {
6704
+ validateStructuralPredicatePlacement(
6168
6705
  predicate2.expression,
6706
+ "fulltext_match",
6707
+ throwInvalidFulltextPredicatePlacement,
6169
6708
  false,
6170
- `predicates[${predicateIndex}].expression`
6709
+ `predicates[${index}].expression`
6710
+ );
6711
+ }
6712
+ }
6713
+ function validateHybridFusionOptions(options) {
6714
+ const method = options.method;
6715
+ if (method !== void 0 && method !== "rrf") {
6716
+ throw new ValidationError(`Unsupported fusion method: ${method}`, {
6717
+ issues: [{ path: "fusion.method", message: `Only "rrf" is supported.` }]
6718
+ });
6719
+ }
6720
+ const { k } = options;
6721
+ if (k !== void 0 && (!Number.isFinite(k) || k <= 0)) {
6722
+ throw new ValidationError(
6723
+ `Fusion k must be a positive finite number, got: ${String(k)}`,
6724
+ {
6725
+ issues: [{ path: "fusion.k", message: "Must be a positive number." }]
6726
+ }
6171
6727
  );
6172
6728
  }
6729
+ const weights = options.weights;
6730
+ if (weights !== void 0) {
6731
+ for (const key of ["vector", "fulltext"]) {
6732
+ const weight = weights[key];
6733
+ if (weight !== void 0 && (!Number.isFinite(weight) || weight < 0)) {
6734
+ throw new ValidationError(
6735
+ `Fusion weight for "${key}" must be a non-negative finite number, got: ${String(weight)}`,
6736
+ {
6737
+ issues: [
6738
+ {
6739
+ path: `fusion.weights.${key}`,
6740
+ message: "Must be a non-negative number."
6741
+ }
6742
+ ]
6743
+ }
6744
+ );
6745
+ }
6746
+ }
6747
+ }
6173
6748
  }
6174
6749
 
6175
6750
  // src/query/builder/ast-builder.ts
6176
6751
  function buildQueryAst(config, state) {
6177
6752
  validateVectorPredicatePlacement(state.predicates);
6753
+ validateFulltextPredicatePlacement(state.predicates);
6178
6754
  const temporalMode = {
6179
6755
  mode: state.temporalMode
6180
6756
  };
@@ -6198,7 +6774,18 @@ function buildQueryAst(config, state) {
6198
6774
  ...state.limit !== void 0 && { limit: state.limit },
6199
6775
  ...state.offset !== void 0 && { offset: state.offset },
6200
6776
  ...state.groupBy !== void 0 && { groupBy: state.groupBy },
6201
- ...state.having !== void 0 && { having: state.having }
6777
+ ...state.having !== void 0 && { having: state.having },
6778
+ ...state.fusion !== void 0 && { fusion: state.fusion }
6779
+ };
6780
+ }
6781
+
6782
+ // src/query/builder/compile-options.ts
6783
+ function buildCompileOptions(config) {
6784
+ const fulltextStrategy = config.backend?.fulltextStrategy;
6785
+ return {
6786
+ dialect: config.dialect ?? "sqlite",
6787
+ schema: config.schema,
6788
+ ...fulltextStrategy === void 0 ? {} : { fulltextStrategy }
6202
6789
  };
6203
6790
  }
6204
6791
  function toLiteral(value) {
@@ -6300,7 +6887,8 @@ function substitutePredicateExpression(expr, bindings) {
6300
6887
  case "array_op":
6301
6888
  case "object_op":
6302
6889
  case "aggregate_comparison":
6303
- case "vector_similarity": {
6890
+ case "vector_similarity":
6891
+ case "fulltext_match": {
6304
6892
  return expr;
6305
6893
  }
6306
6894
  case "exists": {
@@ -6552,7 +7140,8 @@ function collectParameterMetadataFromExpression(expression, names, stringOpParam
6552
7140
  case "array_op":
6553
7141
  case "object_op":
6554
7142
  case "aggregate_comparison":
6555
- case "vector_similarity": {
7143
+ case "vector_similarity":
7144
+ case "fulltext_match": {
6556
7145
  return;
6557
7146
  }
6558
7147
  case "exists": {
@@ -6907,10 +7496,7 @@ var ExecutableQuery = class _ExecutableQuery {
6907
7496
  * Builds compile options from the config.
6908
7497
  */
6909
7498
  #compileOptions() {
6910
- return {
6911
- dialect: this.#config.dialect ?? "sqlite",
6912
- schema: this.#config.schema
6913
- };
7499
+ return buildCompileOptions(this.#config);
6914
7500
  }
6915
7501
  #requireBackend() {
6916
7502
  const { backend } = this.#config;
@@ -7625,14 +8211,8 @@ var ExecutableAggregateQuery = class _ExecutableAggregateQuery {
7625
8211
  this.#cachedCompiled = compiled;
7626
8212
  return compiled;
7627
8213
  }
7628
- /**
7629
- * Builds compile options from the config.
7630
- */
7631
8214
  #compileOptions() {
7632
- return {
7633
- dialect: this.#config.dialect ?? "sqlite",
7634
- schema: this.#config.schema
7635
- };
8215
+ return buildCompileOptions(this.#config);
7636
8216
  }
7637
8217
  /**
7638
8218
  * Executes the query and returns typed results.
@@ -8359,6 +8939,36 @@ var QueryBuilder = class _QueryBuilder {
8359
8939
  having: predicate2
8360
8940
  });
8361
8941
  }
8942
+ /**
8943
+ * Sets fusion parameters for hybrid (vector + fulltext) queries.
8944
+ *
8945
+ * Applies only when the query contains both a `.similarTo()` and a
8946
+ * `.$fulltext.matches()` predicate. Without this call, the default is
8947
+ * RRF with k=60 and equal weights. A mismatch between `.fuseWith()`
8948
+ * configuration and the predicates on the query is caught during
8949
+ * compilation, not here.
8950
+ *
8951
+ * @example
8952
+ * ```typescript
8953
+ * store.query()
8954
+ * .from("Document", "d")
8955
+ * .whereNode("d", d =>
8956
+ * d.$fulltext.matches("renewable energy", 50)
8957
+ * .and(d.embedding.similarTo(vec, 50))
8958
+ * .and(d.tenantId.eq(tenant))
8959
+ * )
8960
+ * .fuseWith({ k: 60, weights: { fulltext: 1.5 } })
8961
+ * .limit(10)
8962
+ * .execute();
8963
+ * ```
8964
+ */
8965
+ fuseWith(options) {
8966
+ validateHybridFusionOptions(options);
8967
+ return new _QueryBuilder(this.#config, {
8968
+ ...this.#state,
8969
+ fusion: options
8970
+ });
8971
+ }
8362
8972
  /**
8363
8973
  * Applies a query fragment to transform this builder.
8364
8974
  *
@@ -8417,35 +9027,7 @@ var QueryBuilder = class _QueryBuilder {
8417
9027
  return this.#buildFieldBuilderForTypeInfo(ref, typeInfo);
8418
9028
  }
8419
9029
  #buildFieldBuilderForTypeInfo(ref, typeInfo) {
8420
- if (!typeInfo) {
8421
- return baseField(ref);
8422
- }
8423
- switch (typeInfo.valueType) {
8424
- case "string": {
8425
- return stringField(ref);
8426
- }
8427
- case "number": {
8428
- return numberField(ref);
8429
- }
8430
- case "boolean": {
8431
- return baseField(ref);
8432
- }
8433
- case "date": {
8434
- return dateField(ref);
8435
- }
8436
- case "array": {
8437
- return arrayField(ref);
8438
- }
8439
- case "object": {
8440
- return objectField(ref, { typeInfo });
8441
- }
8442
- case "embedding": {
8443
- return embeddingField(ref);
8444
- }
8445
- case "unknown": {
8446
- return baseField(ref);
8447
- }
8448
- }
9030
+ return buildFieldBuilderForTypeInfo(ref, typeInfo);
8449
9031
  }
8450
9032
  #createNodeAccessor(alias) {
8451
9033
  const kindNames = this.#getKindNamesForAlias(alias);
@@ -8455,6 +9037,10 @@ var QueryBuilder = class _QueryBuilder {
8455
9037
  const kindAccessor = stringField(
8456
9038
  fieldRef(alias, ["kind"], { valueType: "string" })
8457
9039
  );
9040
+ const fulltextAccessor = createFulltextAccessor(
9041
+ alias,
9042
+ () => this.#hasSearchableField(kindNames)
9043
+ );
8458
9044
  return new Proxy({}, {
8459
9045
  get: (_, property) => {
8460
9046
  if (typeof property === "symbol") return;
@@ -8462,10 +9048,15 @@ var QueryBuilder = class _QueryBuilder {
8462
9048
  if (property === "toJSON") return;
8463
9049
  if (property === "id") return idAccessor;
8464
9050
  if (property === "kind") return kindAccessor;
9051
+ if (property === "$fulltext") return fulltextAccessor;
8465
9052
  return this.#getFieldBuilderForProperty(kindNames, property, alias);
8466
9053
  }
8467
9054
  });
8468
9055
  }
9056
+ #hasSearchableField(kindNames) {
9057
+ if (!kindNames) return false;
9058
+ return this.#config.schemaIntrospector.hasSearchableField(kindNames);
9059
+ }
8469
9060
  #expandTraversalEdgeKinds(edgeKind, includeImplyingEdges) {
8470
9061
  return includeImplyingEdges ? this.#config.registry.expandImplyingEdges(edgeKind) : [edgeKind];
8471
9062
  }
@@ -8691,14 +9282,8 @@ var UnionableQuery = class _UnionableQuery {
8691
9282
  this.#cachedCompiled = compiled;
8692
9283
  return compiled;
8693
9284
  }
8694
- /**
8695
- * Builds compile options from the config.
8696
- */
8697
9285
  #compileOptions() {
8698
- return {
8699
- dialect: this.#config.dialect ?? "sqlite",
8700
- schema: this.#config.schema
8701
- };
9286
+ return buildCompileOptions(this.#config);
8702
9287
  }
8703
9288
  /**
8704
9289
  * Executes the combined query.
@@ -8914,12 +9499,8 @@ function offsetFragment(n) {
8914
9499
  }
8915
9500
 
8916
9501
  // src/query/builder/index.ts
8917
- setQueryBuilderClass(
8918
- QueryBuilder
8919
- );
8920
- setUnionableQueryClass(
8921
- UnionableQuery
8922
- );
9502
+ setQueryBuilderClass(QueryBuilder);
9503
+ setUnionableQueryClass(UnionableQuery);
8923
9504
 
8924
9505
  // src/query/builder.ts
8925
9506
  function createQueryBuilder(graphId, registry, options) {
@@ -8950,7 +9531,8 @@ function createQueryBuilder(graphId, registry, options) {
8950
9531
  temporalMode: "current",
8951
9532
  asOf: void 0,
8952
9533
  groupBy: void 0,
8953
- having: void 0
9534
+ having: void 0,
9535
+ fusion: void 0
8954
9536
  };
8955
9537
  return new QueryBuilder(config, initialState);
8956
9538
  }
@@ -9291,20 +9873,13 @@ function createEdgeCollection(config) {
9291
9873
  const result = {};
9292
9874
  if (options?.matchOn !== void 0)
9293
9875
  result.matchOn = options.matchOn;
9294
- if (options?.props !== void 0)
9295
- result.props = options.props;
9876
+ if (options?.props !== void 0) result.props = options.props;
9296
9877
  return result;
9297
9878
  }
9298
9879
  return {
9299
9880
  async create(from, to, props, options) {
9300
9881
  const result = await executeEdgeCreate2(
9301
- buildCreateEdgeInput(
9302
- kind,
9303
- from,
9304
- to,
9305
- props ?? {},
9306
- options
9307
- ),
9882
+ buildCreateEdgeInput(kind, from, to, props ?? {}, options),
9308
9883
  backend
9309
9884
  );
9310
9885
  return narrowEdge(result);
@@ -9430,10 +10005,7 @@ function createEdgeCollection(config) {
9430
10005
  return backend.countEdgesByKind(params);
9431
10006
  },
9432
10007
  async bulkCreate(items) {
9433
- const batchInputs = mapBulkEdgeInputs(
9434
- kind,
9435
- items
9436
- );
10008
+ const batchInputs = mapBulkEdgeInputs(kind, items);
9437
10009
  if (backend.capabilities.transactions && "transaction" in backend) {
9438
10010
  const results2 = await backend.transaction(
9439
10011
  async (txBackend) => executeEdgeCreateBatch2(batchInputs, txBackend)
@@ -9469,11 +10041,7 @@ function createEdgeCollection(config) {
9469
10041
  if (existing) {
9470
10042
  toUpdate.push({
9471
10043
  index: itemIndex,
9472
- input: buildUpdateEdgeInput(
9473
- item.id,
9474
- item.props ?? {},
9475
- item
9476
- ),
10044
+ input: buildUpdateEdgeInput(item.id, item.props ?? {}, item),
9477
10045
  clearDeleted: existing.deleted_at !== void 0
9478
10046
  });
9479
10047
  } else {
@@ -9512,10 +10080,7 @@ function createEdgeCollection(config) {
9512
10080
  return upsertAll(backend);
9513
10081
  },
9514
10082
  async bulkInsert(items) {
9515
- const batchInputs = mapBulkEdgeInputs(
9516
- kind,
9517
- items
9518
- );
10083
+ const batchInputs = mapBulkEdgeInputs(kind, items);
9519
10084
  if (backend.capabilities.transactions && "transaction" in backend) {
9520
10085
  await backend.transaction(async (txBackend) => {
9521
10086
  await executeEdgeCreateNoReturnBatch2(batchInputs, txBackend);
@@ -9666,11 +10231,7 @@ function createNodeCollection(config) {
9666
10231
  async getByIds(ids, options) {
9667
10232
  if (ids.length === 0) return [];
9668
10233
  if (backend.getNodes !== void 0) {
9669
- const rows = await backend.getNodes(
9670
- graphId,
9671
- kind,
9672
- ids
9673
- );
10234
+ const rows = await backend.getNodes(graphId, kind, ids);
9674
10235
  const rowMap = /* @__PURE__ */ new Map();
9675
10236
  for (const row of rows) {
9676
10237
  rowMap.set(row.id, row);
@@ -9751,11 +10312,7 @@ function createNodeCollection(config) {
9751
10312
  return backend.countNodesByKind(params);
9752
10313
  },
9753
10314
  async upsertById(id, props, options) {
9754
- return this.upsertByIdFromRecord(
9755
- id,
9756
- props,
9757
- options
9758
- );
10315
+ return this.upsertByIdFromRecord(id, props, options);
9759
10316
  },
9760
10317
  async upsertByIdFromRecord(id, data, options) {
9761
10318
  const existing = await backend.getNode(graphId, kind, id);
@@ -9775,10 +10332,7 @@ function createNodeCollection(config) {
9775
10332
  return narrowNode(result);
9776
10333
  },
9777
10334
  async bulkCreate(items) {
9778
- const batchInputs = mapBulkNodeInputs(
9779
- kind,
9780
- items
9781
- );
10335
+ const batchInputs = mapBulkNodeInputs(kind, items);
9782
10336
  if (backend.capabilities.transactions && "transaction" in backend) {
9783
10337
  const results2 = await backend.transaction(
9784
10338
  async (txBackend) => executeNodeCreateBatch2(batchInputs, txBackend)
@@ -9801,11 +10355,7 @@ function createNodeCollection(config) {
9801
10355
  if (row !== void 0) existingMap.set(row.id, row);
9802
10356
  }
9803
10357
  } else {
9804
- const rows = await target.getNodes(
9805
- graphId,
9806
- kind,
9807
- ids
9808
- );
10358
+ const rows = await target.getNodes(graphId, kind, ids);
9809
10359
  for (const row of rows) {
9810
10360
  existingMap.set(row.id, row);
9811
10361
  }
@@ -9818,22 +10368,13 @@ function createNodeCollection(config) {
9818
10368
  if (existing) {
9819
10369
  toUpdate.push({
9820
10370
  index: itemIndex,
9821
- input: buildUpdateInput(
9822
- kind,
9823
- item.id,
9824
- item.props,
9825
- item
9826
- ),
10371
+ input: buildUpdateInput(kind, item.id, item.props, item),
9827
10372
  clearDeleted: existing.deleted_at !== void 0
9828
10373
  });
9829
10374
  } else {
9830
10375
  toCreate.push({
9831
10376
  index: itemIndex,
9832
- input: buildCreateInput(
9833
- kind,
9834
- item.props,
9835
- item
9836
- )
10377
+ input: buildCreateInput(kind, item.props, item)
9837
10378
  });
9838
10379
  }
9839
10380
  itemIndex++;
@@ -9860,10 +10401,7 @@ function createNodeCollection(config) {
9860
10401
  return upsertAll(backend);
9861
10402
  },
9862
10403
  async bulkInsert(items) {
9863
- const batchInputs = mapBulkNodeInputs(
9864
- kind,
9865
- items
9866
- );
10404
+ const batchInputs = mapBulkNodeInputs(kind, items);
9867
10405
  if (backend.capabilities.transactions && "transaction" in backend) {
9868
10406
  await backend.transaction(async (txBackend) => {
9869
10407
  await executeNodeCreateNoReturnBatch2(batchInputs, txBackend);
@@ -10969,15 +11507,19 @@ async function executeEdgeBulkGetOrCreateByEndpoints(ctx, kind, items, backend,
10969
11507
  }
10970
11508
 
10971
11509
  // src/store/embedding-sync.ts
11510
+ var embeddingFieldsCache = /* @__PURE__ */ new WeakMap();
10972
11511
  function getEmbeddingFields(schema) {
10973
- if (schema.type !== "object") {
10974
- return [];
10975
- }
11512
+ const cached = embeddingFieldsCache.get(schema);
11513
+ if (cached) return cached;
11514
+ const fields = computeEmbeddingFields(schema);
11515
+ embeddingFieldsCache.set(schema, fields);
11516
+ return fields;
11517
+ }
11518
+ function computeEmbeddingFields(schema) {
11519
+ if (schema.type !== "object") return [];
10976
11520
  const def = schema.def;
10977
11521
  const shape = def.shape;
10978
- if (!shape) {
10979
- return [];
10980
- }
11522
+ if (!shape) return [];
10981
11523
  const fields = [];
10982
11524
  for (const [fieldPath, fieldSchema] of Object.entries(shape)) {
10983
11525
  const dimensions = getEmbeddingDimensionsFromField(fieldSchema);
@@ -11064,6 +11606,101 @@ function isValidEmbeddingValue(value) {
11064
11606
  return value.every((n) => typeof n === "number" && Number.isFinite(n));
11065
11607
  }
11066
11608
 
11609
+ // src/store/fulltext-sync.ts
11610
+ var searchableFieldsCache = /* @__PURE__ */ new WeakMap();
11611
+ function getSearchableFields(schema) {
11612
+ const cached = searchableFieldsCache.get(schema);
11613
+ if (cached) return cached;
11614
+ const fields = computeSearchableFields(schema);
11615
+ searchableFieldsCache.set(schema, fields);
11616
+ return fields;
11617
+ }
11618
+ function computeSearchableFields(schema) {
11619
+ if (schema.type !== "object") return [];
11620
+ const def = schema.def;
11621
+ const shape = def.shape;
11622
+ if (!shape) return [];
11623
+ const fields = [];
11624
+ for (const [fieldPath, fieldSchema] of Object.entries(shape)) {
11625
+ const metadata = getSearchableMetadata(fieldSchema);
11626
+ if (metadata !== void 0) {
11627
+ fields.push({ fieldPath, metadata });
11628
+ }
11629
+ }
11630
+ warnIfConflictingLanguages(fields);
11631
+ return fields;
11632
+ }
11633
+ function warnIfConflictingLanguages(fields) {
11634
+ if (fields.length < 2) return;
11635
+ const languages = new Set(fields.map((field2) => field2.metadata.language));
11636
+ if (languages.size < 2) return;
11637
+ if (typeof console === "undefined" || typeof console.warn !== "function") {
11638
+ return;
11639
+ }
11640
+ const fieldSummary = fields.map((field2) => `${field2.fieldPath}=${field2.metadata.language}`).join(", ");
11641
+ const winning = fields[0]?.metadata.language ?? DEFAULT_SEARCHABLE_LANGUAGE;
11642
+ console.warn(
11643
+ `[typegraph] searchable() fields declare conflicting languages (${fieldSummary}). The first field's language ("${winning}") is recorded on the fulltext row; true multilingual indexing requires a dedicated node kind per language.`
11644
+ );
11645
+ }
11646
+ var FIELD_SEPARATOR = "\n";
11647
+ function resolveCombinedLanguage(fields) {
11648
+ return fields[0]?.metadata.language ?? DEFAULT_SEARCHABLE_LANGUAGE;
11649
+ }
11650
+ function computeFulltextContent(schema, props) {
11651
+ const searchableFields = getSearchableFields(schema);
11652
+ if (searchableFields.length === 0) return void 0;
11653
+ const parts = [];
11654
+ for (const field2 of searchableFields) {
11655
+ const value = props[field2.fieldPath];
11656
+ if (typeof value === "string" && value.length > 0) {
11657
+ parts.push(value);
11658
+ }
11659
+ }
11660
+ if (parts.length === 0) return void 0;
11661
+ return {
11662
+ content: parts.join(FIELD_SEPARATOR),
11663
+ language: resolveCombinedLanguage(searchableFields)
11664
+ };
11665
+ }
11666
+ async function syncFulltext(ctx, schema, props) {
11667
+ const { backend } = ctx;
11668
+ if (!backend.upsertFulltext || !backend.deleteFulltext) {
11669
+ return;
11670
+ }
11671
+ const computed = computeFulltextContent(schema, props);
11672
+ if (computed !== void 0) {
11673
+ await backend.upsertFulltext({
11674
+ graphId: ctx.graphId,
11675
+ nodeKind: ctx.nodeKind,
11676
+ nodeId: ctx.nodeId,
11677
+ content: computed.content,
11678
+ language: computed.language
11679
+ });
11680
+ return;
11681
+ }
11682
+ if (getSearchableFields(schema).length === 0) return;
11683
+ await backend.deleteFulltext({
11684
+ graphId: ctx.graphId,
11685
+ nodeKind: ctx.nodeKind,
11686
+ nodeId: ctx.nodeId
11687
+ });
11688
+ }
11689
+ async function deleteNodeFulltext(ctx, schema) {
11690
+ const { backend } = ctx;
11691
+ if (!backend.deleteFulltext) {
11692
+ return;
11693
+ }
11694
+ if (getSearchableFields(schema).length === 0) {
11695
+ return;
11696
+ }
11697
+ await backend.deleteFulltext({
11698
+ graphId: ctx.graphId,
11699
+ nodeKind: ctx.nodeKind,
11700
+ nodeId: ctx.nodeId
11701
+ });
11702
+ }
11703
+
11067
11704
  // src/store/uniqueness.ts
11068
11705
  async function checkUniquenessConstraints(ctx, kind, id, props, constraints) {
11069
11706
  for (const constraint of constraints) {
@@ -11257,6 +11894,9 @@ function createUniquenessContext(graphId, registry, backend) {
11257
11894
  function createEmbeddingSyncContext(graphId, nodeKind, nodeId, backend) {
11258
11895
  return { graphId, nodeKind, nodeId, backend };
11259
11896
  }
11897
+ function createFulltextSyncContext(graphId, nodeKind, nodeId, backend) {
11898
+ return { graphId, nodeKind, nodeId, backend };
11899
+ }
11260
11900
  function createNodeBatchValidationBackend(graphId, registry, backend) {
11261
11901
  const nodeCache = /* @__PURE__ */ new Map();
11262
11902
  const pendingNodes = /* @__PURE__ */ new Map();
@@ -11414,6 +12054,11 @@ async function finalizeNodeCreate(ctx, prepared, backend) {
11414
12054
  prepared.nodeKind.schema,
11415
12055
  prepared.validatedProps
11416
12056
  );
12057
+ await syncFulltext(
12058
+ createFulltextSyncContext(ctx.graphId, prepared.kind, prepared.id, backend),
12059
+ prepared.nodeKind.schema,
12060
+ prepared.validatedProps
12061
+ );
11417
12062
  }
11418
12063
  async function performNodeUpdate(ctx, input, backend, options) {
11419
12064
  const { kind, id } = input;
@@ -11454,6 +12099,11 @@ async function performNodeUpdate(ctx, input, backend, options) {
11454
12099
  nodeKind.schema,
11455
12100
  validatedProps
11456
12101
  );
12102
+ await syncFulltext(
12103
+ createFulltextSyncContext(ctx.graphId, kind, id, backend),
12104
+ nodeKind.schema,
12105
+ validatedProps
12106
+ );
11457
12107
  return rowToNode(row);
11458
12108
  }
11459
12109
  async function enforceDeleteBehavior(ctx, kind, id, mode, backend, registration) {
@@ -11670,6 +12320,10 @@ async function executeNodeDelete(ctx, kind, id, backend) {
11670
12320
  createEmbeddingSyncContext(ctx.graphId, kind, id, backend),
11671
12321
  nodeKind.schema
11672
12322
  );
12323
+ await deleteNodeFulltext(
12324
+ createFulltextSyncContext(ctx.graphId, kind, id, backend),
12325
+ nodeKind.schema
12326
+ );
11673
12327
  });
11674
12328
  }
11675
12329
  async function executeNodeHardDelete(ctx, kind, id, backend) {
@@ -11976,6 +12630,503 @@ async function executeNodeBulkGetOrCreateByConstraint(ctx, kind, constraintName,
11976
12630
  return results;
11977
12631
  }
11978
12632
 
12633
+ // src/store/fulltext-rebuild.ts
12634
+ var DEFAULT_PAGE_SIZE = 500;
12635
+ var DEFAULT_MAX_SKIPPED_IDS = 1e4;
12636
+ function validatePageSize(value) {
12637
+ if (value === void 0) return DEFAULT_PAGE_SIZE;
12638
+ if (!Number.isInteger(value) || value <= 0) {
12639
+ throw new ValidationError(
12640
+ `pageSize must be a positive integer, got: ${String(value)}`,
12641
+ {
12642
+ issues: [{ path: "pageSize", message: "Must be a positive integer." }]
12643
+ }
12644
+ );
12645
+ }
12646
+ return value;
12647
+ }
12648
+ function validateMaxSkippedIds(value) {
12649
+ if (value === void 0) return DEFAULT_MAX_SKIPPED_IDS;
12650
+ if (!Number.isInteger(value) || value < 0) {
12651
+ throw new ValidationError(
12652
+ `maxSkippedIds must be a non-negative integer, got: ${String(value)}`,
12653
+ {
12654
+ issues: [
12655
+ {
12656
+ path: "maxSkippedIds",
12657
+ message: "Must be a non-negative integer."
12658
+ }
12659
+ ]
12660
+ }
12661
+ );
12662
+ }
12663
+ return value;
12664
+ }
12665
+ function isPropsObject(value) {
12666
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12667
+ }
12668
+ async function rebuildFulltextIndex(ctx, nodeKind, options) {
12669
+ const { backend, registry } = ctx;
12670
+ if (!backend.upsertFulltext || !backend.deleteFulltext) {
12671
+ throw new ConfigurationError(
12672
+ "Backend does not support fulltext; cannot rebuild index",
12673
+ { backend: backend.dialect, capability: "fulltext" }
12674
+ );
12675
+ }
12676
+ const pageSize = validatePageSize(options.pageSize);
12677
+ const maxSkippedIds = validateMaxSkippedIds(options.maxSkippedIds);
12678
+ const targetKinds = nodeKind === void 0 ? [...registry.nodeKinds.keys()] : [nodeKind];
12679
+ const rebuiltKinds = [];
12680
+ let processed = 0;
12681
+ let upserted = 0;
12682
+ let cleared = 0;
12683
+ let skipped = 0;
12684
+ const skippedIds = [];
12685
+ for (const kind of targetKinds) {
12686
+ const nodeType = registry.getNodeType(kind);
12687
+ if (!nodeType) {
12688
+ throw new ConfigurationError(`Unknown node kind: ${kind}`, { kind });
12689
+ }
12690
+ if (getSearchableFields(nodeType.schema).length === 0) {
12691
+ continue;
12692
+ }
12693
+ rebuiltKinds.push(kind);
12694
+ let cursor;
12695
+ for (; ; ) {
12696
+ const rows = await backend.findNodesByKind({
12697
+ graphId: ctx.graphId,
12698
+ kind,
12699
+ limit: pageSize,
12700
+ excludeDeleted: false,
12701
+ orderBy: "id",
12702
+ ...cursor === void 0 ? {} : { after: cursor }
12703
+ });
12704
+ if (rows.length === 0) break;
12705
+ const pageResult = processPage(nodeType.schema, rows);
12706
+ skipped += pageResult.skipped;
12707
+ const remaining = maxSkippedIds - skippedIds.length;
12708
+ if (remaining > 0 && pageResult.skippedIds.length > 0) {
12709
+ skippedIds.push(...pageResult.skippedIds.slice(0, remaining));
12710
+ }
12711
+ await backend.transaction(async (tx) => {
12712
+ if (pageResult.toUpsert.length > 0) {
12713
+ if (tx.upsertFulltextBatch) {
12714
+ await tx.upsertFulltextBatch({
12715
+ graphId: ctx.graphId,
12716
+ nodeKind: kind,
12717
+ rows: pageResult.toUpsert
12718
+ });
12719
+ } else if (tx.upsertFulltext) {
12720
+ for (const item of pageResult.toUpsert) {
12721
+ await tx.upsertFulltext({
12722
+ graphId: ctx.graphId,
12723
+ nodeKind: kind,
12724
+ nodeId: item.nodeId,
12725
+ content: item.content,
12726
+ language: item.language
12727
+ });
12728
+ }
12729
+ }
12730
+ }
12731
+ if (pageResult.toDelete.length > 0) {
12732
+ if (tx.deleteFulltextBatch) {
12733
+ await tx.deleteFulltextBatch({
12734
+ graphId: ctx.graphId,
12735
+ nodeKind: kind,
12736
+ nodeIds: pageResult.toDelete
12737
+ });
12738
+ } else if (tx.deleteFulltext) {
12739
+ for (const nodeId of pageResult.toDelete) {
12740
+ await tx.deleteFulltext({
12741
+ graphId: ctx.graphId,
12742
+ nodeKind: kind,
12743
+ nodeId
12744
+ });
12745
+ }
12746
+ }
12747
+ }
12748
+ });
12749
+ processed += rows.length;
12750
+ upserted += pageResult.toUpsert.length;
12751
+ cleared += pageResult.toDelete.length;
12752
+ const lastRow = rows.at(-1);
12753
+ if (!lastRow) break;
12754
+ cursor = lastRow.id;
12755
+ if (rows.length < pageSize) break;
12756
+ }
12757
+ }
12758
+ return {
12759
+ kinds: rebuiltKinds,
12760
+ processed,
12761
+ upserted,
12762
+ cleared,
12763
+ skipped,
12764
+ skippedIds,
12765
+ skippedTruncated: skipped > skippedIds.length
12766
+ };
12767
+ }
12768
+ function processPage(schema, rows) {
12769
+ const toUpsert = [];
12770
+ const toDelete = [];
12771
+ const skippedIds = [];
12772
+ let skipped = 0;
12773
+ for (const row of rows) {
12774
+ if (row.deleted_at !== void 0) {
12775
+ toDelete.push(row.id);
12776
+ continue;
12777
+ }
12778
+ let parsed;
12779
+ try {
12780
+ parsed = JSON.parse(row.props);
12781
+ } catch {
12782
+ skipped += 1;
12783
+ skippedIds.push(row.id);
12784
+ continue;
12785
+ }
12786
+ if (!isPropsObject(parsed)) {
12787
+ skipped += 1;
12788
+ skippedIds.push(row.id);
12789
+ continue;
12790
+ }
12791
+ const computed = computeFulltextContent(schema, parsed);
12792
+ if (computed === void 0) {
12793
+ toDelete.push(row.id);
12794
+ } else {
12795
+ toUpsert.push({
12796
+ nodeId: row.id,
12797
+ content: computed.content,
12798
+ language: computed.language
12799
+ });
12800
+ }
12801
+ }
12802
+ return { toUpsert, toDelete, skipped, skippedIds };
12803
+ }
12804
+
12805
+ // src/store/search.ts
12806
+ async function executeFulltextSearch(ctx, nodeKind, options) {
12807
+ const { backend, graphId } = ctx;
12808
+ if (!backend.fulltextSearch) {
12809
+ throw new ConfigurationError("Backend does not support fulltext search", {
12810
+ backend: backend.dialect,
12811
+ capability: "fulltext"
12812
+ });
12813
+ }
12814
+ if (!Number.isInteger(options.limit) || options.limit <= 0) {
12815
+ throw new RangeError(
12816
+ `fulltextSearch.limit must be a positive integer, got: ${options.limit}`
12817
+ );
12818
+ }
12819
+ validateFulltextCallOptions(backend, {
12820
+ mode: options.mode,
12821
+ includeSnippets: options.includeSnippets,
12822
+ language: options.language
12823
+ });
12824
+ const params = {
12825
+ graphId,
12826
+ nodeKind,
12827
+ query: options.query,
12828
+ limit: options.limit,
12829
+ ...options.mode ? { mode: options.mode } : {},
12830
+ ...options.language ? { language: options.language } : {},
12831
+ ...options.minScore === void 0 ? {} : { minScore: options.minScore },
12832
+ ...options.includeSnippets === void 0 ? {} : { includeSnippets: options.includeSnippets }
12833
+ };
12834
+ const rows = await backend.fulltextSearch(params);
12835
+ if (rows.length === 0) return [];
12836
+ const ids = rows.map((r) => r.nodeId);
12837
+ const nodeMap = await fetchNodesByIds(backend, graphId, nodeKind, ids);
12838
+ const hits = [];
12839
+ let rank = 1;
12840
+ for (const row of rows) {
12841
+ const node = nodeMap.get(row.nodeId);
12842
+ if (!node) continue;
12843
+ hits.push({
12844
+ node,
12845
+ score: row.score,
12846
+ rank,
12847
+ ...row.snippet === void 0 ? {} : { snippet: row.snippet }
12848
+ });
12849
+ rank += 1;
12850
+ }
12851
+ return hits;
12852
+ }
12853
+ async function executeHybridSearch(ctx, nodeKind, options) {
12854
+ const { backend, graphId } = ctx;
12855
+ if (!backend.vectorSearch) {
12856
+ throw new ConfigurationError("Backend does not support vector search", {
12857
+ backend: backend.dialect,
12858
+ capability: "vector"
12859
+ });
12860
+ }
12861
+ if (!backend.fulltextSearch) {
12862
+ throw new ConfigurationError("Backend does not support fulltext search", {
12863
+ backend: backend.dialect,
12864
+ capability: "fulltext"
12865
+ });
12866
+ }
12867
+ if (!Number.isInteger(options.limit) || options.limit <= 0) {
12868
+ throw new RangeError(
12869
+ `hybridSearch.limit must be a positive integer, got: ${options.limit}`
12870
+ );
12871
+ }
12872
+ if (options.fusion !== void 0) {
12873
+ validateHybridFusionOptions(options.fusion);
12874
+ }
12875
+ validateFulltextCallOptions(backend, {
12876
+ mode: options.fulltext.mode,
12877
+ includeSnippets: options.fulltext.includeSnippets,
12878
+ language: options.fulltext.language
12879
+ });
12880
+ const fusionK = options.fusion?.k ?? DEFAULT_RRF_K;
12881
+ const vectorWeight = options.fusion?.weights?.vector ?? DEFAULT_RRF_WEIGHT;
12882
+ const fulltextWeight = options.fusion?.weights?.fulltext ?? DEFAULT_RRF_WEIGHT;
12883
+ const overFetchMultiplier = 4;
12884
+ const vectorK = options.vector.k ?? options.limit * overFetchMultiplier;
12885
+ const fulltextK = options.fulltext.k ?? options.limit * overFetchMultiplier;
12886
+ const vectorPromise = backend.vectorSearch({
12887
+ graphId,
12888
+ nodeKind,
12889
+ fieldPath: options.vector.fieldPath,
12890
+ queryEmbedding: options.vector.queryEmbedding,
12891
+ metric: options.vector.metric ?? "cosine",
12892
+ limit: vectorK,
12893
+ ...options.vector.minScore === void 0 ? {} : { minScore: options.vector.minScore }
12894
+ });
12895
+ const fulltextPromise = backend.fulltextSearch({
12896
+ graphId,
12897
+ nodeKind,
12898
+ query: options.fulltext.query,
12899
+ limit: fulltextK,
12900
+ ...options.fulltext.mode ? { mode: options.fulltext.mode } : {},
12901
+ ...options.fulltext.language ? { language: options.fulltext.language } : {},
12902
+ ...options.fulltext.minScore === void 0 ? {} : { minScore: options.fulltext.minScore },
12903
+ ...options.fulltext.includeSnippets === void 0 ? {} : { includeSnippets: options.fulltext.includeSnippets }
12904
+ });
12905
+ const [vectorRows, fulltextRows] = await Promise.all([
12906
+ vectorPromise,
12907
+ fulltextPromise
12908
+ ]);
12909
+ const fused = /* @__PURE__ */ new Map();
12910
+ for (const [index, row] of vectorRows.entries()) {
12911
+ const rank2 = index + 1;
12912
+ const contribution = vectorWeight / (fusionK + rank2);
12913
+ const entry = fused.get(row.nodeId) ?? {
12914
+ nodeId: row.nodeId,
12915
+ fusedScore: 0
12916
+ };
12917
+ entry.fusedScore += contribution;
12918
+ entry.vectorRank = rank2;
12919
+ entry.vectorScore = row.score;
12920
+ fused.set(row.nodeId, entry);
12921
+ }
12922
+ for (const [index, row] of fulltextRows.entries()) {
12923
+ const rank2 = index + 1;
12924
+ const contribution = fulltextWeight / (fusionK + rank2);
12925
+ const entry = fused.get(row.nodeId) ?? {
12926
+ nodeId: row.nodeId,
12927
+ fusedScore: 0
12928
+ };
12929
+ entry.fusedScore += contribution;
12930
+ entry.fulltextRank = rank2;
12931
+ entry.fulltextScore = row.score;
12932
+ if (row.snippet !== void 0) {
12933
+ entry.fulltextSnippet = row.snippet;
12934
+ }
12935
+ fused.set(row.nodeId, entry);
12936
+ }
12937
+ const ranked = [...fused.values()].toSorted(
12938
+ (a, b) => b.fusedScore - a.fusedScore || a.nodeId.localeCompare(b.nodeId)
12939
+ ).slice(0, options.limit);
12940
+ const ids = ranked.map((entry) => entry.nodeId);
12941
+ const nodeMap = await fetchNodesByIds(backend, graphId, nodeKind, ids);
12942
+ const hits = [];
12943
+ let rank = 1;
12944
+ for (const entry of ranked) {
12945
+ const node = nodeMap.get(entry.nodeId);
12946
+ if (!node) continue;
12947
+ const typedNode = node;
12948
+ const hit = {
12949
+ node: typedNode,
12950
+ score: entry.fusedScore,
12951
+ rank,
12952
+ ...entry.vectorRank !== void 0 && entry.vectorScore !== void 0 ? {
12953
+ vector: {
12954
+ node: typedNode,
12955
+ score: entry.vectorScore,
12956
+ rank: entry.vectorRank
12957
+ }
12958
+ } : {},
12959
+ ...entry.fulltextRank !== void 0 && entry.fulltextScore !== void 0 ? {
12960
+ fulltext: {
12961
+ node: typedNode,
12962
+ score: entry.fulltextScore,
12963
+ rank: entry.fulltextRank,
12964
+ ...entry.fulltextSnippet === void 0 ? {} : { snippet: entry.fulltextSnippet }
12965
+ }
12966
+ } : {}
12967
+ };
12968
+ hits.push(hit);
12969
+ rank += 1;
12970
+ }
12971
+ return hits;
12972
+ }
12973
+ function validateFulltextCallOptions(backend, options) {
12974
+ const strategy = backend.fulltextStrategy;
12975
+ const capabilities = backend.capabilities.fulltext;
12976
+ if (options.mode !== void 0) {
12977
+ validateFulltextMode(backend, strategy, capabilities, options.mode);
12978
+ }
12979
+ if (options.includeSnippets === true) {
12980
+ validateFulltextSnippets(backend, strategy, capabilities);
12981
+ }
12982
+ if (options.language !== void 0) {
12983
+ validateFulltextLanguageOption(
12984
+ backend,
12985
+ strategy,
12986
+ capabilities,
12987
+ options.language
12988
+ );
12989
+ }
12990
+ }
12991
+ function validateFulltextMode(backend, strategy, capabilities, mode) {
12992
+ if (strategy !== void 0) {
12993
+ if (strategy.supportedModes.includes(mode)) return;
12994
+ throw new ConfigurationError(
12995
+ `Backend "${backend.dialect}" fulltext strategy "${strategy.name}" does not support mode "${mode}". Supported modes: ${strategy.supportedModes.join(", ")}`,
12996
+ {
12997
+ backend: backend.dialect,
12998
+ strategy: strategy.name,
12999
+ capability: `fulltext.modes.${mode}`
13000
+ }
13001
+ );
13002
+ }
13003
+ if (mode === "phrase" && capabilities && !capabilities.phraseQueries) {
13004
+ throw new ConfigurationError(
13005
+ `Backend "${backend.dialect}" does not support phrase queries`,
13006
+ { backend: backend.dialect, capability: "fulltext.phraseQueries" }
13007
+ );
13008
+ }
13009
+ }
13010
+ function validateFulltextSnippets(backend, strategy, capabilities) {
13011
+ const supportsSnippets = strategy === void 0 ? capabilities?.highlighting !== false : strategy.supportsSnippets;
13012
+ if (supportsSnippets) return;
13013
+ throw new ConfigurationError(
13014
+ `Backend "${backend.dialect}" does not support snippet highlighting; drop includeSnippets or switch to a backend where highlighting is supported`,
13015
+ { backend: backend.dialect, capability: "fulltext.highlighting" }
13016
+ );
13017
+ }
13018
+ function validateFulltextLanguageOption(backend, strategy, capabilities, language) {
13019
+ if (strategy !== void 0 && !strategy.supportsLanguageOverride) {
13020
+ throw new ConfigurationError(
13021
+ `Backend "${backend.dialect}" fulltext strategy "${strategy.name}" does not honor a per-query \`language\` override (its tokenizer is fixed at table-create time). Drop the option, or pick a strategy that advertises \`supportsLanguageOverride: true\`.`,
13022
+ {
13023
+ backend: backend.dialect,
13024
+ strategy: strategy.name,
13025
+ capability: "fulltext.languageOverride"
13026
+ }
13027
+ );
13028
+ }
13029
+ if (capabilities !== void 0) {
13030
+ warnIfLanguageNotAdvertised(backend.dialect, capabilities, language);
13031
+ }
13032
+ }
13033
+ function warnIfLanguageNotAdvertised(dialect, capabilities, language) {
13034
+ if (capabilities.languages.length === 0) return;
13035
+ if (capabilities.languages.includes(language)) return;
13036
+ if (typeof console === "undefined" || typeof console.warn !== "function") {
13037
+ return;
13038
+ }
13039
+ console.warn(
13040
+ `[typegraph] fulltext language "${language}" is not in the advertised list for dialect "${dialect}" (${capabilities.languages.join(", ")}). If your backend has this language installed this warning is safe to ignore.`
13041
+ );
13042
+ }
13043
+ async function fetchNodesByIds(backend, graphId, nodeKind, ids) {
13044
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
13045
+ const map = /* @__PURE__ */ new Map();
13046
+ if (backend.getNodes) {
13047
+ const rows = await backend.getNodes(graphId, nodeKind, ids);
13048
+ for (const row of rows) {
13049
+ if (row.deleted_at !== void 0) continue;
13050
+ map.set(row.id, rowToNode(row));
13051
+ }
13052
+ return map;
13053
+ }
13054
+ for (const id of ids) {
13055
+ const row = await backend.getNode(graphId, nodeKind, id);
13056
+ if (row && row.deleted_at === void 0) map.set(row.id, rowToNode(row));
13057
+ }
13058
+ return map;
13059
+ }
13060
+
13061
+ // src/store/search-facade.ts
13062
+ var StoreSearch = class {
13063
+ #context;
13064
+ constructor(context) {
13065
+ this.#context = context;
13066
+ }
13067
+ /**
13068
+ * Runs a fulltext search against nodes of the given kind.
13069
+ *
13070
+ * Requires fields on the node schema declared with `searchable()`.
13071
+ * The search hits the backend's fulltext index (tsvector + GIN on
13072
+ * Postgres, FTS5 on SQLite — or whatever strategy the backend is
13073
+ * configured with) and resolves the matching node IDs back to typed
13074
+ * `Node` objects.
13075
+ */
13076
+ async fulltext(nodeKind, options) {
13077
+ return executeFulltextSearch(
13078
+ { graphId: this.#context.graphId, backend: this.#context.backend },
13079
+ nodeKind,
13080
+ options
13081
+ );
13082
+ }
13083
+ /**
13084
+ * Runs a vector + fulltext hybrid search and fuses the results with
13085
+ * Reciprocal Rank Fusion.
13086
+ *
13087
+ * RRF is rank-based, so it composes well across heterogeneous score
13088
+ * scales (cosine similarity vs ts_rank_cd vs FTS5 BM25). Default
13089
+ * over-fetch is 4× `limit` from each source — tune via `vector.k` /
13090
+ * `fulltext.k` for higher-recall corpora.
13091
+ */
13092
+ async hybrid(nodeKind, options) {
13093
+ return executeHybridSearch(
13094
+ { graphId: this.#context.graphId, backend: this.#context.backend },
13095
+ nodeKind,
13096
+ options
13097
+ );
13098
+ }
13099
+ /**
13100
+ * Rebuilds the fulltext index from existing node data.
13101
+ *
13102
+ * Use when:
13103
+ * - A node kind gained a `searchable()` field after data was already
13104
+ * written and existing rows were never indexed.
13105
+ * - The fulltext table was dropped / truncated.
13106
+ * - `language` was changed on a `searchable()` field.
13107
+ *
13108
+ * Iterates nodes with keyset pagination (stable under shared
13109
+ * timestamps and light concurrent writes), transacts per page, skips
13110
+ * kinds with no searchable fields, and cleans up stale rows for
13111
+ * soft-deleted nodes. Corrupt or non-object `props` are counted in
13112
+ * `skipped` with their node IDs surfaced via `skippedIds` so operators
13113
+ * can investigate. Concurrent hard-deletes between page fetches can
13114
+ * be missed by a single pass — run during a maintenance window for
13115
+ * full consistency.
13116
+ */
13117
+ async rebuildFulltext(nodeKind, options = {}) {
13118
+ return rebuildFulltextIndex(
13119
+ {
13120
+ graphId: this.#context.graphId,
13121
+ backend: this.#context.backend,
13122
+ registry: this.#context.registry
13123
+ },
13124
+ nodeKind,
13125
+ options
13126
+ );
13127
+ }
13128
+ };
13129
+
11979
13130
  // src/store/store.ts
11980
13131
  var Store = class {
11981
13132
  #graph;
@@ -11987,6 +13138,7 @@ var Store = class {
11987
13138
  #nodeCollections;
11988
13139
  #edgeCollections;
11989
13140
  #algorithms;
13141
+ #search;
11990
13142
  constructor(graph, backend, options) {
11991
13143
  this.#graph = graph;
11992
13144
  this.#backend = backend;
@@ -12235,6 +13387,44 @@ var Store = class {
12235
13387
  query() {
12236
13388
  return this.#createQueryForBackend(this.#backend);
12237
13389
  }
13390
+ // === Search ===
13391
+ /**
13392
+ * Search-related operations (fulltext, hybrid, and rebuild).
13393
+ *
13394
+ * All search methods live under this facade to keep the top-level
13395
+ * Store API focused on CRUD + graph traversal. The facade is
13396
+ * lazy-initialized and cached for the lifetime of the store.
13397
+ *
13398
+ * @example
13399
+ * ```typescript
13400
+ * // Fulltext
13401
+ * const hits = await store.search.fulltext("Document", {
13402
+ * query: "climate warming",
13403
+ * limit: 10,
13404
+ * includeSnippets: true,
13405
+ * });
13406
+ *
13407
+ * // Hybrid (vector + fulltext fused with RRF)
13408
+ * const ranked = await store.search.hybrid("Document", {
13409
+ * limit: 10,
13410
+ * vector: { fieldPath: "embedding", queryEmbedding: vec },
13411
+ * fulltext: { query: "climate warming" },
13412
+ * });
13413
+ *
13414
+ * // Rebuild index after schema change
13415
+ * const stats = await store.search.rebuildFulltext();
13416
+ * ```
13417
+ */
13418
+ get search() {
13419
+ if (this.#search === void 0) {
13420
+ this.#search = new StoreSearch({
13421
+ graphId: this.graphId,
13422
+ backend: this.#backend,
13423
+ registry: this.#registry
13424
+ });
13425
+ }
13426
+ return this.#search;
13427
+ }
12238
13428
  // === Batch Query Execution ===
12239
13429
  /**
12240
13430
  * Executes multiple queries over a single connection with snapshot consistency.
@@ -12379,6 +13569,7 @@ var Store = class {
12379
13569
  this.#nodeCollections = void 0;
12380
13570
  this.#edgeCollections = void 0;
12381
13571
  this.#algorithms = void 0;
13572
+ this.#search = void 0;
12382
13573
  }
12383
13574
  // === Lifecycle ===
12384
13575
  /**
@@ -12485,6 +13676,6 @@ async function createStoreWithSchema(graph, backend, options) {
12485
13676
  return [store, result];
12486
13677
  }
12487
13678
 
12488
- export { DEFAULT_SQL_SCHEMA, ExecutableAggregateQuery, ExecutableQuery, MAX_EXPLICIT_RECURSIVE_DEPTH, MAX_RECURSIVE_DEPTH, PreparedQuery, QueryBuilder, UnionableQuery, avg, broader, composeFragments, core, count, countDistinct, createExternalRef, createFragment, createQueryBuilder, createSqlSchema, createStore, createStoreWithSchema, defineEdge, defineNode, defineSubgraphProject, differentFrom, disjointWith, equivalentTo, exists, externalRef, field, fieldRef, generateId, getExternalRefTable, hasPart, having, havingEq, havingGt, havingGte, havingLt, havingLte, implies, inSubquery, inverseOf, isExternalRefSchema, isParameterRef, limitFragment, max, metaEdge, min, narrower, notExists, notInSubquery, offsetFragment, orderByFragment, param, partOf, relatedTo, sameAs, subClassOf, sum };
13679
+ export { DEFAULT_SQL_SCHEMA, ExecutableAggregateQuery, ExecutableQuery, MAX_EXPLICIT_RECURSIVE_DEPTH, MAX_RECURSIVE_DEPTH, PreparedQuery, QueryBuilder, StoreSearch, UnionableQuery, avg, broader, composeFragments, core, count, countDistinct, createExternalRef, createFragment, createQueryBuilder, createSqlSchema, createStore, createStoreWithSchema, defineEdge, defineNode, defineSubgraphProject, differentFrom, disjointWith, equivalentTo, exists, externalRef, field, fieldRef, generateId, getExternalRefTable, hasPart, having, havingEq, havingGt, havingGte, havingLt, havingLte, implies, inSubquery, inverseOf, isExternalRefSchema, isParameterRef, limitFragment, max, metaEdge, min, narrower, notExists, notInSubquery, offsetFragment, orderByFragment, param, partOf, relatedTo, sameAs, subClassOf, sum };
12489
13680
  //# sourceMappingURL=index.js.map
12490
13681
  //# sourceMappingURL=index.js.map