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.
- package/CHANGELOG.md +26 -0
- package/core/Entity.ts +8 -0
- package/core/cache/RedisCache.ts +5 -4
- package/core/components/BaseComponent.ts +9 -2
- package/core/entity/cacheStrategies.ts +3 -3
- package/core/entity/componentAccess.ts +24 -5
- package/core/entity/getCacheManager.ts +10 -0
- package/core/entity/saveEntity.ts +17 -19
- package/core/health.ts +93 -4
- package/core/remote/StreamConsumer.ts +535 -535
- package/core/validateEnv.ts +10 -0
- package/database/index.ts +14 -0
- package/database/sqlHelpers.ts +3 -1
- package/gql/schema/index.ts +15 -4
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +5 -1
- package/query/OrNode.ts +2 -14
- package/query/Query.ts +51 -33
package/core/validateEnv.ts
CHANGED
|
@@ -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;
|
package/database/sqlHelpers.ts
CHANGED
|
@@ -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
|
|
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/schema/index.ts
CHANGED
|
@@ -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
|
-
|
|
428
|
-
|
|
429
|
-
zodShape
|
|
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 =
|
|
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
|
@@ -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
|
-
|
|
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
|
-
//
|
|
790
|
-
|
|
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
|
-
//
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
|