bunsane 0.5.0 → 0.5.2

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
@@ -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.innerQuery.populate().exec();
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.innerQuery.take(1).populate().exec();
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
 
@@ -990,7 +1035,7 @@ export class BaseArcheType {
990
1035
 
991
1036
  public buildFilterBranches(filter?: FilterSchema<any>): any[] {
992
1037
  if (!filter) return [];
993
- const branches = [];
1038
+ const branches: Array<{ component: any; filters: Array<{ field: string; operator: any; value: any }> }> = [];
994
1039
 
995
1040
  for (const [fieldName, componentCtor] of Object.entries(this.componentMap)) {
996
1041
  const fieldOption = this.fieldOptions[fieldName];
@@ -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
- const schema = ServiceRegistry.getSchema();
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
- if (schema) {
42
- app.yoga = createYogaInstance(
43
- schema,
44
- effectivePlugins,
45
- wrappedContextFactory,
46
- yogaOptions,
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
  }
@@ -2,11 +2,12 @@ import { logger as MainLogger } from "../Logger";
2
2
  import { SchedulerManager } from "../SchedulerManager";
3
3
  import { preparedStatementCache } from "../../database/PreparedStatementCache";
4
4
  import { getDbStats } from "../../database/instrumentedDb";
5
+ import type { CacheManager } from "../cache/CacheManager";
5
6
 
6
7
  const logger = MainLogger.child({ scope: "App" });
7
8
 
8
9
  export async function collectMetrics(app: any) {
9
- let cacheStats = null;
10
+ let cacheStats: Awaited<ReturnType<CacheManager["getStats"]>> | null = null;
10
11
  try {
11
12
  const { CacheManager } = await import('../cache/CacheManager');
12
13
  cacheStats = await CacheManager.getInstance().getStats();
@@ -20,6 +20,20 @@ export async function routeStudio(
20
20
  return await studioEndpoint.handleStudioComponentsRequest();
21
21
  }
22
22
 
23
+ if (url.pathname === "/studio/api/entities") {
24
+ const limit = url.searchParams.get("limit");
25
+ const offset = url.searchParams.get("offset");
26
+ const search = url.searchParams.get("search");
27
+ const includeDeleted = url.searchParams.get("include_deleted");
28
+
29
+ return await studioEndpoint.handleEntityListRequest({
30
+ limit: limit ? parseInt(limit, 10) : undefined,
31
+ offset: offset ? parseInt(offset, 10) : undefined,
32
+ search: search ?? undefined,
33
+ include_deleted: includeDeleted === "true",
34
+ });
35
+ }
36
+
23
37
  if (url.pathname === "/studio/api/query" && method === "POST") {
24
38
  const body = await req.json();
25
39
  return await studioEndpoint.handleStudioQueryRequest(body);
@@ -521,7 +521,12 @@ export function buildZodObjectSchema(
521
521
  graphqlSchema: graphqlSchemaString,
522
522
  });
523
523
 
524
- allArchetypeZodObjects.set(nameFromStorage, r);
524
+ // Only cache the canonical full variant in the shared map. Function-less /
525
+ // relation-less variants (e.g. from getInputSchema) must not overwrite it,
526
+ // or weaveAllArchetypes welds SDL missing @ArcheTypeFunction fields → resolver/schema mismatch.
527
+ if (!excludeRelations && !excludeFunctions) {
528
+ allArchetypeZodObjects.set(nameFromStorage, r);
529
+ }
525
530
 
526
531
  return r;
527
532
  }
@@ -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) {
@@ -226,8 +226,8 @@ export async function doSave(entity: Entity, trx: SQL, signal?: AbortSignal): Pr
226
226
  }
227
227
 
228
228
  // Batch inserts and updates for better performance
229
- const componentsToInsert = [];
230
- const componentsToUpdate = [];
229
+ const componentsToInsert: Array<{ id: string; entity_id: string; name: string; type_id: string; data: Record<string, any> }> = [];
230
+ const componentsToUpdate: Array<{ id: string; entity_id: string; name: string; type_id: string; data: Record<string, any> }> = [];
231
231
 
232
232
  for (const comp of entity.components.values()) {
233
233
  const compName = comp.constructor.name;
@@ -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. Validate all ids up front (synchronous, fails
271
- // fast), then issue the UPDATEs sequentially. They were previously
272
- // fired together via Promise.all to "pipeline" on the transaction
273
- // connection, but multiple concurrent in-flight queries on one
274
- // connection deadlock single-backend servers (PGlite test harness),
275
- // and a single wire serializes them regardless no real gain.
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
- for (const comp of componentsToUpdate) {
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
 
@@ -4,9 +4,16 @@ import type {
4
4
  ComponentPropertyMetadata,
5
5
  IndexedFieldMetadata
6
6
  } from "./definitions/Component";
7
- import type { ArcheTypeMetadata, ArcheTypeFieldOptions } from './definitions/ArcheType';
7
+ import type { ArcheTypeMetadata, ArcheTypeFieldOptions, ArcheTypeFunctionMetadata } from './definitions/ArcheType';
8
8
  import type { RelationOptions } from '../ArcheType';
9
9
 
10
+ // Mirror of decorators.archetypeFunctionsSymbol — referenced via the global symbol
11
+ // registry to avoid a circular import between metadata-storage and archetype/decorators.
12
+ const archetypeFunctionsSymbol = Symbol.for("bunsane:archetypeFunctions");
13
+
14
+ type ArcheTypeFunctionOptions = ArcheTypeFunctionMetadata["options"];
15
+ type ArcheTypeFunctionHandler = (entity: any, ...args: any[]) => any;
16
+
10
17
  function generateTypeId(name: string): string {
11
18
  return createHash('sha256').update(name).digest('hex');
12
19
  }
@@ -87,6 +94,57 @@ export class MetadataStorage {
87
94
  this.archetypes_relations_map.get(archetype_id)!.push({fieldName, relatedArcheType, relationType, options, type});
88
95
  }
89
96
 
97
+ /**
98
+ * Register a computed (@ArcheTypeFunction-equivalent) field at runtime, with no decorator.
99
+ *
100
+ * Wires all three sites the decorator path touches in one call:
101
+ * - prototype symbol array → instances pick it up via `this.functions`
102
+ * - prototype method → resolver invokes `archetype[propertyKey](entity, ...)`
103
+ * - archetype metadata.functions → weaver emits the field in the SDL
104
+ *
105
+ * The archetype must already be registered (via @ArcheType or runtime registration)
106
+ * so its target class is known; throws otherwise.
107
+ */
108
+ collectArchetypeFunction(
109
+ name: string,
110
+ propertyKey: string,
111
+ handler: ArcheTypeFunctionHandler,
112
+ options?: ArcheTypeFunctionOptions
113
+ ) {
114
+ const metadata = this.archetypes.find(a => a.name === name);
115
+ if (!metadata) {
116
+ throw new Error(`Cannot register function '${propertyKey}': archetype '${name}' is not registered`);
117
+ }
118
+
119
+ const prototype = (metadata.target as any).prototype;
120
+
121
+ // 1. prototype symbol array (consumed by BaseArcheType ctor → this.functions)
122
+ if (!prototype[archetypeFunctionsSymbol]) {
123
+ prototype[archetypeFunctionsSymbol] = [];
124
+ }
125
+ const protoFns: ArcheTypeFunctionMetadata[] = prototype[archetypeFunctionsSymbol];
126
+ const protoIdx = protoFns.findIndex(f => f.propertyKey === propertyKey);
127
+ if (protoIdx !== -1) {
128
+ protoFns[protoIdx] = { propertyKey, options };
129
+ } else {
130
+ protoFns.push({ propertyKey, options });
131
+ }
132
+
133
+ // 2. prototype method (invoked by the field resolver)
134
+ prototype[propertyKey] = handler;
135
+
136
+ // 3. metadata.functions (read by the weaver to build SDL)
137
+ if (!metadata.functions) {
138
+ metadata.functions = [];
139
+ }
140
+ const metaIdx = metadata.functions.findIndex(f => f.propertyKey === propertyKey);
141
+ if (metaIdx !== -1) {
142
+ metadata.functions[metaIdx] = { propertyKey, options };
143
+ } else {
144
+ metadata.functions.push({ propertyKey, options });
145
+ }
146
+ }
147
+
90
148
  collectArcheTypeMetadata(metadata: ArcheTypeMetadata) {
91
149
  // Check if archetype already exists and update it
92
150
  const existingIndex = this.archetypes.findIndex(