bunsane 0.3.0 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,58 @@ All notable changes to bunsane are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Added (HR-Screening ticket batch — BUNSANE-002..006)
8
+
9
+ - **`@ScheduledTask` allows entity-less time-based tasks.** Previously
10
+ `SchedulerManager.registerTask` rejected tasks without `query` or
11
+ `componentTarget`, contradicting documented "runs every hour" examples.
12
+ Time-based tasks now register successfully and invoke the handler with
13
+ no entity argument on each tick. Existing entity-targeted tasks
14
+ unchanged. Ticket BUNSANE-002.
15
+
16
+ - **`Entity.requireComponents(ctors)` hydrator.** Batched-load helper
17
+ that ensures the given component constructors are present on the
18
+ in-memory `componentList`. Required before `set` / `save` flows that
19
+ may trigger `@ComponentTargetHook` — hook matching reads
20
+ `componentList()` (in-memory only), so tag components must be loaded
21
+ first for the hook to fire. Ticket BUNSANE-003.
22
+
23
+ - **`ServiceRegistry` class named-exported.** `service/ServiceRegistry.ts`
24
+ now exports the class as named alongside the existing default-instance
25
+ export. Available via `service/index.ts` as `ServiceRegistryClass` for
26
+ type/subclass use; existing `ServiceRegistry` import remains the
27
+ singleton instance for backward compatibility. Ticket BUNSANE-004.
28
+
29
+ - **`CacheManager.invalidateEntities(ids: string[])`.** Batched helper
30
+ that invalidates both the entity-existence cache and all component
31
+ caches for a list of IDs. Call after a raw-SQL write (`db.unsafe`)
32
+ that bypasses `Entity.set` / `Entity.save`. Ticket BUNSANE-005.
33
+
34
+ - **`Entity.reload(opts?)` refresher.** Discards in-memory component
35
+ state and re-hydrates from the `components` table. Preserves entity
36
+ identity — callers holding a reference see fresh data on the same
37
+ instance. Use after raw-SQL writes or when a sibling `Entity`
38
+ instance with the same id mutated persisted data. Ticket BUNSANE-006.
39
+
40
+ - **Empty-string filter values supported.** `Query.filter(field, op, '')`
41
+ and the downstream SQL emit path (`ComponentInclusionNode`,
42
+ `PreparedStatementCache.execute`, `Query.doExec` / `doCount` /
43
+ `doAggregate` param validators) previously rejected empty /
44
+ whitespace-only values with "would cause PostgreSQL UUID parsing errors".
45
+ JSONB text extraction (`c.data->>'field'`) returns text, so `= ''` /
46
+ `!= ''` / `LIKE ''` are legitimate for text fields. The UUID-cast path
47
+ is gated by a value-side regex that an empty string cannot match, so
48
+ unsafe casts never fire. `findById('')` still throws — entity IDs
49
+ remain UUID-typed.
50
+
51
+ - **`Entity.drainPendingSideEffects(timeoutMs)`.** Drainable tracking
52
+ for post-commit work scheduled via `queueMicrotask` from `save()`
53
+ (cache invalidation + lifecycle hooks). Wired into `App.shutdown`
54
+ after `drainPendingCacheOps`. Tests under PGlite can call this in
55
+ `beforeAll` to settle prior-file background work before asserting.
56
+ Partial mitigation for BUNSANE-001 (Bun SQL / PGlite visibility race
57
+ — see `CLAUDE.md` PGlite section for full context).
58
+
7
59
  ### Fixed (PR E — outbox, cache, query hardening)
8
60
 
9
61
  - **OutboxWorker publishes to Redis concurrently and marks rows in bulk.**
package/CLAUDE.md CHANGED
@@ -159,6 +159,26 @@ The wrapper script:
159
159
  - `?|` and `?&` operators not supported (use `@>` / `<@` instead)
160
160
  - `CREATE INDEX CONCURRENTLY` not supported
161
161
  - Single connection only (`POSTGRES_MAX_CONNECTIONS=1`)
162
+ - **Known Bun SQL + PGlite visibility race**: under a single-connection
163
+ pool with background work from a prior test file, `await entity.save()`
164
+ may resolve ≥1ms before the `INSERT INTO entity_components` row is
165
+ visible to a subsequent `db.unsafe('SELECT ...')` on the same driver.
166
+ The per-component partition INSERT (e.g. `components_<compname>`) in
167
+ the same transaction is visible immediately; only the flat
168
+ `entity_components` row lags. This causes multi-component Query
169
+ INTERSECTs to return 0 rows when run immediately after save.
170
+
171
+ **Mitigation (not a full fix):**
172
+ 1. Shut down application-owned background workers (AI processors,
173
+ outbox workers, hook-driven save chains) in `afterAll` of each test
174
+ file — prevents cross-file bleed that amplifies the race.
175
+ 2. Call `await Entity.drainPendingSideEffects()` in `beforeAll` / test
176
+ `setup` to settle hook-triggered post-commit work (helpful but
177
+ insufficient on its own).
178
+ 3. Skip the affected test or poll with a short `setTimeout(1)`.
179
+
180
+ Root cause lives in the Bun SQL adapter's ACK handling, not bunsane.
181
+ File upstream with a minimal repro if you hit this.
162
182
 
163
183
  ## Directory Structure
164
184
 
package/core/App.ts CHANGED
@@ -1410,9 +1410,13 @@ export default class App {
1410
1410
 
1411
1411
  // 4. Drain any fire-and-forget cache ops triggered by entity.set /
1412
1412
  // entity.remove before we disconnect the cache (H-CACHE-1).
1413
+ // Also drain post-commit side effects (cache + hooks scheduled
1414
+ // via queueMicrotask from save()) so hook-triggered DB work
1415
+ // doesn't hit a closed pool.
1413
1416
  try {
1414
1417
  const { Entity } = await import('./Entity');
1415
1418
  await Entity.drainPendingCacheOps(Math.min(budgetRemaining(), 5_000));
1419
+ await Entity.drainPendingSideEffects(Math.min(budgetRemaining(), 5_000));
1416
1420
  } catch (error) {
1417
1421
  logger.warn({ scope: 'cache', component: 'App', msg: 'Entity cache op drain error', err: error });
1418
1422
  }
package/core/Entity.ts CHANGED
@@ -27,6 +27,17 @@ export class Entity implements IEntity {
27
27
  // (H-CACHE-1).
28
28
  private static pendingCacheOps: Set<Promise<void>> = new Set();
29
29
 
30
+ // Drainable set of post-commit side-effect Promises scheduled via
31
+ // queueMicrotask from save(). Includes cache invalidation + lifecycle
32
+ // hooks (EntityCreated / EntityUpdated). Hooks may transitively trigger
33
+ // more DB work (e.g., entity.save() from a handler), which is why this
34
+ // is tracked separately from pendingCacheOps. Tests running against
35
+ // PGlite's single-connection pool should drain this between test files
36
+ // to prevent background work from prior files queueing behind the
37
+ // current file's save and masking visibility of recently-committed
38
+ // rows. See BUNSANE-001.
39
+ private static pendingSideEffects: Set<Promise<void>> = new Set();
40
+
30
41
  /**
31
42
  * Await all pending background cache operations. Call during shutdown
32
43
  * after HTTP drain but before cache.disconnect so setImmediate'd cache
@@ -45,11 +56,36 @@ export class Entity implements IEntity {
45
56
  ]);
46
57
  }
47
58
 
59
+ /**
60
+ * Await all pending post-commit side effects (cache invalidation +
61
+ * lifecycle hooks scheduled via queueMicrotask from save()). Call from
62
+ * test setup/teardown hooks under PGlite to guarantee prior-file
63
+ * background work has settled before the next file's saves run. Bounded
64
+ * by `timeoutMs`. Safe to call repeatedly; no-op when the set is empty.
65
+ */
66
+ public static async drainPendingSideEffects(timeoutMs: number = 5_000): Promise<void> {
67
+ if (Entity.pendingSideEffects.size === 0) return;
68
+ const snapshot = [...Entity.pendingSideEffects];
69
+ const drainTimer = new Promise<'timeout'>((resolve) => {
70
+ const t = setTimeout(() => resolve('timeout'), timeoutMs);
71
+ t.unref?.();
72
+ });
73
+ await Promise.race([
74
+ Promise.allSettled(snapshot).then(() => 'drained' as const),
75
+ drainTimer,
76
+ ]);
77
+ }
78
+
48
79
  private static trackCacheOp(p: Promise<void>): void {
49
80
  Entity.pendingCacheOps.add(p);
50
81
  p.finally(() => Entity.pendingCacheOps.delete(p));
51
82
  }
52
83
 
84
+ private static trackSideEffect(p: Promise<void>): void {
85
+ Entity.pendingSideEffects.add(p);
86
+ p.finally(() => Entity.pendingSideEffects.delete(p));
87
+ }
88
+
53
89
  constructor(id?: string) {
54
90
  // Use || instead of ?? to also handle empty strings
55
91
  this.id = (id && id.trim() !== '') ? id : uuidv7();
@@ -348,6 +384,81 @@ export class Entity implements IEntity {
348
384
  return this._loadComponent(ctor, context);
349
385
  }
350
386
 
387
+ /**
388
+ * Discard in-memory component state and re-hydrate from the database.
389
+ * Preserves entity identity — callers holding a reference see fresh data
390
+ * on the same instance. Use after a raw-SQL write that bypassed
391
+ * `entity.set`/`entity.save`, or when a different `Entity` instance with
392
+ * the same id mutated persisted data.
393
+ *
394
+ * @param opts Optional transaction
395
+ */
396
+ public async reload(opts?: { trx?: SQL }): Promise<this> {
397
+ if (!this.id || this.id.trim() === '') {
398
+ return this;
399
+ }
400
+ this.components.clear();
401
+ this.removedComponents.clear();
402
+ this.savedRemovedComponents.clear();
403
+
404
+ const dbConn = opts?.trx ?? db;
405
+ const rows = await dbConn`
406
+ SELECT c.id, c.type_id, c.data
407
+ FROM components c
408
+ WHERE c.entity_id = ${this.id} AND c.deleted_at IS NULL
409
+ `;
410
+
411
+ const storage = getMetadataStorage();
412
+ for (const row of rows) {
413
+ const ctor = ComponentRegistry.getConstructor(row.type_id);
414
+ if (!ctor) continue;
415
+ const comp: any = new ctor();
416
+ const parsed = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
417
+ Object.assign(comp, parsed);
418
+ comp.id = row.id;
419
+ const props = storage.componentProperties.get(row.type_id);
420
+ if (props) {
421
+ for (const prop of props) {
422
+ if (prop.propertyType === Date && typeof comp[prop.propertyKey] === 'string') {
423
+ comp[prop.propertyKey] = new Date(comp[prop.propertyKey]);
424
+ }
425
+ }
426
+ }
427
+ comp.setPersisted(true);
428
+ comp.setDirty(false);
429
+ this.addComponent(comp);
430
+ }
431
+
432
+ this.setPersisted(true);
433
+ this.setDirty(false);
434
+ return this;
435
+ }
436
+
437
+ /**
438
+ * Ensure the given components are hydrated on this entity's in-memory
439
+ * componentList. No-op for components already loaded. Batched: one SQL
440
+ * call for all missing components.
441
+ *
442
+ * Required when a later `entity.set(...)` + `entity.save()` may trigger
443
+ * a `@ComponentTargetHook` whose `includeComponents` lists tag
444
+ * components. Hook matching reads `componentList()` (in-memory only), so
445
+ * tags must be loaded first for the hook to fire.
446
+ *
447
+ * @param ctors Component constructors to ensure are loaded
448
+ */
449
+ public async requireComponents(ctors: Array<new (...args: any[]) => BaseComponent>): Promise<void> {
450
+ if (ctors.length === 0) return;
451
+ const missing: string[] = [];
452
+ for (const ctor of ctors) {
453
+ const existing = Array.from(this.components.values()).find(c => c instanceof ctor);
454
+ if (existing) continue;
455
+ const typeId = new ctor().getTypeID();
456
+ missing.push(typeId);
457
+ }
458
+ if (missing.length === 0) return;
459
+ await Entity.LoadComponents([this], missing);
460
+ }
461
+
351
462
  private async _loadComponent<T extends BaseComponent>(ctor: new (...args: any[]) => T, context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL }): Promise<T | null> {
352
463
  const comp = Array.from(this.components.values()).find(comp => comp instanceof ctor) as T | undefined;
353
464
  if (typeof comp !== "undefined") {
@@ -465,14 +576,21 @@ export class Entity implements IEntity {
465
576
 
466
577
  // Post-commit side effects are fire-and-forget so Redis / hook
467
578
  // latency cannot consume the save budget or block the caller.
468
- queueMicrotask(() => this.runPostCommitSideEffects(
469
- wasNew,
470
- changedComponentTypeIds,
471
- removedComponentTypeIds,
472
- context,
473
- profile ? phases : undefined,
474
- profile ? phaseStart : undefined,
475
- ));
579
+ // Tracked in pendingSideEffects so tests/shutdown can drain
580
+ // background work before asserting or tearing down.
581
+ const sideEffectPromise = new Promise<void>((resolve) => {
582
+ queueMicrotask(() => {
583
+ this.runPostCommitSideEffects(
584
+ wasNew,
585
+ changedComponentTypeIds,
586
+ removedComponentTypeIds,
587
+ context,
588
+ profile ? phases : undefined,
589
+ profile ? phaseStart : undefined,
590
+ ).finally(() => resolve());
591
+ });
592
+ });
593
+ Entity.trackSideEffect(sideEffectPromise);
476
594
 
477
595
  return true;
478
596
  } catch (error) {
@@ -109,12 +109,10 @@ export class SchedulerManager {
109
109
  throw error;
110
110
  }
111
111
 
112
- // Validate query configuration
113
- if (!taskInfo.options?.query && !taskInfo.options?.componentTarget && !taskInfo.componentTarget) {
114
- const error = new Error(`Invalid task info: must provide either query function, componentTarget config, or legacy componentTarget`);
115
- loggerInstance.error(`Failed to register task: ${error.message}`);
116
- throw error;
117
- }
112
+ // Time-based tasks (no query, no componentTarget) are allowed — they
113
+ // invoke the handler with no entity arguments on each tick. Useful
114
+ // for external polling, stats aggregation, or ad-hoc queries inside
115
+ // the callback.
118
116
 
119
117
  if (!taskInfo.service) {
120
118
  const error = new Error(`Task ${taskInfo.id} has no service instance`);
@@ -395,7 +393,7 @@ export class SchedulerManager {
395
393
  try {
396
394
  // Create query based on targeting configuration
397
395
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
398
- let query: Query<any>;
396
+ let query: Query<any> | null = null;
399
397
 
400
398
  if (taskInfo.options?.query) {
401
399
  // Use custom query function (preferred approach)
@@ -407,16 +405,16 @@ export class SchedulerManager {
407
405
  } else if (taskInfo.componentTarget) {
408
406
  // Use legacy single component targeting (deprecated - use query instead)
409
407
  query = new Query().with(taskInfo.componentTarget);
410
- } else {
411
- throw new Error('No query function or component target specified');
412
408
  }
409
+ // else: time-based task — no entity selection. Handler invoked
410
+ // with no arguments on each tick.
413
411
 
414
412
  // Apply entity limit if specified (can be used with query function)
415
- if (taskInfo.options?.maxEntitiesPerExecution) {
413
+ if (query && taskInfo.options?.maxEntitiesPerExecution) {
416
414
  query.take(taskInfo.options.maxEntitiesPerExecution);
417
415
  }
418
416
 
419
- const entities = await query.exec();
417
+ const entities = query ? await query.exec() : [];
420
418
 
421
419
  // Execute the scheduled method with the entities array
422
420
  const method = taskInfo.service[taskInfo.methodName];
@@ -424,9 +422,11 @@ export class SchedulerManager {
424
422
  throw new Error(`Method ${taskInfo.methodName} not found on service`);
425
423
  }
426
424
 
427
- // Execute with timeout
425
+ // Execute with timeout. Time-based tasks receive no entity arg.
428
426
  const result = await this.executeWithTimeout(
429
- method.call(taskInfo.service, entities),
427
+ query
428
+ ? method.call(taskInfo.service, entities)
429
+ : method.call(taskInfo.service),
430
430
  timeout,
431
431
  taskInfo
432
432
  );
@@ -284,6 +284,24 @@ export class CacheManager {
284
284
  }
285
285
  }
286
286
 
287
+ /**
288
+ * Invalidate cached state (entity + all components) for a batch of
289
+ * entity IDs. Call this after a raw-SQL write (db.unsafe) that bypasses
290
+ * Entity.set/save, so downstream reads observe fresh data instead of
291
+ * stale L1/L2 cache entries.
292
+ */
293
+ public async invalidateEntities(entityIds: string[]): Promise<void> {
294
+ if (!this.config.enabled || entityIds.length === 0) {
295
+ return;
296
+ }
297
+ await Promise.all(
298
+ entityIds.flatMap(id => [
299
+ this.invalidateEntity(id),
300
+ this.invalidateAllEntityComponents(id),
301
+ ])
302
+ );
303
+ }
304
+
287
305
  /**
288
306
  * Invalidate all components for a specific entity from cache
289
307
  * Uses pattern matching to efficiently clear all component caches for an entity
@@ -55,8 +55,18 @@ export class BaseComponent {
55
55
  this.properties().forEach((prop: string) => {
56
56
  let value = (this as any)[prop];
57
57
  const propMeta = props?.find(p => p.propertyKey === prop);
58
- if (propMeta?.propertyType === Date && value instanceof Date) {
59
- value = value.toISOString();
58
+ if (value !== null && value !== undefined) {
59
+ if (propMeta?.propertyType === Date) {
60
+ if (!(value instanceof Date)) {
61
+ throw new Error(`Type mismatch for property '${prop}' on component '${this._comp_name}': expected Date, got ${typeof value}`);
62
+ }
63
+ if (Number.isNaN(value.getTime())) {
64
+ throw new Error(`Invalid Date for property '${prop}' on component '${this._comp_name}'`);
65
+ }
66
+ value = value.toISOString();
67
+ } else if (propMeta?.propertyType === Number && typeof value === 'number' && !Number.isFinite(value)) {
68
+ throw new Error(`Invalid number for property '${prop}' on component '${this._comp_name}': ${value}`);
69
+ }
60
70
  }
61
71
  data[prop] = value;
62
72
  });
@@ -111,19 +111,11 @@ export class PreparedStatementCache {
111
111
  * Execute a prepared statement with parameters
112
112
  */
113
113
  public async execute(statement: any, params: any[], db: any): Promise<any[]> {
114
- // Validate params to catch empty strings that would cause UUID parsing errors
115
- for (let i = 0; i < params.length; i++) {
116
- const param = params[i];
117
- if (param === '' || (typeof param === 'string' && param.trim() === '')) {
118
- logger.error(`[PreparedStatementCache] Empty string parameter at position ${i + 1}`);
119
- logger.error(`[PreparedStatementCache] SQL: ${statement.sql}`);
120
- logger.error(`[PreparedStatementCache] All params: ${JSON.stringify(params)}`);
121
- throw new Error(`PreparedStatementCache.execute: Parameter $${i + 1} is an empty string. SQL: ${statement.sql.substring(0, 100)}...`);
122
- }
123
- }
124
-
125
- // For Bun's SQL, we still use db.unsafe() but with the prepared statement concept
126
- // In a real implementation, this might use a prepared statement pool
114
+ // Empty-string params are legitimate for text-field filters
115
+ // (`c.data->>'field' = ''`). UUID-typed params never reach this
116
+ // point empty — callers (Query.findById etc.) guard at entry. PG
117
+ // emits a clear error at execution time if a UUID cast meets an
118
+ // empty string.
127
119
  return await db.unsafe(statement.sql, params);
128
120
  }
129
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunsane",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "author": {
5
5
  "name": "yaaruu"
6
6
  },
@@ -606,11 +606,11 @@ export class ComponentInclusionNode extends QueryNode {
606
606
  condition = result.sql;
607
607
  // Note: custom builder is responsible for adding parameters via context.addParam()
608
608
  } else {
609
- // Default filter logic
610
- // Validate filter value to prevent PostgreSQL UUID parsing errors
611
- if (filter.value === '' || (typeof filter.value === 'string' && filter.value.trim() === '')) {
612
- throw new Error(`Filter value for field "${filter.field}" is an empty string. This would cause PostgreSQL UUID parsing errors.`);
613
- }
609
+ // Default filter logic. Empty-string values are permitted
610
+ // here `c.data->>'field'` extracts text, so `=`/`!=`/
611
+ // `LIKE` against '' is legitimate. The UUID-cast path
612
+ // below is gated on a regex that empty string cannot
613
+ // match, so unsafe casts never fire.
614
614
 
615
615
  // Check if value looks like a UUID (case-insensitive, with or without hyphens)
616
616
  const valueStr = String(filter.value);
package/query/Query.ts CHANGED
@@ -430,14 +430,11 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
430
430
  console.log('---');
431
431
  }
432
432
 
433
- // Validate params before execution to catch UUID errors early
434
- for (let i = 0; i < result.params.length; i++) {
435
- const param = result.params[i];
436
- if (param === '' || (typeof param === 'string' && param.trim() === '')) {
437
- logger.error(`Empty string parameter detected at position ${i + 1} in count query`);
438
- throw new Error(`Query count parameter $${i + 1} is an empty string. This will cause PostgreSQL UUID parsing errors.`);
439
- }
440
- }
433
+ // Empty-string params are legitimate for text-field filters
434
+ // (`c.data->>'field' = ''`). UUID-typed params never reach this
435
+ // point empty — findById guards at entry; cursor/excluded IDs come
436
+ // from saved entities. PG emits a clear error if a UUID cast meets
437
+ // an empty string at execution time.
441
438
 
442
439
  // Safely extract count from result - handle undefined/null cases
443
440
  if (!countResult || countResult.length === 0 || countResult[0] === undefined) {
@@ -623,14 +620,8 @@ AND c.deleted_at IS NULL`;
623
620
  console.log('---');
624
621
  }
625
622
 
626
- // Validate params
627
- for (let i = 0; i < result.params.length; i++) {
628
- const param = result.params[i];
629
- if (param === '' || (typeof param === 'string' && param.trim() === '')) {
630
- logger.error(`Empty string parameter detected at position ${i + 1} in ${aggregateType} query`);
631
- throw new Error(`Query ${aggregateType} parameter $${i + 1} is an empty string.`);
632
- }
633
- }
623
+ // Empty-string params are legitimate for text-field filters; see
624
+ // comment above in doCount.
634
625
 
635
626
  // Extract result
636
627
  if (!aggregateResult || aggregateResult.length === 0 || aggregateResult[0] === undefined) {
@@ -794,14 +785,11 @@ AND c.deleted_at IS NULL`;
794
785
  console.log('---');
795
786
  }
796
787
 
797
- // Validate params before execution to catch UUID errors early
798
- for (let i = 0; i < result.params.length; i++) {
799
- const param = result.params[i];
800
- if (param === '' || (typeof param === 'string' && param.trim() === '')) {
801
- logger.error(`Empty string parameter detected at position ${i + 1}: SQL=${result.sql.substring(0, 200)}`);
802
- throw new Error(`Query parameter $${i + 1} is an empty string. This will cause PostgreSQL UUID parsing errors. SQL: ${result.sql.substring(0, 100)}...`);
803
- }
804
- }
788
+ // Empty-string params are legitimate for text-field filters
789
+ // (`c.data->>'field' = ''`). UUID-typed params never reach this
790
+ // point empty — findById guards at entry; cursor/excluded IDs
791
+ // originate from saved entities. PG emits a clear error at
792
+ // execution time if a UUID cast meets an empty string.
805
793
 
806
794
  // Validate parameters before execution
807
795
  for (let i = 0; i < result.params.length; i++) {
@@ -1021,10 +1009,6 @@ AND c.deleted_at IS NULL`;
1021
1009
  static filterOp = FilterOp;
1022
1010
 
1023
1011
  public static filter(field: string, operator: FilterOperator, value: any): QueryFilter {
1024
- // Validate value to catch empty strings early
1025
- if (value === '' || (typeof value === 'string' && value.trim() === '')) {
1026
- throw new Error(`Query.filter: Cannot create filter for field "${field}" with empty string value. This would cause PostgreSQL UUID parsing errors.`);
1027
- }
1028
1012
  return { field, operator, value };
1029
1013
  }
1030
1014
 
@@ -3,7 +3,13 @@ import ApplicationLifecycle, {ApplicationPhase, type PhaseChangeEvent} from "../
3
3
  import { generateGraphQLSchemaV2 } from "../gql";
4
4
  import { GraphQLSchema } from "graphql";
5
5
 
6
- class ServiceRegistry {
6
+ /**
7
+ * ServiceRegistry is a singleton. The default export and the re-exported
8
+ * named `ServiceRegistry` from `service/index.ts` both resolve to the
9
+ * singleton instance (for backward compatibility). When you need the class
10
+ * itself (for typing or subclassing), import `ServiceRegistryClass`.
11
+ */
12
+ export class ServiceRegistry {
7
13
  static #instance: ServiceRegistry;
8
14
 
9
15
  private services: Map<string, BaseService> = new Map();
package/service/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import BaseService from "./Service";
2
- import ServiceRegistry from "./ServiceRegistry";
2
+ import ServiceRegistry from "./ServiceRegistry";
3
+ import { ServiceRegistry as ServiceRegistryClass } from "./ServiceRegistry";
3
4
  import { httpEndpoint } from "../rest";
4
5
 
5
6
  export {
6
7
  BaseService,
7
- ServiceRegistry
8
+ ServiceRegistry,
9
+ ServiceRegistryClass,
8
10
  }
9
11
 
10
12
  // Shorthand decorators for HTTP methods
@@ -141,6 +141,26 @@ describe('CacheManager', () => {
141
141
  expect(result).toBeNull();
142
142
  });
143
143
 
144
+ test('invalidateEntities clears entity + all component caches for a batch', async () => {
145
+ const provider = cacheManager.getProvider();
146
+ // Two entities, each with cached entity entry + one component
147
+ await provider.set('entity:e1', 'e1', 3600000);
148
+ await provider.set('entity:e2', 'e2', 3600000);
149
+ await provider.set('component:e1:t1', { data: 'a' }, 3600000);
150
+ await provider.set('component:e2:t1', { data: 'b' }, 3600000);
151
+
152
+ await cacheManager.invalidateEntities(['e1', 'e2']);
153
+
154
+ expect(await cacheManager.getEntity('e1')).toBeNull();
155
+ expect(await cacheManager.getEntity('e2')).toBeNull();
156
+ expect(await cacheManager.getComponentsByEntity('e1', 't1')).toBeNull();
157
+ expect(await cacheManager.getComponentsByEntity('e2', 't1')).toBeNull();
158
+ });
159
+
160
+ test('invalidateEntities is a noop for empty list', async () => {
161
+ await expect(cacheManager.invalidateEntities([])).resolves.toBeUndefined();
162
+ });
163
+
144
164
  test('getEntities returns null for missing entities', async () => {
145
165
  const results = await cacheManager.getEntities(['id1', 'id2', 'id3']);
146
166
  expect(results.length).toBe(3);
@@ -187,6 +187,79 @@ describe('Entity Component Management', () => {
187
187
 
188
188
  expect(data?.createdAt).toBe('2024-01-15T10:30:00.000Z');
189
189
  });
190
+
191
+ test('serializableData throws descriptive error on invalid Date', () => {
192
+ const entity = new Entity();
193
+ entity.add(TestOrder, {
194
+ orderNumber: 'ORD-002',
195
+ total: 50,
196
+ status: 'pending',
197
+ createdAt: new Date('not-a-date')
198
+ });
199
+
200
+ const component = entity.getInMemory(TestOrder);
201
+ expect(() => component?.serializableData()).toThrow(
202
+ /Invalid Date for property 'createdAt' on component 'TestOrder'/
203
+ );
204
+ });
205
+
206
+ test('serializableData throws on Date type mismatch', () => {
207
+ const entity = new Entity();
208
+ entity.add(TestOrder, {
209
+ orderNumber: 'ORD-003',
210
+ total: 50,
211
+ status: 'pending',
212
+ createdAt: '2024-01-15' as any
213
+ });
214
+
215
+ const component = entity.getInMemory(TestOrder);
216
+ expect(() => component?.serializableData()).toThrow(
217
+ /Type mismatch for property 'createdAt' on component 'TestOrder': expected Date, got string/
218
+ );
219
+ });
220
+
221
+ test('serializableData throws on NaN number', () => {
222
+ const entity = new Entity();
223
+ entity.add(TestOrder, {
224
+ orderNumber: 'ORD-004',
225
+ total: NaN,
226
+ status: 'pending',
227
+ createdAt: new Date()
228
+ });
229
+
230
+ const component = entity.getInMemory(TestOrder);
231
+ expect(() => component?.serializableData()).toThrow(
232
+ /Invalid number for property 'total' on component 'TestOrder'/
233
+ );
234
+ });
235
+
236
+ test('serializableData throws on Infinity number', () => {
237
+ const entity = new Entity();
238
+ entity.add(TestOrder, {
239
+ orderNumber: 'ORD-005',
240
+ total: Infinity,
241
+ status: 'pending',
242
+ createdAt: new Date()
243
+ });
244
+
245
+ const component = entity.getInMemory(TestOrder);
246
+ expect(() => component?.serializableData()).toThrow(
247
+ /Invalid number for property 'total' on component 'TestOrder'/
248
+ );
249
+ });
250
+
251
+ test('serializableData allows null/undefined for nullable Date/Number', () => {
252
+ const entity = new Entity();
253
+ entity.add(TestOrder, {
254
+ orderNumber: 'ORD-006',
255
+ total: 0,
256
+ status: 'pending',
257
+ createdAt: null as any
258
+ });
259
+
260
+ const component = entity.getInMemory(TestOrder);
261
+ expect(() => component?.serializableData()).not.toThrow();
262
+ });
190
263
  });
191
264
 
192
265
  describe('component state', () => {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * BUNSANE-001 defensive harness: verify Entity.drainPendingSideEffects()
3
+ * awaits post-commit work scheduled via queueMicrotask from save(), so
4
+ * tests under PGlite can settle prior-file background work before
5
+ * asserting against freshly-committed state.
6
+ */
7
+ import { describe, test, expect, beforeAll } from 'bun:test';
8
+ import { Entity } from '../../../core/Entity';
9
+ import { BaseComponent } from '../../../core/components/BaseComponent';
10
+ import { Component, CompData } from '../../../core/components/Decorators';
11
+ import { ensureComponentsRegistered } from '../../utils';
12
+
13
+ @Component
14
+ class DrainMarker extends BaseComponent {
15
+ @CompData()
16
+ value: string = '';
17
+ }
18
+
19
+ describe('Entity.drainPendingSideEffects', () => {
20
+ beforeAll(async () => {
21
+ await ensureComponentsRegistered(DrainMarker);
22
+ });
23
+
24
+ test('no-op when nothing is pending', async () => {
25
+ await expect(Entity.drainPendingSideEffects(100)).resolves.toBeUndefined();
26
+ });
27
+
28
+ test('awaits post-commit side effects scheduled by save()', async () => {
29
+ const saved = Entity.Create();
30
+ saved.add(DrainMarker, { value: 'pending' });
31
+ await saved.save();
32
+
33
+ // runPostCommitSideEffects is queued as a microtask. drain() must
34
+ // settle it before returning.
35
+ await Entity.drainPendingSideEffects(2_000);
36
+
37
+ // A second drain is a no-op.
38
+ await Entity.drainPendingSideEffects(100);
39
+ });
40
+
41
+ test('bounded by timeout, returns even if drain exceeds it', async () => {
42
+ const saved = Entity.Create();
43
+ saved.add(DrainMarker, { value: 'bounded' });
44
+ await saved.save();
45
+
46
+ const start = Date.now();
47
+ await Entity.drainPendingSideEffects(1);
48
+ const elapsed = Date.now() - start;
49
+ expect(elapsed).toBeLessThan(500);
50
+ });
51
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Unit tests for Entity.reload (BUNSANE-006).
3
+ * Ensures in-memory component state is discarded and re-hydrated from DB.
4
+ */
5
+ import { describe, test, expect, beforeAll } from 'bun:test';
6
+ import { Entity } from '../../../core/Entity';
7
+ import { BaseComponent } from '../../../core/components/BaseComponent';
8
+ import { Component, CompData } from '../../../core/components/Decorators';
9
+ import { ensureComponentsRegistered } from '../../utils';
10
+ import db from '../../../database';
11
+
12
+ @Component
13
+ class ReloadStatus extends BaseComponent {
14
+ @CompData()
15
+ value: string = '';
16
+ }
17
+
18
+ describe('Entity.reload', () => {
19
+ beforeAll(async () => {
20
+ await ensureComponentsRegistered(ReloadStatus);
21
+ });
22
+
23
+ test('no-op on entity without a valid id', async () => {
24
+ const entity = new Entity('');
25
+ await expect(entity.reload()).resolves.toBe(entity);
26
+ });
27
+
28
+ test('refreshes in-memory data after raw-SQL write', async () => {
29
+ const saved = Entity.Create();
30
+ saved.add(ReloadStatus, { value: 'before' });
31
+ await saved.save();
32
+
33
+ const typeId = new ReloadStatus().getTypeID();
34
+
35
+ // Write new value via raw SQL — bypasses entity cache invalidation.
36
+ await db.unsafe(
37
+ `UPDATE components SET data = data || '{"value":"after"}'::jsonb
38
+ WHERE entity_id = $1 AND type_id = $2`,
39
+ [saved.id, typeId]
40
+ );
41
+
42
+ // In-memory copy still holds stale value.
43
+ expect(saved.getInMemory(ReloadStatus)?.value).toBe('before');
44
+
45
+ const returned = await saved.reload();
46
+ expect(returned).toBe(saved);
47
+ expect(saved.getInMemory(ReloadStatus)?.value).toBe('after');
48
+ });
49
+
50
+ test('hydrates a bare Entity instance from DB', async () => {
51
+ const saved = Entity.Create();
52
+ saved.add(ReloadStatus, { value: 'hydrated' });
53
+ await saved.save();
54
+
55
+ const bare = new Entity(saved.id);
56
+ expect(bare.componentList().length).toBe(0);
57
+
58
+ await bare.reload();
59
+
60
+ expect(bare.hasInMemory(ReloadStatus)).toBe(true);
61
+ expect(bare.getInMemory(ReloadStatus)?.value).toBe('hydrated');
62
+ });
63
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Unit tests for Entity.requireComponents (BUNSANE-003).
3
+ * Ensures ComponentTargetHook includeComponents matching sees tag
4
+ * components that weren't eagerly loaded.
5
+ */
6
+ import { describe, test, expect, beforeAll } from 'bun:test';
7
+ import { Entity } from '../../../core/Entity';
8
+ import { BaseComponent } from '../../../core/components/BaseComponent';
9
+ import { Component, CompData } from '../../../core/components/Decorators';
10
+ import { TestUser } from '../../fixtures/components';
11
+ import { ensureComponentsRegistered } from '../../utils';
12
+
13
+ @Component
14
+ class ReqTag extends BaseComponent {}
15
+
16
+ @Component
17
+ class ReqData extends BaseComponent {
18
+ @CompData()
19
+ value: string = '';
20
+ }
21
+
22
+ describe('Entity.requireComponents', () => {
23
+ beforeAll(async () => {
24
+ await ensureComponentsRegistered(TestUser, ReqTag, ReqData);
25
+ });
26
+
27
+ test('no-op for empty list', async () => {
28
+ const entity = Entity.Create();
29
+ await expect(entity.requireComponents([])).resolves.toBeUndefined();
30
+ expect(entity.componentList().length).toBe(0);
31
+ });
32
+
33
+ test('does nothing when components already in memory', async () => {
34
+ const entity = Entity.Create();
35
+ entity.add(ReqTag);
36
+ const before = entity.componentList().length;
37
+ await entity.requireComponents([ReqTag]);
38
+ expect(entity.componentList().length).toBe(before);
39
+ });
40
+
41
+ test('hydrates missing components from DB after save', async () => {
42
+ const saved = Entity.Create();
43
+ saved.add(ReqTag);
44
+ saved.add(ReqData, { value: 'hello' });
45
+ await saved.save();
46
+
47
+ const loaded = new Entity(saved.id);
48
+ expect(loaded.componentList().length).toBe(0);
49
+
50
+ await loaded.requireComponents([ReqTag, ReqData]);
51
+
52
+ expect(loaded.hasInMemory(ReqTag)).toBe(true);
53
+ expect(loaded.hasInMemory(ReqData)).toBe(true);
54
+ expect(loaded.getInMemory(ReqData)?.value).toBe('hello');
55
+ });
56
+
57
+ test('only fetches missing components, not already-loaded ones', async () => {
58
+ const saved = Entity.Create();
59
+ saved.add(ReqTag);
60
+ saved.add(ReqData, { value: 'mix' });
61
+ await saved.save();
62
+
63
+ const loaded = new Entity(saved.id);
64
+ await loaded.requireComponents([ReqData]);
65
+ expect(loaded.hasInMemory(ReqData)).toBe(true);
66
+ expect(loaded.hasInMemory(ReqTag)).toBe(false);
67
+
68
+ await loaded.requireComponents([ReqTag, ReqData]);
69
+ expect(loaded.hasInMemory(ReqTag)).toBe(true);
70
+ expect(loaded.getInMemory(ReqData)?.value).toBe('mix');
71
+ });
72
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Empty-string filter support. JSONB text extraction (c.data->>'field')
3
+ * returns text, so `= ''` / `!= ''` / LIKE against empty string are
4
+ * legitimate. UUID-cast path is gated on a regex that empty cannot match.
5
+ */
6
+ import { describe, test, expect, beforeAll } from 'bun:test';
7
+ import { Entity } from '../../../core/Entity';
8
+ import { BaseComponent } from '../../../core/components/BaseComponent';
9
+ import { Component, CompData } from '../../../core/components/Decorators';
10
+ import { Query, FilterOp } from '../../../query/Query';
11
+ import { ensureComponentsRegistered } from '../../utils';
12
+
13
+ @Component
14
+ class EmptyableNote extends BaseComponent {
15
+ @CompData()
16
+ value: string = '';
17
+ }
18
+
19
+ describe('Query empty-string filter', () => {
20
+ beforeAll(async () => {
21
+ await ensureComponentsRegistered(EmptyableNote);
22
+ });
23
+
24
+ test('Query.filter accepts empty-string value without throwing', () => {
25
+ expect(() => Query.filter('value', FilterOp.EQ, '')).not.toThrow();
26
+ const f = Query.filter('value', FilterOp.EQ, '');
27
+ expect(f.value).toBe('');
28
+ });
29
+
30
+ test('Query.filter accepts whitespace-only value without throwing', () => {
31
+ expect(() => Query.filter('value', FilterOp.EQ, ' ')).not.toThrow();
32
+ });
33
+
34
+ test('.with(C, filter EQ "") executes and returns matching rows', async () => {
35
+ const withEmpty = Entity.Create();
36
+ withEmpty.add(EmptyableNote, { value: '' });
37
+ await withEmpty.save();
38
+
39
+ const withData = Entity.Create();
40
+ withData.add(EmptyableNote, { value: 'not empty' });
41
+ await withData.save();
42
+
43
+ const rows = await new Query()
44
+ .with(EmptyableNote, Query.filters(Query.filter('value', FilterOp.EQ, '')))
45
+ .exec();
46
+
47
+ const ids = rows.map(e => e.id);
48
+ expect(ids).toContain(withEmpty.id);
49
+ expect(ids).not.toContain(withData.id);
50
+ });
51
+
52
+ test('.with(C, filter != "") excludes rows with empty value', async () => {
53
+ const withEmpty = Entity.Create();
54
+ withEmpty.add(EmptyableNote, { value: '' });
55
+ await withEmpty.save();
56
+
57
+ const withData = Entity.Create();
58
+ withData.add(EmptyableNote, { value: 'populated' });
59
+ await withData.save();
60
+
61
+ const rows = await new Query()
62
+ .with(EmptyableNote, Query.filters(Query.filter('value', FilterOp.NEQ, '')))
63
+ .exec();
64
+
65
+ const ids = rows.map(e => e.id);
66
+ expect(ids).toContain(withData.id);
67
+ expect(ids).not.toContain(withEmpty.id);
68
+ });
69
+ });
@@ -192,12 +192,14 @@ describe('Query', () => {
192
192
  expect(filter.value).toBe('John');
193
193
  });
194
194
 
195
- test('throws for empty string value', () => {
196
- expect(() => Query.filter('name', FilterOp.EQ, '')).toThrow();
195
+ test('accepts empty string value', () => {
196
+ const filter = Query.filter('name', FilterOp.EQ, '');
197
+ expect(filter.value).toBe('');
197
198
  });
198
199
 
199
- test('throws for whitespace value', () => {
200
- expect(() => Query.filter('name', FilterOp.EQ, ' ')).toThrow();
200
+ test('accepts whitespace value', () => {
201
+ const filter = Query.filter('name', FilterOp.EQ, ' ');
202
+ expect(filter.value).toBe(' ');
201
203
  });
202
204
  });
203
205
 
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Unit tests for SchedulerManager time-based (entity-less) tasks.
3
+ * Covers BUNSANE-002: @ScheduledTask without query/componentTarget.
4
+ */
5
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
6
+ import { SchedulerManager } from '../../../core/SchedulerManager';
7
+ import { ScheduleInterval } from '../../../types/scheduler.types';
8
+
9
+ describe('SchedulerManager time-based tasks', () => {
10
+ let scheduler: SchedulerManager;
11
+
12
+ beforeEach(() => {
13
+ scheduler = SchedulerManager.getInstance();
14
+ scheduler.updateConfig({
15
+ enabled: true,
16
+ enableLogging: false,
17
+ runOnStart: false,
18
+ distributedLocking: false,
19
+ maxConcurrentTasks: 5,
20
+ defaultTimeout: 5000,
21
+ });
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await scheduler.stop().catch(() => {});
26
+ });
27
+
28
+ test('registers task with no query / no componentTarget', () => {
29
+ let called = 0;
30
+ const service = {
31
+ tick: async () => {
32
+ called++;
33
+ },
34
+ };
35
+
36
+ expect(() =>
37
+ scheduler.registerTask({
38
+ id: 'test.timebased.register',
39
+ name: 'timebased-register',
40
+ interval: ScheduleInterval.MINUTE,
41
+ options: {},
42
+ service,
43
+ methodName: 'tick',
44
+ nextExecution: new Date(),
45
+ executionCount: 0,
46
+ isRunning: false,
47
+ enabled: true,
48
+ })
49
+ ).not.toThrow();
50
+ });
51
+
52
+ test('executes handler with no entity argument', async () => {
53
+ const receivedArgsBox: { args: unknown[] | null } = { args: null };
54
+ const service = {
55
+ tick: async (...args: unknown[]) => {
56
+ receivedArgsBox.args = args;
57
+ },
58
+ };
59
+
60
+ scheduler.registerTask({
61
+ id: 'test.timebased.exec',
62
+ name: 'timebased-exec',
63
+ interval: ScheduleInterval.MINUTE,
64
+ options: {},
65
+ service,
66
+ methodName: 'tick',
67
+ nextExecution: new Date(),
68
+ executionCount: 0,
69
+ isRunning: false,
70
+ enabled: true,
71
+ });
72
+
73
+ const ok = await scheduler.executeTaskNow('test.timebased.exec');
74
+ expect(ok).toBe(true);
75
+ expect(receivedArgsBox.args).toEqual([]);
76
+ });
77
+
78
+ test('rejects task still missing required fields', () => {
79
+ const service = { tick: async () => {} };
80
+ expect(() =>
81
+ scheduler.registerTask({
82
+ // missing id
83
+ name: 'bad',
84
+ interval: ScheduleInterval.MINUTE,
85
+ options: {},
86
+ service,
87
+ methodName: 'tick',
88
+ nextExecution: new Date(),
89
+ executionCount: 0,
90
+ isRunning: false,
91
+ enabled: true,
92
+ } as any)
93
+ ).toThrow(/missing required fields/);
94
+ });
95
+ });