bunsane 0.5.1 → 0.5.3

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,28 @@
2
2
 
3
3
  All notable changes to bunsane are documented here.
4
4
 
5
+ ## 0.5.2 — 2026-06-19
6
+
7
+ ### Added
8
+
9
+ - **`withLock(key, fn, options?)`** — public distributed-lock primitive,
10
+ exported from `bunsane/core`. Runs `fn` while holding a PostgreSQL advisory
11
+ lock and always releases it (even if `fn` throws); only one holder of a given
12
+ `key` runs `fn` at a time across every process pointed at the same database.
13
+ Returns `{ acquired: true, result }`, or `{ acquired: false }` when the lock
14
+ is held elsewhere (`fn` does not run). Wraps the same `DistributedLock`
15
+ singleton and PostgreSQL session the scheduler uses for task exclusion, now
16
+ surfaced for app-level "run once cluster-wide" work — reindex, migration,
17
+ cache rebuild. `options.wait` (ms, default `0` = try once) blocks for the lock
18
+ instead of skipping; `options.retryInterval` (default 100 ms) sets the poll
19
+ cadence. Layers an in-process guard over the advisory lock because PostgreSQL
20
+ advisory locks are reentrant per session — without it, two concurrent
21
+ same-key callers in one process would both win. Not reentrant; crash-safe
22
+ (session-scoped); honors `distributedLocking: false` (then always reports
23
+ `acquired: true` with no real lock). Also re-exported from
24
+ `bunsane/core/scheduler`. A new `core/index.ts` barrel establishes
25
+ `bunsane/core` as a public entry point.
26
+
5
27
  ## 0.5.1 — 2026-06-16
6
28
 
7
29
  ### Added
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();
@@ -198,16 +198,45 @@ export async function handleRequest(app: any, req: Request): Promise<Response> {
198
198
 
199
199
  for (const [route, folder] of app.staticAssets) {
200
200
  if (url.pathname.startsWith(route)) {
201
- const relativePath = url.pathname.slice(route.length);
202
- const filePath = path.join(folder, relativePath);
201
+ const rawRelative = url.pathname.slice(route.length);
202
+ // Decode percent-encoding first so encoded traversal sequences
203
+ // (e.g. %2e%2e%2f) can't slip past the containment check.
204
+ let decodedRelative: string;
203
205
  try {
204
- const file = Bun.file(filePath);
206
+ decodedRelative = decodeURIComponent(rawRelative);
207
+ } catch {
208
+ clearTimeout(timeoutId);
209
+ return wrap(new Response("Bad request", {
210
+ status: 400,
211
+ headers: { "Content-Type": "text/plain" },
212
+ }));
213
+ }
214
+ // Resolve absolutely and confirm the target stays inside the
215
+ // served folder — blocks path traversal (../) out of the dir.
216
+ // Strip leading slashes so path.resolve treats the request as
217
+ // relative to the folder (an absolute-looking arg would reset
218
+ // to the filesystem root and bypass containment).
219
+ const relForResolve = decodedRelative.replace(/^[/\\]+/, "");
220
+ const resolvedBase = path.resolve(folder);
221
+ const resolvedFile = path.resolve(resolvedBase, relForResolve);
222
+ if (
223
+ resolvedFile !== resolvedBase &&
224
+ !resolvedFile.startsWith(resolvedBase + path.sep)
225
+ ) {
226
+ clearTimeout(timeoutId);
227
+ return wrap(new Response("Forbidden", {
228
+ status: 403,
229
+ headers: { "Content-Type": "text/plain" },
230
+ }));
231
+ }
232
+ try {
233
+ const file = Bun.file(resolvedFile);
205
234
  if (await file.exists()) {
206
235
  clearTimeout(timeoutId);
207
236
  return wrap(new Response(file));
208
237
  }
209
238
  } catch (error) {
210
- logger.error(`Error serving static file ${filePath}:`, error as any);
239
+ logger.error(`Error serving static file ${resolvedFile}:`, error as any);
211
240
  }
212
241
  }
213
242
  }
@@ -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;
package/core/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Public `bunsane/core` entry point.
3
+ *
4
+ * Subpath imports (`bunsane/core/App`, `bunsane/core/middleware`, …) remain the
5
+ * primary surface; this barrel re-exports cross-cutting primitives intended to
6
+ * be imported as `bunsane/core`.
7
+ */
8
+
9
+ export {
10
+ withLock,
11
+ type WithLockOptions,
12
+ type LockOutcome,
13
+ DistributedLock,
14
+ getDistributedLock,
15
+ resetDistributedLock,
16
+ DEFAULT_LOCK_CONFIG,
17
+ type DistributedLockConfig,
18
+ type LockResult,
19
+ } from "./scheduler";
@@ -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(
@@ -13,3 +13,9 @@ export {
13
13
  type DistributedLockConfig,
14
14
  type LockResult,
15
15
  } from './DistributedLock';
16
+
17
+ export {
18
+ withLock,
19
+ type WithLockOptions,
20
+ type LockOutcome,
21
+ } from './withLock';
@@ -0,0 +1,98 @@
1
+ /**
2
+ * withLock — run a function while holding a PostgreSQL advisory lock.
3
+ *
4
+ * Thin convenience wrapper over the shared {@link DistributedLock} singleton.
5
+ * Acquires the lock for `key`, runs `fn`, and always releases it — even if
6
+ * `fn` throws. Only one holder of a given `key` runs `fn` at a time, across
7
+ * every process pointed at the same database. When the lock is unavailable the
8
+ * call returns `{ acquired: false }` without running `fn` (unless `wait` is
9
+ * set, in which case it polls until the lock frees or the deadline passes).
10
+ *
11
+ * Two layers of exclusion:
12
+ * - Across processes: PostgreSQL `pg_advisory_lock`, owned by the singleton's
13
+ * pinned connection (one PG session per instance).
14
+ * - Within a process: an in-memory `Set`. PostgreSQL advisory locks are
15
+ * *reentrant per session*, so two concurrent callers sharing this instance's
16
+ * session would both win the pg lock — the Set makes same-process contention
17
+ * exclusive too.
18
+ *
19
+ * Notes:
20
+ * - Not reentrant. Calling `withLock(key, …)` for a key already held by this
21
+ * process returns `{ acquired: false }` (or waits, then gives up).
22
+ * - Shares the scheduler's singleton + PG session. Keys live under the same
23
+ * namespace prefix as scheduler task ids — pick keys unlikely to collide.
24
+ * - Honors the singleton's `enabled` config: if distributed locking was
25
+ * disabled (`getDistributedLock({ enabled: false })`), `tryAcquire` always
26
+ * reports success and no real lock is taken.
27
+ *
28
+ * @example
29
+ * const res = await withLock("rebuild-search-index", async () => {
30
+ * await rebuildIndex();
31
+ * return "done";
32
+ * });
33
+ * if (!res.acquired) {
34
+ * // another instance is already rebuilding — skip
35
+ * } else {
36
+ * console.log(res.result); // "done"
37
+ * }
38
+ */
39
+ import { getDistributedLock } from "./DistributedLock";
40
+
41
+ export interface WithLockOptions {
42
+ /** Max ms to wait for the lock before giving up. 0 (default) = try once. */
43
+ wait?: number;
44
+ /** Poll interval while waiting, in ms. Default 100. */
45
+ retryInterval?: number;
46
+ }
47
+
48
+ export type LockOutcome<T> =
49
+ | { acquired: false; result?: undefined }
50
+ | { acquired: true; result: T };
51
+
52
+ /** In-process holders, keyed by lock key (see "Within a process" above). */
53
+ const localHeld = new Set<string>();
54
+
55
+ const sleep = (ms: number): Promise<void> =>
56
+ new Promise((resolve) => setTimeout(resolve, ms));
57
+
58
+ export async function withLock<T>(
59
+ key: string,
60
+ fn: () => Promise<T> | T,
61
+ options: WithLockOptions = {}
62
+ ): Promise<LockOutcome<T>> {
63
+ const { wait = 0, retryInterval = 100 } = options;
64
+ const deadline = wait > 0 ? Date.now() + wait : 0;
65
+
66
+ // In-process gate. The has-check that exits the loop and the subsequent
67
+ // add() run without an await between them, so this is atomic on JS's single
68
+ // thread — concurrent same-key callers cannot both pass.
69
+ while (localHeld.has(key)) {
70
+ if (!deadline || Date.now() >= deadline) {
71
+ return { acquired: false };
72
+ }
73
+ await sleep(retryInterval);
74
+ }
75
+ localHeld.add(key);
76
+
77
+ try {
78
+ const lock = getDistributedLock();
79
+
80
+ let acquired = (await lock.tryAcquire(key)).acquired;
81
+ while (!acquired && deadline && Date.now() < deadline) {
82
+ await sleep(retryInterval);
83
+ acquired = (await lock.tryAcquire(key)).acquired;
84
+ }
85
+
86
+ if (!acquired) {
87
+ return { acquired: false };
88
+ }
89
+
90
+ try {
91
+ return { acquired: true, result: await fn() };
92
+ } finally {
93
+ await lock.release(key);
94
+ }
95
+ } finally {
96
+ localHeld.delete(key);
97
+ }
98
+ }
@@ -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.3",
4
4
  "author": {
5
5
  "name": "yaaruu"
6
6
  },