bunsane 0.3.1 → 0.4.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 (224) hide show
  1. package/CHANGELOG.md +445 -318
  2. package/config/cache.config.ts +35 -1
  3. package/core/App.ts +24 -1064
  4. package/core/ArcheType.ts +78 -2110
  5. package/core/BatchLoader.ts +56 -32
  6. package/core/Entity.ts +85 -1043
  7. package/core/EntityHookManager.ts +52 -754
  8. package/core/Logger.ts +10 -0
  9. package/core/RequestContext.ts +64 -6
  10. package/core/RequestLoaders.ts +187 -36
  11. package/core/SchedulerManager.ts +28 -600
  12. package/core/app/bootstrap.ts +133 -0
  13. package/core/app/cors.ts +85 -0
  14. package/core/app/graphqlSetup.ts +56 -0
  15. package/core/app/healthEndpoints.ts +31 -0
  16. package/core/app/metricsCollector.ts +27 -0
  17. package/core/app/preparedStatementWarmup.ts +15 -0
  18. package/core/app/processHandlers.ts +43 -0
  19. package/core/app/requestRouter.ts +310 -0
  20. package/core/app/restRegistry.ts +80 -0
  21. package/core/app/shutdown.ts +97 -0
  22. package/core/app/studioRouter.ts +83 -0
  23. package/core/archetype/customTypes.ts +100 -0
  24. package/core/archetype/decorators.ts +171 -0
  25. package/core/archetype/fieldResolvers.ts +666 -0
  26. package/core/archetype/helpers.ts +29 -0
  27. package/core/archetype/relationLoader.ts +161 -0
  28. package/core/archetype/schemaBuilder.ts +141 -0
  29. package/core/archetype/weaver.ts +218 -0
  30. package/core/archetype/zodSchemaBuilder.ts +527 -0
  31. package/core/cache/CacheManager.ts +173 -267
  32. package/core/cache/CompressionUtils.ts +34 -3
  33. package/core/cache/MemoryCache.ts +40 -37
  34. package/core/cache/RedisCache.ts +4 -4
  35. package/core/cache/health.ts +30 -0
  36. package/core/cache/invalidation.ts +96 -0
  37. package/core/cache/strategies/writeInvalidate.ts +111 -0
  38. package/core/cache/strategies/writeThrough.ts +233 -0
  39. package/core/components/BaseComponent.ts +16 -8
  40. package/core/components/ComponentRegistry.ts +28 -0
  41. package/core/decorators/IndexedField.ts +1 -1
  42. package/core/entity/cacheStrategies.ts +97 -0
  43. package/core/entity/componentAccess.ts +364 -0
  44. package/core/entity/finders.ts +202 -0
  45. package/core/entity/pendingOps.ts +72 -0
  46. package/core/entity/saveEntity.ts +377 -0
  47. package/core/hooks/dispatcher.ts +439 -0
  48. package/core/hooks/guards.ts +155 -0
  49. package/core/hooks/registry.ts +247 -0
  50. package/core/metadata/definitions/Component.ts +1 -1
  51. package/core/metadata/index.ts +15 -4
  52. package/core/middleware/AccessLog.ts +8 -1
  53. package/core/middleware/RateLimit.ts +102 -105
  54. package/core/middleware/RequestId.ts +2 -9
  55. package/core/middleware/SecurityHeaders.ts +2 -11
  56. package/core/middleware/headers.ts +28 -0
  57. package/core/remote/OutboxWorker.ts +213 -183
  58. package/core/remote/RemoteManager.ts +401 -400
  59. package/core/remote/types.ts +153 -151
  60. package/core/requestScope.ts +34 -0
  61. package/core/scheduler/cronEvaluator.ts +174 -0
  62. package/core/scheduler/lifecycleHooks.ts +21 -0
  63. package/core/scheduler/lockCoordinator.ts +27 -0
  64. package/core/scheduler/metrics.ts +14 -0
  65. package/core/scheduler/taskRunner.ts +420 -0
  66. package/database/DatabaseHelper.ts +128 -101
  67. package/database/IndexingStrategy.ts +72 -2
  68. package/database/PreparedStatementCache.ts +20 -5
  69. package/database/cancellable.ts +35 -0
  70. package/database/index.ts +15 -3
  71. package/database/instrumentedDb.ts +141 -0
  72. package/endpoints/archetypes.ts +2 -8
  73. package/endpoints/tables.ts +6 -1
  74. package/gql/index.ts +1 -1
  75. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  76. package/package.json +22 -1
  77. package/query/CTENode.ts +5 -3
  78. package/query/ComponentInclusionNode.ts +240 -13
  79. package/query/OrNode.ts +6 -5
  80. package/query/Query.ts +203 -59
  81. package/query/QueryContext.ts +6 -0
  82. package/query/QueryDAG.ts +7 -2
  83. package/query/membershipSource.ts +66 -0
  84. package/storage/LocalStorageProvider.ts +8 -3
  85. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  86. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  87. package/studio/{index.html → dist/index.html} +3 -2
  88. package/swagger/generator.ts +11 -1
  89. package/upload/UploadManager.ts +8 -6
  90. package/utils/uuid.ts +40 -10
  91. package/.claude/settings.local.json +0 -47
  92. package/.prettierrc +0 -4
  93. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  94. package/.serena/memories/architecture.md +0 -154
  95. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  96. package/.serena/memories/code_style_and_conventions.md +0 -76
  97. package/.serena/memories/project_overview.md +0 -43
  98. package/.serena/memories/schema-dsl-plan.md +0 -107
  99. package/.serena/memories/suggested_commands.md +0 -80
  100. package/.serena/memories/typescript-compilation-status.md +0 -54
  101. package/.serena/project.yml +0 -114
  102. package/BunSane.jpg +0 -0
  103. package/CLAUDE.md +0 -198
  104. package/TODO.md +0 -2
  105. package/bun.lock +0 -302
  106. package/bunfig.toml +0 -10
  107. package/docs/SCALABILITY_PLAN.md +0 -175
  108. package/studio/bun.lock +0 -482
  109. package/studio/package.json +0 -39
  110. package/studio/postcss.config.js +0 -6
  111. package/studio/src/components/DataTable.tsx +0 -211
  112. package/studio/src/components/Layout.tsx +0 -13
  113. package/studio/src/components/PageContainer.tsx +0 -9
  114. package/studio/src/components/PageHeader.tsx +0 -13
  115. package/studio/src/components/SearchBar.tsx +0 -57
  116. package/studio/src/components/Sidebar.tsx +0 -294
  117. package/studio/src/components/ui/button.tsx +0 -56
  118. package/studio/src/components/ui/checkbox.tsx +0 -26
  119. package/studio/src/components/ui/input.tsx +0 -25
  120. package/studio/src/hooks/useDataTable.ts +0 -131
  121. package/studio/src/index.css +0 -36
  122. package/studio/src/lib/api.ts +0 -186
  123. package/studio/src/lib/utils.ts +0 -13
  124. package/studio/src/main.tsx +0 -17
  125. package/studio/src/pages/ArcheType.tsx +0 -239
  126. package/studio/src/pages/Components.tsx +0 -124
  127. package/studio/src/pages/EntityInspector.tsx +0 -302
  128. package/studio/src/pages/QueryRunner.tsx +0 -246
  129. package/studio/src/pages/Table.tsx +0 -94
  130. package/studio/src/pages/Welcome.tsx +0 -241
  131. package/studio/src/routes.tsx +0 -45
  132. package/studio/src/store/archeTypeSettings.ts +0 -30
  133. package/studio/src/store/studio.ts +0 -65
  134. package/studio/src/utils/columnHelpers.tsx +0 -114
  135. package/studio/studio-instructions.md +0 -81
  136. package/studio/tailwind.config.js +0 -77
  137. package/studio/utils.ts +0 -54
  138. package/studio/vite.config.js +0 -19
  139. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  140. package/tests/benchmark/bunfig.toml +0 -9
  141. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  142. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  143. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  144. package/tests/benchmark/fixtures/index.ts +0 -6
  145. package/tests/benchmark/index.ts +0 -22
  146. package/tests/benchmark/noop-preload.ts +0 -3
  147. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  148. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  149. package/tests/benchmark/runners/index.ts +0 -4
  150. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  151. package/tests/benchmark/scripts/generate-db.ts +0 -344
  152. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  153. package/tests/e2e/http.test.ts +0 -130
  154. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  155. package/tests/fixtures/components/TestOrder.ts +0 -23
  156. package/tests/fixtures/components/TestProduct.ts +0 -23
  157. package/tests/fixtures/components/TestUser.ts +0 -20
  158. package/tests/fixtures/components/index.ts +0 -6
  159. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  160. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  161. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  162. package/tests/helpers/MockRedisClient.ts +0 -113
  163. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  164. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  165. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  166. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  167. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  168. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  169. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  170. package/tests/integration/query/Query.exec.test.ts +0 -576
  171. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  172. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  173. package/tests/integration/remote/dlq.test.ts +0 -175
  174. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  175. package/tests/integration/remote/outbox.test.ts +0 -130
  176. package/tests/integration/remote/rpc.test.ts +0 -177
  177. package/tests/pglite-setup.ts +0 -62
  178. package/tests/setup.ts +0 -164
  179. package/tests/stress/BenchmarkRunner.ts +0 -203
  180. package/tests/stress/DataSeeder.ts +0 -190
  181. package/tests/stress/StressTestReporter.ts +0 -229
  182. package/tests/stress/cursor-perf-test.ts +0 -171
  183. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  184. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  185. package/tests/stress/index.ts +0 -7
  186. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  187. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  188. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  189. package/tests/unit/BatchLoader.test.ts +0 -196
  190. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  191. package/tests/unit/cache/CacheManager.test.ts +0 -367
  192. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  193. package/tests/unit/cache/RedisCache.test.ts +0 -411
  194. package/tests/unit/entity/Entity.components.test.ts +0 -317
  195. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  196. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  197. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  198. package/tests/unit/entity/Entity.test.ts +0 -345
  199. package/tests/unit/gql/depthLimit.test.ts +0 -203
  200. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  201. package/tests/unit/health/Health.test.ts +0 -129
  202. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  203. package/tests/unit/middleware/Middleware.test.ts +0 -98
  204. package/tests/unit/middleware/RequestId.test.ts +0 -54
  205. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  206. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  207. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  208. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  209. package/tests/unit/query/Query.test.ts +0 -310
  210. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  211. package/tests/unit/remote/RemoteError.test.ts +0 -55
  212. package/tests/unit/remote/decorators.test.ts +0 -195
  213. package/tests/unit/remote/metrics.test.ts +0 -115
  214. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  215. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  216. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  217. package/tests/unit/schema/schema-integration.test.ts +0 -426
  218. package/tests/unit/schema/schema.test.ts +0 -580
  219. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  220. package/tests/unit/upload/RestUpload.test.ts +0 -267
  221. package/tests/unit/validateEnv.test.ts +0 -82
  222. package/tests/utils/entity-tracker.ts +0 -57
  223. package/tests/utils/index.ts +0 -13
  224. package/tests/utils/test-context.ts +0 -149
@@ -319,19 +319,13 @@ export async function handleStudioArcheTypeDeleteRequest(
319
319
  .join(", ");
320
320
 
321
321
  // Delete in correct order to avoid foreign key constraint violations
322
- // 1. Delete from entity_components (junction table)
323
- await db.unsafe(
324
- `DELETE FROM entity_components WHERE entity_id IN (${idPlaceholders})`,
325
- entityIds
326
- );
327
-
328
- // 2. Delete from components
322
+ // 1. Delete from components (membership source of truth)
329
323
  await db.unsafe(
330
324
  `DELETE FROM components WHERE entity_id IN (${idPlaceholders})`,
331
325
  entityIds
332
326
  );
333
327
 
334
- // 3. Delete from entities
328
+ // 2. Delete from entities
335
329
  await db.unsafe(
336
330
  `DELETE FROM entities WHERE id IN (${idPlaceholders})`,
337
331
  entityIds
@@ -179,7 +179,12 @@ export async function handleStudioTableDeleteRequest(
179
179
 
180
180
  export async function handleGetTables(): Promise<Response> {
181
181
  try {
182
- // Fetch all tables except ECS tables
182
+ // Exclude framework-internal tables and the legacy entity_components table.
183
+ // entity_components is no longer written by the framework (Phase 3 of
184
+ // docs/ENTITY_COMPONENTS_REMOVAL_PLAN.md) but may still exist as an orphan
185
+ // in upgraded databases. Keeping it out of the Studio listing avoids
186
+ // exposing a confusingly schema'd legacy table with no ECS UI support.
187
+ // Users are directed to drop it via the startup orphan-notice log.
183
188
  const ecsTables = ['components', 'entities', 'entity_components', 'spatial_ref_sys'];
184
189
  const ecsTablePlaceholders = ecsTables.map((_, index) => `$${index + 1}`).join(", ");
185
190
 
package/gql/index.ts CHANGED
@@ -116,7 +116,7 @@ const maskError = (error: any, message: string): GraphQLError => {
116
116
  }
117
117
 
118
118
  // Pass through known application-level GraphQL error codes
119
- const isGQLError = (e: any): e is GraphQLError =>
119
+ const isGQLError = (e: any): e is { message: string; extensions?: Record<string, unknown> } =>
120
120
  e instanceof GraphQLError ||
121
121
  (e !== null && typeof e === 'object' && 'extensions' in e && 'message' in e && typeof e.message === 'string');
122
122
  const knownCodes = ['FORBIDDEN', 'NOT_FOUND', 'BAD_USER_INPUT', 'BAD_REQUEST'];
@@ -18,24 +18,45 @@ export class ResolverGeneratorVisitor extends GraphVisitor {
18
18
  this.services = services;
19
19
  this.resolverBuilder = new ResolverBuilder();
20
20
 
21
- // Add Date scalar resolver
21
+ // Add Date scalar resolver.
22
+ // Safety net: gqloom's `z.date()` currently maps to GraphQLString
23
+ // (see @gqloom/zod isZodDate → GraphQLString), so this custom Date
24
+ // scalar is rarely wired by the auto-generated archetype schema.
25
+ // Component-prop leaf resolvers normalize Date → ISO string upstream
26
+ // (core/archetype/fieldResolvers.ts) so GraphQLString coercion does
27
+ // not call Date.valueOf() and emit epoch ms. We still harden this
28
+ // serializer to accept Date, number, and numeric-string inputs in
29
+ // case a downstream user types a field as the `Date` scalar
30
+ // directly.
22
31
  this.resolverBuilder.addScalarResolver('Date', {
23
32
  serialize: (value: any) => {
24
- if (value instanceof Date) {
25
- return value.toISOString();
33
+ if (value === null || value === undefined) return value;
34
+ if (value instanceof Date) return value.toISOString();
35
+ if (typeof value === 'number') return new Date(value).toISOString();
36
+ if (typeof value === 'string') {
37
+ if (/^\d+$/.test(value)) {
38
+ return new Date(Number(value)).toISOString();
39
+ }
40
+ return value;
26
41
  }
27
- return value;
42
+ throw new Error(`Date scalar cannot serialize ${typeof value}`);
28
43
  },
29
44
  parseValue: (value: any) => {
30
45
  if (typeof value === 'string') {
31
46
  return new Date(value);
32
47
  }
48
+ if (typeof value === 'number') {
49
+ return new Date(value);
50
+ }
33
51
  return value;
34
52
  },
35
53
  parseLiteral: (ast: any) => {
36
54
  if (ast.kind === 'StringValue') {
37
55
  return new Date(ast.value);
38
56
  }
57
+ if (ast.kind === 'IntValue') {
58
+ return new Date(Number(ast.value));
59
+ }
39
60
  return null;
40
61
  }
41
62
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunsane",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "author": {
5
5
  "name": "yaaruu"
6
6
  },
@@ -14,6 +14,27 @@
14
14
  ],
15
15
  "module": "index.ts",
16
16
  "type": "module",
17
+ "files": [
18
+ "index.ts",
19
+ "config",
20
+ "core",
21
+ "database",
22
+ "endpoints",
23
+ "gql",
24
+ "plugins",
25
+ "query",
26
+ "rest",
27
+ "scheduler",
28
+ "service",
29
+ "storage",
30
+ "studio/dist",
31
+ "swagger",
32
+ "types",
33
+ "upload",
34
+ "utils",
35
+ "tsconfig.json",
36
+ "CHANGELOG.md"
37
+ ],
17
38
  "scripts": {
18
39
  "build": "bun run build:studio && tsc",
19
40
  "build:studio": "cd studio && bun install && bun run build",
package/query/CTENode.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { QueryNode } from "./QueryNode";
2
2
  import type { QueryResult } from "./QueryNode";
3
3
  import { QueryContext } from "./QueryContext";
4
+ import { getMembershipTable } from "./membershipSource";
4
5
 
5
6
  export class CTENode extends QueryNode {
6
7
  public execute(context: QueryContext): QueryResult {
7
8
  // Generate CTE for base entity filtering
8
9
  const componentIds = Array.from(context.componentIds);
9
10
  const excludedIds = Array.from(context.excludedComponentIds);
11
+ const membershipTable = getMembershipTable();
10
12
 
11
13
  if (componentIds.length === 0) {
12
14
  throw new Error("CTENode requires at least one component type to filter on");
@@ -26,7 +28,7 @@ export class CTENode extends QueryNode {
26
28
  if (excludedIds.length > 0) {
27
29
  const excludedPlaceholders = excludedIds.map((id) => `$${context.addParam(id)}`).join(', ');
28
30
  exclusionCondition = ` AND NOT EXISTS (
29
- SELECT 1 FROM entity_components ec_ex
31
+ SELECT 1 FROM ${membershipTable} ec_ex
30
32
  WHERE ec_ex.entity_id = ec.entity_id
31
33
  AND ec_ex.type_id IN (${excludedPlaceholders})
32
34
  AND ec_ex.deleted_at IS NULL
@@ -45,7 +47,7 @@ export class CTENode extends QueryNode {
45
47
  // Single component - simple query, no INTERSECT needed
46
48
  const paramIdx = context.addParam(componentIds[0]);
47
49
  cteSql += ` SELECT DISTINCT ec.entity_id\n`;
48
- cteSql += ` FROM entity_components ec\n`;
50
+ cteSql += ` FROM ${membershipTable} ec\n`;
49
51
  cteSql += ` WHERE ec.type_id = $${paramIdx}::text\n`;
50
52
  cteSql += ` AND ec.deleted_at IS NULL\n`;
51
53
  if (cursorCondition) cteSql += ` ${cursorCondition.trim()}\n`;
@@ -57,7 +59,7 @@ export class CTENode extends QueryNode {
57
59
  // then efficiently merge results, avoiding Cartesian product explosion
58
60
  const intersectQueries = componentIds.map((compId) => {
59
61
  const paramIdx = context.addParam(compId);
60
- let subquery = `SELECT ec.entity_id FROM entity_components ec WHERE ec.type_id = $${paramIdx}::text AND ec.deleted_at IS NULL`;
62
+ let subquery = `SELECT ec.entity_id FROM ${membershipTable} ec WHERE ec.type_id = $${paramIdx}::text AND ec.deleted_at IS NULL`;
61
63
  // Add cursor/exclusion conditions to each subquery for efficiency
62
64
  if (cursorCondition) subquery += cursorCondition;
63
65
  if (exclusionCondition) subquery += exclusionCondition;
@@ -6,6 +6,7 @@ import { FilterBuilderRegistry } from "./FilterBuilderRegistry";
6
6
  import { ComponentRegistry } from "../core/components";
7
7
  import { getMetadataStorage } from "../core/metadata";
8
8
  import { assertIdentifier } from "./SqlIdentifier";
9
+ import { getMembershipSource, getMembershipTable } from "./membershipSource";
9
10
 
10
11
  /**
11
12
  * Check if a component property is numeric based on metadata
@@ -40,6 +41,209 @@ export class ComponentInclusionNode extends QueryNode {
40
41
  return 'components';
41
42
  }
42
43
 
44
+ /**
45
+ * Whether the multi-component sort-driven scan applies. Must be pure
46
+ * (no param side effects) — QueryDAG consults it to skip CTE planning
47
+ * and execute() consults it before building any SQL.
48
+ *
49
+ * Eligible shape: exactly one sort order on a required component, two or
50
+ * more required components, no findById, no cursor pagination. Filters
51
+ * on any component are supported (applied inline / via EXISTS).
52
+ */
53
+ public static canUseSortDrivenScan(context: QueryContext): boolean {
54
+ if (context.sortOrders.length !== 1) return false;
55
+ if (context.componentIds.size < 2) return false;
56
+ if (context.withId) return false;
57
+ if (context.cursorId !== null) return false;
58
+ if (context.hasOrQuery) return false;
59
+ const sortTypeId = ComponentRegistry.getComponentId(context.sortOrders[0]!.component);
60
+ if (!sortTypeId || !context.componentIds.has(sortTypeId)) return false;
61
+ return true;
62
+ }
63
+
64
+ /**
65
+ * Build a filter condition against `<alias>.data`. Mirrors the default
66
+ * logic in applyComponentFiltersWithState but with a configurable alias
67
+ * so the sort-driven scan can filter the driving table (`s`) and EXISTS
68
+ * probes (`cf`) without string surgery.
69
+ */
70
+ private buildFilterCondition(filter: { field: string; operator: string; value: any }, alias: string, context: QueryContext): string {
71
+ if (FilterBuilderRegistry.has(filter.operator)) {
72
+ const options = FilterBuilderRegistry.getOptions(filter.operator);
73
+ if (options?.validate && !options.validate(filter as any)) {
74
+ throw new Error(`Invalid filter value for operator '${filter.operator}': ${JSON.stringify(filter.value)}`);
75
+ }
76
+ return FilterBuilderRegistry.get(filter.operator)!(filter as any, alias, context).sql;
77
+ }
78
+
79
+ let jsonPath: string;
80
+ if (filter.field.includes('.')) {
81
+ const parts = filter.field.split('.');
82
+ const lastPart = parts.pop()!;
83
+ const nestedPath = parts.map(p => `'${p}'`).join('->');
84
+ jsonPath = `${alias}.data->${nestedPath}->>'${lastPart}'`;
85
+ } else {
86
+ jsonPath = `${alias}.data->>'${filter.field}'`;
87
+ }
88
+
89
+ const valueStr = String(filter.value);
90
+ const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(valueStr);
91
+
92
+ if (isUUID && filter.operator === '=') {
93
+ return `${jsonPath} = $${context.addParam(filter.value)}`;
94
+ } else if (filter.operator === 'LIKE' || filter.operator === 'NOT LIKE' || filter.operator === 'ILIKE') {
95
+ return `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}`;
96
+ } else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
97
+ if (Array.isArray(filter.value) && filter.value.length > 0) {
98
+ const placeholders = filter.value.map((v: any) => `$${context.addParam(v)}`).join(', ');
99
+ return `${jsonPath} ${filter.operator} (${placeholders})`;
100
+ } else if (Array.isArray(filter.value) && filter.value.length === 0) {
101
+ return filter.operator === 'IN' ? 'FALSE' : 'TRUE';
102
+ }
103
+ throw new Error(`${filter.operator} operator requires an array of values`);
104
+ } else if (typeof filter.value === 'number') {
105
+ return `(${jsonPath})::numeric ${filter.operator} $${context.addParam(filter.value)}::numeric`;
106
+ } else if (typeof filter.value === 'boolean') {
107
+ return `(${jsonPath})::boolean ${filter.operator} $${context.addParam(filter.value)}`;
108
+ }
109
+ return `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}`;
110
+ }
111
+
112
+ /**
113
+ * Sort-driven scan for multi-component sorted queries.
114
+ *
115
+ * The previous shape (INTERSECT/CTE base set + correlated scalar
116
+ * subquery ORDER BY) forces PostgreSQL to materialize EVERY matching
117
+ * entity, run one correlated lookup per row for the sort key, sort the
118
+ * whole set, then apply LIMIT. This shape instead drives the scan from
119
+ * the sort component's table so the planner can walk the functional
120
+ * index on the sort expression and stop after LIMIT rows, probing the
121
+ * other component requirements with cheap EXISTS lookups per visited
122
+ * row:
123
+ *
124
+ * SELECT s.entity_id AS id FROM <sort component table> s
125
+ * WHERE s.type_id = $1 AND s.deleted_at IS NULL
126
+ * AND <filters on sort component (inline on s)>
127
+ * AND EXISTS (... other required component ...) -- per component
128
+ * AND EXISTS (... other component filter ...) -- per filter
129
+ * ORDER BY (s.data->>'prop')::numeric ASC NULLS LAST
130
+ * LIMIT $n OFFSET $m
131
+ *
132
+ * The form leaves the planner free to fall back to filter-first +
133
+ * sort when the predicate is highly selective — unlike the correlated
134
+ * subquery ORDER BY, which can never use an index for ordering.
135
+ */
136
+ private applySortDrivenScan(context: QueryContext): string | null {
137
+ if (!ComponentInclusionNode.canUseSortDrivenScan(context)) return null;
138
+
139
+ const sortOrder = context.sortOrders[0]!;
140
+ const sortTypeId = ComponentRegistry.getComponentId(sortOrder.component)!;
141
+ const componentIds = Array.from(context.componentIds);
142
+ const otherComponentIds = componentIds.filter(id => id !== sortTypeId);
143
+
144
+ const safeProperty = assertIdentifier(sortOrder.property, 'sortOrder.property');
145
+ const isNumeric = isNumericProperty(sortOrder.component, sortOrder.property);
146
+ const sortExpr = isNumeric
147
+ ? `(s.data->>'${safeProperty}')::numeric`
148
+ : `s.data->>'${safeProperty}'`;
149
+ const nullsClause = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
150
+
151
+ const sortTable = this.getComponentTableName(sortTypeId);
152
+ const driveDirect = shouldUseDirectPartition() && sortTable !== 'components';
153
+
154
+ const conditions: string[] = [];
155
+
156
+ // Filters on the sort component apply inline on the driving table.
157
+ for (const filter of context.componentFilters.get(sortTypeId) ?? []) {
158
+ conditions.push(this.buildFilterCondition(filter, 's', context));
159
+ }
160
+
161
+ // Presence probe per other required component.
162
+ for (const compId of otherComponentIds) {
163
+ conditions.push(`EXISTS (
164
+ SELECT 1 FROM ${getMembershipTable()} ec_r
165
+ WHERE ec_r.entity_id = s.entity_id
166
+ AND ec_r.type_id = $${context.addParam(compId)}::text
167
+ AND ec_r.deleted_at IS NULL
168
+ )`);
169
+ }
170
+
171
+ // Filters on other components probe their own table.
172
+ for (const compId of otherComponentIds) {
173
+ const filters = context.componentFilters.get(compId) ?? [];
174
+ for (const filter of filters) {
175
+ const condition = this.buildFilterCondition(filter, 'cf', context);
176
+ const compTable = this.getComponentTableName(compId);
177
+ const filterDirect = shouldUseDirectPartition() && compTable !== 'components';
178
+ if (filterDirect || !getMembershipSource().isLegacy) {
179
+ // Single-table predicate on the component (partition) table:
180
+ // membership lives in the same row, so no junction join.
181
+ conditions.push(`EXISTS (
182
+ SELECT 1 FROM ${compTable} cf
183
+ WHERE cf.entity_id = s.entity_id
184
+ AND cf.type_id = $${context.addParam(compId)}::text
185
+ AND ${condition}
186
+ AND cf.deleted_at IS NULL
187
+ )`);
188
+ } else {
189
+ conditions.push(`EXISTS (
190
+ SELECT 1 FROM entity_components ec_f
191
+ JOIN ${compTable} cf ON ec_f.component_id = cf.id
192
+ WHERE ec_f.entity_id = s.entity_id
193
+ AND ec_f.type_id = $${context.addParam(compId)}::text
194
+ AND ${condition}
195
+ AND ec_f.deleted_at IS NULL
196
+ AND cf.deleted_at IS NULL
197
+ )`);
198
+ }
199
+ }
200
+ }
201
+
202
+ // Excluded components / entities.
203
+ if (context.excludedComponentIds.size > 0) {
204
+ const excludedPlaceholders = Array.from(context.excludedComponentIds)
205
+ .map((id) => `$${context.addParam(id)}`).join(', ');
206
+ conditions.push(`NOT EXISTS (
207
+ SELECT 1 FROM ${getMembershipTable()} ec_ex
208
+ WHERE ec_ex.entity_id = s.entity_id
209
+ AND ec_ex.type_id IN (${excludedPlaceholders})
210
+ AND ec_ex.deleted_at IS NULL
211
+ )`);
212
+ }
213
+ if (context.excludedEntityIds.size > 0) {
214
+ const entityPlaceholders = Array.from(context.excludedEntityIds)
215
+ .map((id) => `$${context.addParam(id)}`).join(', ');
216
+ conditions.push(`s.entity_id NOT IN (${entityPlaceholders})`);
217
+ }
218
+
219
+ const extraConditions = conditions.length > 0 ? `\n AND ${conditions.join('\n AND ')}` : '';
220
+
221
+ let sql: string;
222
+ if (driveDirect || !getMembershipSource().isLegacy) {
223
+ // Drive directly from the sort component (partition) table —
224
+ // membership and component data are the same row.
225
+ sql = `SELECT s.entity_id as id FROM ${sortTable} s
226
+ WHERE s.type_id = $${context.addParam(sortTypeId)}::text
227
+ AND s.deleted_at IS NULL${extraConditions}
228
+ ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}`;
229
+ } else {
230
+ sql = `SELECT s.entity_id as id FROM entity_components ec
231
+ JOIN ${sortTable} s ON s.id = ec.component_id AND s.deleted_at IS NULL
232
+ WHERE ec.type_id = $${context.addParam(sortTypeId)}::text
233
+ AND ec.deleted_at IS NULL${extraConditions}
234
+ ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}`;
235
+ }
236
+
237
+ if (context.limit !== null) {
238
+ sql += ` LIMIT $${context.addParam(context.limit)}`;
239
+ }
240
+ if (context.offsetValue > 0 || context.limit !== null) {
241
+ sql += ` OFFSET $${context.addParam(context.offsetValue)}`;
242
+ }
243
+
244
+ return sql;
245
+ }
246
+
43
247
  public execute(context: QueryContext): QueryResult {
44
248
  const componentIds = Array.from(context.componentIds);
45
249
  const excludedIds = Array.from(context.excludedComponentIds);
@@ -71,6 +275,18 @@ export class ComponentInclusionNode extends QueryNode {
71
275
  // Check if we need custom sorting (sortOrders specified)
72
276
  const hasSortOrders = context.sortOrders.length > 0;
73
277
 
278
+ // Multi-component sorted queries: drive the scan from the sort
279
+ // component so the planner can use the sort-expression index and
280
+ // stop at LIMIT, instead of materializing the full INTERSECT set and
281
+ // sorting it via correlated subqueries. Checked before any params
282
+ // are added so a null fallback leaves the context clean.
283
+ if (!useCTE && hasSortOrders && ComponentInclusionNode.canUseSortDrivenScan(context)) {
284
+ const sortDriven = this.applySortDrivenScan(context);
285
+ if (sortDriven) {
286
+ return { sql: sortDriven, params: context.params, context };
287
+ }
288
+ }
289
+
74
290
  if (componentCount === 1) {
75
291
  // Single component case
76
292
  const componentId = componentIds[0]!;
@@ -100,14 +316,14 @@ export class ComponentInclusionNode extends QueryNode {
100
316
  // Filter by the specific component type if not already in CTE
101
317
  if (!componentIds.some(id => context.componentIds.has(id))) {
102
318
  sql += ` WHERE EXISTS (
103
- SELECT 1 FROM entity_components ec
319
+ SELECT 1 FROM ${getMembershipTable()} ec
104
320
  WHERE ec.entity_id = ${context.cteName}.entity_id
105
321
  AND ec.type_id = $${context.addParam(componentId)}::text
106
322
  AND ec.deleted_at IS NULL
107
323
  )`;
108
324
  }
109
325
  } else {
110
- sql = `SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.type_id = $${context.addParam(componentId)}::text AND ec.deleted_at IS NULL`;
326
+ sql = `SELECT DISTINCT ec.entity_id as id FROM ${getMembershipTable()} ec WHERE ec.type_id = $${context.addParam(componentId)}::text AND ec.deleted_at IS NULL`;
111
327
  }
112
328
 
113
329
  if (context.withId) {
@@ -122,7 +338,7 @@ export class ComponentInclusionNode extends QueryNode {
122
338
  const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
123
339
  const excludedPlaceholders = excludedIds.map((id) => `$${context.addParam(id)}`).join(', ');
124
340
  sql += ` ${whereKeyword} NOT EXISTS (
125
- SELECT 1 FROM entity_components ec_ex
341
+ SELECT 1 FROM ${getMembershipTable()} ec_ex
126
342
  WHERE ec_ex.entity_id = ${tableAlias}.entity_id
127
343
  AND ec_ex.type_id IN (${excludedPlaceholders})
128
344
  AND ec_ex.deleted_at IS NULL
@@ -191,7 +407,7 @@ export class ComponentInclusionNode extends QueryNode {
191
407
  componentParamIndices.set(compId, context.addParam(compId));
192
408
  }
193
409
  return `EXISTS (
194
- SELECT 1 FROM entity_components ec
410
+ SELECT 1 FROM ${getMembershipTable()} ec
195
411
  WHERE ec.entity_id = ${context.cteName}.entity_id
196
412
  AND ec.type_id = $${componentParamIndices.get(compId)}::text
197
413
  AND ec.deleted_at IS NULL
@@ -206,7 +422,7 @@ export class ComponentInclusionNode extends QueryNode {
206
422
  if (!componentParamIndices.has(compId)) {
207
423
  componentParamIndices.set(compId, context.addParam(compId));
208
424
  }
209
- return `SELECT ec.entity_id FROM entity_components ec WHERE ec.type_id = $${componentParamIndices.get(compId)}::text AND ec.deleted_at IS NULL`;
425
+ return `SELECT ec.entity_id FROM ${getMembershipTable()} ec WHERE ec.type_id = $${componentParamIndices.get(compId)}::text AND ec.deleted_at IS NULL`;
210
426
  });
211
427
  sql = `SELECT intersected.entity_id as id FROM (${intersectQueries.join(' INTERSECT ')}) AS intersected`;
212
428
  }
@@ -229,7 +445,7 @@ export class ComponentInclusionNode extends QueryNode {
229
445
  const whereKeyword = outerHasWhere ? 'AND' : 'WHERE';
230
446
  const excludedPlaceholders = excludedIds.map((id) => `$${context.addParam(id)}`).join(', ');
231
447
  sql += ` ${whereKeyword} NOT EXISTS (
232
- SELECT 1 FROM entity_components ec_ex
448
+ SELECT 1 FROM ${getMembershipTable()} ec_ex
233
449
  WHERE ec_ex.entity_id = ${multiCompAlias}.entity_id
234
450
  AND ec_ex.type_id IN (${excludedPlaceholders})
235
451
  AND ec_ex.deleted_at IS NULL
@@ -343,7 +559,8 @@ export class ComponentInclusionNode extends QueryNode {
343
559
  ? `(sort_c.data->>'${safeProperty}')::numeric`
344
560
  : `sort_c.data->>'${safeProperty}'`;
345
561
 
346
- const subquery = `(
562
+ const subquery = getMembershipSource().isLegacy
563
+ ? `(
347
564
  SELECT ${sortExpr}
348
565
  FROM entity_components sort_ec
349
566
  JOIN ${sortComponentTableName} sort_c ON sort_c.id = sort_ec.component_id
@@ -352,6 +569,14 @@ export class ComponentInclusionNode extends QueryNode {
352
569
  AND sort_ec.deleted_at IS NULL
353
570
  AND sort_c.deleted_at IS NULL
354
571
  LIMIT 1
572
+ )`
573
+ : `(
574
+ SELECT ${sortExpr}
575
+ FROM ${sortComponentTableName} sort_c
576
+ WHERE sort_c.entity_id = base_entities.id
577
+ AND sort_c.type_id = $${context.addParam(typeId)}::text
578
+ AND sort_c.deleted_at IS NULL
579
+ LIMIT 1
355
580
  )`;
356
581
 
357
582
  orderByClauses.push(`${subquery} ${sortOrder.direction} ${nullsClause}`);
@@ -465,8 +690,8 @@ export class ComponentInclusionNode extends QueryNode {
465
690
  : `c.data->>'${safeProperty}'`;
466
691
 
467
692
  let sql: string;
468
- if (useDirectPartition) {
469
- // Direct partition access - most efficient
693
+ if (useDirectPartition || !getMembershipSource().isLegacy) {
694
+ // Direct access on the component (partition) table - most efficient.
470
695
  // No DISTINCT needed since each entity has one component of this type
471
696
  sql = `SELECT c.entity_id as id FROM ${componentTableName} c
472
697
  WHERE c.type_id = $${context.addParam(sortTypeId)}::text
@@ -673,8 +898,9 @@ export class ComponentInclusionNode extends QueryNode {
673
898
  const componentTableName = this.getComponentTableName(compId);
674
899
  const useDirectPartition = shouldUseDirectPartition() && componentTableName !== 'components';
675
900
 
676
- if (useDirectPartition) {
677
- // Direct partition access - query partition table directly by entity_id
901
+ if (useDirectPartition || !getMembershipSource().isLegacy) {
902
+ // Single-table predicate on the component (partition)
903
+ // table — membership is the same row, no junction join.
678
904
  lateralJoins.push(
679
905
  `CROSS JOIN LATERAL (
680
906
  SELECT 1 FROM ${componentTableName} c
@@ -706,8 +932,9 @@ export class ComponentInclusionNode extends QueryNode {
706
932
  const componentTableName = this.getComponentTableName(compId);
707
933
  const useDirectPartition = shouldUseDirectPartition() && componentTableName !== 'components';
708
934
 
709
- if (useDirectPartition) {
710
- // Direct partition access - query partition table directly by entity_id
935
+ if (useDirectPartition || !getMembershipSource().isLegacy) {
936
+ // Single-table predicate on the component (partition)
937
+ // table — membership is the same row, no junction join.
711
938
  sql += ` ${whereKeyword} EXISTS (
712
939
  SELECT 1 FROM ${componentTableName} c
713
940
  WHERE c.entity_id = ${tableAlias}.entity_id
package/query/OrNode.ts CHANGED
@@ -4,6 +4,7 @@ import { QueryContext } from "./QueryContext";
4
4
  import { OrQuery } from "./OrQuery";
5
5
  import { ComponentRegistry } from "../core/components";
6
6
  import { shouldUseDirectPartition } from "../core/Config";
7
+ import { getMembershipTable } from "./membershipSource";
7
8
 
8
9
  export class OrNode extends QueryNode {
9
10
  private orQuery: OrQuery;
@@ -149,7 +150,7 @@ export class OrNode extends QueryNode {
149
150
  if (context.excludedComponentIds.size > 0) {
150
151
  const excludedTypes = Array.from(context.excludedComponentIds);
151
152
  const placeholders = excludedTypes.map(() => `$${paramIndex++}`).join(', ');
152
- conditions.push(`NOT EXISTS (SELECT 1 FROM entity_components ec_ex WHERE ec_ex.entity_id = or_results.id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
153
+ conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = or_results.id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
153
154
  context.params.push(...excludedTypes);
154
155
  }
155
156
 
@@ -271,7 +272,7 @@ export class OrNode extends QueryNode {
271
272
  if (context.excludedComponentIds.size > 0) {
272
273
  const excludedTypes = Array.from(context.excludedComponentIds);
273
274
  const placeholders = excludedTypes.map(() => `$${paramIndex++}`).join(', ');
274
- conditions.push(`NOT EXISTS (SELECT 1 FROM entity_components ec_ex WHERE ec_ex.entity_id = ${partitionTable}.entity_id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
275
+ conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = ${partitionTable}.entity_id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
275
276
  context.params.push(...excludedTypes);
276
277
  }
277
278
 
@@ -368,7 +369,7 @@ export class OrNode extends QueryNode {
368
369
  const componentTableName = this.getComponentTableName(componentId);
369
370
  branchSql = `
370
371
  SELECT ec.entity_id
371
- FROM entity_components ec
372
+ FROM ${getMembershipTable()} ec
372
373
  WHERE ec.type_id = $${componentIdParamIndex} AND ec.deleted_at IS NULL
373
374
  AND EXISTS (
374
375
  SELECT 1 FROM ${componentTableName} c
@@ -458,7 +459,7 @@ export class OrNode extends QueryNode {
458
459
  for (const componentType of allComponentTypes) {
459
460
  const componentId = ComponentRegistry.getComponentId(componentType);
460
461
  if (componentId) {
461
- componentConditions.push(`EXISTS (SELECT 1 FROM entity_components ec_all WHERE ec_all.entity_id = or_results.id AND ec_all.type_id = $${paramIndex} AND ec_all.deleted_at IS NULL)`);
462
+ componentConditions.push(`EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_all WHERE ec_all.entity_id = or_results.id AND ec_all.type_id = $${paramIndex} AND ec_all.deleted_at IS NULL)`);
462
463
  context.params.push(componentId);
463
464
  paramIndex++;
464
465
  }
@@ -480,7 +481,7 @@ export class OrNode extends QueryNode {
480
481
  if (context.excludedComponentIds.size > 0) {
481
482
  const excludedTypes = Array.from(context.excludedComponentIds);
482
483
  const placeholders = excludedTypes.map(() => `$${paramIndex++}`).join(', ');
483
- conditions.push(`NOT EXISTS (SELECT 1 FROM entity_components ec_ex WHERE ec_ex.entity_id = or_results.id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
484
+ conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = or_results.id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
484
485
  context.params.push(...excludedTypes);
485
486
  }
486
487