bunsane 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/core/App.ts +28 -1
- package/core/ArcheType.ts +47 -2
- package/core/app/graphqlSetup.ts +10 -16
- package/core/cache/index.ts +10 -1
- package/core/cache/txInvalidation.ts +183 -0
- package/core/components/BaseComponent.ts +5 -0
- package/core/entity/saveEntity.ts +15 -9
- package/database/DatabaseHelper.ts +26 -2
- package/gql/index.ts +33 -8
- package/package.json +1 -1
- package/service/ServiceRegistry.ts +26 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to bunsane are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.5.1 — 2026-06-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Transaction-aware cache invalidation** — component writes made via
|
|
10
|
+
`comp.save(trx, id)` inside the new `transaction()` wrapper now bust the
|
|
11
|
+
component cache on commit, using the same
|
|
12
|
+
`CacheManager.invalidateEntityComponents` path (L1 + L2 + cross-instance
|
|
13
|
+
pub/sub) that `Entity.save` uses. Touched `(entityId, typeId)` pairs are
|
|
14
|
+
tracked automatically (keyed by the transaction handle), then flushed after
|
|
15
|
+
the transaction commits. The `tx` context also exposes `tx.markDirty(entityId,
|
|
16
|
+
component)` for components not saved directly and `tx.onCommit(cb)` for
|
|
17
|
+
post-commit side effects. Exported from `bunsane/core/cache` as `transaction`,
|
|
18
|
+
`txMarkDirty`, `txOnCommit`. No behavior change for `comp.save` outside the
|
|
19
|
+
wrapper — tracking is a no-op there.
|
|
20
|
+
- **`ArcheTypeQuery.select(...fields)`** — opt-in projection for archetype
|
|
21
|
+
queries. Loads data only for the selected component fields instead of every
|
|
22
|
+
component in the archetype, cutting JSONB wire + parse cost for wide
|
|
23
|
+
archetypes read with narrow selections. Membership filtering is unaffected
|
|
24
|
+
(matching still requires all components); unselected fields remain
|
|
25
|
+
lazy-loadable. Backward-compatible — without `select()`, all components load as
|
|
26
|
+
before.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **RedisCache test connects on `127.0.0.1`** instead of `localhost`, which
|
|
31
|
+
resolves to IPv6 `::1` first on Windows and times out against an IPv4-only
|
|
32
|
+
Redis. Test-only change.
|
|
33
|
+
|
|
5
34
|
## 0.5.0 — 2026-06-15
|
|
6
35
|
|
|
7
36
|
### Added
|
package/core/App.ts
CHANGED
|
@@ -11,7 +11,20 @@ import {
|
|
|
11
11
|
} from "../database/DatabaseHelper";
|
|
12
12
|
import { ComponentRegistry } from "./components";
|
|
13
13
|
import { logger as MainLogger } from "./Logger";
|
|
14
|
+
import { readFileSync } from "fs";
|
|
14
15
|
const logger = MainLogger.child({ scope: "App" });
|
|
16
|
+
|
|
17
|
+
// BunSane framework version, read from the package's own package.json at module
|
|
18
|
+
// load. Resolved relative to this module file so it works regardless of cwd or
|
|
19
|
+
// how the consumer installs the package.
|
|
20
|
+
let BUNSANE_VERSION = "unknown";
|
|
21
|
+
try {
|
|
22
|
+
BUNSANE_VERSION = JSON.parse(
|
|
23
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
24
|
+
).version;
|
|
25
|
+
} catch {
|
|
26
|
+
// version stays "unknown" if package.json can't be read
|
|
27
|
+
}
|
|
15
28
|
import ServiceRegistry from "../service/ServiceRegistry";
|
|
16
29
|
import { type Plugin, createPubSub } from "graphql-yoga";
|
|
17
30
|
import * as path from "path";
|
|
@@ -351,6 +364,20 @@ export default class App {
|
|
|
351
364
|
this.maxRequestBodySize = bytes;
|
|
352
365
|
}
|
|
353
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Re-weave the GraphQL schema from the currently registered services and
|
|
369
|
+
* swap it into the live Yoga instance — no restart, no Yoga recreation.
|
|
370
|
+
* The next request observes the new schema (Yoga reads it via a factory).
|
|
371
|
+
*
|
|
372
|
+
* Phase 0 primitive for runtime schema mutation: register/modify a service
|
|
373
|
+
* (or its @GraphQLOperation metadata), then call this to reflect it live.
|
|
374
|
+
* Returns the new schema version number (monotonic, starts at 1).
|
|
375
|
+
*/
|
|
376
|
+
public rebuildGraphQLSchema(): number {
|
|
377
|
+
ServiceRegistry.rebuildSchema();
|
|
378
|
+
return ServiceRegistry.getSchemaVersion();
|
|
379
|
+
}
|
|
380
|
+
|
|
354
381
|
private async warmUpPreparedStatementCache(): Promise<void> {
|
|
355
382
|
return warmUpPreparedStatementCacheFn(this);
|
|
356
383
|
}
|
|
@@ -396,7 +423,7 @@ export default class App {
|
|
|
396
423
|
`Server is running on ${new URL(
|
|
397
424
|
this.yoga?.graphqlEndpoint || "/graphql",
|
|
398
425
|
`http://${this.server.hostname}:${this.server.port}`
|
|
399
|
-
)}`
|
|
426
|
+
)} (BunSane v${BUNSANE_VERSION})`
|
|
400
427
|
);
|
|
401
428
|
|
|
402
429
|
// Signal handlers now registered in init() via registerProcessHandlers()
|
package/core/ArcheType.ts
CHANGED
|
@@ -160,6 +160,7 @@ export class ArcheTypeQuery<T extends BaseArcheType> {
|
|
|
160
160
|
private innerQuery: Query<any>;
|
|
161
161
|
private archetypeInstance: T;
|
|
162
162
|
private archetypeCtor: new () => T;
|
|
163
|
+
private selectedFields: string[] | null = null;
|
|
163
164
|
|
|
164
165
|
constructor(archetypeCtor: new () => T) {
|
|
165
166
|
this.archetypeCtor = archetypeCtor;
|
|
@@ -241,6 +242,50 @@ export class ArcheTypeQuery<T extends BaseArcheType> {
|
|
|
241
242
|
return this;
|
|
242
243
|
}
|
|
243
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Project: load data only for the given archetype fields (components).
|
|
247
|
+
*
|
|
248
|
+
* Membership filtering is unaffected — matching the archetype still requires
|
|
249
|
+
* all its components. This only limits which component DATA is fetched, so a
|
|
250
|
+
* wide archetype read with a narrow selection skips the JSONB wire+parse cost
|
|
251
|
+
* of unselected components. Unselected fields are absent from results; they
|
|
252
|
+
* remain lazy-loadable later via entity.get() under a request scope.
|
|
253
|
+
*
|
|
254
|
+
* Backward-compatible: without select(), exec()/first() load all components.
|
|
255
|
+
*
|
|
256
|
+
* @example
|
|
257
|
+
* ```typescript
|
|
258
|
+
* const players = await Player.query().select('position', 'health').exec();
|
|
259
|
+
* // only position + health component data loaded; velocity etc. skipped
|
|
260
|
+
* ```
|
|
261
|
+
*/
|
|
262
|
+
public select<K extends keyof ArcheTypeOwnProperties<T>>(...fields: K[]): this {
|
|
263
|
+
this.selectedFields = fields.map((f) => {
|
|
264
|
+
const name = String(f);
|
|
265
|
+
if (!this.archetypeInstance.componentMap[name]) {
|
|
266
|
+
throw new Error(`Field '${name}' is not a component field on this archetype`);
|
|
267
|
+
}
|
|
268
|
+
return name;
|
|
269
|
+
});
|
|
270
|
+
return this;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private selectedComponentCtors(): Array<new () => BaseComponent> {
|
|
274
|
+
return (this.selectedFields ?? []).map(
|
|
275
|
+
(f) => this.archetypeInstance.componentMap[f] as unknown as new () => BaseComponent
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Apply the load strategy: projected (eager-load selected components) when
|
|
281
|
+
* select() was used, otherwise populate() all archetype components.
|
|
282
|
+
*/
|
|
283
|
+
private withLoadStrategy(): Query<any> {
|
|
284
|
+
return this.selectedFields
|
|
285
|
+
? this.innerQuery.eagerLoadComponents(this.selectedComponentCtors())
|
|
286
|
+
: this.innerQuery.populate();
|
|
287
|
+
}
|
|
288
|
+
|
|
244
289
|
/**
|
|
245
290
|
* Enable populate mode to load all component data
|
|
246
291
|
*/
|
|
@@ -261,7 +306,7 @@ export class ArcheTypeQuery<T extends BaseArcheType> {
|
|
|
261
306
|
* Execute the query and return typed archetype results
|
|
262
307
|
*/
|
|
263
308
|
public async exec(): Promise<ArcheTypeResult<T>[]> {
|
|
264
|
-
const entities = await this.
|
|
309
|
+
const entities = await this.withLoadStrategy().exec();
|
|
265
310
|
return entities.map(entity => this.wrapAsArchetype(entity as Entity));
|
|
266
311
|
}
|
|
267
312
|
|
|
@@ -269,7 +314,7 @@ export class ArcheTypeQuery<T extends BaseArcheType> {
|
|
|
269
314
|
* Execute the query and return the first result (or null)
|
|
270
315
|
*/
|
|
271
316
|
public async first(): Promise<ArcheTypeResult<T> | null> {
|
|
272
|
-
const results = await this.
|
|
317
|
+
const results = await this.withLoadStrategy().take(1).exec();
|
|
273
318
|
return results[0] ? this.wrapAsArchetype(results[0] as Entity) : null;
|
|
274
319
|
}
|
|
275
320
|
|
package/core/app/graphqlSetup.ts
CHANGED
|
@@ -4,7 +4,10 @@ import { createYogaInstance } from "../../gql";
|
|
|
4
4
|
import { createRequestContextPlugin } from "../RequestContext";
|
|
5
5
|
|
|
6
6
|
export function setupGraphQL(app: any): void {
|
|
7
|
-
|
|
7
|
+
// Provide the schema as a live factory rather than a fixed reference, so
|
|
8
|
+
// ServiceRegistry.rebuildSchema() is observed by the next request without
|
|
9
|
+
// recreating Yoga. Falls back to the static placeholder while null.
|
|
10
|
+
const schemaProvider = () => ServiceRegistry.getSchema();
|
|
8
11
|
|
|
9
12
|
const wrappedContextFactory = app.contextFactory
|
|
10
13
|
? async (yogaContext: any) => {
|
|
@@ -38,19 +41,10 @@ export function setupGraphQL(app: any): void {
|
|
|
38
41
|
? [createRequestContextPlugin(), ...app.yogaPlugins]
|
|
39
42
|
: [...app.yogaPlugins];
|
|
40
43
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
);
|
|
48
|
-
} else {
|
|
49
|
-
app.yoga = createYogaInstance(
|
|
50
|
-
undefined,
|
|
51
|
-
effectivePlugins,
|
|
52
|
-
wrappedContextFactory,
|
|
53
|
-
yogaOptions,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
44
|
+
app.yoga = createYogaInstance(
|
|
45
|
+
schemaProvider,
|
|
46
|
+
effectivePlugins,
|
|
47
|
+
wrappedContextFactory,
|
|
48
|
+
yogaOptions,
|
|
49
|
+
);
|
|
56
50
|
}
|
package/core/cache/index.ts
CHANGED
|
@@ -3,4 +3,13 @@ export { MemoryCache } from './MemoryCache';
|
|
|
3
3
|
export type { MemoryCacheConfig } from './MemoryCache';
|
|
4
4
|
export { NoOpCache } from './NoOpCache';
|
|
5
5
|
export { CacheManager } from './CacheManager';
|
|
6
|
-
export { CacheFactory } from './CacheFactory';
|
|
6
|
+
export { CacheFactory } from './CacheFactory';
|
|
7
|
+
export {
|
|
8
|
+
transaction,
|
|
9
|
+
markDirty as txMarkDirty,
|
|
10
|
+
registerOnCommit as txOnCommit,
|
|
11
|
+
trackComponentDirty,
|
|
12
|
+
beginTxTracking,
|
|
13
|
+
flushTxTracking,
|
|
14
|
+
} from './txInvalidation';
|
|
15
|
+
export type { TxContext } from './txInvalidation';
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transaction-aware cache invalidation.
|
|
3
|
+
*
|
|
4
|
+
* `comp.save(trx, id)` writes the DB but does no cache invalidation on its own.
|
|
5
|
+
* Inside a tracked transaction we accumulate the (entityId, typeId) pairs that
|
|
6
|
+
* were touched and, once the transaction COMMITS, run the same invalidation that
|
|
7
|
+
* entity.save() uses (CacheManager.invalidateEntityComponents → deleteMany +
|
|
8
|
+
* cross-instance pub/sub).
|
|
9
|
+
*
|
|
10
|
+
* Bun.SQL exposes no commit hook, so "on commit" means: after the
|
|
11
|
+
* `db.transaction(cb)` promise resolves. The `transaction()` wrapper below owns
|
|
12
|
+
* that boundary. Tracking is keyed by the trx object via a WeakMap, so
|
|
13
|
+
* `trackComponentDirty` is a cheap no-op for any comp.save() that runs outside a
|
|
14
|
+
* tracked transaction (top-level db, or entity.save() which handles its own
|
|
15
|
+
* cache) — zero behavior change for existing callers.
|
|
16
|
+
*/
|
|
17
|
+
import { logger as MainLogger } from '../Logger';
|
|
18
|
+
|
|
19
|
+
const logger = MainLogger.child({ scope: 'TxCacheInvalidation' });
|
|
20
|
+
|
|
21
|
+
/** Anything carrying a component type_id — avoids a hard BaseComponent import (cycle). */
|
|
22
|
+
type ComponentRef = string | { _typeId?: string } | (new (...args: any[]) => any);
|
|
23
|
+
|
|
24
|
+
type SQLLike = Bun.SQL;
|
|
25
|
+
|
|
26
|
+
interface TxState {
|
|
27
|
+
/** entityId -> set of touched component type_ids */
|
|
28
|
+
dirty: Map<string, Set<string>>;
|
|
29
|
+
onCommit: Array<() => void | Promise<void>>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Tracking state keyed by the transaction's SQL handle. */
|
|
33
|
+
const txRegistry = new WeakMap<SQLLike, TxState>();
|
|
34
|
+
|
|
35
|
+
/** Begin tracking for a transaction handle. Idempotent. */
|
|
36
|
+
export function beginTxTracking(trx: SQLLike): TxState {
|
|
37
|
+
let state = txRegistry.get(trx);
|
|
38
|
+
if (!state) {
|
|
39
|
+
state = { dirty: new Map(), onCommit: [] };
|
|
40
|
+
txRegistry.set(trx, state);
|
|
41
|
+
}
|
|
42
|
+
return state;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getTxState(trx: SQLLike): TxState | undefined {
|
|
46
|
+
return txRegistry.get(trx);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Record that a component (entityId + typeId) was written under this trx.
|
|
51
|
+
* No-op when the trx is not tracked (i.e. not inside transaction()).
|
|
52
|
+
*/
|
|
53
|
+
export function trackComponentDirty(trx: SQLLike, entityId: string, typeId: string): void {
|
|
54
|
+
const state = txRegistry.get(trx);
|
|
55
|
+
if (!state || !entityId || !typeId) return;
|
|
56
|
+
let set = state.dirty.get(entityId);
|
|
57
|
+
if (!set) {
|
|
58
|
+
set = new Set();
|
|
59
|
+
state.dirty.set(entityId, set);
|
|
60
|
+
}
|
|
61
|
+
set.add(typeId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve a component ctor / instance / raw typeId string to its type_id. */
|
|
65
|
+
function resolveTypeId(component: ComponentRef): string | null {
|
|
66
|
+
if (typeof component === 'string') return component;
|
|
67
|
+
// Instance carrying a _typeId (BaseComponent) — duck-typed to avoid an import cycle.
|
|
68
|
+
const instanceTypeId = (component as { _typeId?: string })._typeId;
|
|
69
|
+
if (typeof instanceTypeId === 'string' && instanceTypeId.length > 0) return instanceTypeId;
|
|
70
|
+
// Constructor: derive from class name via metadata.
|
|
71
|
+
if (typeof component === 'function') {
|
|
72
|
+
try {
|
|
73
|
+
const { getMetadataStorage } = require('../metadata');
|
|
74
|
+
return getMetadataStorage().getComponentId(component.name);
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Explicitly mark a component dirty for invalidation on commit.
|
|
84
|
+
* Accepts a component constructor, instance, or raw type_id string.
|
|
85
|
+
*/
|
|
86
|
+
export function markDirty(trx: SQLLike, entityId: string, component: ComponentRef): void {
|
|
87
|
+
const typeId = resolveTypeId(component);
|
|
88
|
+
if (!typeId) {
|
|
89
|
+
logger.warn({ entityId, msg: 'markDirty: could not resolve component type_id; skipping' });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
trackComponentDirty(trx, entityId, typeId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Register a callback to run after the transaction commits. */
|
|
96
|
+
export function registerOnCommit(trx: SQLLike, cb: () => void | Promise<void>): void {
|
|
97
|
+
const state = beginTxTracking(trx);
|
|
98
|
+
state.onCommit.push(cb);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Flush accumulated invalidations + run onCommit callbacks. Call ONLY after the
|
|
103
|
+
* transaction has committed. Errors are logged, never thrown — a cache flush
|
|
104
|
+
* failure must not surface as a transaction failure (the data is already
|
|
105
|
+
* committed; stale cache is recoverable, a thrown error is not).
|
|
106
|
+
*/
|
|
107
|
+
export async function flushTxTracking(state: TxState | undefined): Promise<void> {
|
|
108
|
+
if (!state) return;
|
|
109
|
+
try {
|
|
110
|
+
if (state.dirty.size > 0) {
|
|
111
|
+
const { CacheManager } = require('./CacheManager');
|
|
112
|
+
const cacheManager = CacheManager.getInstance();
|
|
113
|
+
await Promise.all(
|
|
114
|
+
Array.from(state.dirty.entries()).map(([entityId, typeIds]) =>
|
|
115
|
+
cacheManager
|
|
116
|
+
.invalidateEntityComponents(entityId, Array.from(typeIds), { includeEntityKey: true })
|
|
117
|
+
.catch((error: unknown) =>
|
|
118
|
+
logger.error({ entityId, error, msg: 'Failed to invalidate entity components on commit' }),
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
logger.error({ error, msg: 'Error during transaction cache flush' });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const cb of state.onCommit) {
|
|
128
|
+
try {
|
|
129
|
+
await cb();
|
|
130
|
+
} catch (error) {
|
|
131
|
+
logger.error({ error, msg: 'onCommit callback threw' });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Context handed to the transaction() callback for explicit control. */
|
|
137
|
+
export interface TxContext {
|
|
138
|
+
/** Mark a component dirty for invalidation on commit. */
|
|
139
|
+
markDirty(entityId: string, component: ComponentRef): void;
|
|
140
|
+
/** Run a callback after the transaction commits (cache already flushed). */
|
|
141
|
+
onCommit(cb: () => void | Promise<void>): void;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Run a transaction with automatic, transaction-aware cache invalidation.
|
|
146
|
+
*
|
|
147
|
+
* Any `comp.save(trx, entityId)` performed with the provided `trx` is tracked
|
|
148
|
+
* automatically; on commit, those components are invalidated using the same
|
|
149
|
+
* logic entity.save() uses. The `tx` context adds explicit markDirty/onCommit
|
|
150
|
+
* escape hatches.
|
|
151
|
+
*
|
|
152
|
+
* Invalidation runs inline (awaited) after commit, so when this resolves the
|
|
153
|
+
* cache is already consistent.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```typescript
|
|
157
|
+
* await transaction(async (trx, tx) => {
|
|
158
|
+
* await positionComp.save(trx, entityId); // auto-tracked
|
|
159
|
+
* tx.markDirty(entityId, Velocity); // explicit
|
|
160
|
+
* tx.onCommit(() => metrics.bump()); // after commit
|
|
161
|
+
* });
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
export async function transaction<T>(
|
|
165
|
+
fn: (trx: SQLLike, tx: TxContext) => Promise<T>,
|
|
166
|
+
): Promise<T> {
|
|
167
|
+
const { getDb } = require('../../database');
|
|
168
|
+
const db: SQLLike = getDb();
|
|
169
|
+
|
|
170
|
+
let state: TxState | undefined;
|
|
171
|
+
const result = await db.transaction(async (trx: SQLLike) => {
|
|
172
|
+
state = beginTxTracking(trx);
|
|
173
|
+
const ctx: TxContext = {
|
|
174
|
+
markDirty: (entityId, component) => markDirty(trx, entityId, component),
|
|
175
|
+
onCommit: (cb) => registerOnCommit(trx, cb),
|
|
176
|
+
};
|
|
177
|
+
return await fn(trx, ctx);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Transaction committed (resolved without throwing) → flush invalidations.
|
|
181
|
+
await flushTxTracking(state);
|
|
182
|
+
return result as T;
|
|
183
|
+
}
|
|
@@ -5,6 +5,7 @@ import ComponentRegistry from "./ComponentRegistry";
|
|
|
5
5
|
import { type ComponentDataType } from './Interfaces';
|
|
6
6
|
import { uuidv7 } from '../../utils/uuid';
|
|
7
7
|
import { getMetadataStorage } from '../metadata';
|
|
8
|
+
import { trackComponentDirty } from '../cache/txInvalidation';
|
|
8
9
|
const logger = MainLogger.child({ scope: "Components" });
|
|
9
10
|
|
|
10
11
|
// Cached property-name arrays keyed by typeId. Metadata is immutable after
|
|
@@ -104,6 +105,10 @@ export class BaseComponent {
|
|
|
104
105
|
await this.insert(trx, entity_id);
|
|
105
106
|
this._persisted = true;
|
|
106
107
|
}
|
|
108
|
+
// Transaction-aware cache invalidation: record this write so the
|
|
109
|
+
// transaction() wrapper can bust the component cache on commit.
|
|
110
|
+
// No-op outside a tracked transaction (cheap WeakMap miss).
|
|
111
|
+
trackComponentDirty(trx, entity_id, this._typeId);
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
async insert(trx: Bun.SQL, entity_id: string) {
|
|
@@ -254,8 +254,13 @@ export async function doSave(entity: Entity, trx: SQL, signal?: AbortSignal): Pr
|
|
|
254
254
|
(comp as any).setPersisted(true);
|
|
255
255
|
(comp as any).setDirty(false);
|
|
256
256
|
} else if ((comp as any)._dirty) {
|
|
257
|
+
// Full columns so the batched upsert below can encode every row
|
|
258
|
+
// through the same sql(arr, cols) path as the INSERT batch.
|
|
257
259
|
componentsToUpdate.push({
|
|
258
260
|
id: comp.id,
|
|
261
|
+
entity_id: entity.id,
|
|
262
|
+
name: compName,
|
|
263
|
+
type_id: comp.getTypeID(),
|
|
259
264
|
data: comp.serializableData()
|
|
260
265
|
});
|
|
261
266
|
(comp as any).setDirty(false);
|
|
@@ -267,12 +272,15 @@ export async function doSave(entity: Entity, trx: SQL, signal?: AbortSignal): Pr
|
|
|
267
272
|
await run(saveTrx`INSERT INTO components ${sql(componentsToInsert, 'id', 'entity_id', 'name', 'type_id', 'data')}`);
|
|
268
273
|
}
|
|
269
274
|
|
|
270
|
-
// Perform updates
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
275
|
+
// Perform updates as a SINGLE batched upsert. Dirty components already
|
|
276
|
+
// exist (persisted, live), so the ON CONFLICT path always fires and
|
|
277
|
+
// updates `data` for every row in one round-trip — replacing the
|
|
278
|
+
// previous N sequential UPDATEs (N wire round-trips inside the txn).
|
|
279
|
+
// Conflict target is the (id, type_id) PRIMARY KEY, which contains the
|
|
280
|
+
// partition key `type_id` — required for ON CONFLICT on the partitioned
|
|
281
|
+
// `components` table. Reuses the same sql(arr, cols) encoder as the
|
|
282
|
+
// INSERT batch, so jsonb encoding is identical across PostgreSQL and
|
|
283
|
+
// PGlite. `created_at` is preserved (DO UPDATE only touches `data`).
|
|
276
284
|
if (componentsToUpdate.length > 0) {
|
|
277
285
|
const traceEnabled = logger.isLevelEnabled?.('trace') === true;
|
|
278
286
|
for (const comp of componentsToUpdate) {
|
|
@@ -287,9 +295,7 @@ export async function doSave(entity: Entity, trx: SQL, signal?: AbortSignal): Pr
|
|
|
287
295
|
logger.trace({ componentId: comp.id, data: comp.data }, `[Entity.doSave] Updating component`);
|
|
288
296
|
}
|
|
289
297
|
}
|
|
290
|
-
|
|
291
|
-
await run(saveTrx`UPDATE components SET data = ${comp.data} WHERE id = ${comp.id}`);
|
|
292
|
-
}
|
|
298
|
+
await run(saveTrx`INSERT INTO components ${sql(componentsToUpdate, 'id', 'entity_id', 'name', 'type_id', 'data')} ON CONFLICT (id, type_id) DO UPDATE SET data = EXCLUDED.data`);
|
|
293
299
|
}
|
|
294
300
|
};
|
|
295
301
|
|
|
@@ -156,7 +156,7 @@ export const CreateComponentTable = async () => {
|
|
|
156
156
|
) PARTITION BY LIST (type_id);`;
|
|
157
157
|
await db`CREATE INDEX IF NOT EXISTS idx_components_entity_id ON components (entity_id)`;
|
|
158
158
|
await db`CREATE INDEX IF NOT EXISTS idx_components_type_id ON components (type_id)`;
|
|
159
|
-
await
|
|
159
|
+
await ensureDataGinIndex();
|
|
160
160
|
await db`CREATE INDEX IF NOT EXISTS idx_components_entity_type_deleted ON components (entity_id, type_id, deleted_at)`;
|
|
161
161
|
await db`CREATE INDEX IF NOT EXISTS idx_components_type_deleted ON components (type_id, deleted_at) WHERE deleted_at IS NULL`;
|
|
162
162
|
await db`CREATE INDEX IF NOT EXISTS idx_components_deleted_entity ON components (deleted_at, entity_id) WHERE deleted_at IS NULL`;
|
|
@@ -226,6 +226,30 @@ const dropOrphanedPartitionTables = async () => {
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* The whole-`data` GIN index (`idx_components_data_gin`) only serves top-level
|
|
231
|
+
* JSONB containment / existence on the entire `data` column (`data @> ...`,
|
|
232
|
+
* `data ? key`, `data ?| / ?&`). The Query layer never emits those forms — it
|
|
233
|
+
* uses per-field text extraction (`data->>'field'`, served by per-field
|
|
234
|
+
* btree/expression indexes) and sub-path containment (`data->'field' @> ...`,
|
|
235
|
+
* served by per-field sub-path GIN). So this index is pure write amplification
|
|
236
|
+
* for framework queries AND it blocks HOT updates (any `data` write must touch
|
|
237
|
+
* it). It is therefore OPT-IN. Set BUNSANE_COMPONENTS_DATA_GIN=true only if you
|
|
238
|
+
* run raw SQL doing top-level containment on the whole component payload.
|
|
239
|
+
*/
|
|
240
|
+
const ensureDataGinIndex = async (): Promise<void> => {
|
|
241
|
+
if (process.env.BUNSANE_COMPONENTS_DATA_GIN === 'true') {
|
|
242
|
+
await db`CREATE INDEX IF NOT EXISTS idx_components_data_gin ON components USING GIN (data)`;
|
|
243
|
+
logger.info("Created whole-data GIN index idx_components_data_gin (BUNSANE_COMPONENTS_DATA_GIN=true).");
|
|
244
|
+
} else {
|
|
245
|
+
logger.info(
|
|
246
|
+
"Skipped whole-data GIN index idx_components_data_gin to cut write amplification and enable HOT updates " +
|
|
247
|
+
"(BUNSANE_COMPONENTS_DATA_GIN!=true). Per-field indexes serve all framework queries. A pre-existing DB " +
|
|
248
|
+
"that still has it can drop it manually: DROP INDEX CONCURRENTLY IF EXISTS idx_components_data_gin;"
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
229
253
|
export const CreateHashPartitionedComponentTable = async (partitionCount: number = 16) => {
|
|
230
254
|
await db`CREATE TABLE IF NOT EXISTS components (
|
|
231
255
|
id UUID,
|
|
@@ -249,7 +273,7 @@ export const CreateHashPartitionedComponentTable = async (partitionCount: number
|
|
|
249
273
|
|
|
250
274
|
await db`CREATE INDEX IF NOT EXISTS idx_components_entity_id ON components (entity_id)`;
|
|
251
275
|
await db`CREATE INDEX IF NOT EXISTS idx_components_type_id ON components (type_id)`;
|
|
252
|
-
await
|
|
276
|
+
await ensureDataGinIndex();
|
|
253
277
|
await db`CREATE INDEX IF NOT EXISTS idx_components_entity_type_deleted ON components (entity_id, type_id, deleted_at)`;
|
|
254
278
|
await db`CREATE INDEX IF NOT EXISTS idx_components_type_deleted ON components (type_id, deleted_at) WHERE deleted_at IS NULL`;
|
|
255
279
|
await db`CREATE INDEX IF NOT EXISTS idx_components_deleted_entity ON components (deleted_at, entity_id) WHERE deleted_at IS NULL`;
|
package/gql/index.ts
CHANGED
|
@@ -149,8 +149,19 @@ export interface YogaInstanceOptions {
|
|
|
149
149
|
maxComplexity?: number;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* A schema provider may be a concrete `GraphQLSchema` or a factory returning
|
|
154
|
+
* the current schema. A factory is read per-request by Yoga, which lets the
|
|
155
|
+
* schema be swapped at runtime (e.g. ServiceRegistry.rebuildSchema()) without
|
|
156
|
+
* recreating the Yoga instance. Returning `null`/`undefined` falls back to the
|
|
157
|
+
* static placeholder schema.
|
|
158
|
+
*/
|
|
159
|
+
export type SchemaProvider =
|
|
160
|
+
| GraphQLSchema
|
|
161
|
+
| (() => GraphQLSchema | null | undefined);
|
|
162
|
+
|
|
152
163
|
export function createYogaInstance(
|
|
153
|
-
schema?:
|
|
164
|
+
schema?: SchemaProvider,
|
|
154
165
|
plugins: Plugin[] = [],
|
|
155
166
|
contextFactory?: (context: any) => any,
|
|
156
167
|
options?: YogaInstanceOptions
|
|
@@ -188,16 +199,30 @@ export function createYogaInstance(
|
|
|
188
199
|
yogaConfig.context = contextFactory;
|
|
189
200
|
}
|
|
190
201
|
|
|
191
|
-
|
|
202
|
+
// Memoized static placeholder schema. Kept stable so Yoga's per-schema
|
|
203
|
+
// internal caches (parse/validate) are not thrashed when a factory falls
|
|
204
|
+
// back to it across requests.
|
|
205
|
+
let fallbackSchema: GraphQLSchema | undefined;
|
|
206
|
+
const getFallback = (): GraphQLSchema => {
|
|
207
|
+
if (!fallbackSchema) {
|
|
208
|
+
fallbackSchema = createSchema({
|
|
209
|
+
typeDefs: staticTypeDefs,
|
|
210
|
+
resolvers: staticResolvers,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return fallbackSchema;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (typeof schema === "function") {
|
|
217
|
+
// Factory form: read per request so runtime swaps reflect live.
|
|
218
|
+
// Stable refs keep Yoga's caches warm; only a changed ref re-primes.
|
|
219
|
+
yogaConfig.schema = () => schema() ?? getFallback();
|
|
220
|
+
} else if (schema) {
|
|
192
221
|
yogaConfig.schema = schema;
|
|
193
|
-
return createYoga(yogaConfig);
|
|
194
222
|
} else {
|
|
195
|
-
yogaConfig.schema =
|
|
196
|
-
typeDefs: staticTypeDefs,
|
|
197
|
-
resolvers: staticResolvers,
|
|
198
|
-
});
|
|
199
|
-
return createYoga(yogaConfig);
|
|
223
|
+
yogaConfig.schema = getFallback();
|
|
200
224
|
}
|
|
225
|
+
return createYoga(yogaConfig);
|
|
201
226
|
}
|
|
202
227
|
|
|
203
228
|
export const Upload = z.union([z.literal("Upload"), z.any()]);
|
package/package.json
CHANGED
|
@@ -14,6 +14,7 @@ export class ServiceRegistry {
|
|
|
14
14
|
|
|
15
15
|
private services: Map<string, BaseService> = new Map();
|
|
16
16
|
private schema: GraphQLSchema | null = null;
|
|
17
|
+
private schemaVersion: number = 0;
|
|
17
18
|
private phaseListener: ((event: PhaseChangeEvent) => void) | null = null;
|
|
18
19
|
|
|
19
20
|
|
|
@@ -29,13 +30,7 @@ export class ServiceRegistry {
|
|
|
29
30
|
this.phaseListener = (event: PhaseChangeEvent) => {
|
|
30
31
|
switch(event.detail) {
|
|
31
32
|
case ApplicationPhase.SYSTEM_REGISTERING: {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const result = generateGraphQLSchemaV2(servicesArray, {
|
|
35
|
-
enableArchetypeOperations: false
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
this.schema = result.schema;
|
|
33
|
+
this.rebuildSchema();
|
|
39
34
|
ApplicationLifecycle.setPhase(ApplicationPhase.SYSTEM_READY);
|
|
40
35
|
break;
|
|
41
36
|
};
|
|
@@ -74,6 +69,30 @@ export class ServiceRegistry {
|
|
|
74
69
|
public getSchema(): GraphQLSchema | null {
|
|
75
70
|
return this.schema;
|
|
76
71
|
}
|
|
72
|
+
|
|
73
|
+
public getSchemaVersion(): number {
|
|
74
|
+
return this.schemaVersion;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Re-generate the GraphQL schema from the currently registered services
|
|
79
|
+
* and swap the stored reference. The live Yoga instance reads the schema
|
|
80
|
+
* via a factory (see graphqlSetup), so the next request observes the new
|
|
81
|
+
* schema without recreating Yoga or restarting the process.
|
|
82
|
+
*
|
|
83
|
+
* This is the Phase 0 "live re-weave" primitive: register a service (or
|
|
84
|
+
* mutate one's __graphqlOperations) then call this to reflect it.
|
|
85
|
+
* Returns the new schema (or null if generation produced none).
|
|
86
|
+
*/
|
|
87
|
+
public rebuildSchema(): GraphQLSchema | null {
|
|
88
|
+
const servicesArray = Array.from(this.services.values());
|
|
89
|
+
const result = generateGraphQLSchemaV2(servicesArray, {
|
|
90
|
+
enableArchetypeOperations: false
|
|
91
|
+
});
|
|
92
|
+
this.schema = result.schema;
|
|
93
|
+
this.schemaVersion++;
|
|
94
|
+
return this.schema;
|
|
95
|
+
}
|
|
77
96
|
}
|
|
78
97
|
|
|
79
98
|
export default ServiceRegistry.instance;
|