@superbuilders/primer-tives 1.0.0 → 1.1.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.
@@ -1,6 +1,316 @@
1
- // src/client/create.ts
2
- import * as errors10 from "@superbuilders/errors";
1
+ // src/contracts/validation.ts
2
+ import { z as z2 } from "zod";
3
+
4
+ // src/contracts/pci-schemas.ts
5
+ import { z } from "zod";
6
+ var DivisionRemainderPropsSchema = z.object({
7
+ dividend: z.number(),
8
+ divisor: z.number()
9
+ });
10
+ var DivisionRemainderSubmissionSchema = z.object({
11
+ quotient: z.string(),
12
+ remainder: z.string()
13
+ });
14
+ var FractionOperandSchema = z.object({
15
+ numerator: z.number(),
16
+ denominator: z.number()
17
+ });
18
+ var FractionAdditionPropsSchema = z.object({
19
+ left: FractionOperandSchema,
20
+ right: FractionOperandSchema
21
+ });
22
+ var FractionAdditionSubmissionSchema = z.object({
23
+ numerator: z.string(),
24
+ denominator: z.string()
25
+ });
3
26
 
27
+ // src/contracts/validation.ts
28
+ var MatchPairSchema = z2.object({
29
+ source: z2.string(),
30
+ target: z2.string()
31
+ });
32
+ var ChoiceSubmissionSchema = z2.object({
33
+ type: z2.literal("choice"),
34
+ selectedKeys: z2.array(z2.string())
35
+ });
36
+ var TextEntrySubmissionSchema = z2.object({
37
+ type: z2.literal("text-entry"),
38
+ value: z2.string()
39
+ });
40
+ var ExtendedTextSubmissionSchema = z2.object({
41
+ type: z2.literal("extended-text"),
42
+ values: z2.array(z2.string()).min(1)
43
+ });
44
+ var OrderSubmissionSchema = z2.object({
45
+ type: z2.literal("order"),
46
+ orderedKeys: z2.array(z2.string())
47
+ });
48
+ var MatchSubmissionSchema = z2.object({
49
+ type: z2.literal("match"),
50
+ pairs: z2.array(MatchPairSchema)
51
+ });
52
+ var DivisionRemainderPciSubmissionSchema = z2.object({
53
+ type: z2.literal("portable-custom"),
54
+ pciId: z2.literal("urn:primer:pci:division-remainder"),
55
+ value: DivisionRemainderSubmissionSchema
56
+ });
57
+ var FractionAdditionPciSubmissionSchema = z2.object({
58
+ type: z2.literal("portable-custom"),
59
+ pciId: z2.literal("urn:primer:pci:fraction-addition"),
60
+ value: FractionAdditionSubmissionSchema
61
+ });
62
+ var RendererSubmissionSchema = z2.union([
63
+ ChoiceSubmissionSchema,
64
+ TextEntrySubmissionSchema,
65
+ ExtendedTextSubmissionSchema,
66
+ OrderSubmissionSchema,
67
+ MatchSubmissionSchema,
68
+ DivisionRemainderPciSubmissionSchema,
69
+ FractionAdditionPciSubmissionSchema
70
+ ]);
71
+ function invalid(issues) {
72
+ return {
73
+ ok: false,
74
+ issues: Array.isArray(issues) ? issues : [issues]
75
+ };
76
+ }
77
+ function duplicates(values, keyOf) {
78
+ const seen = new Set;
79
+ const duplicated = new Set;
80
+ for (const value of values) {
81
+ const key = keyOf(value);
82
+ if (seen.has(key)) {
83
+ duplicated.add(key);
84
+ continue;
85
+ }
86
+ seen.add(key);
87
+ }
88
+ return [...duplicated];
89
+ }
90
+ function findUnknownIds(values, choices) {
91
+ const ids = new Set(choices.map(function getId(choice) {
92
+ return choice.identifier;
93
+ }));
94
+ return values.filter(function isUnknown(value) {
95
+ return !ids.has(value);
96
+ });
97
+ }
98
+ function countByIdentifier(pairs, side) {
99
+ const counts = new Map;
100
+ for (const pair of pairs) {
101
+ const key = pair[side];
102
+ const currentCount = counts.get(key);
103
+ if (currentCount === undefined) {
104
+ counts.set(key, 1);
105
+ continue;
106
+ }
107
+ counts.set(key, currentCount + 1);
108
+ }
109
+ return counts;
110
+ }
111
+ function validateUsageBounds(choices, counts, side) {
112
+ const issues = [];
113
+ for (const choice of choices) {
114
+ const maybeCount = counts.get(choice.identifier);
115
+ const count = maybeCount === undefined ? 0 : maybeCount;
116
+ if (choice.matchMax !== 0 && count > choice.matchMax) {
117
+ issues.push(`${side} '${choice.identifier}' used ${count} times, max ${choice.matchMax}`);
118
+ }
119
+ if (count < choice.matchMin) {
120
+ issues.push(`${side} '${choice.identifier}' used ${count} times, min ${choice.matchMin}`);
121
+ }
122
+ }
123
+ return issues;
124
+ }
125
+ function pciSubmissionSchema(pciId) {
126
+ switch (pciId) {
127
+ case "urn:primer:pci:division-remainder":
128
+ return DivisionRemainderSubmissionSchema;
129
+ case "urn:primer:pci:fraction-addition":
130
+ return FractionAdditionSubmissionSchema;
131
+ }
132
+ const exhaustiveCheck = pciId;
133
+ return exhaustiveCheck;
134
+ }
135
+ function validateChoiceSubmission(interaction, submission) {
136
+ if (submission.type !== "choice") {
137
+ return invalid(`submission type '${submission.type}' does not match interaction type 'choice'`);
138
+ }
139
+ const issues = [];
140
+ if (submission.selectedKeys.length < interaction.minChoices) {
141
+ issues.push(`need at least ${interaction.minChoices} selections`);
142
+ }
143
+ if (submission.selectedKeys.length > interaction.maxChoices) {
144
+ issues.push(`at most ${interaction.maxChoices} selections`);
145
+ }
146
+ const duplicateKeys = duplicates(submission.selectedKeys, function keyOf(value) {
147
+ return value;
148
+ });
149
+ if (duplicateKeys.length > 0) {
150
+ issues.push("duplicate selections");
151
+ }
152
+ const unknownKeys = findUnknownIds(submission.selectedKeys, interaction.options);
153
+ if (unknownKeys.length > 0) {
154
+ issues.push(`unknown options: ${unknownKeys.join(", ")}`);
155
+ }
156
+ if (issues.length > 0) {
157
+ return invalid(issues);
158
+ }
159
+ return {
160
+ ok: true,
161
+ value: { ...submission }
162
+ };
163
+ }
164
+ function validateTextEntrySubmission(interaction, submission) {
165
+ if (submission.type !== "text-entry") {
166
+ return invalid(`submission type '${submission.type}' does not match interaction type 'text-entry'`);
167
+ }
168
+ return {
169
+ ok: true,
170
+ value: { ...submission }
171
+ };
172
+ }
173
+ function validateExtendedTextSubmission(interaction, submission) {
174
+ if (submission.type !== "extended-text") {
175
+ return invalid(`submission type '${submission.type}' does not match interaction type 'extended-text'`);
176
+ }
177
+ const issues = [];
178
+ if (interaction.cardinality === "single") {
179
+ if (submission.values.length !== 1) {
180
+ issues.push("single-cardinality extended-text requires exactly one value");
181
+ }
182
+ } else {
183
+ if (submission.values.length < interaction.minStrings) {
184
+ issues.push(`need at least ${interaction.minStrings} values`);
185
+ }
186
+ if (submission.values.length > interaction.maxStrings) {
187
+ issues.push(`at most ${interaction.maxStrings} values`);
188
+ }
189
+ const duplicateValues = duplicates(submission.values, function keyOf(value) {
190
+ return value;
191
+ });
192
+ if (duplicateValues.length > 0) {
193
+ issues.push("duplicate values are not allowed for multiple-cardinality extended-text");
194
+ }
195
+ }
196
+ if (issues.length > 0) {
197
+ return invalid(issues);
198
+ }
199
+ return {
200
+ ok: true,
201
+ value: { ...submission }
202
+ };
203
+ }
204
+ function validateOrderSubmission(interaction, submission) {
205
+ if (submission.type !== "order") {
206
+ return invalid(`submission type '${submission.type}' does not match interaction type 'order'`);
207
+ }
208
+ const issues = [];
209
+ if (submission.orderedKeys.length < interaction.minChoices) {
210
+ issues.push(`need at least ${interaction.minChoices} selections`);
211
+ }
212
+ if (submission.orderedKeys.length > interaction.maxChoices) {
213
+ issues.push(`at most ${interaction.maxChoices} selections`);
214
+ }
215
+ const duplicateKeys = duplicates(submission.orderedKeys, function keyOf(value) {
216
+ return value;
217
+ });
218
+ if (duplicateKeys.length > 0) {
219
+ issues.push("duplicate selections");
220
+ }
221
+ const unknownKeys = findUnknownIds(submission.orderedKeys, interaction.choices);
222
+ if (unknownKeys.length > 0) {
223
+ issues.push(`unknown choices: ${unknownKeys.join(", ")}`);
224
+ }
225
+ if (issues.length > 0) {
226
+ return invalid(issues);
227
+ }
228
+ return {
229
+ ok: true,
230
+ value: { ...submission }
231
+ };
232
+ }
233
+ function validateMatchSubmission(interaction, submission) {
234
+ if (submission.type !== "match") {
235
+ return invalid(`submission type '${submission.type}' does not match interaction type 'match'`);
236
+ }
237
+ const issues = [];
238
+ if (submission.pairs.length < interaction.minAssociations) {
239
+ issues.push(`need at least ${interaction.minAssociations} associations`);
240
+ }
241
+ if (submission.pairs.length > interaction.maxAssociations) {
242
+ issues.push(`at most ${interaction.maxAssociations} associations`);
243
+ }
244
+ const duplicatePairs = duplicates(submission.pairs, function keyOf(pair) {
245
+ return `${pair.source}->${pair.target}`;
246
+ });
247
+ if (duplicatePairs.length > 0) {
248
+ issues.push("duplicate associations are not allowed");
249
+ }
250
+ const sourceIds = new Set(interaction.sourceChoices.map(function getId(choice) {
251
+ return choice.identifier;
252
+ }));
253
+ const targetIds = new Set(interaction.targetChoices.map(function getId(choice) {
254
+ return choice.identifier;
255
+ }));
256
+ for (const pair of submission.pairs) {
257
+ if (!sourceIds.has(pair.source)) {
258
+ issues.push(`unknown source '${pair.source}'`);
259
+ }
260
+ if (!targetIds.has(pair.target)) {
261
+ issues.push(`unknown target '${pair.target}'`);
262
+ }
263
+ }
264
+ const sourceCounts = countByIdentifier(submission.pairs, "source");
265
+ const targetCounts = countByIdentifier(submission.pairs, "target");
266
+ issues.push(...validateUsageBounds(interaction.sourceChoices, sourceCounts, "source"));
267
+ issues.push(...validateUsageBounds(interaction.targetChoices, targetCounts, "target"));
268
+ if (issues.length > 0) {
269
+ return invalid(issues);
270
+ }
271
+ return {
272
+ ok: true,
273
+ value: { ...submission }
274
+ };
275
+ }
276
+ function validatePortableCustomSubmission(interaction, submission) {
277
+ if (submission.type !== "portable-custom") {
278
+ return invalid(`submission type '${submission.type}' does not match interaction type 'portable-custom'`);
279
+ }
280
+ if (submission.pciId !== interaction.pciId) {
281
+ return invalid(`submission PCI '${submission.pciId}' does not match interaction PCI '${interaction.pciId}'`);
282
+ }
283
+ const schema = pciSubmissionSchema(interaction.pciId);
284
+ const result = schema.safeParse(submission.value);
285
+ if (!result.success) {
286
+ return invalid(result.error.issues.map(function toIssue(issue) {
287
+ return issue.message;
288
+ }));
289
+ }
290
+ return {
291
+ ok: true,
292
+ value: { ...submission }
293
+ };
294
+ }
295
+ function validateSubmissionForInteraction(interaction, submission) {
296
+ switch (interaction.type) {
297
+ case "choice":
298
+ return validateChoiceSubmission(interaction, submission);
299
+ case "text-entry":
300
+ return validateTextEntrySubmission(interaction, submission);
301
+ case "extended-text":
302
+ return validateExtendedTextSubmission(interaction, submission);
303
+ case "order":
304
+ return validateOrderSubmission(interaction, submission);
305
+ case "match":
306
+ return validateMatchSubmission(interaction, submission);
307
+ case "portable-custom":
308
+ return validatePortableCustomSubmission(interaction, submission);
309
+ }
310
+ }
311
+ function submissionValidationMessage(result) {
312
+ return result.issues.join("; ");
313
+ }
4
314
  // src/errors.ts
5
315
  import * as errors from "@superbuilders/errors";
6
316
  var ErrNetwork = errors.new("network");
@@ -24,15 +334,48 @@ var ErrInvalidSecretKey = errors.new("invalid secret key");
24
334
  var ErrStudentNotFound = errors.new("student not found");
25
335
  var ErrUnsupportedGrade = errors.new("unsupported grade");
26
336
  var ErrTimebackUnavailable = errors.new("timeback unavailable");
337
+ // src/client/create.ts
338
+ import * as errors10 from "@superbuilders/errors";
27
339
 
28
340
  // src/client/transport.ts
29
341
  import * as errors2 from "@superbuilders/errors";
30
342
  var ADVANCE_PATH = "/api/v0/advance";
31
- function httpSentinel(status) {
343
+ function isAdvanceErrorBody(value) {
344
+ if (typeof value !== "object" || value === null) {
345
+ return false;
346
+ }
347
+ if ("error" in value && value.error !== undefined && typeof value.error !== "string") {
348
+ return false;
349
+ }
350
+ if ("detail" in value && value.detail !== undefined && typeof value.detail !== "string") {
351
+ return false;
352
+ }
353
+ return true;
354
+ }
355
+ function parseAdvanceErrorBody(body) {
356
+ if (body.length === 0) {
357
+ return null;
358
+ }
359
+ const parsed = errors2.trySync(function parseJson() {
360
+ return JSON.parse(body);
361
+ });
362
+ if (parsed.error) {
363
+ return null;
364
+ }
365
+ if (!isAdvanceErrorBody(parsed.data)) {
366
+ return null;
367
+ }
368
+ return parsed.data;
369
+ }
370
+ function httpSentinel(status, body) {
32
371
  if (status === 400) {
33
372
  return ErrBadRequest;
34
373
  }
35
374
  if (status === 401) {
375
+ const parsed = parseAdvanceErrorBody(body);
376
+ if (parsed?.detail === "token_expired") {
377
+ return ErrTokenExpired;
378
+ }
36
379
  return ErrInvalidAccessToken;
37
380
  }
38
381
  if (status === 403) {
@@ -106,7 +449,7 @@ function createTransport(tc) {
106
449
  const text = await res.text().catch(function fallback() {
107
450
  return "";
108
451
  });
109
- const sentinel = res.status === 422 ? ErrUnsupportedPci : httpSentinel(res.status);
452
+ const sentinel = res.status === 422 ? ErrUnsupportedPci : httpSentinel(res.status, text);
110
453
  log?.error("transport http error", {
111
454
  status: res.status
112
455
  });
@@ -141,38 +484,6 @@ function poisonToJSON() {
141
484
 
142
485
  // src/client/choice-state.ts
143
486
  import * as errors3 from "@superbuilders/errors";
144
- function findUnknownKey(selectedKeys, options) {
145
- const optionIds = new Set(options.map(function getId(o) {
146
- return o.identifier;
147
- }));
148
- for (const key of selectedKeys) {
149
- if (!optionIds.has(key)) {
150
- return key;
151
- }
152
- }
153
- return null;
154
- }
155
- function validateChoiceSubmission(log, selectedKeys, options, maxChoices, minChoices) {
156
- if (selectedKeys.length < minChoices) {
157
- log?.error("choice submit below minChoices", { selectedKeys, minChoices });
158
- return errors3.wrap(ErrInvalidSubmission, `need at least ${minChoices} selections`);
159
- }
160
- if (selectedKeys.length > maxChoices) {
161
- log?.error("choice submit above maxChoices", { selectedKeys, maxChoices });
162
- return errors3.wrap(ErrInvalidSubmission, `at most ${maxChoices} selections`);
163
- }
164
- const uniqueKeys = new Set(selectedKeys);
165
- if (uniqueKeys.size !== selectedKeys.length) {
166
- log?.error("choice submit has duplicates", { selectedKeys });
167
- return errors3.wrap(ErrInvalidSubmission, "duplicate selections");
168
- }
169
- const unknownKey = findUnknownKey(selectedKeys, options);
170
- if (unknownKey) {
171
- log?.error("choice submit has unknown key", { key: unknownKey });
172
- return errors3.wrap(ErrInvalidSubmission, `unknown option '${unknownKey}'`);
173
- }
174
- return null;
175
- }
176
487
  function choiceState(ctx, stimulus, interaction, options, maxChoices, minChoices) {
177
488
  let submitPending;
178
489
  let submitKey;
@@ -189,9 +500,10 @@ function choiceState(ctx, stimulus, interaction, options, maxChoices, minChoices
189
500
  }
190
501
  return Promise.resolve(ctx.errored(errors3.wrap(ErrConflict, "cannot submit a different choice payload while submit is in flight"), "interaction", { kind: "interaction", submission }));
191
502
  }
192
- const validationError = validateChoiceSubmission(ctx.log, selectedKeys, options, maxChoices, minChoices);
193
- if (validationError) {
194
- return Promise.resolve(ctx.errored(validationError, "interaction", { kind: "interaction", submission }));
503
+ const validation = validateSubmissionForInteraction(interaction, submission);
504
+ if (!validation.ok) {
505
+ ctx.log?.error("choice submit invalid", { selectedKeys, issues: validation.issues });
506
+ return Promise.resolve(ctx.errored(errors3.wrap(ErrInvalidSubmission, submissionValidationMessage(validation)), "interaction", { kind: "interaction", submission }));
195
507
  }
196
508
  submitKey = key;
197
509
  submitPending = ctx.execute({ kind: "interaction", submission }, "interaction").finally(function clearPending() {
@@ -229,17 +541,6 @@ function choiceState(ctx, stimulus, interaction, options, maxChoices, minChoices
229
541
 
230
542
  // src/client/extended-text-state.ts
231
543
  import * as errors4 from "@superbuilders/errors";
232
- function validateExtendedTextSubmission(log, values, minStrings, maxStrings) {
233
- if (values.length < minStrings) {
234
- log?.error("extended-text submit below minStrings", { count: values.length, minStrings });
235
- return errors4.wrap(ErrInvalidSubmission, `need at least ${minStrings} values`);
236
- }
237
- if (values.length > maxStrings) {
238
- log?.error("extended-text submit above maxStrings", { count: values.length, maxStrings });
239
- return errors4.wrap(ErrInvalidSubmission, `at most ${maxStrings} values`);
240
- }
241
- return null;
242
- }
243
544
  function extendedTextState(ctx, stimulus, interaction) {
244
545
  if (interaction.cardinality === "single") {
245
546
  let submitText = function(value) {
@@ -254,6 +555,11 @@ function extendedTextState(ctx, stimulus, interaction) {
254
555
  }
255
556
  return Promise.resolve(ctx.errored(errors4.wrap(ErrConflict, "cannot submit a different extended-text payload while submit is in flight"), "interaction", { kind: "interaction", submission }));
256
557
  }
558
+ const validation = validateSubmissionForInteraction(interaction, submission);
559
+ if (!validation.ok) {
560
+ ctx.log?.error("extended-text submit invalid", { value, issues: validation.issues });
561
+ return Promise.resolve(ctx.errored(errors4.wrap(ErrInvalidSubmission, submissionValidationMessage(validation)), "interaction", { kind: "interaction", submission }));
562
+ }
257
563
  submitKey2 = key;
258
564
  submitPending2 = ctx.execute({ kind: "interaction", submission }, "interaction").finally(function clearPending() {
259
565
  submitPending2 = undefined;
@@ -303,9 +609,10 @@ function extendedTextState(ctx, stimulus, interaction) {
303
609
  }
304
610
  return Promise.resolve(ctx.errored(errors4.wrap(ErrConflict, "cannot submit a different extended-text payload while submit is in flight"), "interaction", { kind: "interaction", submission }));
305
611
  }
306
- const validationError = validateExtendedTextSubmission(ctx.log, values, multi.minStrings, multi.maxStrings);
307
- if (validationError) {
308
- return Promise.resolve(ctx.errored(validationError, "interaction", { kind: "interaction", submission }));
612
+ const validation = validateSubmissionForInteraction(multi, submission);
613
+ if (!validation.ok) {
614
+ ctx.log?.error("extended-text submit invalid", { values, issues: validation.issues });
615
+ return Promise.resolve(ctx.errored(errors4.wrap(ErrInvalidSubmission, submissionValidationMessage(validation)), "interaction", { kind: "interaction", submission }));
309
616
  }
310
617
  submitKey = key;
311
618
  submitPending = ctx.execute({ kind: "interaction", submission }, "interaction").finally(function clearPending() {
@@ -365,92 +672,6 @@ function feedbackState(ctx, stimulus, interaction, submission, isCorrect, feedba
365
672
 
366
673
  // src/client/match-state.ts
367
674
  import * as errors5 from "@superbuilders/errors";
368
- function bumpCount(counts, key) {
369
- const prev = counts.get(key);
370
- const next = prev === undefined ? 1 : prev + 1;
371
- counts.set(key, next);
372
- }
373
- function getCount(counts, key) {
374
- const recorded = counts.get(key);
375
- return recorded === undefined ? 0 : recorded;
376
- }
377
- function validateAssociationBounds(log, pairs, minAssociations, maxAssociations) {
378
- if (pairs.length < minAssociations) {
379
- log?.error("match submit below minAssociations", { pairs, minAssociations });
380
- return errors5.wrap(ErrInvalidSubmission, `need at least ${minAssociations} associations`);
381
- }
382
- if (pairs.length > maxAssociations) {
383
- log?.error("match submit above maxAssociations", { pairs, maxAssociations });
384
- return errors5.wrap(ErrInvalidSubmission, `at most ${maxAssociations} associations`);
385
- }
386
- return null;
387
- }
388
- function validatePairsReferToKnownChoices(log, pairs, sourceIds, targetIds) {
389
- for (const pair of pairs) {
390
- if (!sourceIds.has(pair.source)) {
391
- log?.error("match submit has unknown source", { source: pair.source });
392
- return errors5.wrap(ErrInvalidSubmission, `unknown source '${pair.source}'`);
393
- }
394
- if (!targetIds.has(pair.target)) {
395
- log?.error("match submit has unknown target", { target: pair.target });
396
- return errors5.wrap(ErrInvalidSubmission, `unknown target '${pair.target}'`);
397
- }
398
- }
399
- return null;
400
- }
401
- function validateChoiceCaps(log, side, choices, counts) {
402
- for (const choice of choices) {
403
- const count = getCount(counts, choice.identifier);
404
- if (choice.matchMax !== 0 && count > choice.matchMax) {
405
- log?.error(`match submit exceeds ${side} matchMax`, {
406
- identifier: choice.identifier,
407
- count,
408
- matchMax: choice.matchMax
409
- });
410
- return errors5.wrap(ErrInvalidSubmission, `${side} '${choice.identifier}' used ${count} times, max ${choice.matchMax}`);
411
- }
412
- if (count < choice.matchMin) {
413
- log?.error(`match submit below ${side} matchMin`, {
414
- identifier: choice.identifier,
415
- count,
416
- matchMin: choice.matchMin
417
- });
418
- return errors5.wrap(ErrInvalidSubmission, `${side} '${choice.identifier}' used ${count} times, min ${choice.matchMin}`);
419
- }
420
- }
421
- return null;
422
- }
423
- function validateMatchSubmission(log, pairs, sourceChoices, targetChoices, minAssociations, maxAssociations) {
424
- const boundsError = validateAssociationBounds(log, pairs, minAssociations, maxAssociations);
425
- if (boundsError) {
426
- return boundsError;
427
- }
428
- const sourceIds = new Set(sourceChoices.map(function getId(c) {
429
- return c.identifier;
430
- }));
431
- const targetIds = new Set(targetChoices.map(function getId(c) {
432
- return c.identifier;
433
- }));
434
- const referenceError = validatePairsReferToKnownChoices(log, pairs, sourceIds, targetIds);
435
- if (referenceError) {
436
- return referenceError;
437
- }
438
- const sourceCounts = new Map;
439
- const targetCounts = new Map;
440
- for (const pair of pairs) {
441
- bumpCount(sourceCounts, pair.source);
442
- bumpCount(targetCounts, pair.target);
443
- }
444
- const sourceCapError = validateChoiceCaps(log, "source", sourceChoices, sourceCounts);
445
- if (sourceCapError) {
446
- return sourceCapError;
447
- }
448
- const targetCapError = validateChoiceCaps(log, "target", targetChoices, targetCounts);
449
- if (targetCapError) {
450
- return targetCapError;
451
- }
452
- return null;
453
- }
454
675
  function matchState(ctx, stimulus, interaction) {
455
676
  let submitPending;
456
677
  let submitKey;
@@ -467,9 +688,10 @@ function matchState(ctx, stimulus, interaction) {
467
688
  }
468
689
  return Promise.resolve(ctx.errored(errors5.wrap(ErrConflict, "cannot submit a different match payload while submit is in flight"), "interaction", { kind: "interaction", submission }));
469
690
  }
470
- const validationError = validateMatchSubmission(ctx.log, pairs, interaction.sourceChoices, interaction.targetChoices, interaction.minAssociations, interaction.maxAssociations);
471
- if (validationError) {
472
- return Promise.resolve(ctx.errored(validationError, "interaction", { kind: "interaction", submission }));
691
+ const validation = validateSubmissionForInteraction(interaction, submission);
692
+ if (!validation.ok) {
693
+ ctx.log?.error("match submit invalid", { pairs, issues: validation.issues });
694
+ return Promise.resolve(ctx.errored(errors5.wrap(ErrInvalidSubmission, submissionValidationMessage(validation)), "interaction", { kind: "interaction", submission }));
473
695
  }
474
696
  submitKey = key;
475
697
  submitPending = ctx.execute({ kind: "interaction", submission }, "interaction").finally(function clearPending() {
@@ -525,38 +747,6 @@ function observationState(ctx, stimulus) {
525
747
 
526
748
  // src/client/order-state.ts
527
749
  import * as errors6 from "@superbuilders/errors";
528
- function findUnknownKey2(orderedKeys, choices) {
529
- const choiceIds = new Set(choices.map(function getId(c) {
530
- return c.identifier;
531
- }));
532
- for (const key of orderedKeys) {
533
- if (!choiceIds.has(key)) {
534
- return key;
535
- }
536
- }
537
- return null;
538
- }
539
- function validateOrderSubmission(log, orderedKeys, choices, minChoices, maxChoices) {
540
- if (orderedKeys.length < minChoices) {
541
- log?.error("order submit below minChoices", { orderedKeys, minChoices });
542
- return errors6.wrap(ErrInvalidSubmission, `need at least ${minChoices} selections`);
543
- }
544
- if (orderedKeys.length > maxChoices) {
545
- log?.error("order submit above maxChoices", { orderedKeys, maxChoices });
546
- return errors6.wrap(ErrInvalidSubmission, `at most ${maxChoices} selections`);
547
- }
548
- const uniqueKeys = new Set(orderedKeys);
549
- if (uniqueKeys.size !== orderedKeys.length) {
550
- log?.error("order submit has duplicates", { orderedKeys });
551
- return errors6.wrap(ErrInvalidSubmission, "duplicate selections");
552
- }
553
- const unknownKey = findUnknownKey2(orderedKeys, choices);
554
- if (unknownKey) {
555
- log?.error("order submit has unknown key", { key: unknownKey });
556
- return errors6.wrap(ErrInvalidSubmission, `unknown choice '${unknownKey}'`);
557
- }
558
- return null;
559
- }
560
750
  function orderState(ctx, stimulus, interaction) {
561
751
  let submitPending;
562
752
  let submitKey;
@@ -573,9 +763,10 @@ function orderState(ctx, stimulus, interaction) {
573
763
  }
574
764
  return Promise.resolve(ctx.errored(errors6.wrap(ErrConflict, "cannot submit a different order payload while submit is in flight"), "interaction", { kind: "interaction", submission }));
575
765
  }
576
- const validationError = validateOrderSubmission(ctx.log, orderedKeys, interaction.choices, interaction.minChoices, interaction.maxChoices);
577
- if (validationError) {
578
- return Promise.resolve(ctx.errored(validationError, "interaction", { kind: "interaction", submission }));
766
+ const validation = validateSubmissionForInteraction(interaction, submission);
767
+ if (!validation.ok) {
768
+ ctx.log?.error("order submit invalid", { orderedKeys, issues: validation.issues });
769
+ return Promise.resolve(ctx.errored(errors6.wrap(ErrInvalidSubmission, submissionValidationMessage(validation)), "interaction", { kind: "interaction", submission }));
579
770
  }
580
771
  submitKey = key;
581
772
  submitPending = ctx.execute({ kind: "interaction", submission }, "interaction").finally(function clearPending() {
@@ -683,6 +874,11 @@ function textEntryState(ctx, stimulus, interaction) {
683
874
  }
684
875
  return Promise.resolve(ctx.errored(errors8.wrap(ErrConflict, "cannot submit a different text payload while submit is in flight"), "interaction", { kind: "interaction", submission }));
685
876
  }
877
+ const validation = validateSubmissionForInteraction(interaction, submission);
878
+ if (!validation.ok) {
879
+ ctx.log?.error("text-entry submit invalid", { value, issues: validation.issues });
880
+ return Promise.resolve(ctx.errored(errors8.wrap(ErrInvalidSubmission, submissionValidationMessage(validation)), "interaction", { kind: "interaction", submission }));
881
+ }
686
882
  submitKey = key;
687
883
  submitPending = ctx.execute({ kind: "interaction", submission }, "interaction").finally(function clearPending() {
688
884
  submitPending = undefined;
@@ -924,4 +1120,4 @@ export {
924
1120
  ErrBadRequest
925
1121
  };
926
1122
 
927
- //# debugId=03C66069BFA6BE2F64756E2164756E21
1123
+ //# debugId=3AF97B3EB454BA4764756E2164756E21