bunsane 0.3.2 → 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 (214) hide show
  1. package/CHANGELOG.md +445 -370
  2. package/core/BatchLoader.ts +56 -32
  3. package/core/Entity.ts +85 -1020
  4. package/core/EntityHookManager.ts +52 -754
  5. package/core/Logger.ts +10 -0
  6. package/core/RequestContext.ts +94 -85
  7. package/core/RequestLoaders.ts +98 -5
  8. package/core/SchedulerManager.ts +28 -600
  9. package/core/app/cors.ts +2 -11
  10. package/core/app/preparedStatementWarmup.ts +9 -49
  11. package/core/app/requestRouter.ts +9 -8
  12. package/core/app/restRegistry.ts +8 -0
  13. package/core/archetype/fieldResolvers.ts +85 -40
  14. package/core/archetype/relationLoader.ts +135 -92
  15. package/core/cache/CacheManager.ts +91 -302
  16. package/core/cache/CompressionUtils.ts +34 -3
  17. package/core/cache/MemoryCache.ts +40 -37
  18. package/core/cache/RedisCache.ts +4 -4
  19. package/core/cache/health.ts +30 -0
  20. package/core/cache/invalidation.ts +96 -0
  21. package/core/cache/strategies/writeInvalidate.ts +111 -0
  22. package/core/cache/strategies/writeThrough.ts +233 -0
  23. package/core/components/BaseComponent.ts +16 -8
  24. package/core/components/ComponentRegistry.ts +28 -0
  25. package/core/decorators/IndexedField.ts +1 -1
  26. package/core/entity/cacheStrategies.ts +97 -0
  27. package/core/entity/componentAccess.ts +364 -0
  28. package/core/entity/finders.ts +202 -0
  29. package/core/entity/pendingOps.ts +72 -0
  30. package/core/entity/saveEntity.ts +377 -0
  31. package/core/hooks/dispatcher.ts +439 -0
  32. package/core/hooks/guards.ts +155 -0
  33. package/core/hooks/registry.ts +247 -0
  34. package/core/metadata/definitions/Component.ts +1 -1
  35. package/core/metadata/index.ts +15 -4
  36. package/core/middleware/RateLimit.ts +102 -105
  37. package/core/middleware/RequestId.ts +2 -9
  38. package/core/middleware/SecurityHeaders.ts +2 -11
  39. package/core/middleware/headers.ts +28 -0
  40. package/core/remote/OutboxWorker.ts +213 -183
  41. package/core/remote/RemoteManager.ts +401 -400
  42. package/core/remote/types.ts +153 -151
  43. package/core/requestScope.ts +34 -0
  44. package/core/scheduler/cronEvaluator.ts +174 -0
  45. package/core/scheduler/lifecycleHooks.ts +21 -0
  46. package/core/scheduler/lockCoordinator.ts +27 -0
  47. package/core/scheduler/metrics.ts +14 -0
  48. package/core/scheduler/taskRunner.ts +420 -0
  49. package/database/DatabaseHelper.ts +128 -101
  50. package/database/IndexingStrategy.ts +72 -2
  51. package/database/PreparedStatementCache.ts +8 -2
  52. package/database/cancellable.ts +35 -22
  53. package/database/index.ts +15 -3
  54. package/database/instrumentedDb.ts +141 -141
  55. package/endpoints/archetypes.ts +2 -8
  56. package/endpoints/tables.ts +6 -1
  57. package/gql/index.ts +1 -1
  58. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  59. package/package.json +22 -1
  60. package/query/CTENode.ts +5 -3
  61. package/query/ComponentInclusionNode.ts +240 -13
  62. package/query/OrNode.ts +6 -5
  63. package/query/Query.ts +157 -46
  64. package/query/QueryContext.ts +6 -0
  65. package/query/QueryDAG.ts +7 -2
  66. package/query/membershipSource.ts +66 -0
  67. package/storage/LocalStorageProvider.ts +8 -3
  68. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  69. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  70. package/studio/{index.html → dist/index.html} +3 -2
  71. package/swagger/generator.ts +11 -1
  72. package/upload/UploadManager.ts +8 -6
  73. package/utils/uuid.ts +40 -10
  74. package/.claude/scheduled_tasks.lock +0 -1
  75. package/.claude/settings.local.json +0 -47
  76. package/.prettierrc +0 -4
  77. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  78. package/.serena/memories/architecture.md +0 -154
  79. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  80. package/.serena/memories/code_style_and_conventions.md +0 -76
  81. package/.serena/memories/project_overview.md +0 -43
  82. package/.serena/memories/schema-dsl-plan.md +0 -107
  83. package/.serena/memories/suggested_commands.md +0 -80
  84. package/.serena/memories/typescript-compilation-status.md +0 -54
  85. package/.serena/project.yml +0 -114
  86. package/BunSane.jpg +0 -0
  87. package/CLAUDE.md +0 -198
  88. package/TODO.md +0 -2
  89. package/bun.lock +0 -302
  90. package/bunfig.toml +0 -10
  91. package/docs/RFC_APP_REFACTOR.md +0 -248
  92. package/docs/RFC_REFACTOR_TARGETS.md +0 -251
  93. package/docs/SCALABILITY_PLAN.md +0 -175
  94. package/studio/bun.lock +0 -482
  95. package/studio/package.json +0 -39
  96. package/studio/postcss.config.js +0 -6
  97. package/studio/src/components/DataTable.tsx +0 -211
  98. package/studio/src/components/Layout.tsx +0 -13
  99. package/studio/src/components/PageContainer.tsx +0 -9
  100. package/studio/src/components/PageHeader.tsx +0 -13
  101. package/studio/src/components/SearchBar.tsx +0 -57
  102. package/studio/src/components/Sidebar.tsx +0 -294
  103. package/studio/src/components/ui/button.tsx +0 -56
  104. package/studio/src/components/ui/checkbox.tsx +0 -26
  105. package/studio/src/components/ui/input.tsx +0 -25
  106. package/studio/src/hooks/useDataTable.ts +0 -131
  107. package/studio/src/index.css +0 -36
  108. package/studio/src/lib/api.ts +0 -186
  109. package/studio/src/lib/utils.ts +0 -13
  110. package/studio/src/main.tsx +0 -17
  111. package/studio/src/pages/ArcheType.tsx +0 -239
  112. package/studio/src/pages/Components.tsx +0 -124
  113. package/studio/src/pages/EntityInspector.tsx +0 -302
  114. package/studio/src/pages/QueryRunner.tsx +0 -246
  115. package/studio/src/pages/Table.tsx +0 -94
  116. package/studio/src/pages/Welcome.tsx +0 -241
  117. package/studio/src/routes.tsx +0 -45
  118. package/studio/src/store/archeTypeSettings.ts +0 -30
  119. package/studio/src/store/studio.ts +0 -65
  120. package/studio/src/utils/columnHelpers.tsx +0 -114
  121. package/studio/studio-instructions.md +0 -81
  122. package/studio/tailwind.config.js +0 -77
  123. package/studio/utils.ts +0 -54
  124. package/studio/vite.config.js +0 -19
  125. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  126. package/tests/benchmark/bunfig.toml +0 -9
  127. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  128. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  129. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  130. package/tests/benchmark/fixtures/index.ts +0 -6
  131. package/tests/benchmark/index.ts +0 -22
  132. package/tests/benchmark/noop-preload.ts +0 -3
  133. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  134. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  135. package/tests/benchmark/runners/index.ts +0 -4
  136. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  137. package/tests/benchmark/scripts/generate-db.ts +0 -344
  138. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  139. package/tests/e2e/http.test.ts +0 -130
  140. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  141. package/tests/fixtures/components/TestOrder.ts +0 -23
  142. package/tests/fixtures/components/TestProduct.ts +0 -23
  143. package/tests/fixtures/components/TestUser.ts +0 -20
  144. package/tests/fixtures/components/index.ts +0 -6
  145. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  146. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  147. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  148. package/tests/helpers/MockRedisClient.ts +0 -113
  149. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  150. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  151. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  152. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  153. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  154. package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
  155. package/tests/integration/query/Query.abort.test.ts +0 -66
  156. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  157. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  158. package/tests/integration/query/Query.exec.test.ts +0 -576
  159. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  160. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  161. package/tests/integration/remote/dlq.test.ts +0 -175
  162. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  163. package/tests/integration/remote/outbox.test.ts +0 -130
  164. package/tests/integration/remote/rpc.test.ts +0 -177
  165. package/tests/pglite-setup.ts +0 -62
  166. package/tests/setup.ts +0 -164
  167. package/tests/stress/BenchmarkRunner.ts +0 -203
  168. package/tests/stress/DataSeeder.ts +0 -190
  169. package/tests/stress/StressTestReporter.ts +0 -229
  170. package/tests/stress/cursor-perf-test.ts +0 -171
  171. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  172. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  173. package/tests/stress/index.ts +0 -7
  174. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  175. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  176. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  177. package/tests/unit/BatchLoader.test.ts +0 -196
  178. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  179. package/tests/unit/cache/CacheManager.test.ts +0 -498
  180. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  181. package/tests/unit/cache/RedisCache.test.ts +0 -411
  182. package/tests/unit/database/cancellable.test.ts +0 -81
  183. package/tests/unit/database/instrumentedDb.test.ts +0 -160
  184. package/tests/unit/entity/Entity.components.test.ts +0 -317
  185. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  186. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  187. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  188. package/tests/unit/entity/Entity.test.ts +0 -345
  189. package/tests/unit/gql/depthLimit.test.ts +0 -203
  190. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  191. package/tests/unit/health/Health.test.ts +0 -129
  192. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  193. package/tests/unit/middleware/Middleware.test.ts +0 -98
  194. package/tests/unit/middleware/RequestId.test.ts +0 -54
  195. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  196. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  197. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  198. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  199. package/tests/unit/query/Query.test.ts +0 -310
  200. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  201. package/tests/unit/remote/RemoteError.test.ts +0 -55
  202. package/tests/unit/remote/decorators.test.ts +0 -195
  203. package/tests/unit/remote/metrics.test.ts +0 -115
  204. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  205. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  206. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  207. package/tests/unit/schema/schema-integration.test.ts +0 -426
  208. package/tests/unit/schema/schema.test.ts +0 -580
  209. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  210. package/tests/unit/upload/RestUpload.test.ts +0 -267
  211. package/tests/unit/validateEnv.test.ts +0 -82
  212. package/tests/utils/entity-tracker.ts +0 -57
  213. package/tests/utils/index.ts +0 -13
  214. package/tests/utils/test-context.ts +0 -149
@@ -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