bunsane 0.3.2 → 0.5.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 (220) hide show
  1. package/CHANGELOG.md +471 -370
  2. package/core/BatchLoader.ts +56 -32
  3. package/core/Entity.ts +93 -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 +8 -7
  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 +25 -10
  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 +383 -0
  28. package/core/entity/finders.ts +202 -0
  29. package/core/entity/getCacheManager.ts +10 -0
  30. package/core/entity/pendingOps.ts +72 -0
  31. package/core/entity/saveEntity.ts +375 -0
  32. package/core/health.ts +93 -4
  33. package/core/hooks/dispatcher.ts +439 -0
  34. package/core/hooks/guards.ts +155 -0
  35. package/core/hooks/registry.ts +247 -0
  36. package/core/metadata/definitions/Component.ts +1 -1
  37. package/core/metadata/index.ts +15 -4
  38. package/core/middleware/RateLimit.ts +102 -105
  39. package/core/middleware/RequestId.ts +2 -9
  40. package/core/middleware/SecurityHeaders.ts +2 -11
  41. package/core/middleware/headers.ts +28 -0
  42. package/core/remote/OutboxWorker.ts +213 -183
  43. package/core/remote/RemoteManager.ts +401 -400
  44. package/core/remote/StreamConsumer.ts +535 -535
  45. package/core/remote/types.ts +153 -151
  46. package/core/requestScope.ts +34 -0
  47. package/core/scheduler/cronEvaluator.ts +174 -0
  48. package/core/scheduler/lifecycleHooks.ts +21 -0
  49. package/core/scheduler/lockCoordinator.ts +27 -0
  50. package/core/scheduler/metrics.ts +14 -0
  51. package/core/scheduler/taskRunner.ts +420 -0
  52. package/core/validateEnv.ts +10 -0
  53. package/database/DatabaseHelper.ts +128 -101
  54. package/database/IndexingStrategy.ts +72 -2
  55. package/database/PreparedStatementCache.ts +8 -2
  56. package/database/cancellable.ts +35 -22
  57. package/database/index.ts +29 -3
  58. package/database/instrumentedDb.ts +141 -141
  59. package/database/sqlHelpers.ts +3 -1
  60. package/endpoints/archetypes.ts +2 -8
  61. package/endpoints/tables.ts +6 -1
  62. package/gql/index.ts +1 -1
  63. package/gql/schema/index.ts +15 -4
  64. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  65. package/package.json +22 -1
  66. package/query/CTENode.ts +5 -3
  67. package/query/ComponentInclusionNode.ts +245 -14
  68. package/query/OrNode.ts +8 -19
  69. package/query/Query.ts +208 -79
  70. package/query/QueryContext.ts +6 -0
  71. package/query/QueryDAG.ts +7 -2
  72. package/query/membershipSource.ts +66 -0
  73. package/storage/LocalStorageProvider.ts +8 -3
  74. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  75. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  76. package/studio/{index.html → dist/index.html} +3 -2
  77. package/swagger/generator.ts +11 -1
  78. package/upload/UploadManager.ts +8 -6
  79. package/utils/uuid.ts +40 -10
  80. package/.claude/scheduled_tasks.lock +0 -1
  81. package/.claude/settings.local.json +0 -47
  82. package/.prettierrc +0 -4
  83. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  84. package/.serena/memories/architecture.md +0 -154
  85. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  86. package/.serena/memories/code_style_and_conventions.md +0 -76
  87. package/.serena/memories/project_overview.md +0 -43
  88. package/.serena/memories/schema-dsl-plan.md +0 -107
  89. package/.serena/memories/suggested_commands.md +0 -80
  90. package/.serena/memories/typescript-compilation-status.md +0 -54
  91. package/.serena/project.yml +0 -114
  92. package/BunSane.jpg +0 -0
  93. package/CLAUDE.md +0 -198
  94. package/TODO.md +0 -2
  95. package/bun.lock +0 -302
  96. package/bunfig.toml +0 -10
  97. package/docs/RFC_APP_REFACTOR.md +0 -248
  98. package/docs/RFC_REFACTOR_TARGETS.md +0 -251
  99. package/docs/SCALABILITY_PLAN.md +0 -175
  100. package/studio/bun.lock +0 -482
  101. package/studio/package.json +0 -39
  102. package/studio/postcss.config.js +0 -6
  103. package/studio/src/components/DataTable.tsx +0 -211
  104. package/studio/src/components/Layout.tsx +0 -13
  105. package/studio/src/components/PageContainer.tsx +0 -9
  106. package/studio/src/components/PageHeader.tsx +0 -13
  107. package/studio/src/components/SearchBar.tsx +0 -57
  108. package/studio/src/components/Sidebar.tsx +0 -294
  109. package/studio/src/components/ui/button.tsx +0 -56
  110. package/studio/src/components/ui/checkbox.tsx +0 -26
  111. package/studio/src/components/ui/input.tsx +0 -25
  112. package/studio/src/hooks/useDataTable.ts +0 -131
  113. package/studio/src/index.css +0 -36
  114. package/studio/src/lib/api.ts +0 -186
  115. package/studio/src/lib/utils.ts +0 -13
  116. package/studio/src/main.tsx +0 -17
  117. package/studio/src/pages/ArcheType.tsx +0 -239
  118. package/studio/src/pages/Components.tsx +0 -124
  119. package/studio/src/pages/EntityInspector.tsx +0 -302
  120. package/studio/src/pages/QueryRunner.tsx +0 -246
  121. package/studio/src/pages/Table.tsx +0 -94
  122. package/studio/src/pages/Welcome.tsx +0 -241
  123. package/studio/src/routes.tsx +0 -45
  124. package/studio/src/store/archeTypeSettings.ts +0 -30
  125. package/studio/src/store/studio.ts +0 -65
  126. package/studio/src/utils/columnHelpers.tsx +0 -114
  127. package/studio/studio-instructions.md +0 -81
  128. package/studio/tailwind.config.js +0 -77
  129. package/studio/utils.ts +0 -54
  130. package/studio/vite.config.js +0 -19
  131. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  132. package/tests/benchmark/bunfig.toml +0 -9
  133. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  134. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  135. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  136. package/tests/benchmark/fixtures/index.ts +0 -6
  137. package/tests/benchmark/index.ts +0 -22
  138. package/tests/benchmark/noop-preload.ts +0 -3
  139. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  140. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  141. package/tests/benchmark/runners/index.ts +0 -4
  142. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  143. package/tests/benchmark/scripts/generate-db.ts +0 -344
  144. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  145. package/tests/e2e/http.test.ts +0 -130
  146. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  147. package/tests/fixtures/components/TestOrder.ts +0 -23
  148. package/tests/fixtures/components/TestProduct.ts +0 -23
  149. package/tests/fixtures/components/TestUser.ts +0 -20
  150. package/tests/fixtures/components/index.ts +0 -6
  151. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  152. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  153. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  154. package/tests/helpers/MockRedisClient.ts +0 -113
  155. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  156. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  157. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  158. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  159. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  160. package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
  161. package/tests/integration/query/Query.abort.test.ts +0 -66
  162. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  163. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  164. package/tests/integration/query/Query.exec.test.ts +0 -576
  165. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  166. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  167. package/tests/integration/remote/dlq.test.ts +0 -175
  168. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  169. package/tests/integration/remote/outbox.test.ts +0 -130
  170. package/tests/integration/remote/rpc.test.ts +0 -177
  171. package/tests/pglite-setup.ts +0 -62
  172. package/tests/setup.ts +0 -164
  173. package/tests/stress/BenchmarkRunner.ts +0 -203
  174. package/tests/stress/DataSeeder.ts +0 -190
  175. package/tests/stress/StressTestReporter.ts +0 -229
  176. package/tests/stress/cursor-perf-test.ts +0 -171
  177. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  178. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  179. package/tests/stress/index.ts +0 -7
  180. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  181. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  182. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  183. package/tests/unit/BatchLoader.test.ts +0 -196
  184. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  185. package/tests/unit/cache/CacheManager.test.ts +0 -498
  186. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  187. package/tests/unit/cache/RedisCache.test.ts +0 -411
  188. package/tests/unit/database/cancellable.test.ts +0 -81
  189. package/tests/unit/database/instrumentedDb.test.ts +0 -160
  190. package/tests/unit/entity/Entity.components.test.ts +0 -317
  191. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  192. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  193. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  194. package/tests/unit/entity/Entity.test.ts +0 -345
  195. package/tests/unit/gql/depthLimit.test.ts +0 -203
  196. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  197. package/tests/unit/health/Health.test.ts +0 -129
  198. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  199. package/tests/unit/middleware/Middleware.test.ts +0 -98
  200. package/tests/unit/middleware/RequestId.test.ts +0 -54
  201. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  202. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  203. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  204. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  205. package/tests/unit/query/Query.test.ts +0 -310
  206. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  207. package/tests/unit/remote/RemoteError.test.ts +0 -55
  208. package/tests/unit/remote/decorators.test.ts +0 -195
  209. package/tests/unit/remote/metrics.test.ts +0 -115
  210. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  211. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  212. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  213. package/tests/unit/schema/schema-integration.test.ts +0 -426
  214. package/tests/unit/schema/schema.test.ts +0 -580
  215. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  216. package/tests/unit/upload/RestUpload.test.ts +0 -267
  217. package/tests/unit/validateEnv.test.ts +0 -82
  218. package/tests/utils/entity-tracker.ts +0 -57
  219. package/tests/utils/index.ts +0 -13
  220. 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
@@ -637,7 +862,11 @@ export class ComponentInclusionNode extends QueryNode {
637
862
  } else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
638
863
  // IN/NOT IN comparison - handle arrays properly
639
864
  if (Array.isArray(filter.value) && filter.value.length > 0) {
640
- const placeholders = Array.from({length: filter.value.length}, (_, i) => `$${context.addParam(filter.value[i])}`).join(', ');
865
+ let placeholders = '';
866
+ for (let i = 0; i < filter.value.length; i++) {
867
+ if (i) placeholders += ', ';
868
+ placeholders += '$' + context.addParam(filter.value[i]);
869
+ }
641
870
  condition = `${jsonPath} ${filter.operator} (${placeholders})`;
642
871
  } else if (Array.isArray(filter.value) && filter.value.length === 0) {
643
872
  // Empty array: IN () is always false, NOT IN () is always true
@@ -673,8 +902,9 @@ export class ComponentInclusionNode extends QueryNode {
673
902
  const componentTableName = this.getComponentTableName(compId);
674
903
  const useDirectPartition = shouldUseDirectPartition() && componentTableName !== 'components';
675
904
 
676
- if (useDirectPartition) {
677
- // Direct partition access - query partition table directly by entity_id
905
+ if (useDirectPartition || !getMembershipSource().isLegacy) {
906
+ // Single-table predicate on the component (partition)
907
+ // table — membership is the same row, no junction join.
678
908
  lateralJoins.push(
679
909
  `CROSS JOIN LATERAL (
680
910
  SELECT 1 FROM ${componentTableName} c
@@ -706,8 +936,9 @@ export class ComponentInclusionNode extends QueryNode {
706
936
  const componentTableName = this.getComponentTableName(compId);
707
937
  const useDirectPartition = shouldUseDirectPartition() && componentTableName !== 'components';
708
938
 
709
- if (useDirectPartition) {
710
- // Direct partition access - query partition table directly by entity_id
939
+ if (useDirectPartition || !getMembershipSource().isLegacy) {
940
+ // Single-table predicate on the component (partition)
941
+ // table — membership is the same row, no junction join.
711
942
  sql += ` ${whereKeyword} EXISTS (
712
943
  SELECT 1 FROM ${componentTableName} c
713
944
  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
 
@@ -356,30 +357,18 @@ export class OrNode extends QueryNode {
356
357
  WHERE EXISTS (
357
358
  SELECT 1 FROM ${componentTableName} c
358
359
  WHERE c.entity_id = base.id
359
- AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL
360
- AND c.created_at = (
361
- SELECT MAX(c2.created_at)
362
- FROM ${componentTableName} c2
363
- WHERE c2.entity_id = c.entity_id
364
- AND c2.type_id = $${componentIdParamIndex} AND c2.deleted_at IS NULL
365
- )`;
360
+ AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL`;
366
361
  } else {
367
362
  // Use original query without base
368
363
  const componentTableName = this.getComponentTableName(componentId);
369
364
  branchSql = `
370
365
  SELECT ec.entity_id
371
- FROM entity_components ec
366
+ FROM ${getMembershipTable()} ec
372
367
  WHERE ec.type_id = $${componentIdParamIndex} AND ec.deleted_at IS NULL
373
368
  AND EXISTS (
374
369
  SELECT 1 FROM ${componentTableName} c
375
370
  WHERE c.entity_id = ec.entity_id
376
- AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL
377
- AND c.created_at = (
378
- SELECT MAX(c2.created_at)
379
- FROM ${componentTableName} c2
380
- WHERE c2.entity_id = c.entity_id
381
- AND c2.type_id = $${componentIdParamIndex} AND c2.deleted_at IS NULL
382
- )`;
371
+ AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL`;
383
372
  }
384
373
 
385
374
  context.params.push(componentId);
@@ -458,7 +447,7 @@ export class OrNode extends QueryNode {
458
447
  for (const componentType of allComponentTypes) {
459
448
  const componentId = ComponentRegistry.getComponentId(componentType);
460
449
  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)`);
450
+ 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
451
  context.params.push(componentId);
463
452
  paramIndex++;
464
453
  }
@@ -480,7 +469,7 @@ export class OrNode extends QueryNode {
480
469
  if (context.excludedComponentIds.size > 0) {
481
470
  const excludedTypes = Array.from(context.excludedComponentIds);
482
471
  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)`);
472
+ 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
473
  context.params.push(...excludedTypes);
485
474
  }
486
475