@memberjunction/server 5.16.0 → 5.18.0

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.
@@ -0,0 +1,351 @@
1
+ /**
2
+ * RSUResolver — GraphQL API for the Runtime Schema Update pipeline.
3
+ *
4
+ * Exposes:
5
+ * - Query: RuntimeSchemaUpdateStatus — current RSU system status
6
+ * - Mutation: RunRuntimeSchemaUpdate — execute the full RSU pipeline
7
+ * - Mutation: PreviewRuntimeSchemaUpdate — dry-run preview
8
+ *
9
+ * All mutations require system user authorization.
10
+ */
11
+ import {
12
+ Arg,
13
+ Ctx,
14
+ Field,
15
+ InputType,
16
+ Mutation,
17
+ ObjectType,
18
+ Query,
19
+ Resolver,
20
+ Int,
21
+ } from 'type-graphql';
22
+ import { AppContext } from '../types.js';
23
+ import { RequireSystemUser } from '../directives/RequireSystemUser.js';
24
+ import {
25
+ RuntimeSchemaManager,
26
+ type RSUPipelineInput,
27
+ } from '@memberjunction/schema-engine';
28
+ import { readFileSync, existsSync } from 'node:fs';
29
+
30
+ // ─── RSU Input Types ─────────────────────────────────────────────────
31
+
32
+ @InputType()
33
+ export class RSUMetadataFileInput {
34
+ @Field(() => String)
35
+ Path: string;
36
+
37
+ @Field(() => String)
38
+ Content: string;
39
+ }
40
+
41
+ @InputType()
42
+ export class RSUPipelineInputGQL {
43
+ @Field(() => String, { description: 'The migration SQL to execute' })
44
+ MigrationSQL: string;
45
+
46
+ @Field(() => String, { description: 'Descriptive name for this schema change' })
47
+ Description: string;
48
+
49
+ @Field(() => [String], { description: 'Tables being created or modified' })
50
+ AffectedTables: string[];
51
+
52
+ @Field(() => String, { nullable: true, description: 'additionalSchemaInfo JSON for soft FKs' })
53
+ AdditionalSchemaInfo?: string;
54
+
55
+ @Field(() => [RSUMetadataFileInput], { nullable: true, description: 'Metadata JSON files for mj-sync' })
56
+ MetadataFiles?: RSUMetadataFileInput[];
57
+
58
+ @Field(() => Boolean, { nullable: true, description: 'Skip git commit/push step' })
59
+ SkipGitCommit?: boolean;
60
+
61
+ @Field(() => Boolean, { nullable: true, description: 'Skip MJAPI restart' })
62
+ SkipRestart?: boolean;
63
+ }
64
+
65
+ // ─── Output Types ────────────────────────────────────────────────────
66
+
67
+ @ObjectType()
68
+ export class RSUPipelineStepGQL {
69
+ @Field(() => String)
70
+ Name: string;
71
+
72
+ @Field(() => String)
73
+ Status: string;
74
+
75
+ @Field(() => Int)
76
+ DurationMs: number;
77
+
78
+ @Field(() => String)
79
+ Message: string;
80
+ }
81
+
82
+ @ObjectType()
83
+ export class RSUPipelineResultGQL {
84
+ @Field(() => Boolean)
85
+ Success: boolean;
86
+
87
+ @Field(() => String, { nullable: true })
88
+ BranchName?: string;
89
+
90
+ @Field(() => String, { nullable: true })
91
+ MigrationFilePath?: string;
92
+
93
+ @Field(() => Int, { nullable: true })
94
+ EntitiesProcessed?: number;
95
+
96
+ @Field(() => Boolean)
97
+ APIRestarted: boolean;
98
+
99
+ @Field(() => Boolean)
100
+ GitCommitSuccess: boolean;
101
+
102
+ @Field(() => [RSUPipelineStepGQL])
103
+ Steps: RSUPipelineStepGQL[];
104
+
105
+ @Field(() => String, { nullable: true })
106
+ ErrorMessage?: string;
107
+
108
+ @Field(() => String, { nullable: true })
109
+ ErrorStep?: string;
110
+ }
111
+
112
+ @ObjectType()
113
+ export class RSUPipelineBatchResultGQL {
114
+ @Field(() => [RSUPipelineResultGQL])
115
+ Results: RSUPipelineResultGQL[];
116
+
117
+ @Field(() => Int)
118
+ SuccessCount: number;
119
+
120
+ @Field(() => Int)
121
+ FailureCount: number;
122
+
123
+ @Field(() => Int)
124
+ TotalCount: number;
125
+ }
126
+
127
+ @ObjectType()
128
+ export class RSUPreviewResultGQL {
129
+ @Field(() => String)
130
+ MigrationSQL: string;
131
+
132
+ @Field(() => [String])
133
+ AffectedTables: string[];
134
+
135
+ @Field(() => [String])
136
+ ValidationErrors: string[];
137
+
138
+ @Field(() => Boolean)
139
+ WouldExecute: boolean;
140
+ }
141
+
142
+ @ObjectType()
143
+ export class RSUStatusGQL {
144
+ @Field(() => Boolean)
145
+ Enabled: boolean;
146
+
147
+ @Field(() => Boolean)
148
+ Running: boolean;
149
+
150
+ @Field(() => Boolean)
151
+ OutOfSync: boolean;
152
+
153
+ @Field(() => Date, { nullable: true })
154
+ OutOfSyncSince?: Date | null;
155
+
156
+ @Field(() => Date, { nullable: true })
157
+ LastRunAt?: Date | null;
158
+
159
+ @Field(() => String, { nullable: true })
160
+ LastRunResult?: string | null;
161
+ }
162
+
163
+ @ObjectType()
164
+ export class RSUQueueStatusGQL {
165
+ @Field(() => Int)
166
+ PendingCount: number;
167
+
168
+ @Field(() => Boolean)
169
+ IsCycleRunning: boolean;
170
+ }
171
+
172
+ // ─── Resolver ────────────────────────────────────────────────────────
173
+
174
+ @Resolver()
175
+ export class RSUResolver {
176
+ /**
177
+ * Query: Get current RSU system status.
178
+ * Available to any authenticated user (status is informational).
179
+ */
180
+ @Query(() => RSUStatusGQL, { description: 'Returns the current Runtime Schema Update status' })
181
+ RuntimeSchemaUpdateStatus(): RSUStatusGQL {
182
+ const rsm = RuntimeSchemaManager.Instance;
183
+ const status = rsm.GetStatus();
184
+ return {
185
+ Enabled: status.Enabled,
186
+ Running: status.Running,
187
+ OutOfSync: status.OutOfSync,
188
+ OutOfSyncSince: status.OutOfSyncSince,
189
+ LastRunAt: status.LastRunAt,
190
+ LastRunResult: status.LastRunResult,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Query: Tail the RSU pipeline log file. Returns the last N lines.
196
+ */
197
+ @Query(() => [String], { description: 'Returns recent lines from the RSU pipeline log' })
198
+ RuntimeSchemaUpdateLogTail(
199
+ @Arg('lines', () => Int, { defaultValue: 100 }) lines: number
200
+ ): string[] {
201
+ try {
202
+ const logPath = `${process.env.RSU_WORK_DIR || process.cwd()}/rsu-pipeline.log`;
203
+ if (!existsSync(logPath)) return ['(no log file found)'];
204
+ const content = readFileSync(logPath, 'utf-8');
205
+ const allLines = content.split('\n').filter(l => l.trim());
206
+ const maxLines = Math.min(Math.max(lines, 1), 1000);
207
+ return allLines.slice(-maxLines);
208
+ } catch (e) {
209
+ return [`Error reading log: ${(e as Error).message}`];
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Query: Get RSU CodeGen queue status.
215
+ * Shows how many requests are pending and whether a cycle is running.
216
+ */
217
+ @Query(() => RSUQueueStatusGQL, { description: 'Returns the RSU pipeline status' })
218
+ RuntimeSchemaUpdateQueueStatus(): RSUQueueStatusGQL {
219
+ const rsm = RuntimeSchemaManager.Instance;
220
+ return {
221
+ PendingCount: 0, // Batching is explicit via RunPipelineBatch — no implicit queue
222
+ IsCycleRunning: rsm.IsRunning,
223
+ };
224
+ }
225
+
226
+ /**
227
+ * Mutation: Execute the full RSU pipeline.
228
+ * Requires system user authorization.
229
+ */
230
+ @Mutation(() => RSUPipelineResultGQL, {
231
+ description: 'Execute the Runtime Schema Update pipeline. Requires system user.',
232
+ })
233
+ @RequireSystemUser()
234
+ async RunRuntimeSchemaUpdate(
235
+ @Arg('input', () => RSUPipelineInputGQL) input: RSUPipelineInputGQL,
236
+ @Ctx() _ctx: AppContext
237
+ ): Promise<RSUPipelineResultGQL> {
238
+ const rsm = RuntimeSchemaManager.Instance;
239
+
240
+ const pipelineInput: RSUPipelineInput = {
241
+ MigrationSQL: input.MigrationSQL,
242
+ Description: input.Description,
243
+ AffectedTables: input.AffectedTables,
244
+ AdditionalSchemaInfo: input.AdditionalSchemaInfo ?? undefined,
245
+ MetadataFiles: input.MetadataFiles?.map(mf => ({ Path: mf.Path, Content: mf.Content })),
246
+ SkipGitCommit: input.SkipGitCommit ?? undefined,
247
+ SkipRestart: input.SkipRestart ?? undefined,
248
+ };
249
+
250
+ const result = await rsm.RunPipeline(pipelineInput);
251
+
252
+ return {
253
+ Success: result.Success,
254
+ BranchName: result.BranchName,
255
+ MigrationFilePath: result.MigrationFilePath,
256
+ EntitiesProcessed: result.EntitiesProcessed,
257
+ APIRestarted: result.APIRestarted,
258
+ GitCommitSuccess: result.GitCommitSuccess,
259
+ Steps: result.Steps.map(s => ({
260
+ Name: s.Name,
261
+ Status: s.Status,
262
+ DurationMs: s.DurationMs,
263
+ Message: s.Message,
264
+ })),
265
+ ErrorMessage: result.ErrorMessage,
266
+ ErrorStep: result.ErrorStep,
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Mutation: Execute the RSU pipeline for a batch of inputs.
272
+ * All migrations execute under one lock, then one CodeGen/compile/restart/git.
273
+ * Per-item migration results + shared post-migration result.
274
+ */
275
+ @Mutation(() => RSUPipelineBatchResultGQL, {
276
+ description: 'Execute Runtime Schema Update for a batch of inputs. Requires system user.',
277
+ })
278
+ @RequireSystemUser()
279
+ async RunRuntimeSchemaUpdateBatch(
280
+ @Arg('inputs', () => [RSUPipelineInputGQL]) inputs: RSUPipelineInputGQL[],
281
+ @Ctx() _ctx: AppContext
282
+ ): Promise<RSUPipelineBatchResultGQL> {
283
+ const rsm = RuntimeSchemaManager.Instance;
284
+
285
+ const pipelineInputs: RSUPipelineInput[] = inputs.map(input => ({
286
+ MigrationSQL: input.MigrationSQL,
287
+ Description: input.Description,
288
+ AffectedTables: input.AffectedTables,
289
+ AdditionalSchemaInfo: input.AdditionalSchemaInfo ?? undefined,
290
+ MetadataFiles: input.MetadataFiles?.map(mf => ({ Path: mf.Path, Content: mf.Content })),
291
+ SkipGitCommit: input.SkipGitCommit ?? undefined,
292
+ SkipRestart: input.SkipRestart ?? undefined,
293
+ }));
294
+
295
+ const batchResult = await rsm.RunPipelineBatch(pipelineInputs);
296
+
297
+ return {
298
+ Results: batchResult.Results.map(result => ({
299
+ Success: result.Success,
300
+ BranchName: result.BranchName,
301
+ MigrationFilePath: result.MigrationFilePath,
302
+ EntitiesProcessed: result.EntitiesProcessed,
303
+ APIRestarted: result.APIRestarted,
304
+ GitCommitSuccess: result.GitCommitSuccess,
305
+ Steps: result.Steps.map(s => ({
306
+ Name: s.Name,
307
+ Status: s.Status,
308
+ DurationMs: s.DurationMs,
309
+ Message: s.Message,
310
+ })),
311
+ ErrorMessage: result.ErrorMessage,
312
+ ErrorStep: result.ErrorStep,
313
+ })),
314
+ SuccessCount: batchResult.SuccessCount,
315
+ FailureCount: batchResult.FailureCount,
316
+ TotalCount: batchResult.TotalCount,
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Mutation: Preview mode — validate SQL and return what would happen.
322
+ * Requires system user authorization.
323
+ */
324
+ @Mutation(() => RSUPreviewResultGQL, {
325
+ description: 'Preview Runtime Schema Update without executing. Requires system user.',
326
+ })
327
+ @RequireSystemUser()
328
+ PreviewRuntimeSchemaUpdate(
329
+ @Arg('input', () => RSUPipelineInputGQL) input: RSUPipelineInputGQL,
330
+ @Ctx() _ctx: AppContext
331
+ ): RSUPreviewResultGQL {
332
+ const rsm = RuntimeSchemaManager.Instance;
333
+
334
+ const pipelineInput: RSUPipelineInput = {
335
+ MigrationSQL: input.MigrationSQL,
336
+ Description: input.Description,
337
+ AffectedTables: input.AffectedTables,
338
+ };
339
+
340
+ const preview = rsm.Preview(pipelineInput);
341
+
342
+ return {
343
+ MigrationSQL: preview.MigrationSQL,
344
+ AffectedTables: preview.AffectedTables,
345
+ ValidationErrors: preview.ValidationErrors,
346
+ WouldExecute: preview.WouldExecute,
347
+ };
348
+ }
349
+ }
350
+
351
+ export default RSUResolver;