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 +22 -0
- package/core/ArcheType.ts +1 -1
- package/core/app/metricsCollector.ts +2 -1
- package/core/app/requestRouter.ts +33 -4
- package/core/app/studioRouter.ts +14 -0
- package/core/archetype/zodSchemaBuilder.ts +6 -1
- package/core/entity/saveEntity.ts +2 -2
- package/core/index.ts +19 -0
- package/core/metadata/metadata-storage.ts +59 -1
- package/core/scheduler/index.ts +6 -0
- package/core/scheduler/withLock.ts +98 -0
- package/database/DatabaseHelper.ts +50 -15
- package/endpoints/entity.ts +86 -1
- package/endpoints/index.ts +2 -1
- package/endpoints/types.ts +25 -0
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +13 -7
- package/query/FilterBuilder.ts +19 -0
- package/query/OrNode.ts +42 -16
- package/studio/dist/assets/index-BFzJDkHx.js +254 -0
- package/studio/dist/assets/index-TmEdOhTL.css +1 -0
- package/studio/dist/index.html +2 -2
- package/studio/dist/assets/index-BMZ67Npg.js +0 -254
- package/studio/dist/assets/index-BpbuYz9g.css +0 -1
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
|
|
202
|
-
|
|
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
|
-
|
|
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 ${
|
|
239
|
+
logger.error(`Error serving static file ${resolvedFile}:`, error as any);
|
|
211
240
|
}
|
|
212
241
|
}
|
|
213
242
|
}
|
package/core/app/studioRouter.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
package/core/scheduler/index.ts
CHANGED
|
@@ -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
|
|
105
|
-
updated_at
|
|
106
|
-
deleted_at
|
|
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
|
|
152
|
-
updated_at
|
|
153
|
-
deleted_at
|
|
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
|
|
261
|
-
updated_at
|
|
262
|
-
deleted_at
|
|
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
|
|
487
|
-
updated_at
|
|
488
|
-
deleted_at
|
|
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
|
|
650
|
-
updated_at
|
|
651
|
-
deleted_at
|
|
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);`);
|
package/endpoints/entity.ts
CHANGED
|
@@ -1,8 +1,93 @@
|
|
|
1
1
|
import db from "../database";
|
|
2
|
-
import type {
|
|
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> {
|
package/endpoints/index.ts
CHANGED
|
@@ -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,
|
package/endpoints/types.ts
CHANGED
|
@@ -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,
|