bunsane 0.4.0 → 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.
@@ -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) => {
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
  }
@@ -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.0",
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