bunsane 0.4.0 → 0.5.1
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 +55 -0
- package/core/App.ts +28 -1
- package/core/ArcheType.ts +47 -2
- package/core/Entity.ts +8 -0
- package/core/app/graphqlSetup.ts +10 -16
- package/core/cache/RedisCache.ts +5 -4
- package/core/cache/index.ts +10 -1
- package/core/cache/txInvalidation.ts +183 -0
- package/core/components/BaseComponent.ts +14 -2
- package/core/entity/cacheStrategies.ts +3 -3
- package/core/entity/componentAccess.ts +24 -5
- package/core/entity/getCacheManager.ts +10 -0
- package/core/entity/saveEntity.ts +24 -20
- package/core/health.ts +93 -4
- package/core/remote/StreamConsumer.ts +535 -535
- package/core/validateEnv.ts +10 -0
- package/database/DatabaseHelper.ts +26 -2
- package/database/index.ts +14 -0
- package/database/sqlHelpers.ts +3 -1
- package/gql/index.ts +33 -8
- package/gql/schema/index.ts +15 -4
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +5 -1
- package/query/OrNode.ts +2 -14
- package/query/Query.ts +51 -33
- package/service/ServiceRegistry.ts +26 -7
package/core/validateEnv.ts
CHANGED
|
@@ -60,6 +60,16 @@ const envSchema = z
|
|
|
60
60
|
.string()
|
|
61
61
|
.regex(/^\d+$/, "DB_CONNECTION_TIMEOUT must be numeric (seconds)")
|
|
62
62
|
.optional(),
|
|
63
|
+
DB_HEALTH_WRITE_TIMEOUT: z
|
|
64
|
+
.string()
|
|
65
|
+
.regex(/^\d+$/, "DB_HEALTH_WRITE_TIMEOUT must be numeric (milliseconds)")
|
|
66
|
+
.optional(),
|
|
67
|
+
HEALTH_DB_WRITE_PROBE: z
|
|
68
|
+
.enum(["true", "false"])
|
|
69
|
+
.optional(),
|
|
70
|
+
DB_DISABLE_PREPARE: z
|
|
71
|
+
.enum(["true", "false"])
|
|
72
|
+
.optional(),
|
|
63
73
|
})
|
|
64
74
|
.refine(
|
|
65
75
|
(env) => {
|
|
@@ -156,7 +156,7 @@ export const CreateComponentTable = async () => {
|
|
|
156
156
|
) PARTITION BY LIST (type_id);`;
|
|
157
157
|
await db`CREATE INDEX IF NOT EXISTS idx_components_entity_id ON components (entity_id)`;
|
|
158
158
|
await db`CREATE INDEX IF NOT EXISTS idx_components_type_id ON components (type_id)`;
|
|
159
|
-
await
|
|
159
|
+
await ensureDataGinIndex();
|
|
160
160
|
await db`CREATE INDEX IF NOT EXISTS idx_components_entity_type_deleted ON components (entity_id, type_id, deleted_at)`;
|
|
161
161
|
await db`CREATE INDEX IF NOT EXISTS idx_components_type_deleted ON components (type_id, deleted_at) WHERE deleted_at IS NULL`;
|
|
162
162
|
await db`CREATE INDEX IF NOT EXISTS idx_components_deleted_entity ON components (deleted_at, entity_id) WHERE deleted_at IS NULL`;
|
|
@@ -226,6 +226,30 @@ const dropOrphanedPartitionTables = async () => {
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* The whole-`data` GIN index (`idx_components_data_gin`) only serves top-level
|
|
231
|
+
* JSONB containment / existence on the entire `data` column (`data @> ...`,
|
|
232
|
+
* `data ? key`, `data ?| / ?&`). The Query layer never emits those forms — it
|
|
233
|
+
* uses per-field text extraction (`data->>'field'`, served by per-field
|
|
234
|
+
* btree/expression indexes) and sub-path containment (`data->'field' @> ...`,
|
|
235
|
+
* served by per-field sub-path GIN). So this index is pure write amplification
|
|
236
|
+
* for framework queries AND it blocks HOT updates (any `data` write must touch
|
|
237
|
+
* it). It is therefore OPT-IN. Set BUNSANE_COMPONENTS_DATA_GIN=true only if you
|
|
238
|
+
* run raw SQL doing top-level containment on the whole component payload.
|
|
239
|
+
*/
|
|
240
|
+
const ensureDataGinIndex = async (): Promise<void> => {
|
|
241
|
+
if (process.env.BUNSANE_COMPONENTS_DATA_GIN === 'true') {
|
|
242
|
+
await db`CREATE INDEX IF NOT EXISTS idx_components_data_gin ON components USING GIN (data)`;
|
|
243
|
+
logger.info("Created whole-data GIN index idx_components_data_gin (BUNSANE_COMPONENTS_DATA_GIN=true).");
|
|
244
|
+
} else {
|
|
245
|
+
logger.info(
|
|
246
|
+
"Skipped whole-data GIN index idx_components_data_gin to cut write amplification and enable HOT updates " +
|
|
247
|
+
"(BUNSANE_COMPONENTS_DATA_GIN!=true). Per-field indexes serve all framework queries. A pre-existing DB " +
|
|
248
|
+
"that still has it can drop it manually: DROP INDEX CONCURRENTLY IF EXISTS idx_components_data_gin;"
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
229
253
|
export const CreateHashPartitionedComponentTable = async (partitionCount: number = 16) => {
|
|
230
254
|
await db`CREATE TABLE IF NOT EXISTS components (
|
|
231
255
|
id UUID,
|
|
@@ -249,7 +273,7 @@ export const CreateHashPartitionedComponentTable = async (partitionCount: number
|
|
|
249
273
|
|
|
250
274
|
await db`CREATE INDEX IF NOT EXISTS idx_components_entity_id ON components (entity_id)`;
|
|
251
275
|
await db`CREATE INDEX IF NOT EXISTS idx_components_type_id ON components (type_id)`;
|
|
252
|
-
await
|
|
276
|
+
await ensureDataGinIndex();
|
|
253
277
|
await db`CREATE INDEX IF NOT EXISTS idx_components_entity_type_deleted ON components (entity_id, type_id, deleted_at)`;
|
|
254
278
|
await db`CREATE INDEX IF NOT EXISTS idx_components_type_deleted ON components (type_id, deleted_at) WHERE deleted_at IS NULL`;
|
|
255
279
|
await db`CREATE INDEX IF NOT EXISTS idx_components_deleted_entity ON components (deleted_at, entity_id) WHERE deleted_at IS NULL`;
|
package/database/index.ts
CHANGED
|
@@ -47,12 +47,26 @@ function createDatabase(): SQL {
|
|
|
47
47
|
// workers (schedulers, outbox, migrations) can keep higher values.
|
|
48
48
|
const connTimeout = parseInt(process.env.DB_CONNECTION_TIMEOUT ?? '30', 10);
|
|
49
49
|
|
|
50
|
+
// DB_DISABLE_PREPARE (opt-in): turn off Bun SQL's automatic server-side
|
|
51
|
+
// prepared statements (driver default `prepare: true`). REQUIRED behind
|
|
52
|
+
// PgBouncer in transaction pooling mode — each transaction may land on a
|
|
53
|
+
// different backend, so a prepared statement created on one connection is
|
|
54
|
+
// absent on the next, yielding `prepared statement "..." does not exist`
|
|
55
|
+
// errors that can poison the pooled client and wedge the write path. Costs
|
|
56
|
+
// a little per-query planning; negligible next to the failure it prevents.
|
|
57
|
+
const disablePrepare = process.env.DB_DISABLE_PREPARE === 'true';
|
|
58
|
+
if (disablePrepare) {
|
|
59
|
+
logger.info('Prepared statements disabled (DB_DISABLE_PREPARE=true) — required for PgBouncer transaction pooling');
|
|
60
|
+
}
|
|
61
|
+
|
|
50
62
|
return new SQL({
|
|
51
63
|
url,
|
|
52
64
|
max,
|
|
53
65
|
idleTimeout: 30000,
|
|
54
66
|
maxLifetime: 600000,
|
|
55
67
|
connectionTimeout: connTimeout,
|
|
68
|
+
// Only override when disabling; otherwise leave Bun's default (true).
|
|
69
|
+
...(disablePrepare ? { prepare: false } : {}),
|
|
56
70
|
onclose: (err) => {
|
|
57
71
|
if (err) {
|
|
58
72
|
const errCode = (err as unknown as { code: string }).code;
|
package/database/sqlHelpers.ts
CHANGED
|
@@ -12,6 +12,8 @@ export function inList<T>(values: T[], paramIndex: number): { sql: string, param
|
|
|
12
12
|
|
|
13
13
|
if (filteredValues.length === 0) return { sql: '()', params: [], newParamIndex: paramIndex };
|
|
14
14
|
|
|
15
|
-
const
|
|
15
|
+
const n = filteredValues.length;
|
|
16
|
+
let placeholders = '';
|
|
17
|
+
for (let i = 0; i < n; i++) { placeholders += (i ? ', $' : '$') + (paramIndex + i); }
|
|
16
18
|
return { sql: `(${placeholders})`, params: filteredValues, newParamIndex: paramIndex + filteredValues.length };
|
|
17
19
|
}
|
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/gql/schema/index.ts
CHANGED
|
@@ -406,6 +406,12 @@ export function isSchemaInput(
|
|
|
406
406
|
|
|
407
407
|
// ─── Validation ─────────────────────────────────────────────────────────────────
|
|
408
408
|
|
|
409
|
+
// Keyed by schema object identity. Safe only when field builders inside the
|
|
410
|
+
// schema are not mutated (via .required()/.optional()/.nullable()) after the
|
|
411
|
+
// first validateInput call for that schema. Module-level schema constants
|
|
412
|
+
// satisfy this constraint; inline schemas constructed per-call simply miss.
|
|
413
|
+
const _zodCache = new WeakMap<object, ZodType>();
|
|
414
|
+
|
|
409
415
|
export class SchemaValidationError extends Error {
|
|
410
416
|
readonly fieldErrors: Array<{ path: string; message: string }>;
|
|
411
417
|
|
|
@@ -424,11 +430,16 @@ export function validateInput<T extends Record<string, SchemaType>>(
|
|
|
424
430
|
data: unknown,
|
|
425
431
|
operationName: string = "input",
|
|
426
432
|
): InferInput<T> {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
zodShape
|
|
433
|
+
let zodObject = _zodCache.get(schema);
|
|
434
|
+
if (!zodObject) {
|
|
435
|
+
const zodShape: Record<string, ZodType> = {};
|
|
436
|
+
for (const [key, field] of Object.entries(schema)) {
|
|
437
|
+
zodShape[key] = field.toZod();
|
|
438
|
+
}
|
|
439
|
+
zodObject = z.object(zodShape);
|
|
440
|
+
_zodCache.set(schema, zodObject);
|
|
430
441
|
}
|
|
431
|
-
const result =
|
|
442
|
+
const result = zodObject.safeParse(data);
|
|
432
443
|
if (result.success) {
|
|
433
444
|
return result.data as InferInput<T>;
|
|
434
445
|
}
|
package/package.json
CHANGED
|
@@ -862,7 +862,11 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
862
862
|
} else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
|
|
863
863
|
// IN/NOT IN comparison - handle arrays properly
|
|
864
864
|
if (Array.isArray(filter.value) && filter.value.length > 0) {
|
|
865
|
-
|
|
865
|
+
let placeholders = '';
|
|
866
|
+
for (let i = 0; i < filter.value.length; i++) {
|
|
867
|
+
if (i) placeholders += ', ';
|
|
868
|
+
placeholders += '$' + context.addParam(filter.value[i]);
|
|
869
|
+
}
|
|
866
870
|
condition = `${jsonPath} ${filter.operator} (${placeholders})`;
|
|
867
871
|
} else if (Array.isArray(filter.value) && filter.value.length === 0) {
|
|
868
872
|
// Empty array: IN () is always false, NOT IN () is always true
|
package/query/OrNode.ts
CHANGED
|
@@ -357,13 +357,7 @@ export class OrNode extends QueryNode {
|
|
|
357
357
|
WHERE EXISTS (
|
|
358
358
|
SELECT 1 FROM ${componentTableName} c
|
|
359
359
|
WHERE c.entity_id = base.id
|
|
360
|
-
AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL
|
|
361
|
-
AND c.created_at = (
|
|
362
|
-
SELECT MAX(c2.created_at)
|
|
363
|
-
FROM ${componentTableName} c2
|
|
364
|
-
WHERE c2.entity_id = c.entity_id
|
|
365
|
-
AND c2.type_id = $${componentIdParamIndex} AND c2.deleted_at IS NULL
|
|
366
|
-
)`;
|
|
360
|
+
AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL`;
|
|
367
361
|
} else {
|
|
368
362
|
// Use original query without base
|
|
369
363
|
const componentTableName = this.getComponentTableName(componentId);
|
|
@@ -374,13 +368,7 @@ export class OrNode extends QueryNode {
|
|
|
374
368
|
AND EXISTS (
|
|
375
369
|
SELECT 1 FROM ${componentTableName} c
|
|
376
370
|
WHERE c.entity_id = ec.entity_id
|
|
377
|
-
AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL
|
|
378
|
-
AND c.created_at = (
|
|
379
|
-
SELECT MAX(c2.created_at)
|
|
380
|
-
FROM ${componentTableName} c2
|
|
381
|
-
WHERE c2.entity_id = c.entity_id
|
|
382
|
-
AND c2.type_id = $${componentIdParamIndex} AND c2.deleted_at IS NULL
|
|
383
|
-
)`;
|
|
371
|
+
AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL`;
|
|
384
372
|
}
|
|
385
373
|
|
|
386
374
|
context.params.push(componentId);
|
package/query/Query.ts
CHANGED
|
@@ -21,6 +21,40 @@ import { getMembershipSource } from "./membershipSource";
|
|
|
21
21
|
const DEFAULT_QUERY_LIMIT = parseInt(process.env.BUNSANE_DEFAULT_QUERY_LIMIT ?? '10000', 10);
|
|
22
22
|
let warnedDefaultLimit = false;
|
|
23
23
|
|
|
24
|
+
// Gated once — dev keeps param diagnostics, production skips the loop entirely.
|
|
25
|
+
const DEBUG_PARAMS = process.env.NODE_ENV !== 'production';
|
|
26
|
+
|
|
27
|
+
// Shared across all TypedEntity instances — avoids one closure allocation per row.
|
|
28
|
+
// Must be called as a method (entity.getTyped(Ctor)) so `this` resolves correctly.
|
|
29
|
+
async function sharedGetTyped(
|
|
30
|
+
this: any,
|
|
31
|
+
ctor: any
|
|
32
|
+
): Promise<any> {
|
|
33
|
+
const data = await this.get(ctor);
|
|
34
|
+
if (!data) {
|
|
35
|
+
throw new Error(`Component ${ctor.name} not found on entity ${this.id}, but it was expected from query`);
|
|
36
|
+
}
|
|
37
|
+
return data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Hoisted descriptor for _queriedComponents — non-enumerable by design (hidden from
|
|
41
|
+
// Object.keys / spreads). Descriptor is reused; only `value` is patched per row.
|
|
42
|
+
const queriedComponentsDescriptor: PropertyDescriptor = {
|
|
43
|
+
value: undefined as any,
|
|
44
|
+
writable: false,
|
|
45
|
+
enumerable: false,
|
|
46
|
+
configurable: false,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// getTyped stays non-enumerable like the original defineProperty version; the value
|
|
50
|
+
// never varies, so the descriptor is fully static.
|
|
51
|
+
const getTypedDescriptor: PropertyDescriptor = {
|
|
52
|
+
value: sharedGetTyped,
|
|
53
|
+
writable: false,
|
|
54
|
+
enumerable: false,
|
|
55
|
+
configurable: false,
|
|
56
|
+
};
|
|
57
|
+
|
|
24
58
|
export type FilterOperator = "=" | ">" | "<" | ">=" | "<=" | "!=" | "LIKE" | "ILIKE" | "IN" | "NOT IN" | string;
|
|
25
59
|
|
|
26
60
|
export const FilterOp = {
|
|
@@ -786,34 +820,16 @@ AND c.deleted_at IS NULL`;
|
|
|
786
820
|
// Create typed entity wrapper
|
|
787
821
|
const typedEntity = entity as TypedEntity<TComponents>;
|
|
788
822
|
|
|
789
|
-
//
|
|
790
|
-
|
|
791
|
-
value: componentData as ComponentRecord<TComponents>,
|
|
792
|
-
writable: false,
|
|
793
|
-
enumerable: true
|
|
794
|
-
});
|
|
823
|
+
// Plain assignment — enumerable: true matches prior behavior; no defineProperty overhead.
|
|
824
|
+
(typedEntity as any).componentData = componentData as ComponentRecord<TComponents>;
|
|
795
825
|
|
|
796
|
-
//
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
enumerable: false
|
|
801
|
-
});
|
|
826
|
+
// _queriedComponents must stay non-enumerable (hidden from Object.keys / spreads).
|
|
827
|
+
queriedComponentsDescriptor.value = componentCtors as unknown as TComponents;
|
|
828
|
+
Object.defineProperty(typedEntity, '_queriedComponents', queriedComponentsDescriptor);
|
|
829
|
+
queriedComponentsDescriptor.value = undefined; // don't retain ref
|
|
802
830
|
|
|
803
|
-
//
|
|
804
|
-
Object.defineProperty(typedEntity, 'getTyped',
|
|
805
|
-
value: async function<T extends TComponents[number]>(
|
|
806
|
-
ctor: T
|
|
807
|
-
): Promise<T extends ComponentConstructor<infer C> ? C extends BaseComponent ? ComponentDataType<C> : never : never> {
|
|
808
|
-
const data = await entity.get(ctor as any);
|
|
809
|
-
if (!data) {
|
|
810
|
-
throw new Error(`Component ${(ctor as any).name} not found on entity ${entity.id}, but it was expected from query`);
|
|
811
|
-
}
|
|
812
|
-
return data as any;
|
|
813
|
-
},
|
|
814
|
-
writable: false,
|
|
815
|
-
enumerable: false
|
|
816
|
-
});
|
|
831
|
+
// Shared function — one allocation per module, not per row.
|
|
832
|
+
Object.defineProperty(typedEntity, 'getTyped', getTypedDescriptor);
|
|
817
833
|
|
|
818
834
|
return typedEntity;
|
|
819
835
|
}
|
|
@@ -878,13 +894,15 @@ AND c.deleted_at IS NULL`;
|
|
|
878
894
|
// originate from saved entities. PG emits a clear error at
|
|
879
895
|
// execution time if a UUID cast meets an empty string.
|
|
880
896
|
|
|
881
|
-
// Validate parameters before execution
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
897
|
+
// Validate parameters before execution (dev only — skipped in production)
|
|
898
|
+
if (DEBUG_PARAMS) {
|
|
899
|
+
for (let i = 0; i < result.params.length; i++) {
|
|
900
|
+
if (result.params[i] === undefined || result.params[i] === null) {
|
|
901
|
+
console.error(`❌ Query parameter $${i + 1} is undefined/null`);
|
|
902
|
+
console.error(`SQL: ${result.sql}`);
|
|
903
|
+
console.error(`All params: ${JSON.stringify(result.params)}`);
|
|
904
|
+
throw new Error(`Query parameter $${i + 1} is undefined/null. SQL: ${result.sql.substring(0, 100)}...`);
|
|
905
|
+
}
|
|
888
906
|
}
|
|
889
907
|
}
|
|
890
908
|
|
|
@@ -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;
|