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.
@@ -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,15 +183,15 @@ 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);`;
157
192
  await db`CREATE INDEX IF NOT EXISTS idx_components_entity_id ON components (entity_id)`;
158
193
  await db`CREATE INDEX IF NOT EXISTS idx_components_type_id ON components (type_id)`;
159
- await db`CREATE INDEX IF NOT EXISTS idx_components_data_gin ON components USING GIN (data)`;
194
+ await ensureDataGinIndex();
160
195
  await db`CREATE INDEX IF NOT EXISTS idx_components_entity_type_deleted ON components (entity_id, type_id, deleted_at)`;
161
196
  await db`CREATE INDEX IF NOT EXISTS idx_components_type_deleted ON components (type_id, deleted_at) WHERE deleted_at IS NULL`;
162
197
  await db`CREATE INDEX IF NOT EXISTS idx_components_deleted_entity ON components (deleted_at, entity_id) WHERE deleted_at IS NULL`;
@@ -226,6 +261,30 @@ const dropOrphanedPartitionTables = async () => {
226
261
  }
227
262
  }
228
263
 
264
+ /**
265
+ * The whole-`data` GIN index (`idx_components_data_gin`) only serves top-level
266
+ * JSONB containment / existence on the entire `data` column (`data @> ...`,
267
+ * `data ? key`, `data ?| / ?&`). The Query layer never emits those forms — it
268
+ * uses per-field text extraction (`data->>'field'`, served by per-field
269
+ * btree/expression indexes) and sub-path containment (`data->'field' @> ...`,
270
+ * served by per-field sub-path GIN). So this index is pure write amplification
271
+ * for framework queries AND it blocks HOT updates (any `data` write must touch
272
+ * it). It is therefore OPT-IN. Set BUNSANE_COMPONENTS_DATA_GIN=true only if you
273
+ * run raw SQL doing top-level containment on the whole component payload.
274
+ */
275
+ const ensureDataGinIndex = async (): Promise<void> => {
276
+ if (process.env.BUNSANE_COMPONENTS_DATA_GIN === 'true') {
277
+ await db`CREATE INDEX IF NOT EXISTS idx_components_data_gin ON components USING GIN (data)`;
278
+ logger.info("Created whole-data GIN index idx_components_data_gin (BUNSANE_COMPONENTS_DATA_GIN=true).");
279
+ } else {
280
+ logger.info(
281
+ "Skipped whole-data GIN index idx_components_data_gin to cut write amplification and enable HOT updates " +
282
+ "(BUNSANE_COMPONENTS_DATA_GIN!=true). Per-field indexes serve all framework queries. A pre-existing DB " +
283
+ "that still has it can drop it manually: DROP INDEX CONCURRENTLY IF EXISTS idx_components_data_gin;"
284
+ );
285
+ }
286
+ };
287
+
229
288
  export const CreateHashPartitionedComponentTable = async (partitionCount: number = 16) => {
230
289
  await db`CREATE TABLE IF NOT EXISTS components (
231
290
  id UUID,
@@ -233,9 +292,9 @@ export const CreateHashPartitionedComponentTable = async (partitionCount: number
233
292
  type_id varchar(64) NOT NULL,
234
293
  name varchar(128),
235
294
  data jsonb,
236
- created_at TIMESTAMP DEFAULT NOW(),
237
- updated_at TIMESTAMP DEFAULT NOW(),
238
- deleted_at TIMESTAMP,
295
+ created_at TIMESTAMPTZ DEFAULT NOW(),
296
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
297
+ deleted_at TIMESTAMPTZ,
239
298
  PRIMARY KEY (id, type_id),
240
299
  UNIQUE(entity_id, type_id)
241
300
  ) PARTITION BY HASH (type_id);`;
@@ -249,7 +308,7 @@ export const CreateHashPartitionedComponentTable = async (partitionCount: number
249
308
 
250
309
  await db`CREATE INDEX IF NOT EXISTS idx_components_entity_id ON components (entity_id)`;
251
310
  await db`CREATE INDEX IF NOT EXISTS idx_components_type_id ON components (type_id)`;
252
- await db`CREATE INDEX IF NOT EXISTS idx_components_data_gin ON components USING GIN (data)`;
311
+ await ensureDataGinIndex();
253
312
  await db`CREATE INDEX IF NOT EXISTS idx_components_entity_type_deleted ON components (entity_id, type_id, deleted_at)`;
254
313
  await db`CREATE INDEX IF NOT EXISTS idx_components_type_deleted ON components (type_id, deleted_at) WHERE deleted_at IS NULL`;
255
314
  await db`CREATE INDEX IF NOT EXISTS idx_components_deleted_entity ON components (deleted_at, entity_id) WHERE deleted_at IS NULL`;
@@ -459,9 +518,9 @@ export const CreateEntityComponentTable = async () => {
459
518
  entity_id UUID REFERENCES entities(id) ON DELETE CASCADE,
460
519
  type_id VARCHAR(64) NOT NULL,
461
520
  component_id UUID,
462
- created_at TIMESTAMP DEFAULT NOW(),
463
- updated_at TIMESTAMP DEFAULT NOW(),
464
- deleted_at TIMESTAMP,
521
+ created_at TIMESTAMPTZ DEFAULT NOW(),
522
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
523
+ deleted_at TIMESTAMPTZ,
465
524
  UNIQUE(entity_id, type_id)
466
525
  );`;
467
526
  const concurrently = process.env.USE_PGLITE ? '' : ' CONCURRENTLY';
@@ -622,9 +681,9 @@ export const BenchmarkPartitionCounts = async (partitionCounts: number[] = [8, 1
622
681
  type_id varchar(64) NOT NULL,
623
682
  name varchar(128),
624
683
  data jsonb,
625
- created_at TIMESTAMP DEFAULT NOW(),
626
- updated_at TIMESTAMP DEFAULT NOW(),
627
- deleted_at TIMESTAMP,
684
+ created_at TIMESTAMPTZ DEFAULT NOW(),
685
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
686
+ deleted_at TIMESTAMPTZ,
628
687
  PRIMARY KEY (id, type_id),
629
688
  UNIQUE(entity_id, type_id)
630
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/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?: GraphQLSchema,
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
- if (schema) {
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 = createSchema({
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunsane",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "author": {
5
5
  "name": "yaaruu"
6
6
  },
@@ -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
- const servicesArray = Array.from(this.services.values());
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;