bunsane 0.4.0 → 0.5.1

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.
@@ -60,6 +60,16 @@ const envSchema = z
60
60
  .string()
61
61
  .regex(/^\d+$/, "DB_CONNECTION_TIMEOUT must be numeric (seconds)")
62
62
  .optional(),
63
+ DB_HEALTH_WRITE_TIMEOUT: z
64
+ .string()
65
+ .regex(/^\d+$/, "DB_HEALTH_WRITE_TIMEOUT must be numeric (milliseconds)")
66
+ .optional(),
67
+ HEALTH_DB_WRITE_PROBE: z
68
+ .enum(["true", "false"])
69
+ .optional(),
70
+ DB_DISABLE_PREPARE: z
71
+ .enum(["true", "false"])
72
+ .optional(),
63
73
  })
64
74
  .refine(
65
75
  (env) => {
@@ -156,7 +156,7 @@ export const CreateComponentTable = async () => {
156
156
  ) PARTITION BY LIST (type_id);`;
157
157
  await db`CREATE INDEX IF NOT EXISTS idx_components_entity_id ON components (entity_id)`;
158
158
  await db`CREATE INDEX IF NOT EXISTS idx_components_type_id ON components (type_id)`;
159
- await db`CREATE INDEX IF NOT EXISTS idx_components_data_gin ON components USING GIN (data)`;
159
+ await ensureDataGinIndex();
160
160
  await db`CREATE INDEX IF NOT EXISTS idx_components_entity_type_deleted ON components (entity_id, type_id, deleted_at)`;
161
161
  await db`CREATE INDEX IF NOT EXISTS idx_components_type_deleted ON components (type_id, deleted_at) WHERE deleted_at IS NULL`;
162
162
  await db`CREATE INDEX IF NOT EXISTS idx_components_deleted_entity ON components (deleted_at, entity_id) WHERE deleted_at IS NULL`;
@@ -226,6 +226,30 @@ const dropOrphanedPartitionTables = async () => {
226
226
  }
227
227
  }
228
228
 
229
+ /**
230
+ * The whole-`data` GIN index (`idx_components_data_gin`) only serves top-level
231
+ * JSONB containment / existence on the entire `data` column (`data @> ...`,
232
+ * `data ? key`, `data ?| / ?&`). The Query layer never emits those forms — it
233
+ * uses per-field text extraction (`data->>'field'`, served by per-field
234
+ * btree/expression indexes) and sub-path containment (`data->'field' @> ...`,
235
+ * served by per-field sub-path GIN). So this index is pure write amplification
236
+ * for framework queries AND it blocks HOT updates (any `data` write must touch
237
+ * it). It is therefore OPT-IN. Set BUNSANE_COMPONENTS_DATA_GIN=true only if you
238
+ * run raw SQL doing top-level containment on the whole component payload.
239
+ */
240
+ const ensureDataGinIndex = async (): Promise<void> => {
241
+ if (process.env.BUNSANE_COMPONENTS_DATA_GIN === 'true') {
242
+ await db`CREATE INDEX IF NOT EXISTS idx_components_data_gin ON components USING GIN (data)`;
243
+ logger.info("Created whole-data GIN index idx_components_data_gin (BUNSANE_COMPONENTS_DATA_GIN=true).");
244
+ } else {
245
+ logger.info(
246
+ "Skipped whole-data GIN index idx_components_data_gin to cut write amplification and enable HOT updates " +
247
+ "(BUNSANE_COMPONENTS_DATA_GIN!=true). Per-field indexes serve all framework queries. A pre-existing DB " +
248
+ "that still has it can drop it manually: DROP INDEX CONCURRENTLY IF EXISTS idx_components_data_gin;"
249
+ );
250
+ }
251
+ };
252
+
229
253
  export const CreateHashPartitionedComponentTable = async (partitionCount: number = 16) => {
230
254
  await db`CREATE TABLE IF NOT EXISTS components (
231
255
  id UUID,
@@ -249,7 +273,7 @@ export const CreateHashPartitionedComponentTable = async (partitionCount: number
249
273
 
250
274
  await db`CREATE INDEX IF NOT EXISTS idx_components_entity_id ON components (entity_id)`;
251
275
  await db`CREATE INDEX IF NOT EXISTS idx_components_type_id ON components (type_id)`;
252
- await db`CREATE INDEX IF NOT EXISTS idx_components_data_gin ON components USING GIN (data)`;
276
+ await ensureDataGinIndex();
253
277
  await db`CREATE INDEX IF NOT EXISTS idx_components_entity_type_deleted ON components (entity_id, type_id, deleted_at)`;
254
278
  await db`CREATE INDEX IF NOT EXISTS idx_components_type_deleted ON components (type_id, deleted_at) WHERE deleted_at IS NULL`;
255
279
  await db`CREATE INDEX IF NOT EXISTS idx_components_deleted_entity ON components (deleted_at, entity_id) WHERE deleted_at IS NULL`;
package/database/index.ts CHANGED
@@ -47,12 +47,26 @@ function createDatabase(): SQL {
47
47
  // workers (schedulers, outbox, migrations) can keep higher values.
48
48
  const connTimeout = parseInt(process.env.DB_CONNECTION_TIMEOUT ?? '30', 10);
49
49
 
50
+ // DB_DISABLE_PREPARE (opt-in): turn off Bun SQL's automatic server-side
51
+ // prepared statements (driver default `prepare: true`). REQUIRED behind
52
+ // PgBouncer in transaction pooling mode — each transaction may land on a
53
+ // different backend, so a prepared statement created on one connection is
54
+ // absent on the next, yielding `prepared statement "..." does not exist`
55
+ // errors that can poison the pooled client and wedge the write path. Costs
56
+ // a little per-query planning; negligible next to the failure it prevents.
57
+ const disablePrepare = process.env.DB_DISABLE_PREPARE === 'true';
58
+ if (disablePrepare) {
59
+ logger.info('Prepared statements disabled (DB_DISABLE_PREPARE=true) — required for PgBouncer transaction pooling');
60
+ }
61
+
50
62
  return new SQL({
51
63
  url,
52
64
  max,
53
65
  idleTimeout: 30000,
54
66
  maxLifetime: 600000,
55
67
  connectionTimeout: connTimeout,
68
+ // Only override when disabling; otherwise leave Bun's default (true).
69
+ ...(disablePrepare ? { prepare: false } : {}),
56
70
  onclose: (err) => {
57
71
  if (err) {
58
72
  const errCode = (err as unknown as { code: string }).code;
@@ -12,6 +12,8 @@ export function inList<T>(values: T[], paramIndex: number): { sql: string, param
12
12
 
13
13
  if (filteredValues.length === 0) return { sql: '()', params: [], newParamIndex: paramIndex };
14
14
 
15
- const placeholders = Array.from({length: filteredValues.length}, (_, i) => `$${paramIndex + i}`).join(', ');
15
+ const n = filteredValues.length;
16
+ let placeholders = '';
17
+ for (let i = 0; i < n; i++) { placeholders += (i ? ', $' : '$') + (paramIndex + i); }
16
18
  return { sql: `(${placeholders})`, params: filteredValues, newParamIndex: paramIndex + filteredValues.length };
17
19
  }
package/gql/index.ts CHANGED
@@ -149,8 +149,19 @@ export interface YogaInstanceOptions {
149
149
  maxComplexity?: number;
150
150
  }
151
151
 
152
+ /**
153
+ * A schema provider may be a concrete `GraphQLSchema` or a factory returning
154
+ * the current schema. A factory is read per-request by Yoga, which lets the
155
+ * schema be swapped at runtime (e.g. ServiceRegistry.rebuildSchema()) without
156
+ * recreating the Yoga instance. Returning `null`/`undefined` falls back to the
157
+ * static placeholder schema.
158
+ */
159
+ export type SchemaProvider =
160
+ | GraphQLSchema
161
+ | (() => GraphQLSchema | null | undefined);
162
+
152
163
  export function createYogaInstance(
153
- schema?: GraphQLSchema,
164
+ schema?: SchemaProvider,
154
165
  plugins: Plugin[] = [],
155
166
  contextFactory?: (context: any) => any,
156
167
  options?: YogaInstanceOptions
@@ -188,16 +199,30 @@ export function createYogaInstance(
188
199
  yogaConfig.context = contextFactory;
189
200
  }
190
201
 
191
- if (schema) {
202
+ // Memoized static placeholder schema. Kept stable so Yoga's per-schema
203
+ // internal caches (parse/validate) are not thrashed when a factory falls
204
+ // back to it across requests.
205
+ let fallbackSchema: GraphQLSchema | undefined;
206
+ const getFallback = (): GraphQLSchema => {
207
+ if (!fallbackSchema) {
208
+ fallbackSchema = createSchema({
209
+ typeDefs: staticTypeDefs,
210
+ resolvers: staticResolvers,
211
+ });
212
+ }
213
+ return fallbackSchema;
214
+ };
215
+
216
+ if (typeof schema === "function") {
217
+ // Factory form: read per request so runtime swaps reflect live.
218
+ // Stable refs keep Yoga's caches warm; only a changed ref re-primes.
219
+ yogaConfig.schema = () => schema() ?? getFallback();
220
+ } else if (schema) {
192
221
  yogaConfig.schema = schema;
193
- return createYoga(yogaConfig);
194
222
  } else {
195
- yogaConfig.schema = createSchema({
196
- typeDefs: staticTypeDefs,
197
- resolvers: staticResolvers,
198
- });
199
- return createYoga(yogaConfig);
223
+ yogaConfig.schema = getFallback();
200
224
  }
225
+ return createYoga(yogaConfig);
201
226
  }
202
227
 
203
228
  export const Upload = z.union([z.literal("Upload"), z.any()]);
@@ -406,6 +406,12 @@ export function isSchemaInput(
406
406
 
407
407
  // ─── Validation ─────────────────────────────────────────────────────────────────
408
408
 
409
+ // Keyed by schema object identity. Safe only when field builders inside the
410
+ // schema are not mutated (via .required()/.optional()/.nullable()) after the
411
+ // first validateInput call for that schema. Module-level schema constants
412
+ // satisfy this constraint; inline schemas constructed per-call simply miss.
413
+ const _zodCache = new WeakMap<object, ZodType>();
414
+
409
415
  export class SchemaValidationError extends Error {
410
416
  readonly fieldErrors: Array<{ path: string; message: string }>;
411
417
 
@@ -424,11 +430,16 @@ export function validateInput<T extends Record<string, SchemaType>>(
424
430
  data: unknown,
425
431
  operationName: string = "input",
426
432
  ): InferInput<T> {
427
- const zodShape: Record<string, ZodType> = {};
428
- for (const [key, field] of Object.entries(schema)) {
429
- zodShape[key] = field.toZod();
433
+ let zodObject = _zodCache.get(schema);
434
+ if (!zodObject) {
435
+ const zodShape: Record<string, ZodType> = {};
436
+ for (const [key, field] of Object.entries(schema)) {
437
+ zodShape[key] = field.toZod();
438
+ }
439
+ zodObject = z.object(zodShape);
440
+ _zodCache.set(schema, zodObject);
430
441
  }
431
- const result = z.object(zodShape).safeParse(data);
442
+ const result = zodObject.safeParse(data);
432
443
  if (result.success) {
433
444
  return result.data as InferInput<T>;
434
445
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunsane",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "author": {
5
5
  "name": "yaaruu"
6
6
  },
@@ -862,7 +862,11 @@ export class ComponentInclusionNode extends QueryNode {
862
862
  } else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
863
863
  // IN/NOT IN comparison - handle arrays properly
864
864
  if (Array.isArray(filter.value) && filter.value.length > 0) {
865
- 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
+ }
866
870
  condition = `${jsonPath} ${filter.operator} (${placeholders})`;
867
871
  } else if (Array.isArray(filter.value) && filter.value.length === 0) {
868
872
  // Empty array: IN () is always false, NOT IN () is always true
package/query/OrNode.ts CHANGED
@@ -357,13 +357,7 @@ export class OrNode extends QueryNode {
357
357
  WHERE EXISTS (
358
358
  SELECT 1 FROM ${componentTableName} c
359
359
  WHERE c.entity_id = base.id
360
- AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL
361
- AND c.created_at = (
362
- SELECT MAX(c2.created_at)
363
- FROM ${componentTableName} c2
364
- WHERE c2.entity_id = c.entity_id
365
- AND c2.type_id = $${componentIdParamIndex} AND c2.deleted_at IS NULL
366
- )`;
360
+ AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL`;
367
361
  } else {
368
362
  // Use original query without base
369
363
  const componentTableName = this.getComponentTableName(componentId);
@@ -374,13 +368,7 @@ export class OrNode extends QueryNode {
374
368
  AND EXISTS (
375
369
  SELECT 1 FROM ${componentTableName} c
376
370
  WHERE c.entity_id = ec.entity_id
377
- AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL
378
- AND c.created_at = (
379
- SELECT MAX(c2.created_at)
380
- FROM ${componentTableName} c2
381
- WHERE c2.entity_id = c.entity_id
382
- AND c2.type_id = $${componentIdParamIndex} AND c2.deleted_at IS NULL
383
- )`;
371
+ AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL`;
384
372
  }
385
373
 
386
374
  context.params.push(componentId);
package/query/Query.ts CHANGED
@@ -21,6 +21,40 @@ import { getMembershipSource } from "./membershipSource";
21
21
  const DEFAULT_QUERY_LIMIT = parseInt(process.env.BUNSANE_DEFAULT_QUERY_LIMIT ?? '10000', 10);
22
22
  let warnedDefaultLimit = false;
23
23
 
24
+ // Gated once — dev keeps param diagnostics, production skips the loop entirely.
25
+ const DEBUG_PARAMS = process.env.NODE_ENV !== 'production';
26
+
27
+ // Shared across all TypedEntity instances — avoids one closure allocation per row.
28
+ // Must be called as a method (entity.getTyped(Ctor)) so `this` resolves correctly.
29
+ async function sharedGetTyped(
30
+ this: any,
31
+ ctor: any
32
+ ): Promise<any> {
33
+ const data = await this.get(ctor);
34
+ if (!data) {
35
+ throw new Error(`Component ${ctor.name} not found on entity ${this.id}, but it was expected from query`);
36
+ }
37
+ return data;
38
+ }
39
+
40
+ // Hoisted descriptor for _queriedComponents — non-enumerable by design (hidden from
41
+ // Object.keys / spreads). Descriptor is reused; only `value` is patched per row.
42
+ const queriedComponentsDescriptor: PropertyDescriptor = {
43
+ value: undefined as any,
44
+ writable: false,
45
+ enumerable: false,
46
+ configurable: false,
47
+ };
48
+
49
+ // getTyped stays non-enumerable like the original defineProperty version; the value
50
+ // never varies, so the descriptor is fully static.
51
+ const getTypedDescriptor: PropertyDescriptor = {
52
+ value: sharedGetTyped,
53
+ writable: false,
54
+ enumerable: false,
55
+ configurable: false,
56
+ };
57
+
24
58
  export type FilterOperator = "=" | ">" | "<" | ">=" | "<=" | "!=" | "LIKE" | "ILIKE" | "IN" | "NOT IN" | string;
25
59
 
26
60
  export const FilterOp = {
@@ -786,34 +820,16 @@ AND c.deleted_at IS NULL`;
786
820
  // Create typed entity wrapper
787
821
  const typedEntity = entity as TypedEntity<TComponents>;
788
822
 
789
- // Define componentData property
790
- Object.defineProperty(typedEntity, 'componentData', {
791
- value: componentData as ComponentRecord<TComponents>,
792
- writable: false,
793
- enumerable: true
794
- });
823
+ // Plain assignment — enumerable: true matches prior behavior; no defineProperty overhead.
824
+ (typedEntity as any).componentData = componentData as ComponentRecord<TComponents>;
795
825
 
796
- // Define _queriedComponents property for runtime reflection
797
- Object.defineProperty(typedEntity, '_queriedComponents', {
798
- value: componentCtors as unknown as TComponents,
799
- writable: false,
800
- enumerable: false
801
- });
826
+ // _queriedComponents must stay non-enumerable (hidden from Object.keys / spreads).
827
+ queriedComponentsDescriptor.value = componentCtors as unknown as TComponents;
828
+ Object.defineProperty(typedEntity, '_queriedComponents', queriedComponentsDescriptor);
829
+ queriedComponentsDescriptor.value = undefined; // don't retain ref
802
830
 
803
- // Define getTyped method
804
- Object.defineProperty(typedEntity, 'getTyped', {
805
- value: async function<T extends TComponents[number]>(
806
- ctor: T
807
- ): Promise<T extends ComponentConstructor<infer C> ? C extends BaseComponent ? ComponentDataType<C> : never : never> {
808
- const data = await entity.get(ctor as any);
809
- if (!data) {
810
- throw new Error(`Component ${(ctor as any).name} not found on entity ${entity.id}, but it was expected from query`);
811
- }
812
- return data as any;
813
- },
814
- writable: false,
815
- enumerable: false
816
- });
831
+ // Shared function — one allocation per module, not per row.
832
+ Object.defineProperty(typedEntity, 'getTyped', getTypedDescriptor);
817
833
 
818
834
  return typedEntity;
819
835
  }
@@ -878,13 +894,15 @@ AND c.deleted_at IS NULL`;
878
894
  // originate from saved entities. PG emits a clear error at
879
895
  // execution time if a UUID cast meets an empty string.
880
896
 
881
- // Validate parameters before execution
882
- for (let i = 0; i < result.params.length; i++) {
883
- if (result.params[i] === undefined || result.params[i] === null) {
884
- console.error(`❌ Query parameter $${i + 1} is undefined/null`);
885
- console.error(`SQL: ${result.sql}`);
886
- console.error(`All params: ${JSON.stringify(result.params)}`);
887
- throw new Error(`Query parameter $${i + 1} is undefined/null. SQL: ${result.sql.substring(0, 100)}...`);
897
+ // Validate parameters before execution (dev only — skipped in production)
898
+ if (DEBUG_PARAMS) {
899
+ for (let i = 0; i < result.params.length; i++) {
900
+ if (result.params[i] === undefined || result.params[i] === null) {
901
+ console.error(`❌ Query parameter $${i + 1} is undefined/null`);
902
+ console.error(`SQL: ${result.sql}`);
903
+ console.error(`All params: ${JSON.stringify(result.params)}`);
904
+ throw new Error(`Query parameter $${i + 1} is undefined/null. SQL: ${result.sql.substring(0, 100)}...`);
905
+ }
888
906
  }
889
907
  }
890
908
 
@@ -14,6 +14,7 @@ export class ServiceRegistry {
14
14
 
15
15
  private services: Map<string, BaseService> = new Map();
16
16
  private schema: GraphQLSchema | null = null;
17
+ private schemaVersion: number = 0;
17
18
  private phaseListener: ((event: PhaseChangeEvent) => void) | null = null;
18
19
 
19
20
 
@@ -29,13 +30,7 @@ export class ServiceRegistry {
29
30
  this.phaseListener = (event: PhaseChangeEvent) => {
30
31
  switch(event.detail) {
31
32
  case ApplicationPhase.SYSTEM_REGISTERING: {
32
- const servicesArray = Array.from(this.services.values());
33
-
34
- const result = generateGraphQLSchemaV2(servicesArray, {
35
- enableArchetypeOperations: false
36
- });
37
-
38
- this.schema = result.schema;
33
+ this.rebuildSchema();
39
34
  ApplicationLifecycle.setPhase(ApplicationPhase.SYSTEM_READY);
40
35
  break;
41
36
  };
@@ -74,6 +69,30 @@ export class ServiceRegistry {
74
69
  public getSchema(): GraphQLSchema | null {
75
70
  return this.schema;
76
71
  }
72
+
73
+ public getSchemaVersion(): number {
74
+ return this.schemaVersion;
75
+ }
76
+
77
+ /**
78
+ * Re-generate the GraphQL schema from the currently registered services
79
+ * and swap the stored reference. The live Yoga instance reads the schema
80
+ * via a factory (see graphqlSetup), so the next request observes the new
81
+ * schema without recreating Yoga or restarting the process.
82
+ *
83
+ * This is the Phase 0 "live re-weave" primitive: register a service (or
84
+ * mutate one's __graphqlOperations) then call this to reflect it.
85
+ * Returns the new schema (or null if generation produced none).
86
+ */
87
+ public rebuildSchema(): GraphQLSchema | null {
88
+ const servicesArray = Array.from(this.services.values());
89
+ const result = generateGraphQLSchemaV2(servicesArray, {
90
+ enableArchetypeOperations: false
91
+ });
92
+ this.schema = result.schema;
93
+ this.schemaVersion++;
94
+ return this.schema;
95
+ }
77
96
  }
78
97
 
79
98
  export default ServiceRegistry.instance;