@memberjunction/server 6.2.0-edge.2 → 6.2.0-edge.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/README.md +30 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/config.d.ts +2189 -71
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +123 -3
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +11 -23
- package/dist/context.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts +928 -0
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +8482 -3649
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +43 -3
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +142 -27
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +4 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +33 -1
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +115 -49
- package/dist/index.js.map +1 -1
- package/dist/postgresqlCompat.d.ts +8 -0
- package/dist/postgresqlCompat.d.ts.map +1 -0
- package/dist/postgresqlCompat.js +19 -0
- package/dist/postgresqlCompat.js.map +1 -0
- package/dist/postgresqlPoolSettings.d.ts +108 -0
- package/dist/postgresqlPoolSettings.d.ts.map +1 -0
- package/dist/postgresqlPoolSettings.js +134 -0
- package/dist/postgresqlPoolSettings.js.map +1 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts +23 -18
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +77 -167
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +9 -47
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +9 -0
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
- package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
- package/dist/resolvers/HumanHandoffResolver.js +324 -0
- package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
- package/dist/resolvers/MeetingResolver.d.ts +69 -0
- package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
- package/dist/resolvers/MeetingResolver.js +381 -0
- package/dist/resolvers/MeetingResolver.js.map +1 -0
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +34 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.js +202 -7
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +1 -83
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +4 -206
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/adhocTimeout.d.ts +7 -0
- package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
- package/dist/resolvers/adhocTimeout.js +12 -0
- package/dist/resolvers/adhocTimeout.js.map +1 -0
- package/dist/resolvers/meetings/index.d.ts +5 -0
- package/dist/resolvers/meetings/index.d.ts.map +1 -0
- package/dist/resolvers/meetings/index.js +5 -0
- package/dist/resolvers/meetings/index.js.map +1 -0
- package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
- package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingHelpers.js +212 -0
- package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
- package/dist/resolvers/meetings/meetingMutations.d.ts +12 -0
- package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingMutations.js +418 -0
- package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
- package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
- package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingQueries.js +113 -0
- package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
- package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
- package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingTypes.js +345 -0
- package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +8 -5
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/services/TaskGraphActionRunner.d.ts +8 -0
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +27 -1
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/dist/services/WorkQueueHostService.d.ts +36 -0
- package/dist/services/WorkQueueHostService.d.ts.map +1 -0
- package/dist/services/WorkQueueHostService.js +97 -0
- package/dist/services/WorkQueueHostService.js.map +1 -0
- package/dist/services/workQueueConfig.d.ts +103 -0
- package/dist/services/workQueueConfig.d.ts.map +1 -0
- package/dist/services/workQueueConfig.js +48 -0
- package/dist/services/workQueueConfig.js.map +1 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +95 -94
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
- package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
- package/src/__tests__/HumanHandoffResolver.test.ts +233 -0
- package/src/__tests__/MeetingResolver.test.ts +374 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +240 -2
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
- package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +41 -0
- package/src/__tests__/WorkQueueHostService.test.ts +133 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/config.ts +130 -3
- package/src/context.ts +11 -24
- package/src/generated/graphql-schemas/__mj.ts +22138 -18770
- package/src/generic/ResolverBase.ts +171 -27
- package/src/generic/RunViewResolver.ts +33 -2
- package/src/index.ts +127 -50
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/resolvers/AdhocQueryResolver.ts +88 -188
- package/src/resolvers/EntityRecordNameResolver.ts +9 -51
- package/src/resolvers/GetDataResolver.ts +10 -0
- package/src/resolvers/HumanHandoffResolver.ts +304 -0
- package/src/resolvers/MeetingResolver.ts +438 -0
- package/src/resolvers/RealtimeBridgeResolver.ts +227 -13
- package/src/resolvers/RealtimeClientSessionResolver.ts +6 -252
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/resolvers/meetings/index.ts +4 -0
- package/src/resolvers/meetings/meetingHelpers.ts +307 -0
- package/src/resolvers/meetings/meetingMutations.ts +535 -0
- package/src/resolvers/meetings/meetingQueries.ts +172 -0
- package/src/resolvers/meetings/meetingTypes.ts +238 -0
- package/src/rest/ViewOperationsHandler.ts +8 -5
- package/src/services/TaskGraphActionRunner.ts +29 -1
- package/src/services/WorkQueueHostService.ts +129 -0
- package/src/services/workQueueConfig.ts +52 -0
- package/src/types.ts +5 -0
- package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
- package/dist/resolvers/adhoc-query-helpers.js +0 -50
- package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
- package/src/resolvers/adhoc-query-helpers.ts +0 -67
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { ObjectType, InputType, Field } from 'type-graphql';
|
|
2
|
+
|
|
3
|
+
@ObjectType()
|
|
4
|
+
export class MeetingParticipantType {
|
|
5
|
+
@Field(() => String)
|
|
6
|
+
ID: string;
|
|
7
|
+
|
|
8
|
+
@Field(() => String)
|
|
9
|
+
MeetingID: string;
|
|
10
|
+
|
|
11
|
+
@Field(() => String, { nullable: true })
|
|
12
|
+
UserID?: string;
|
|
13
|
+
|
|
14
|
+
@Field(() => String, { nullable: true })
|
|
15
|
+
UserName?: string;
|
|
16
|
+
|
|
17
|
+
@Field(() => String, { nullable: true })
|
|
18
|
+
UserEmail?: string;
|
|
19
|
+
|
|
20
|
+
@Field(() => String, { nullable: true })
|
|
21
|
+
AgentID?: string;
|
|
22
|
+
|
|
23
|
+
@Field(() => String, { nullable: true })
|
|
24
|
+
AgentName?: string;
|
|
25
|
+
|
|
26
|
+
@Field(() => String, { nullable: true })
|
|
27
|
+
ExternalName?: string;
|
|
28
|
+
|
|
29
|
+
@Field(() => String, { nullable: true })
|
|
30
|
+
ExternalEmail?: string;
|
|
31
|
+
|
|
32
|
+
@Field(() => String, { nullable: true })
|
|
33
|
+
ExternalPhone?: string;
|
|
34
|
+
|
|
35
|
+
@Field(() => String)
|
|
36
|
+
Role: string;
|
|
37
|
+
|
|
38
|
+
@Field(() => String)
|
|
39
|
+
InviteStatus: string;
|
|
40
|
+
|
|
41
|
+
@Field(() => Date, { nullable: true })
|
|
42
|
+
JoinedAt?: Date;
|
|
43
|
+
|
|
44
|
+
@Field(() => Date, { nullable: true })
|
|
45
|
+
LeftAt?: Date;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@ObjectType()
|
|
49
|
+
export class MeetingType {
|
|
50
|
+
@Field(() => String)
|
|
51
|
+
ID: string;
|
|
52
|
+
|
|
53
|
+
@Field(() => String)
|
|
54
|
+
Title: string;
|
|
55
|
+
|
|
56
|
+
@Field(() => String, { nullable: true })
|
|
57
|
+
Description?: string;
|
|
58
|
+
|
|
59
|
+
@Field(() => String)
|
|
60
|
+
HostUserID: string;
|
|
61
|
+
|
|
62
|
+
@Field(() => String, { nullable: true })
|
|
63
|
+
HostUserName?: string;
|
|
64
|
+
|
|
65
|
+
@Field(() => String)
|
|
66
|
+
RoomName: string;
|
|
67
|
+
|
|
68
|
+
@Field(() => String)
|
|
69
|
+
Status: string;
|
|
70
|
+
|
|
71
|
+
@Field(() => Date, { nullable: true })
|
|
72
|
+
ScheduledStartAt?: Date;
|
|
73
|
+
|
|
74
|
+
@Field(() => Date, { nullable: true })
|
|
75
|
+
ScheduledEndAt?: Date;
|
|
76
|
+
|
|
77
|
+
@Field(() => Date, { nullable: true })
|
|
78
|
+
StartedAt?: Date;
|
|
79
|
+
|
|
80
|
+
@Field(() => Date, { nullable: true })
|
|
81
|
+
EndedAt?: Date;
|
|
82
|
+
|
|
83
|
+
@Field(() => Boolean)
|
|
84
|
+
AllowPhoneDialIn: boolean;
|
|
85
|
+
|
|
86
|
+
@Field(() => String, { nullable: true })
|
|
87
|
+
DialInPhoneNumberID?: string;
|
|
88
|
+
|
|
89
|
+
@Field(() => String, { nullable: true })
|
|
90
|
+
DialInPhoneNumber?: string;
|
|
91
|
+
|
|
92
|
+
@Field(() => String, { nullable: true })
|
|
93
|
+
DialInCode?: string;
|
|
94
|
+
|
|
95
|
+
@Field(() => String)
|
|
96
|
+
RecordingPolicy: string;
|
|
97
|
+
|
|
98
|
+
@Field(() => String, { nullable: true })
|
|
99
|
+
ConversationID?: string;
|
|
100
|
+
|
|
101
|
+
@Field(() => [MeetingParticipantType], { nullable: true })
|
|
102
|
+
Participants?: MeetingParticipantType[];
|
|
103
|
+
|
|
104
|
+
@Field(() => Date)
|
|
105
|
+
CreatedAt: Date;
|
|
106
|
+
|
|
107
|
+
@Field(() => Date)
|
|
108
|
+
UpdatedAt: Date;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@ObjectType()
|
|
112
|
+
export class MeetingResult {
|
|
113
|
+
@Field(() => Boolean)
|
|
114
|
+
Success: boolean;
|
|
115
|
+
|
|
116
|
+
@Field(() => String, { nullable: true })
|
|
117
|
+
ErrorMessage?: string;
|
|
118
|
+
|
|
119
|
+
@Field(() => MeetingType, { nullable: true })
|
|
120
|
+
Meeting?: MeetingType;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@ObjectType()
|
|
124
|
+
export class StartMeetingResult {
|
|
125
|
+
@Field(() => Boolean)
|
|
126
|
+
Success: boolean;
|
|
127
|
+
|
|
128
|
+
@Field(() => String, { nullable: true })
|
|
129
|
+
ErrorMessage?: string;
|
|
130
|
+
|
|
131
|
+
@Field(() => MeetingType, { nullable: true })
|
|
132
|
+
Meeting?: MeetingType;
|
|
133
|
+
|
|
134
|
+
@Field(() => String, { nullable: true })
|
|
135
|
+
RoomName?: string;
|
|
136
|
+
|
|
137
|
+
@Field(() => String, { nullable: true })
|
|
138
|
+
ClientToken?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@ObjectType()
|
|
142
|
+
export class DialInPhoneNumberType {
|
|
143
|
+
@Field(() => String)
|
|
144
|
+
ID: string;
|
|
145
|
+
|
|
146
|
+
@Field(() => String)
|
|
147
|
+
Number: string;
|
|
148
|
+
|
|
149
|
+
@Field(() => String, { nullable: true })
|
|
150
|
+
Label?: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@InputType()
|
|
154
|
+
export class MeetingParticipantInput {
|
|
155
|
+
@Field(() => String, { nullable: true })
|
|
156
|
+
UserID?: string;
|
|
157
|
+
|
|
158
|
+
@Field(() => String, { nullable: true })
|
|
159
|
+
AgentID?: string;
|
|
160
|
+
|
|
161
|
+
@Field(() => String, { nullable: true })
|
|
162
|
+
ExternalName?: string;
|
|
163
|
+
|
|
164
|
+
@Field(() => String, { nullable: true })
|
|
165
|
+
ExternalEmail?: string;
|
|
166
|
+
|
|
167
|
+
@Field(() => String, { nullable: true })
|
|
168
|
+
ExternalPhone?: string;
|
|
169
|
+
|
|
170
|
+
@Field(() => String, { nullable: true })
|
|
171
|
+
Role?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
@InputType()
|
|
175
|
+
export class CreateMeetingInput {
|
|
176
|
+
@Field(() => String)
|
|
177
|
+
Title: string;
|
|
178
|
+
|
|
179
|
+
@Field(() => String, { nullable: true })
|
|
180
|
+
Description?: string;
|
|
181
|
+
|
|
182
|
+
@Field(() => Date, { nullable: true })
|
|
183
|
+
ScheduledStartAt?: Date;
|
|
184
|
+
|
|
185
|
+
@Field(() => Date, { nullable: true })
|
|
186
|
+
ScheduledEndAt?: Date;
|
|
187
|
+
|
|
188
|
+
@Field(() => Boolean, { nullable: true })
|
|
189
|
+
AllowPhoneDialIn?: boolean;
|
|
190
|
+
|
|
191
|
+
@Field(() => String, { nullable: true })
|
|
192
|
+
DialInPhoneNumberID?: string;
|
|
193
|
+
|
|
194
|
+
@Field(() => String, { nullable: true })
|
|
195
|
+
RecordingPolicy?: string;
|
|
196
|
+
|
|
197
|
+
@Field(() => [MeetingParticipantInput], { nullable: true })
|
|
198
|
+
Participants?: MeetingParticipantInput[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@InputType()
|
|
202
|
+
export class UpdateMeetingInput {
|
|
203
|
+
@Field(() => String)
|
|
204
|
+
MeetingID: string;
|
|
205
|
+
|
|
206
|
+
@Field(() => String, { nullable: true })
|
|
207
|
+
Title?: string;
|
|
208
|
+
|
|
209
|
+
@Field(() => String, { nullable: true })
|
|
210
|
+
Description?: string;
|
|
211
|
+
|
|
212
|
+
@Field(() => Date, { nullable: true })
|
|
213
|
+
ScheduledStartAt?: Date;
|
|
214
|
+
|
|
215
|
+
@Field(() => Date, { nullable: true })
|
|
216
|
+
ScheduledEndAt?: Date;
|
|
217
|
+
|
|
218
|
+
@Field(() => Boolean, { nullable: true })
|
|
219
|
+
AllowPhoneDialIn?: boolean;
|
|
220
|
+
|
|
221
|
+
@Field(() => String, { nullable: true })
|
|
222
|
+
DialInPhoneNumberID?: string;
|
|
223
|
+
|
|
224
|
+
@Field(() => String, { nullable: true })
|
|
225
|
+
RecordingPolicy?: string;
|
|
226
|
+
|
|
227
|
+
@Field(() => [MeetingParticipantInput], { nullable: true })
|
|
228
|
+
Participants?: MeetingParticipantInput[];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
@InputType()
|
|
232
|
+
export class RSVPMeetingInput {
|
|
233
|
+
@Field(() => String)
|
|
234
|
+
MeetingID: string;
|
|
235
|
+
|
|
236
|
+
@Field(() => String)
|
|
237
|
+
InviteStatus: string;
|
|
238
|
+
}
|
|
@@ -206,17 +206,20 @@ export class ViewOperationsHandler {
|
|
|
206
206
|
params.Fields = (params.Fields as string).split(',');
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
// Sanitize numeric values
|
|
209
|
+
// Sanitize numeric values: base-10, integral, and clamped to a sane range so a
|
|
210
|
+
// negative/garbage value can never reach the SQL layer.
|
|
210
211
|
if (params.MaxRows !== undefined) {
|
|
211
|
-
|
|
212
|
-
? parseInt(params.MaxRows as string)
|
|
212
|
+
const parsed = typeof params.MaxRows === 'string'
|
|
213
|
+
? parseInt(params.MaxRows as string, 10)
|
|
213
214
|
: params.MaxRows;
|
|
215
|
+
params.MaxRows = Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed as number)) : undefined;
|
|
214
216
|
}
|
|
215
217
|
|
|
216
218
|
if (params.StartRow !== undefined) {
|
|
217
|
-
|
|
218
|
-
? parseInt(params.StartRow as string)
|
|
219
|
+
const parsed = typeof params.StartRow === 'string'
|
|
220
|
+
? parseInt(params.StartRow as string, 10)
|
|
219
221
|
: params.StartRow;
|
|
222
|
+
params.StartRow = Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed as number)) : undefined;
|
|
220
223
|
}
|
|
221
224
|
|
|
222
225
|
// Default ResultType if not provided
|
|
@@ -35,9 +35,19 @@ export class TaskGraphActionRunner implements TaskActionRunner {
|
|
|
35
35
|
Filters: [],
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
const output = this.buildOutput(result.Params);
|
|
39
|
+
const unstorable = this.findUnstorableOutput(output);
|
|
40
|
+
if (unstorable) {
|
|
41
|
+
const message =
|
|
42
|
+
`Action "${action.Name}" returned output param "${unstorable.Name}", which cannot be stored ` +
|
|
43
|
+
`as the step's result: ${unstorable.Reason}. Output params of a durable step must be plain data.`;
|
|
44
|
+
LogError(`[TaskGraphActionRunner] Task ${params.TaskID}: ${message}`);
|
|
45
|
+
return { Success: false, ErrorMessage: message, ActionLogID: result.LogEntry?.ID };
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
return {
|
|
39
49
|
Success: result.Success,
|
|
40
|
-
Output:
|
|
50
|
+
Output: output,
|
|
41
51
|
ErrorMessage: result.Success ? undefined : result.Message,
|
|
42
52
|
// The engine already wrote the log; this is the only place its id is in hand. Without
|
|
43
53
|
// carrying it out, a workflow's action step has no path back to its own execution
|
|
@@ -51,6 +61,24 @@ export class TaskGraphActionRunner implements TaskActionRunner {
|
|
|
51
61
|
}
|
|
52
62
|
}
|
|
53
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The first output param that cannot be written to the Task row as JSON, or null.
|
|
66
|
+
*
|
|
67
|
+
* Checked here because this is the last place that knows the action's and the param's NAMES: the
|
|
68
|
+
* dispatcher serializes the whole Output later and, for a live object (an entity, an observable),
|
|
69
|
+
* fails with a bare "Converting circular structure to JSON" that names neither.
|
|
70
|
+
*/
|
|
71
|
+
private findUnstorableOutput(output: Record<string, unknown>): { Name: string; Reason: string } | null {
|
|
72
|
+
for (const [Name, value] of Object.entries(output)) {
|
|
73
|
+
try {
|
|
74
|
+
JSON.stringify(value);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return { Name, Reason: e instanceof Error ? e.message.split('\n')[0] : String(e) };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
54
82
|
/**
|
|
55
83
|
* Projects the action's parameters onto the flat result a workflow step is expected to produce.
|
|
56
84
|
*
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Starts the durable work-queue host inside MJServer when `workQueue.enabled` is set.
|
|
3
|
+
* @module MJServer/services
|
|
4
|
+
*/
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { hostname } from 'node:os';
|
|
7
|
+
import sql from 'mssql';
|
|
8
|
+
import { LogError, LogStatus } from '@memberjunction/core';
|
|
9
|
+
import type { DatabaseProviderBase, IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
10
|
+
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
11
|
+
import { MJWorkLogger, WorkQueueEngine, WorkQueueHost } from '@memberjunction/work-queue-engine';
|
|
12
|
+
import type { WorkQueueHostConfig, WorkQueueProviderSource } from '@memberjunction/work-queue-engine';
|
|
13
|
+
import { TaskGraphProviderFactory } from './TaskGraphProviderFactory.js';
|
|
14
|
+
import type { WorkQueueConfig } from './workQueueConfig.js';
|
|
15
|
+
|
|
16
|
+
export const WORK_QUEUE_DISABLE_ENV = 'MJ_DISABLE_WORK_QUEUE_HOST';
|
|
17
|
+
|
|
18
|
+
/** Matches the width of WorkQueueDelivery.LeaseOwner. */
|
|
19
|
+
const MAX_INSTANCE_ID_LENGTH = 200;
|
|
20
|
+
|
|
21
|
+
type EnvironmentVariables = Record<string, string | undefined>;
|
|
22
|
+
|
|
23
|
+
export interface StartableWorkQueueHost {
|
|
24
|
+
Start(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Seams for tests; production uses DEFAULT_WORK_QUEUE_HOST_START_DEPENDENCIES. */
|
|
28
|
+
export interface WorkQueueHostStartDependencies {
|
|
29
|
+
Env: EnvironmentVariables;
|
|
30
|
+
FindUserByEmail(email: string): UserInfo | undefined;
|
|
31
|
+
ConfigureEngine(user: UserInfo, provider: DatabaseProviderBase): Promise<WorkQueueEngine>;
|
|
32
|
+
CreateHost(config: WorkQueueHostConfig, engine: WorkQueueEngine, user: UserInfo, provider: DatabaseProviderBase, providerSource: WorkQueueProviderSource): StartableWorkQueueHost;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_WORK_QUEUE_HOST_START_DEPENDENCIES: WorkQueueHostStartDependencies = {
|
|
36
|
+
Env: process.env,
|
|
37
|
+
FindUserByEmail: email => UserCache.Users.find(user => user.Email?.trim().toLowerCase() === email.trim().toLowerCase()),
|
|
38
|
+
ConfigureEngine: async (user, provider) => {
|
|
39
|
+
await WorkQueueEngine.Instance.Config(false, user, provider);
|
|
40
|
+
await ReportTopologyIssues(WorkQueueEngine.Instance);
|
|
41
|
+
return WorkQueueEngine.Instance;
|
|
42
|
+
},
|
|
43
|
+
CreateHost: (config, engine, user, provider, providerSource) =>
|
|
44
|
+
new WorkQueueHost(config, engine, user, provider, new MJWorkLogger(), { ProviderSource: providerSource }),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Topology rows, capability gating, driver bindings and database prerequisites (03 §11) — including SQL Server's
|
|
49
|
+
* READ_COMMITTED_SNAPSHOT requirement (03 §7). Problems are logged, never thrown: the planner still gates each
|
|
50
|
+
* subscription, and an operator can read the same list from WorkQueue.ValidateBindings.
|
|
51
|
+
*/
|
|
52
|
+
async function ReportTopologyIssues(engine: WorkQueueEngine): Promise<void> {
|
|
53
|
+
try {
|
|
54
|
+
for (const issue of await engine.ValidateTopology()) {
|
|
55
|
+
const line = `[WorkQueue] ${issue.Severity}: ${issue.Subject} — ${issue.Message}`;
|
|
56
|
+
if (issue.Severity === 'Error') {
|
|
57
|
+
LogError(line);
|
|
58
|
+
} else {
|
|
59
|
+
LogStatus(line);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
LogError(`[WorkQueue] Topology validation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function IsWorkQueueHostDisabledByEnv(env: EnvironmentVariables): boolean {
|
|
68
|
+
return env[WORK_QUEUE_DISABLE_ENV] === '1';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Host and pid for humans reading logs; the random suffix for uniqueness — containers routinely share pid 1 and
|
|
73
|
+
* HOSTNAME is not always exported. A restart is a new instance, so a dead process's leases simply expire.
|
|
74
|
+
*/
|
|
75
|
+
export function CreateWorkQueueInstanceID(env: EnvironmentVariables = process.env): string {
|
|
76
|
+
const host = env.HOSTNAME ?? hostname();
|
|
77
|
+
return `${host}-${process.pid}-${randomBytes(4).toString('hex')}`.slice(0, MAX_INSTANCE_ID_LENGTH);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function BuildWorkQueueHostConfig(config: WorkQueueConfig, instanceID: string): WorkQueueHostConfig {
|
|
81
|
+
return {
|
|
82
|
+
InstanceID: instanceID,
|
|
83
|
+
Subscriptions: config.subscriptions.map(entry => ({ Name: entry.name, Concurrency: entry.concurrency })),
|
|
84
|
+
IdlePollMinMs: config.idlePollMinMs,
|
|
85
|
+
IdlePollMaxMs: config.idlePollMaxMs,
|
|
86
|
+
ShutdownDrainMs: config.shutdownDrainMs,
|
|
87
|
+
SweeperIntervalMs: config.sweeperEnabled ? config.sweeperIntervalMs : 0,
|
|
88
|
+
ReconcileIntervalMs: config.reconcileIntervalMs,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A fresh SQL Server provider per delivery over the shared pool; the server provider otherwise. */
|
|
93
|
+
export class MJServerWorkQueueProviderSource implements WorkQueueProviderSource {
|
|
94
|
+
private readonly factory: TaskGraphProviderFactory | null;
|
|
95
|
+
|
|
96
|
+
constructor(pool: sql.ConnectionPool | null, private readonly fallback: IMetadataProvider) {
|
|
97
|
+
this.factory = pool ? new TaskGraphProviderFactory(pool) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public CreateProvider(): Promise<IMetadataProvider> {
|
|
101
|
+
return this.factory ? this.factory.CreateProvider() : Promise.resolve(this.fallback);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Returns the started host, or null when this instance runs no subscriptions. */
|
|
106
|
+
export async function StartWorkQueueHost(
|
|
107
|
+
config: WorkQueueConfig,
|
|
108
|
+
provider: DatabaseProviderBase,
|
|
109
|
+
providerSource: WorkQueueProviderSource,
|
|
110
|
+
dependencies: WorkQueueHostStartDependencies = DEFAULT_WORK_QUEUE_HOST_START_DEPENDENCIES,
|
|
111
|
+
): Promise<StartableWorkQueueHost | null> {
|
|
112
|
+
if (!config.enabled) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
if (IsWorkQueueHostDisabledByEnv(dependencies.Env)) {
|
|
116
|
+
LogStatus(`[WorkQueue] Host disabled by ${WORK_QUEUE_DISABLE_ENV}=1 — this process publishes but runs no subscriptions.`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const user = dependencies.FindUserByEmail(config.systemUserEmail);
|
|
120
|
+
if (!user) {
|
|
121
|
+
throw new Error(`[WorkQueue] System user not found with email: ${config.systemUserEmail} — set workQueue.systemUserEmail in mj.config.cjs`);
|
|
122
|
+
}
|
|
123
|
+
const engine = await dependencies.ConfigureEngine(user, provider);
|
|
124
|
+
const hostConfig = BuildWorkQueueHostConfig(config, CreateWorkQueueInstanceID(dependencies.Env));
|
|
125
|
+
const host = dependencies.CreateHost(hostConfig, engine, user, provider, providerSource);
|
|
126
|
+
await host.Start();
|
|
127
|
+
LogStatus(`🔄 Work Queue host ${hostConfig.InstanceID}: ${hostConfig.Subscriptions.map(s => `${s.Name}×${s.Concurrency}`).join(', ')}`);
|
|
128
|
+
return host;
|
|
129
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The `workQueue` configuration section. Kept out of config.ts so it can be tested without
|
|
3
|
+
* config.ts's module-load database validation.
|
|
4
|
+
* @module MJServer/services
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
export const WorkQueueSubscriptionEntrySchema = z.object({
|
|
9
|
+
/** A subscription name, or '*' for every MJWorker subscription. */
|
|
10
|
+
name: z.string().min(1),
|
|
11
|
+
/** Deliveries this instance processes concurrently for the subscription. */
|
|
12
|
+
concurrency: z.number().int().positive().optional().default(4),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Durable work-queue host. When enabled, this process runs its share of MJWorker subscriptions. Every claim is atomic
|
|
17
|
+
* against shared state, so any number of instances may enable it against one database.
|
|
18
|
+
*/
|
|
19
|
+
export const WorkQueueSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
enabled: z.boolean().optional().default(false),
|
|
22
|
+
systemUserEmail: z.string().optional().default('system@memberjunction.org'),
|
|
23
|
+
subscriptions: z.array(WorkQueueSubscriptionEntrySchema).optional().default([{ name: '*', concurrency: 4 }]),
|
|
24
|
+
idlePollMinMs: z.number().int().positive().optional().default(250),
|
|
25
|
+
idlePollMaxMs: z.number().int().positive().optional().default(5000),
|
|
26
|
+
shutdownDrainMs: z.number().int().nonnegative().optional().default(8000),
|
|
27
|
+
sweeperEnabled: z.boolean().optional().default(true),
|
|
28
|
+
sweeperIntervalMs: z.number().int().positive().optional().default(60000),
|
|
29
|
+
reconcileIntervalMs: z.number().int().nonnegative().optional().default(30000),
|
|
30
|
+
})
|
|
31
|
+
.refine(config => config.idlePollMaxMs >= config.idlePollMinMs, {
|
|
32
|
+
message: 'workQueue.idlePollMaxMs must be >= workQueue.idlePollMinMs',
|
|
33
|
+
path: ['idlePollMaxMs'],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type WorkQueueConfig = z.infer<typeof WorkQueueSchema>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The merge base for mj.config.cjs. Like scheduledJobs and integrationSyncWorker, the EFFECTIVE default system user
|
|
40
|
+
* is the placeholder: set workQueue.systemUserEmail explicitly, or StartWorkQueueHost fails fast and says so.
|
|
41
|
+
*/
|
|
42
|
+
export const DEFAULT_WORK_QUEUE_CONFIG: WorkQueueConfig = {
|
|
43
|
+
enabled: false,
|
|
44
|
+
systemUserEmail: 'not.set@nowhere.com',
|
|
45
|
+
subscriptions: [{ name: '*', concurrency: 4 }],
|
|
46
|
+
idlePollMinMs: 250,
|
|
47
|
+
idlePollMaxMs: 5000,
|
|
48
|
+
shutdownDrainMs: 8000,
|
|
49
|
+
sweeperEnabled: true,
|
|
50
|
+
sweeperIntervalMs: 60000,
|
|
51
|
+
reconcileIntervalMs: 30000,
|
|
52
|
+
};
|
package/src/types.ts
CHANGED
|
@@ -121,6 +121,11 @@ export type RunViewGenericParams = {
|
|
|
121
121
|
* Propagated to `RunViewParams.DataSource`.
|
|
122
122
|
*/
|
|
123
123
|
dataSource?: 'Live' | 'Materialized';
|
|
124
|
+
/**
|
|
125
|
+
* When true, binary fields (`varbinary` / `bytea`, as base64 strings) are returned.
|
|
126
|
+
* Propagated to `RunViewParams.IncludeBinaryFields`.
|
|
127
|
+
*/
|
|
128
|
+
includeBinaryFields?: boolean;
|
|
124
129
|
};
|
|
125
130
|
|
|
126
131
|
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure, dependency-free helpers for {@link AdhocQueryResolver}. Kept in their own
|
|
3
|
-
* module (no type-graphql / mssql imports) so the total-row-count boundary logic is
|
|
4
|
-
* unit-testable without standing up the GraphQL resolver or a database.
|
|
5
|
-
*/
|
|
6
|
-
/**
|
|
7
|
-
* Determines the exact total row count from the page alone, when it can be known
|
|
8
|
-
* without a separate `COUNT(*)`:
|
|
9
|
-
*
|
|
10
|
-
* - **Not paging** (`maxRows == null`): the query ran uncapped, so every row came
|
|
11
|
-
* back — the total is just the number returned.
|
|
12
|
-
* - **Short page** (`pageLength < maxRows`): a page shorter than the page size means
|
|
13
|
-
* there are no rows beyond it, so the total is `startRow + pageLength` exactly.
|
|
14
|
-
* - **Full page** (`pageLength >= maxRows`): more rows may exist — returns `null` to
|
|
15
|
-
* signal that a `COUNT(*)` is required to know the true total.
|
|
16
|
-
*/
|
|
17
|
-
export declare function ExactTotalFromPage(startRow: number, pageLength: number, maxRows: number | null): number | null;
|
|
18
|
-
/** @deprecated Use {@link ExactTotalFromPage}. */
|
|
19
|
-
export declare function exactTotalFromPage(startRow: number, pageLength: number, maxRows: number | null): number | null;
|
|
20
|
-
/**
|
|
21
|
-
* Reads the total row count from a `COUNT(*)` recordset (`[{ TotalRowCount }]`),
|
|
22
|
-
* falling back to `fallback` when the count is absent, non-numeric, or negative — so
|
|
23
|
-
* an unexpected count shape never yields a misleading total. `fallback` should be a
|
|
24
|
-
* safe lower bound (e.g. `startRow + rowsReturned`).
|
|
25
|
-
*/
|
|
26
|
-
export declare function ResolveAdhocTotalRowCount(countRows: ReadonlyArray<{
|
|
27
|
-
TotalRowCount?: unknown;
|
|
28
|
-
}> | null | undefined, fallback: number): number;
|
|
29
|
-
/** @deprecated Use {@link ResolveAdhocTotalRowCount}. */
|
|
30
|
-
export declare function resolveAdhocTotalRowCount(countRows: ReadonlyArray<{
|
|
31
|
-
TotalRowCount?: unknown;
|
|
32
|
-
}> | null | undefined, fallback: number): number;
|
|
33
|
-
//# sourceMappingURL=adhoc-query-helpers.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"adhoc-query-helpers.d.ts","sourceRoot":"","sources":["../../src/resolvers/adhoc-query-helpers.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAC9B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GAAG,IAAI,GACvB,MAAM,GAAG,IAAI,CAQf;AAED,kDAAkD;AAClD,wBAAgB,kBAAkB,CAC9B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GAAG,IAAI,GACvB,MAAM,GAAG,IAAI,CAEf;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CACrC,SAAS,EAAE,aAAa,CAAC;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,GAAG,IAAI,GAAG,SAAS,EACxE,QAAQ,EAAE,MAAM,GACjB,MAAM,CASR;AAED,yDAAyD;AACzD,wBAAgB,yBAAyB,CACrC,SAAS,EAAE,aAAa,CAAC;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,GAAG,IAAI,GAAG,SAAS,EACxE,QAAQ,EAAE,MAAM,GACjB,MAAM,CAER"}
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure, dependency-free helpers for {@link AdhocQueryResolver}. Kept in their own
|
|
3
|
-
* module (no type-graphql / mssql imports) so the total-row-count boundary logic is
|
|
4
|
-
* unit-testable without standing up the GraphQL resolver or a database.
|
|
5
|
-
*/
|
|
6
|
-
/**
|
|
7
|
-
* Determines the exact total row count from the page alone, when it can be known
|
|
8
|
-
* without a separate `COUNT(*)`:
|
|
9
|
-
*
|
|
10
|
-
* - **Not paging** (`maxRows == null`): the query ran uncapped, so every row came
|
|
11
|
-
* back — the total is just the number returned.
|
|
12
|
-
* - **Short page** (`pageLength < maxRows`): a page shorter than the page size means
|
|
13
|
-
* there are no rows beyond it, so the total is `startRow + pageLength` exactly.
|
|
14
|
-
* - **Full page** (`pageLength >= maxRows`): more rows may exist — returns `null` to
|
|
15
|
-
* signal that a `COUNT(*)` is required to know the true total.
|
|
16
|
-
*/
|
|
17
|
-
export function ExactTotalFromPage(startRow, pageLength, maxRows) {
|
|
18
|
-
if (maxRows == null) {
|
|
19
|
-
return pageLength;
|
|
20
|
-
}
|
|
21
|
-
if (pageLength < maxRows) {
|
|
22
|
-
return startRow + pageLength;
|
|
23
|
-
}
|
|
24
|
-
return null;
|
|
25
|
-
}
|
|
26
|
-
/** @deprecated Use {@link ExactTotalFromPage}. */
|
|
27
|
-
export function exactTotalFromPage(startRow, pageLength, maxRows) {
|
|
28
|
-
return ExactTotalFromPage(startRow, pageLength, maxRows);
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Reads the total row count from a `COUNT(*)` recordset (`[{ TotalRowCount }]`),
|
|
32
|
-
* falling back to `fallback` when the count is absent, non-numeric, or negative — so
|
|
33
|
-
* an unexpected count shape never yields a misleading total. `fallback` should be a
|
|
34
|
-
* safe lower bound (e.g. `startRow + rowsReturned`).
|
|
35
|
-
*/
|
|
36
|
-
export function ResolveAdhocTotalRowCount(countRows, fallback) {
|
|
37
|
-
const raw = countRows?.[0]?.TotalRowCount;
|
|
38
|
-
if (raw != null) {
|
|
39
|
-
const n = Number(raw);
|
|
40
|
-
if (Number.isFinite(n) && n >= 0) {
|
|
41
|
-
return Math.floor(n);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return fallback;
|
|
45
|
-
}
|
|
46
|
-
/** @deprecated Use {@link ResolveAdhocTotalRowCount}. */
|
|
47
|
-
export function resolveAdhocTotalRowCount(countRows, fallback) {
|
|
48
|
-
return ResolveAdhocTotalRowCount(countRows, fallback);
|
|
49
|
-
}
|
|
50
|
-
//# sourceMappingURL=adhoc-query-helpers.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"adhoc-query-helpers.js","sourceRoot":"","sources":["../../src/resolvers/adhoc-query-helpers.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,UAAU,kBAAkB,CAC9B,QAAgB,EAChB,UAAkB,EAClB,OAAsB;IAEtB,IAAI,OAAO,IAAI,IAAI,EAAE,CAAC;QAClB,OAAO,UAAU,CAAC;IACtB,CAAC;IACD,IAAI,UAAU,GAAG,OAAO,EAAE,CAAC;QACvB,OAAO,QAAQ,GAAG,UAAU,CAAC;IACjC,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED,kDAAkD;AAClD,MAAM,UAAU,kBAAkB,CAC9B,QAAgB,EAChB,UAAkB,EAClB,OAAsB;IAEtB,OAAO,kBAAkB,CAAC,QAAQ,EAAE,UAAU,EAAE,OAAO,CAAC,CAAC;AAC7D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,yBAAyB,CACrC,SAAwE,EACxE,QAAgB;IAEhB,MAAM,GAAG,GAAG,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,aAAa,CAAC;IAC1C,IAAI,GAAG,IAAI,IAAI,EAAE,CAAC;QACd,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;QACtB,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;YAC/B,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACzB,CAAC;IACL,CAAC;IACD,OAAO,QAAQ,CAAC;AACpB,CAAC;AAED,yDAAyD;AACzD,MAAM,UAAU,yBAAyB,CACrC,SAAwE,EACxE,QAAgB;IAEhB,OAAO,yBAAyB,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;AAC1D,CAAC"}
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { ExactTotalFromPage, ResolveAdhocTotalRowCount } from '../resolvers/adhoc-query-helpers';
|
|
3
|
-
|
|
4
|
-
describe('exactTotalFromPage', () => {
|
|
5
|
-
it('returns the row count when not paging (maxRows null) — the query was uncapped', () => {
|
|
6
|
-
expect(ExactTotalFromPage(0, 47, null)).toBe(47);
|
|
7
|
-
expect(ExactTotalFromPage(0, 0, null)).toBe(0);
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
it('returns startRow + pageLength for a SHORT page (fewer rows than the page size)', () => {
|
|
11
|
-
expect(ExactTotalFromPage(0, 47, 100)).toBe(47); // page 1, short → exact total
|
|
12
|
-
expect(ExactTotalFromPage(200, 30, 100)).toBe(230); // page 3, short last page
|
|
13
|
-
expect(ExactTotalFromPage(100, 0, 100)).toBe(100); // empty page past the end
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it('returns null for a FULL page — a COUNT is required because more rows may exist', () => {
|
|
17
|
-
expect(ExactTotalFromPage(0, 100, 100)).toBeNull();
|
|
18
|
-
expect(ExactTotalFromPage(500, 100, 100)).toBeNull();
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it('treats an over-full page defensively as "needs count" (should not occur with OFFSET/FETCH)', () => {
|
|
22
|
-
expect(ExactTotalFromPage(0, 150, 100)).toBeNull();
|
|
23
|
-
});
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
describe('resolveAdhocTotalRowCount', () => {
|
|
27
|
-
it('reads TotalRowCount from the count recordset', () => {
|
|
28
|
-
expect(ResolveAdhocTotalRowCount([{ TotalRowCount: 2087 }], 100)).toBe(2087);
|
|
29
|
-
expect(ResolveAdhocTotalRowCount([{ TotalRowCount: 0 }], 100)).toBe(0);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('floors a fractional/stringified count', () => {
|
|
33
|
-
expect(ResolveAdhocTotalRowCount([{ TotalRowCount: 42.9 }], 0)).toBe(42);
|
|
34
|
-
expect(ResolveAdhocTotalRowCount([{ TotalRowCount: '2087' as unknown as number }], 0)).toBe(2087);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('falls back when the count is absent, empty, null, or malformed', () => {
|
|
38
|
-
expect(ResolveAdhocTotalRowCount(null, 100)).toBe(100);
|
|
39
|
-
expect(ResolveAdhocTotalRowCount(undefined, 100)).toBe(100);
|
|
40
|
-
expect(ResolveAdhocTotalRowCount([], 100)).toBe(100);
|
|
41
|
-
expect(ResolveAdhocTotalRowCount([{}], 100)).toBe(100);
|
|
42
|
-
expect(ResolveAdhocTotalRowCount([{ TotalRowCount: null }], 100)).toBe(100);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('falls back on non-finite or negative counts (never reports a misleading total)', () => {
|
|
46
|
-
expect(ResolveAdhocTotalRowCount([{ TotalRowCount: Number.NaN }], 100)).toBe(100);
|
|
47
|
-
expect(ResolveAdhocTotalRowCount([{ TotalRowCount: -5 }], 100)).toBe(100);
|
|
48
|
-
expect(ResolveAdhocTotalRowCount([{ TotalRowCount: Infinity }], 100)).toBe(100);
|
|
49
|
-
});
|
|
50
|
-
});
|