bunsane 0.5.1 → 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/core/ArcheType.ts CHANGED
@@ -1035,7 +1035,7 @@ export class BaseArcheType {
1035
1035
 
1036
1036
  public buildFilterBranches(filter?: FilterSchema<any>): any[] {
1037
1037
  if (!filter) return [];
1038
- const branches = [];
1038
+ const branches: Array<{ component: any; filters: Array<{ field: string; operator: any; value: any }> }> = [];
1039
1039
 
1040
1040
  for (const [fieldName, componentCtor] of Object.entries(this.componentMap)) {
1041
1041
  const fieldOption = this.fieldOptions[fieldName];
@@ -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
  }
@@ -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;
@@ -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(
@@ -77,6 +77,41 @@ export const PrepareDatabase = async () => {
77
77
  // `entity_components` is no longer created or written. `components`
78
78
  // (UNIQUE(entity_id, type_id)) is the single source of membership truth
79
79
  // as of Phase 3 of docs/ENTITY_COMPONENTS_REMOVAL_PLAN.md.
80
+ try {
81
+ await MigrateTimestampsToTimestamptz();
82
+ } catch (error) {
83
+ logger.error(`Failed to migrate timestamp columns to timestamptz: ${error}`);
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Auto-migrate base-table timestamp columns from `timestamp without time zone`
90
+ * to `timestamptz`. Idempotent: only ALTERs columns still typed as bare
91
+ * timestamp, so fresh DBs (created with TIMESTAMPTZ DDL) and already-migrated
92
+ * DBs are no-ops. Existing bare-timestamp values are interpreted as UTC — the
93
+ * framework only ever writes them via NOW()/CURRENT_TIMESTAMP, which assume the
94
+ * DB session timezone; UTC is the correct assumption for any DB run in UTC.
95
+ * `components` is partitioned — PostgreSQL propagates the type change to every
96
+ * partition (a rewrite that briefly locks the table; one-time cost).
97
+ */
98
+ export const MigrateTimestampsToTimestamptz = async () => {
99
+ const targets: Array<{ table: string; columns: string[] }> = [
100
+ { table: "entities", columns: ["created_at", "updated_at", "deleted_at"] },
101
+ { table: "components", columns: ["created_at", "updated_at", "deleted_at"] },
102
+ ];
103
+ for (const { table, columns } of targets) {
104
+ for (const col of columns) {
105
+ const rows = await db.unsafe(`
106
+ SELECT data_type FROM information_schema.columns
107
+ WHERE table_schema = 'public' AND table_name = '${table}' AND column_name = '${col}'
108
+ `);
109
+ if (rows.length === 0) continue; // table or column absent
110
+ if ((rows[0] as any).data_type !== "timestamp without time zone") continue; // already timestamptz
111
+ logger.warn(`Migrating ${table}.${col} timestamp → timestamptz (assuming stored values are UTC)...`);
112
+ await db.unsafe(`ALTER TABLE ${table} ALTER COLUMN ${col} TYPE timestamptz USING ${col} AT TIME ZONE 'UTC'`);
113
+ }
114
+ }
80
115
  }
81
116
 
82
117
  export const GetDatabaseDataSize = async () => {
@@ -101,9 +136,9 @@ export const SetupDatabaseExtensions = async () => {
101
136
  export const CreateEntityTable = async () => {
102
137
  await db`CREATE TABLE IF NOT EXISTS entities (
103
138
  id UUID PRIMARY KEY,
104
- created_at TIMESTAMP DEFAULT NOW(),
105
- updated_at TIMESTAMP DEFAULT NOW(),
106
- deleted_at TIMESTAMP
139
+ created_at TIMESTAMPTZ DEFAULT NOW(),
140
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
141
+ deleted_at TIMESTAMPTZ
107
142
  );`;
108
143
 
109
144
  // Add partial index for soft-delete queries - critical for 1M+ scale
@@ -148,9 +183,9 @@ export const CreateComponentTable = async () => {
148
183
  type_id varchar(64) NOT NULL,
149
184
  name varchar(128),
150
185
  data jsonb,
151
- created_at TIMESTAMP DEFAULT NOW(),
152
- updated_at TIMESTAMP DEFAULT NOW(),
153
- deleted_at TIMESTAMP,
186
+ created_at TIMESTAMPTZ DEFAULT NOW(),
187
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
188
+ deleted_at TIMESTAMPTZ,
154
189
  PRIMARY KEY (id, type_id),
155
190
  UNIQUE(entity_id, type_id)
156
191
  ) PARTITION BY LIST (type_id);`;
@@ -257,9 +292,9 @@ export const CreateHashPartitionedComponentTable = async (partitionCount: number
257
292
  type_id varchar(64) NOT NULL,
258
293
  name varchar(128),
259
294
  data jsonb,
260
- created_at TIMESTAMP DEFAULT NOW(),
261
- updated_at TIMESTAMP DEFAULT NOW(),
262
- deleted_at TIMESTAMP,
295
+ created_at TIMESTAMPTZ DEFAULT NOW(),
296
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
297
+ deleted_at TIMESTAMPTZ,
263
298
  PRIMARY KEY (id, type_id),
264
299
  UNIQUE(entity_id, type_id)
265
300
  ) PARTITION BY HASH (type_id);`;
@@ -483,9 +518,9 @@ export const CreateEntityComponentTable = async () => {
483
518
  entity_id UUID REFERENCES entities(id) ON DELETE CASCADE,
484
519
  type_id VARCHAR(64) NOT NULL,
485
520
  component_id UUID,
486
- created_at TIMESTAMP DEFAULT NOW(),
487
- updated_at TIMESTAMP DEFAULT NOW(),
488
- deleted_at TIMESTAMP,
521
+ created_at TIMESTAMPTZ DEFAULT NOW(),
522
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
523
+ deleted_at TIMESTAMPTZ,
489
524
  UNIQUE(entity_id, type_id)
490
525
  );`;
491
526
  const concurrently = process.env.USE_PGLITE ? '' : ' CONCURRENTLY';
@@ -646,9 +681,9 @@ export const BenchmarkPartitionCounts = async (partitionCounts: number[] = [8, 1
646
681
  type_id varchar(64) NOT NULL,
647
682
  name varchar(128),
648
683
  data jsonb,
649
- created_at TIMESTAMP DEFAULT NOW(),
650
- updated_at TIMESTAMP DEFAULT NOW(),
651
- deleted_at TIMESTAMP,
684
+ created_at TIMESTAMPTZ DEFAULT NOW(),
685
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
686
+ deleted_at TIMESTAMPTZ,
652
687
  PRIMARY KEY (id, type_id),
653
688
  UNIQUE(entity_id, type_id)
654
689
  ) PARTITION BY HASH (type_id);`);
@@ -1,8 +1,93 @@
1
1
  import db from "../database";
2
- import type { EntityInspectorResponse } from "./types";
2
+ import type {
3
+ EntityInspectorResponse,
4
+ StudioEntityListQueryParams,
5
+ StudioEntityListResponse,
6
+ EntityListItem,
7
+ } from "./types";
3
8
 
4
9
  const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
10
 
11
+ export async function handleEntityListRequest(
12
+ params: StudioEntityListQueryParams = {}
13
+ ): Promise<Response> {
14
+ const limit = Math.min(Math.max(params.limit ?? 50, 1), 1000);
15
+ const offset = Math.max(params.offset ?? 0, 0);
16
+ const searchTerm = params.search?.trim() ?? "";
17
+ const includeDeleted = params.include_deleted ?? false;
18
+
19
+ const deletedFilter = includeDeleted ? "" : "AND e.deleted_at IS NULL";
20
+
21
+ try {
22
+ let rows: Record<string, unknown>[];
23
+ let totalResult: { count: number }[];
24
+
25
+ if (searchTerm) {
26
+ const searchPattern = `%${searchTerm}%`;
27
+ rows = await db.unsafe(
28
+ `SELECT e.id, e.created_at, e.updated_at, e.deleted_at,
29
+ (SELECT COUNT(*) FROM components c
30
+ WHERE c.entity_id = e.id AND c.deleted_at IS NULL) AS component_count
31
+ FROM entities e
32
+ WHERE e.id::text ILIKE $1 ${deletedFilter}
33
+ ORDER BY e.created_at DESC NULLS LAST
34
+ LIMIT $2 OFFSET $3`,
35
+ [searchPattern, limit, offset]
36
+ );
37
+ totalResult = await db.unsafe(
38
+ `SELECT COUNT(*) AS count FROM entities e
39
+ WHERE e.id::text ILIKE $1 ${deletedFilter}`,
40
+ [searchPattern]
41
+ );
42
+ } else {
43
+ rows = await db.unsafe(
44
+ `SELECT e.id, e.created_at, e.updated_at, e.deleted_at,
45
+ (SELECT COUNT(*) FROM components c
46
+ WHERE c.entity_id = e.id AND c.deleted_at IS NULL) AS component_count
47
+ FROM entities e
48
+ WHERE TRUE ${deletedFilter}
49
+ ORDER BY e.created_at DESC NULLS LAST
50
+ LIMIT $1 OFFSET $2`,
51
+ [limit, offset]
52
+ );
53
+ totalResult = await db.unsafe(
54
+ `SELECT COUNT(*) AS count FROM entities e WHERE TRUE ${deletedFilter}`
55
+ );
56
+ }
57
+
58
+ const entities: EntityListItem[] = rows.map((row) => ({
59
+ id: row.id as string,
60
+ created_at: row.created_at as string,
61
+ updated_at: row.updated_at as string,
62
+ deleted_at: (row.deleted_at as string) ?? null,
63
+ component_count: Number(row.component_count ?? 0),
64
+ }));
65
+
66
+ const responseData: StudioEntityListResponse = {
67
+ entities,
68
+ total: Number(totalResult[0]?.count ?? 0),
69
+ limit,
70
+ offset,
71
+ };
72
+
73
+ return new Response(JSON.stringify(responseData), {
74
+ headers: { "Content-Type": "application/json" },
75
+ });
76
+ } catch (error) {
77
+ const errorMessage =
78
+ error instanceof Error ? error.message : "Unknown error";
79
+ return new Response(
80
+ JSON.stringify({
81
+ error: `Failed to fetch entities: ${errorMessage}`,
82
+ }),
83
+ {
84
+ status: 500,
85
+ headers: { "Content-Type": "application/json" },
86
+ }
87
+ );
88
+ }
89
+ }
90
+
6
91
  export async function handleEntityInspectorRequest(
7
92
  entityId: string
8
93
  ): Promise<Response> {
@@ -7,7 +7,7 @@ import {
7
7
  handleStudioArcheTypeRecordsRequest,
8
8
  handleStudioArcheTypeDeleteRequest,
9
9
  } from "./archetypes";
10
- import { handleEntityInspectorRequest } from "./entity";
10
+ import { handleEntityInspectorRequest, handleEntityListRequest } from "./entity";
11
11
  import { handleStudioStatsRequest } from "./stats";
12
12
  import { handleStudioComponentsRequest } from "./components";
13
13
  import { handleStudioQueryRequest } from "./query";
@@ -18,6 +18,7 @@ const studioEndpoint = {
18
18
  handleStudioTableDeleteRequest,
19
19
  handleStudioArcheTypeDeleteRequest,
20
20
  handleEntityInspectorRequest,
21
+ handleEntityListRequest,
21
22
  handleStudioStatsRequest,
22
23
  handleStudioComponentsRequest,
23
24
  handleStudioQueryRequest,
@@ -88,6 +88,28 @@ interface EntityInspectorResponse {
88
88
  components: EntityComponent[];
89
89
  }
90
90
 
91
+ interface StudioEntityListQueryParams {
92
+ limit?: number;
93
+ offset?: number;
94
+ search?: string;
95
+ include_deleted?: boolean;
96
+ }
97
+
98
+ interface EntityListItem {
99
+ id: string;
100
+ created_at: string;
101
+ updated_at: string;
102
+ deleted_at: string | null;
103
+ component_count: number;
104
+ }
105
+
106
+ interface StudioEntityListResponse {
107
+ entities: EntityListItem[];
108
+ total: number;
109
+ limit: number;
110
+ offset: number;
111
+ }
112
+
91
113
  interface ComponentTypeStats {
92
114
  name: string;
93
115
  count: number;
@@ -145,6 +167,9 @@ export type {
145
167
  DeleteResponse,
146
168
  EntityComponent,
147
169
  EntityInspectorResponse,
170
+ StudioEntityListQueryParams,
171
+ EntityListItem,
172
+ StudioEntityListResponse,
148
173
  ComponentTypeStats,
149
174
  ArcheTypeStats,
150
175
  StudioStatsResponse,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunsane",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "author": {
5
5
  "name": "yaaruu"
6
6
  },