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 +29 -0
- package/core/App.ts +28 -1
- package/core/ArcheType.ts +48 -3
- package/core/app/graphqlSetup.ts +10 -16
- package/core/app/metricsCollector.ts +2 -1
- package/core/app/studioRouter.ts +14 -0
- package/core/archetype/zodSchemaBuilder.ts +6 -1
- package/core/cache/index.ts +10 -1
- package/core/cache/txInvalidation.ts +183 -0
- package/core/components/BaseComponent.ts +5 -0
- package/core/entity/saveEntity.ts +17 -11
- package/core/metadata/metadata-storage.ts +59 -1
- package/database/DatabaseHelper.ts +76 -17
- package/endpoints/entity.ts +86 -1
- package/endpoints/index.ts +2 -1
- package/endpoints/types.ts +25 -0
- package/gql/index.ts +33 -8
- package/package.json +1 -1
- package/service/ServiceRegistry.ts +26 -7
- 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
|
@@ -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,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
|
|
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);`;
|
|
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
|
|
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
|
|
237
|
-
updated_at
|
|
238
|
-
deleted_at
|
|
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
|
|
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
|
|
463
|
-
updated_at
|
|
464
|
-
deleted_at
|
|
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
|
|
626
|
-
updated_at
|
|
627
|
-
deleted_at
|
|
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);`);
|
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,
|
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?:
|
|
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
|
-
|
|
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 =
|
|
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
|
@@ -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
|
-
|
|
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;
|