@ixo/oracle-runtime-workers 0.31.0 → 0.33.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.
@@ -1,16 +1,11 @@
1
- import {
2
- ElementFactory,
1
+ import type * as SurveyCore from 'survey-core';
2
+ import type {
3
+ ItemValue,
3
4
  Model,
4
5
  Question,
5
- QuestionBooleanModel,
6
- QuestionCheckboxModel,
7
- QuestionFileModel,
8
6
  QuestionPanelDynamicModel,
9
7
  QuestionRatingModel,
10
8
  QuestionSelectBase,
11
- QuestionTextModel,
12
- Serializer,
13
- type ItemValue,
14
9
  } from 'survey-core';
15
10
  import { MAX_SECTION_ITEMS } from './drafts';
16
11
  import type { ChoiceQuestion } from './judge';
@@ -37,45 +32,93 @@ const DEFAULT_GRID_PRECISION = 7;
37
32
  /** Choices shown per question; a longer list is cut (Yoma forms have at most a few dozen). */
38
33
  const MAX_OPTIONS = 60;
39
34
 
40
- class QuestionMapGridSelectorModel extends Question {
41
- override getType() {
42
- return MAP_GRID_TYPE;
43
- }
44
- override isEmpty(): boolean {
45
- const value: unknown = this.value;
46
- if (value === null || value === undefined) return true;
47
- return Array.isArray(value) ? value.length === 0 : false;
35
+ /**
36
+ * Jambo's map question as a survey-core question type, registered with the
37
+ * loaded module's `Serializer` and `ElementFactory` so forms that use it
38
+ * open. Defined here rather than at module scope: a class needs its base
39
+ * class when it is defined.
40
+ */
41
+ function defineMapGridQuestion(core: typeof SurveyCore) {
42
+ class QuestionMapGridSelectorModel extends core.Question {
43
+ override getType() {
44
+ return MAP_GRID_TYPE;
45
+ }
46
+ override isEmpty(): boolean {
47
+ const value: unknown = this.value;
48
+ if (value === null || value === undefined) return true;
49
+ return Array.isArray(value) ? value.length === 0 : false;
50
+ }
51
+ get gridPrecision(): number {
52
+ return (
53
+ Number(this.getPropertyValue('gridPrecision')) || DEFAULT_GRID_PRECISION
54
+ );
55
+ }
56
+ set gridPrecision(value: number) {
57
+ this.setPropertyValue('gridPrecision', value);
58
+ }
59
+ get allowMultiple(): boolean {
60
+ return this.getPropertyValue('allowMultiple') === true;
61
+ }
62
+ set allowMultiple(value: boolean) {
63
+ this.setPropertyValue('allowMultiple', value);
64
+ }
48
65
  }
49
- get gridPrecision(): number {
50
- return (
51
- Number(this.getPropertyValue('gridPrecision')) || DEFAULT_GRID_PRECISION
66
+
67
+ if (!core.Serializer.findClass(MAP_GRID_TYPE)) {
68
+ core.Serializer.addClass(
69
+ MAP_GRID_TYPE,
70
+ [
71
+ { name: 'gridPrecision:number', default: DEFAULT_GRID_PRECISION },
72
+ { name: 'allowMultiple:boolean', default: false },
73
+ ],
74
+ () => new QuestionMapGridSelectorModel(''),
75
+ 'question',
76
+ );
77
+ core.ElementFactory.Instance.registerElement(
78
+ MAP_GRID_TYPE,
79
+ (name) => new QuestionMapGridSelectorModel(name),
52
80
  );
53
81
  }
54
- set gridPrecision(value: number) {
55
- this.setPropertyValue('gridPrecision', value);
56
- }
57
- get allowMultiple(): boolean {
58
- return this.getPropertyValue('allowMultiple') === true;
59
- }
60
- set allowMultiple(value: boolean) {
61
- this.setPropertyValue('allowMultiple', value);
62
- }
82
+ return QuestionMapGridSelectorModel;
63
83
  }
64
84
 
65
- if (!Serializer.findClass(MAP_GRID_TYPE)) {
66
- Serializer.addClass(
67
- MAP_GRID_TYPE,
68
- [
69
- { name: 'gridPrecision:number', default: DEFAULT_GRID_PRECISION },
70
- { name: 'allowMultiple:boolean', default: false },
71
- ],
72
- () => new QuestionMapGridSelectorModel(''),
73
- 'question',
74
- );
75
- ElementFactory.Instance.registerElement(
76
- MAP_GRID_TYPE,
77
- (name) => new QuestionMapGridSelectorModel(name),
85
+ /** survey-core once loaded, with the map question registered on it. */
86
+ export interface LoadedSurveyCore {
87
+ core: typeof SurveyCore;
88
+ MapGridQuestion: ReturnType<typeof defineMapGridQuestion>;
89
+ }
90
+
91
+ let loaded: LoadedSurveyCore | undefined;
92
+ let loading: Promise<void> | undefined;
93
+
94
+ /**
95
+ * Loads `survey-core` on first use rather than when the Worker starts:
96
+ * evaluating it (2.5 MB, every question class registered as its module
97
+ * runs) at startup pushed an oracle with this plugin to the Workers
98
+ * startup-time limit. Every function of this module that opens or reads a
99
+ * form model needs it, so callers await this first; it loads once per
100
+ * isolate, and a failed load is tried again on the next call.
101
+ */
102
+ export function loadSurveyCore(): Promise<void> {
103
+ loading ??= import('survey-core').then(
104
+ (core) => {
105
+ loaded = { core, MapGridQuestion: defineMapGridQuestion(core) };
106
+ },
107
+ (error: unknown) => {
108
+ loading = undefined;
109
+ throw error;
110
+ },
78
111
  );
112
+ return loading;
113
+ }
114
+
115
+ /** The loaded survey-core; throws when {@link loadSurveyCore} has not finished. */
116
+ export function surveyCore(): LoadedSurveyCore {
117
+ if (!loaded)
118
+ throw new Error(
119
+ 'survey-core is not loaded: await loadSurveyCore() before using the form engine',
120
+ );
121
+ return loaded;
79
122
  }
80
123
 
81
124
  /** How a question is answered in chat. */
@@ -188,7 +231,8 @@ export function openModel(
188
231
  template: unknown,
189
232
  data?: Record<string, unknown>,
190
233
  ): Model {
191
- const model = new Model(surveyJson(template));
234
+ const { core } = surveyCore();
235
+ const model = new core.Model(surveyJson(template));
192
236
  if (data) model.data = structuredClone(data);
193
237
  return model;
194
238
  }
@@ -225,10 +269,11 @@ function numericRating(question: QuestionRatingModel): number[] | undefined {
225
269
  }
226
270
 
227
271
  function inputFor(question: Question): ChatInput | undefined {
228
- if (question instanceof QuestionTextModel)
272
+ const { core, MapGridQuestion } = surveyCore();
273
+ if (question instanceof core.QuestionTextModel)
229
274
  return INPUT_BY_TEXT_TYPE[question.inputType] ?? 'text';
230
- if (question instanceof QuestionMapGridSelectorModel) return 'location';
231
- if (question instanceof QuestionRatingModel)
275
+ if (question instanceof MapGridQuestion) return 'location';
276
+ if (question instanceof core.QuestionRatingModel)
232
277
  return numericRating(question) ? 'rating' : 'pick-one';
233
278
  switch (question.getType()) {
234
279
  case 'comment':
@@ -270,7 +315,7 @@ function specialItems(question: QuestionSelectBase): {
270
315
  question.dontKnowItem,
271
316
  ];
272
317
  const skipped = new Set<ItemValue>([...extras, question.otherItem]);
273
- if (question instanceof QuestionCheckboxModel)
318
+ if (question instanceof surveyCore().core.QuestionCheckboxModel)
274
319
  skipped.add(question.selectAllItem);
275
320
  return { extras, other: question.otherItem, skipped };
276
321
  }
@@ -287,6 +332,7 @@ function describe(
287
332
  ): FormQuestion | undefined {
288
333
  const input = inputFor(question);
289
334
  if (!input) return undefined;
335
+ const { core, MapGridQuestion } = surveyCore();
290
336
  const view: FormQuestion = {
291
337
  path,
292
338
  name: question.name,
@@ -303,7 +349,7 @@ function describe(
303
349
  required: question.isRequired,
304
350
  ...(item ? { item } : {}),
305
351
  };
306
- if (question instanceof QuestionSelectBase) {
352
+ if (question instanceof core.QuestionSelectBase) {
307
353
  const { extras, other, skipped } = specialItems(question);
308
354
  const shown = new Set<ItemValue>(question.visibleChoices);
309
355
  const regular = question.visibleChoices.filter((c) => !skipped.has(c));
@@ -323,7 +369,7 @@ function describe(
323
369
  value: other.value,
324
370
  };
325
371
  }
326
- if (question instanceof QuestionBooleanModel)
372
+ if (question instanceof core.QuestionBooleanModel)
327
373
  view.options = [
328
374
  {
329
375
  value: question.getValueTrue(),
@@ -334,7 +380,7 @@ function describe(
334
380
  text: clip(question.labelFalse || 'No', 200),
335
381
  },
336
382
  ];
337
- if (question instanceof QuestionRatingModel) {
383
+ if (question instanceof core.QuestionRatingModel) {
338
384
  const values = numericRating(question);
339
385
  if (values) {
340
386
  view.rateValues = values;
@@ -342,11 +388,11 @@ function describe(
342
388
  view.rateMax = values[values.length - 1];
343
389
  } else view.options = question.visibleRateValues.map(optionOf);
344
390
  }
345
- if (question instanceof QuestionFileModel) {
391
+ if (question instanceof core.QuestionFileModel) {
346
392
  if (question.acceptedTypes) view.acceptedTypes = question.acceptedTypes;
347
393
  view.allowMultiple = question.allowMultiple;
348
394
  }
349
- if (question instanceof QuestionMapGridSelectorModel)
395
+ if (question instanceof MapGridQuestion)
350
396
  view.allowMultiple = question.allowMultiple;
351
397
  return view;
352
398
  }
@@ -372,9 +418,10 @@ type Walked =
372
418
  * where "add another?" is asked), never a question the form hides.
373
419
  */
374
420
  function* walk(model: Model): Generator<Walked> {
421
+ const { core } = surveyCore();
375
422
  for (const question of model.getAllQuestions(false)) {
376
423
  if (!shown(question)) continue;
377
- if (question instanceof QuestionPanelDynamicModel) {
424
+ if (question instanceof core.QuestionPanelDynamicModel) {
378
425
  const title = sectionTitle(question);
379
426
  for (const [index, panel] of question.panels.entries()) {
380
427
  if (!panel.isVisible) continue;
@@ -433,7 +480,9 @@ function dynamicSection(
433
480
  name: string,
434
481
  ): QuestionPanelDynamicModel | undefined {
435
482
  const question = model.getQuestionByName(name);
436
- return question instanceof QuestionPanelDynamicModel ? question : undefined;
483
+ return question instanceof surveyCore().core.QuestionPanelDynamicModel
484
+ ? question
485
+ : undefined;
437
486
  }
438
487
 
439
488
  /** The live survey-core question at a path, or undefined when the form has none. */
@@ -572,7 +621,10 @@ export function setAnswer(
572
621
  };
573
622
  const before = structuredClone(dataOf(model));
574
623
  question.value = value;
575
- if (otherText !== undefined && question instanceof QuestionSelectBase)
624
+ if (
625
+ otherText !== undefined &&
626
+ question instanceof surveyCore().core.QuestionSelectBase
627
+ )
576
628
  question.otherValue = otherText;
577
629
  const errors = errorsFor(question);
578
630
  if (errors.length) {
@@ -887,7 +939,7 @@ export function gridCell(
887
939
  ): Record<string, unknown> {
888
940
  const question = questionAt(model, path);
889
941
  const asked =
890
- question instanceof QuestionMapGridSelectorModel
942
+ question instanceof surveyCore().MapGridQuestion
891
943
  ? question.gridPrecision
892
944
  : DEFAULT_GRID_PRECISION;
893
945
  const precision = Math.min(
@@ -5,17 +5,14 @@ import {
5
5
  type ClaimSubmitOperation,
6
6
  type SubmissionSummaryRow,
7
7
  } from '@ixo/action-protocol';
8
- import {
9
- QuestionPanelDynamicModel,
10
- type Model,
11
- type Question,
12
- } from 'survey-core';
8
+ import type { Model, Question } from 'survey-core';
13
9
  import type { ChainCall } from '../ixo-chain/http';
14
10
  import type { Draft, Evidence } from './drafts';
15
11
  import {
16
12
  clearHiddenAnswers,
17
13
  questionAt,
18
14
  submittableData,
15
+ surveyCore,
19
16
  visibleQuestions,
20
17
  type FormQuestion,
21
18
  } from './engine';
@@ -187,7 +184,7 @@ function labelFor(
187
184
  ? `${titleOf(question)}${own && question.getValueName() === own ? ' (own answer)' : ''}`
188
185
  : head;
189
186
  if (
190
- question instanceof QuestionPanelDynamicModel &&
187
+ question instanceof surveyCore().core.QuestionPanelDynamicModel &&
191
188
  rest.length > 1 &&
192
189
  /^\d+$/.test(rest[0]!)
193
190
  ) {
@@ -53,6 +53,7 @@ import {
53
53
  formErrors,
54
54
  formTitle,
55
55
  gridCell,
56
+ loadSurveyCore,
56
57
  nextStep,
57
58
  openModel,
58
59
  parsePin,
@@ -748,7 +749,9 @@ type Applied =
748
749
  * of logged work. Two tools; reads go through ixo-chain's bounded HTTP
749
750
  * policy and never throw. Not bundled: a host adds `new YomaPlugin()` from
750
751
  * `@ixo/oracle-runtime-workers/plugins/yoma`, so `survey-core` lands only
751
- * in oracles that use it.
752
+ * in oracles that use it, and even there it is loaded on the first Yoma
753
+ * tool call, not at Worker startup (both tools await `loadSurveyCore`
754
+ * before any form model is opened).
752
755
  */
753
756
  export class YomaPlugin extends OraclePlugin {
754
757
  readonly name = 'yoma';
@@ -771,7 +774,10 @@ export class YomaPlugin extends OraclePlugin {
771
774
  ].join(' '),
772
775
  schema: lookupSchema,
773
776
  effect: 'read',
774
- handler: (input, ctx) => this.lookup(lookupSchema.parse(input), ctx),
777
+ handler: async (input, ctx) => {
778
+ await loadSurveyCore();
779
+ return this.lookup(lookupSchema.parse(input), ctx);
780
+ },
775
781
  },
776
782
  {
777
783
  name: 'yoma_form',
@@ -781,7 +787,10 @@ export class YomaPlugin extends OraclePlugin {
781
787
  ].join(' '),
782
788
  schema: formSchema,
783
789
  effect: 'write',
784
- handler: (input, ctx) => this.form(formSchema.parse(input), ctx),
790
+ handler: async (input, ctx) => {
791
+ await loadSurveyCore();
792
+ return this.form(formSchema.parse(input), ctx);
793
+ },
785
794
  },
786
795
  ];
787
796
  }