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 +52 -0
- package/CLAUDE.md +20 -0
- package/core/App.ts +4 -0
- package/core/Entity.ts +126 -8
- package/core/SchedulerManager.ts +13 -13
- package/core/cache/CacheManager.ts +18 -0
- package/core/components/BaseComponent.ts +12 -2
- package/database/PreparedStatementCache.ts +5 -13
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +5 -5
- package/query/Query.ts +12 -28
- package/service/ServiceRegistry.ts +7 -1
- package/service/index.ts +4 -2
- package/tests/unit/cache/CacheManager.test.ts +20 -0
- package/tests/unit/entity/Entity.components.test.ts +73 -0
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
- package/tests/unit/entity/Entity.reload.test.ts +63 -0
- package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
- package/tests/unit/query/Query.emptyString.test.ts +69 -0
- package/tests/unit/query/Query.test.ts +6 -4
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +95 -0
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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) {
|
package/core/SchedulerManager.ts
CHANGED
|
@@ -109,12 +109,10 @@ export class SchedulerManager {
|
|
|
109
109
|
throw error;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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 (
|
|
59
|
-
|
|
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
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
@@ -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
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
//
|
|
627
|
-
|
|
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
|
-
//
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
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
|
|
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('
|
|
196
|
-
|
|
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('
|
|
200
|
-
|
|
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
|
+
});
|