@case-framework/survey-core 0.7.0 → 0.9.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.
package/README.md CHANGED
@@ -1,178 +1,185 @@
1
1
  # @case-framework/survey-core
2
2
 
3
- Headless TypeScript core for defining, editing, running, and exporting surveys in the CASE ecosystem.
3
+ Headless TypeScript contracts for defining, editing, running, and exporting CASE
4
+ surveys. No React, network, persistence, or assistant dependency is required.
4
5
 
5
- ## Intention
6
+ See [Composable architecture](https://github.com/case-framework/case-survey-toolkit/blob/main/docs/composition-architecture.md) for the
7
+ complete document, identity, localization, expression, and host contracts.
6
8
 
7
- `@case-framework/survey-core` is the domain/runtime layer behind CASE surveys. It is designed to:
9
+ ## Public entry points
8
10
 
9
- - Provide a framework-agnostic survey model.
10
- - Support custom item types through a plugin registry.
11
- - Evaluate display logic, validations, and template expressions at runtime.
12
- - Produce normalized responses and export-ready data (including CSV).
11
+ - `@case-framework/survey-core`: schemas, `Survey`, element registration,
12
+ `SurveyStateGraph`, `SurveySession`, expressions, response values, and export.
13
+ - `@case-framework/survey-core/editor`: `SurveyEditor`, field/history integration,
14
+ copy/remapping, and response naming.
15
+ - `@case-framework/survey-core/editor/colors`: editor color helpers.
16
+ - `@case-framework/survey-core/schema`: data schemas for protocol consumers.
13
17
 
14
- ## Scope
18
+ The survey format is version 3; submitted responses are version 2; retained
19
+ participant session snapshots are version 1. Unsupported formats are rejected.
20
+ There are no legacy readers or converters.
15
21
 
16
- This package includes:
22
+ ## Breaking changes
17
23
 
18
- - Survey model and serialization (`Survey`).
19
- - Item contracts and registry utilities (`SurveyItemCore`, `createItemCore`, `createFullRegistry`).
20
- - Runtime engine (`SurveyEngineCore`) for render order, conditions, template values, events, and responses.
21
- - Expression model + evaluator (`Expression`, `FunctionExpression`, `ExpressionEvaluator`, etc.).
22
- - Response models (`SurveyResponse`, `SurveyItemResponse`, `ResponseItem`).
23
- - Response export tooling (`SurveyResponseExporter`, CSV helpers, codebook generation).
24
- - Editor state/manipulation APIs via the `@case-framework/survey-core/editor` entrypoint.
24
+ The composition cutover intentionally replaces these APIs; there are no aliases:
25
25
 
26
- This package does not include:
26
+ - `SurveyEditor.undoRedo` is now `SurveyEditor.history`.
27
+ - The former `survey`, `survey/utils`, `expressions`, and `engine` source barrels
28
+ are consolidated in the root export. Use the public entry points above.
29
+ - Import `SURVEY_EDITOR_ITEM_COLORS` from `@case-framework/survey-core/editor/colors`,
30
+ rather than the package root.
27
31
 
28
- - UI rendering components.
29
- - Network or persistence layers.
30
- - App-level state management.
32
+ Survey Mode must update its imports and history access when adopting this version.
31
33
 
32
- For UI integration, use `@case-framework/survey-ui` on top of this core package.
34
+ ## Load and run
33
35
 
34
- ## Installation
35
-
36
- ```bash
37
- pnpm add @case-framework/survey-core
36
+ ```ts
37
+ import { Survey, SurveySession } from "@case-framework/survey-core";
38
+ import { defaultElementRegistry } from "@case-framework/survey-items";
39
+
40
+ const survey = Survey.parse(savedDefinition, defaultElementRegistry);
41
+ const session = await SurveySession.create(survey, {
42
+ mode: "new",
43
+ binding: { sessionId: "session-1", definitionId: "release-1" },
44
+ context: { locale: "en" },
45
+ });
46
+ session.write([{ slotId: "name-slot", value: { type: "string", value: "Example" } }]);
47
+ const submission = session.submission("response-1");
48
+ const snapshot = session.snapshot();
49
+ const resumed = await SurveySession.create(survey, {
50
+ mode: "resumed",
51
+ binding: { sessionId: "session-1", definitionId: "release-1" },
52
+ snapshot,
53
+ });
38
54
  ```
39
55
 
40
- ## Entrypoints
56
+ The example slot must be declared by the loaded definition. Writes reject unknown
57
+ slots, incompatible values, computed targets, and disabled controls. Hidden answers
58
+ are retained but excluded from ordinary expressions and submission. Disabled
59
+ visible answers remain active. Prefills run only for new sessions. Resume checks
60
+ binding and restores retained values without reapplying prefills.
41
61
 
42
- - `@case-framework/survey-core`: runtime/model/expressions/exporter APIs.
43
- - `@case-framework/survey-core/editor`: editing APIs (`SurveyEditor`, undo/redo, copy/paste helpers).
62
+ A host must authorize the participant and select the exact collecting definition.
63
+ `validateSurveySubmission(survey, input, trustedContext, { definitionId })` rejects a
64
+ response recorded against another definition, validates the envelope and domains,
65
+ recomputes active answers/validation, and records server-owned context.
66
+ Do not trust client-supplied visibility, disabling, or identity as authorization.
44
67
 
45
- ## Usage
68
+ ## Editing and extension
46
69
 
47
- ### 1. Define a custom item type and registry
70
+ Elements are plain data with stateless definitions from `defineElement`. One
71
+ annotated schema declares configuration, identities, references, wording, and
72
+ follow-up bodies. `createElementRegistry` supplies the shared recursive body.
73
+ See the architecture guide for a custom definition example.
48
74
 
49
75
  ```ts
50
- import {
51
- SurveyItemCore,
52
- ValueRefTypeLookup,
53
- ValueType,
54
- ItemTypeRegistry,
55
- } from "@case-framework/survey-core";
56
-
57
- type SingleChoiceConfig = {
58
- id: string;
59
- options: Array<{ id: string; key: string; type: string }>;
60
- };
61
-
62
- class SingleChoiceQuestionItemCore extends SurveyItemCore<
63
- "singleChoiceQuestion",
64
- SingleChoiceConfig
65
- > {
66
- readonly type = "singleChoiceQuestion";
67
-
68
- parseConfig(rawConfig: unknown): SingleChoiceConfig {
69
- const cfg = (rawConfig ?? {}) as Partial<SingleChoiceConfig>;
70
- return {
71
- id: cfg.id ?? this.id,
72
- options: cfg.options ?? [],
73
- };
74
- }
75
-
76
- isInteractive(): boolean {
77
- return true;
78
- }
79
-
80
- getAvailableResponseValueSlots(): ValueRefTypeLookup {
81
- return {
82
- [`${this.id}...get...${this.config.id}`]: ValueType.reference,
83
- [`${this.id}...isDefined...${this.config.id}`]: ValueType.boolean,
84
- };
85
- }
86
- }
87
-
88
- const pluginRegistry: ItemTypeRegistry = {
89
- singleChoiceQuestion: SingleChoiceQuestionItemCore,
90
- };
76
+ import { SurveyEditor } from "@case-framework/survey-core/editor";
77
+ import { createBuiltInElement } from "@case-framework/survey-items";
78
+
79
+ const editor = new SurveyEditor(survey);
80
+ const names = new Set(survey.slots.definitions.map((slot) => slot.variableName));
81
+ const element = createBuiltInElement("text", names);
82
+ const { itemId } = editor.insertElement(element);
83
+ editor.setLocalizedContent({ scope: "content", ownerId: itemId, role: "title" }, "en", {
84
+ type: "plain",
85
+ content: "Your name",
86
+ });
91
87
  ```
92
88
 
93
- ### 2. Load a survey and run it with the engine
89
+ Without a layout target, `insertElement` creates a standalone content item.
90
+ User and assistant edits share the same transactions and undo/redo. Parsing and
91
+ rendering never allocate IDs. Copies allocate/remap owned identities; moves keep
92
+ them. `survey.slots`, `survey.owners`, and `survey.elements` are derived indexes.
94
93
 
95
- ```ts
96
- import {
97
- Survey,
98
- SurveyEngineCore,
99
- ResponseItem,
100
- ValueType,
101
- ReservedSurveyItemTypes,
102
- } from "@case-framework/survey-core";
103
-
104
- const survey = Survey.fromJson(
105
- {
106
- $schema:
107
- "https://github.com/case-framework/case-survey-toolkit/packages/survey-core/schemas/survey-schema.json",
108
- surveyItems: [
109
- {
110
- id: "root",
111
- key: "root",
112
- itemType: ReservedSurveyItemTypes.Group,
113
- config: { isRoot: true, items: ["q1"] },
114
- },
115
- {
116
- id: "q1",
117
- key: "q1",
118
- itemType: "singleChoiceQuestion",
119
- config: {
120
- id: "q1",
121
- options: [
122
- { id: "yes", key: "yes", type: "option" },
123
- { id: "no", key: "no", type: "option" },
124
- ],
125
- },
126
- },
127
- ],
128
- },
129
- pluginRegistry,
130
- );
131
-
132
- const engine = new SurveyEngineCore(survey, { locale: "en" });
133
-
134
- // Set one item response (slot id == config.id in this example).
135
- engine.setResponse("q1", new ResponseItem([["q1", { type: ValueType.reference, value: "yes" }]]));
136
-
137
- const pages = engine.getSurveyPages("large");
138
- const responses = engine.getResponses();
139
- const events = engine.getEvents();
140
- ```
94
+ ## Expressions
141
95
 
142
- ### 3. Export responses to CSV
96
+ Canonical JSON nodes replace runtime expression classes. Editor builders return
97
+ these nodes directly with `getExpression()`. Array operand gaps are `null`.
98
+ `collectExpressionDependencies` includes answer and disabled-state references;
99
+ `visitExpression`, `transformExpression`, and `remapExpression` handle the grammar
100
+ exhaustively. Never find or rewrite references by matching arbitrary JSON strings.
101
+ Registered custom callbacks are not serialized expression behavior: hosts record
102
+ custom values so server validation and export can replay the same context.
103
+
104
+ `list_count(list, filter?)` counts array entries (a missing list counts as 0), optionally
105
+ only those also present in a same-typed `filter` list. `if_else(condition, then, else?)`
106
+ serializes as the `if` function: it evaluates only the chosen branch, returns missing for
107
+ a missing condition, and takes its result type from the branches, which must match.
108
+
109
+ ## Export
143
110
 
144
111
  ```ts
145
112
  import { SurveyResponse, SurveyResponseExporter } from "@case-framework/survey-core";
146
113
 
147
- const response = new SurveyResponse("response-1", "v1");
148
- response.responses = new Map(responses.map((r) => [r.itemId, r]));
149
- response.submittedAt = Math.floor(Date.now() / 1000);
150
-
114
+ const response = SurveyResponse.deserialize(submission);
151
115
  const exporter = new SurveyResponseExporter([
152
- {
153
- versionId: "v1",
154
- surveyKey: survey.surveyKey ?? "survey",
155
- survey,
156
- },
116
+ { schema: survey, responses: [response], sourceId: "release-1" },
157
117
  ]);
158
-
159
- const csv = exporter.exportResponsesToCsv([response]);
118
+ const csv = exporter.exportResponsesToCsv();
160
119
  ```
161
120
 
162
- ### 4. Edit surveys with the editor entrypoint
121
+ Responses store flat `answers[slotId]`, event/metadata state, and recorded context.
122
+ The host supplies the exact definition in every export batch. Computed slots are
123
+ replayed from active stored answers, not accepted as participant writes. Events use
124
+ millisecond instants; calendar answers below use calendar strings.
125
+
126
+ Names use unique lowercase snake_case (maximum 32 characters). Atomic naming edits
127
+ use slot IDs or `{ elementId, prefix?, rows?, columns? }` for matrices. Export codes
128
+ and variable names never change answer identity. Categorical values use declared
129
+ option IDs, including dropdowns and matrix cells.
130
+
131
+ `ExporterProfile` has `schemaVersion: 1`. Slot overrides use exact slot IDs;
132
+ column headers/order use stable encoded column IDs. Existing categorical modes,
133
+ boolean mappings, masks, date formatting, number precision, and duration units
134
+ remain available. Missing answers are blank; answered empty selections differ
135
+ from missing answers. Invalid codes, names, or profile targets are diagnostics.
136
+
137
+ ## Calendar dates
138
+
139
+ Date responses and expression constants store calendar strings, not Unix timestamps:
140
+ `{ type: "date", value: "2026-09-20" }`. Year (`"2026"`) and month (`"2026-09"`)
141
+ precision are preserved; `date[]` stores arrays of these strings. Numeric dates and
142
+ ISO date-time strings are rejected. This is a breaking response and expression API change;
143
+ there is no implicit conversion of old timestamp data. Actual event timestamps retain their
144
+ existing instant semantics.
145
+
146
+ Use `const_date("2026-09-20")`, `response_date(ref)`, and the `date_eq`, `date_gt`,
147
+ `date_gte`, `date_lt`, `date_lte`, `date_min`, `date_max` builders. Equality includes
148
+ precision; ordering/min/max require matching precision. `date_add_days`,
149
+ `date_add_months`, and `date_add_years` accept a date expression and signed integer
150
+ number expression. They preserve precision and clamp month-end dates. Nonzero offsets
151
+ cannot require finer precision than the input. `date_diff(a, b, unit)` returns the signed
152
+ number of completed `"years"`, `"months"`, or `"days"` in a-b, comparing mixed precisions at
153
+ the coarser one; a unit finer than that precision returns undefined. Missing values,
154
+ ordering precision mismatches, and arithmetic outside years 0001–9999 return undefined.
155
+ Calendar arithmetic uses date-fns on UTC dates, so results do not depend on the host timezone.
156
+
157
+ `ctx_current_date()` reads the session's current date. A new session defaults it to the
158
+ participant-local day at start (`context.currentDate`, `YYYY-MM-DD`), keeps it across
159
+ context refreshes and resume, and records it in the response's `evaluationContext` for
160
+ submission validation and export replay. Hosts may pass it explicitly. Age in years is
161
+ `date_diff(ctx_current_date(), response_date(birthRef), "years")`.
162
+
163
+ Presence is the element's `required` flag. Number and date inputs (and matrix fields) take
164
+ `min`/`max` bounds that are a literal or an expression of the input's type, with an optional
165
+ `fallback`. For example:
163
166
 
164
167
  ```ts
165
- import { SurveyEditor, CommitSource } from "@case-framework/survey-core/editor";
166
-
167
- const editor = new SurveyEditor(survey, { pluginRegistry });
168
+ const min = {
169
+ expression: date_add_days(
170
+ response_date({ slotId: "start-slot", method: "get" }),
171
+ const_number(2),
172
+ ).getExpression()!,
173
+ };
174
+ ```
168
175
 
169
- const newItem = survey.createItemFromRaw({
170
- id: "q2",
171
- key: "q2",
172
- itemType: "singleChoiceQuestion",
173
- config: { id: "q2", options: [] },
174
- });
176
+ Bounds evaluate against current responses, including after the source answer changes.
177
+ Date minimums use the start of their period, maximums the end; an answer's entire period
178
+ must fit. Optional unanswered fields pass. A bound whose expression has no value does not
179
+ apply unless it has a `fallback`; slider bounds from an expression require one. Other
180
+ checks (lengths, pattern, email, url, integer, must be true) are element `validations`, and
181
+ custom expression rules live on the content item. Registered domain validators receive an expression evaluator alongside the active-answer reader.
175
182
 
176
- editor.addItem({ parentId: survey.rootItem!.id }, newItem);
177
- editor.commit({ label: "Add q2", source: CommitSource.USER });
178
- ```
183
+ Default exports preserve the calendar string. Custom formatting and date templates are
184
+ timezone-independent; partial dates retain their canonical value even if a pattern requests
185
+ missing components. Use local Date adapters only at calendar-widget boundaries.
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ //#region src/schema/answer-types.d.ts
3
+ /** Tuple entries preserve opaque IDs such as __proto__ across JSON/schema roundtrips. */
4
+ declare const answerTypeReservationsSchema: z.ZodArray<z.ZodTuple<[z.ZodString, z.ZodEnum<{
5
+ readonly string: "string";
6
+ readonly duration: "duration";
7
+ readonly reference: "reference";
8
+ readonly number: "number";
9
+ readonly boolean: "boolean";
10
+ readonly date: "date";
11
+ readonly stringArray: "string[]";
12
+ readonly durationArray: "duration[]";
13
+ readonly referenceArray: "reference[]";
14
+ readonly numberArray: "number[]";
15
+ readonly dateArray: "date[]";
16
+ }>], null>>;
17
+ type AnswerTypeReservations = z.infer<typeof answerTypeReservationsSchema>;
18
+ //#endregion
19
+ export { answerTypeReservationsSchema as n, AnswerTypeReservations as t };
20
+ //# sourceMappingURL=answer-types-DH0gv0Ea.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"answer-types-DH0gv0Ea.d.mts","names":[],"sources":["../src/schema/answer-types.ts"],"mappings":";;;cAIa,8BAA4B,EAAA,SAAA,EAAA,UAAA,EAAA,WAAA,EAAA;;;;;;;;;;;;;KAc7B,yBAAyB,EAAE,aAAa"}
@@ -0,0 +1,19 @@
1
+ import { B as ValueType } from "./localization-1lzNpyFX.mjs";
2
+ import { z } from "zod";
3
+ //#region src/schema/answer-types.ts
4
+ /** Tuple entries preserve opaque IDs such as __proto__ across JSON/schema roundtrips. */
5
+ const answerTypeReservationsSchema = z.array(z.tuple([z.string().min(1), z.enum(ValueType)])).superRefine((entries, context) => {
6
+ const seen = /* @__PURE__ */ new Set();
7
+ entries.forEach(([id], index) => {
8
+ if (seen.has(id)) context.addIssue({
9
+ code: "custom",
10
+ path: [index, 0],
11
+ message: `Duplicate answer type reservation '${id}'`
12
+ });
13
+ seen.add(id);
14
+ });
15
+ });
16
+ //#endregion
17
+ export { answerTypeReservationsSchema as t };
18
+
19
+ //# sourceMappingURL=answer-types-Dtm6MqZQ.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"answer-types-Dtm6MqZQ.mjs","names":[],"sources":["../src/schema/answer-types.ts"],"sourcesContent":["import { z } from \"zod\";\nimport { ValueType } from \"../survey/responses/value-types\";\n\n/** Tuple entries preserve opaque IDs such as __proto__ across JSON/schema roundtrips. */\nexport const answerTypeReservationsSchema = z\n .array(z.tuple([z.string().min(1), z.enum(ValueType)]))\n .superRefine((entries, context) => {\n const seen = new Set<string>();\n entries.forEach(([id], index) => {\n if (seen.has(id))\n context.addIssue({\n code: \"custom\",\n path: [index, 0],\n message: `Duplicate answer type reservation '${id}'`,\n });\n seen.add(id);\n });\n });\nexport type AnswerTypeReservations = z.infer<typeof answerTypeReservationsSchema>;\n"],"mappings":";;;;AAIA,MAAa,+BAA+B,EACzC,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,CACtD,aAAa,SAAS,YAAY;CACjC,MAAM,uBAAO,IAAI,IAAY;CAC7B,QAAQ,SAAS,CAAC,KAAK,UAAU;EAC/B,IAAI,KAAK,IAAI,EAAE,GACb,QAAQ,SAAS;GACf,MAAM;GACN,MAAM,CAAC,OAAO,CAAC;GACf,SAAS,sCAAsC,GAAG;EACpD,CAAC;EACH,KAAK,IAAI,EAAE;CACb,CAAC;AACH,CAAC"}
@@ -6,7 +6,6 @@
6
6
  * without enabling the assistant, while assistant proposals must validate
7
7
  * against the same values.
8
8
  */
9
- declare const SURVEY_EDITOR_ITEM_COLORS: readonly ["#404040", "#b91c1c", "#c2410c", "#a16207", "#4d7c0f", "#047857", "#0369a1", "#4338ca", "#7e22ce", "#86198f", "#be123c"];
9
+ export declare const SURVEY_EDITOR_ITEM_COLORS: readonly ["#404040", "#b91c1c", "#c2410c", "#a16207", "#4d7c0f", "#047857", "#0369a1", "#4338ca", "#7e22ce", "#86198f", "#be123c"];
10
10
  //#endregion
11
- export { SURVEY_EDITOR_ITEM_COLORS };
12
11
  //# sourceMappingURL=colors.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"colors.d.mts","names":[],"sources":["../../src/editor/item-colors.ts"],"mappings":";;AAOA;;;;;;cAAa,yBAAA"}
1
+ {"version":3,"file":"colors.d.mts","names":[],"sources":["../../src/editor/item-colors.ts"],"mappings":";;;;;;;;qBAOa"}
@@ -1 +1 @@
1
- {"version":3,"file":"colors.mjs","names":[],"sources":["../../src/editor/item-colors.ts"],"sourcesContent":["/**\n * Editor-only item-color swatches persisted in `metadata.editorItemColor`.\n *\n * This is shared survey-editor configuration: hosts may render the editor\n * without enabling the assistant, while assistant proposals must validate\n * against the same values.\n */\nexport const SURVEY_EDITOR_ITEM_COLORS = [\n \"#404040\",\n \"#b91c1c\",\n \"#c2410c\",\n \"#a16207\",\n \"#4d7c0f\",\n \"#047857\",\n \"#0369a1\",\n \"#4338ca\",\n \"#7e22ce\",\n \"#86198f\",\n \"#be123c\",\n] as const;\n"],"mappings":";;;;;;;;AAOA,MAAa,4BAA4B;CACvC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD"}
1
+ {"version":3,"file":"colors.mjs","names":[],"sources":["../../src/editor/item-colors.ts"],"sourcesContent":["/**\n * Editor-only item-color swatches persisted in `metadata.editorItemColor`.\n *\n * This is shared survey-editor configuration: hosts may render the editor\n * without enabling the assistant, while assistant proposals must validate\n * against the same values.\n */\nexport const SURVEY_EDITOR_ITEM_COLORS = [\n \"#404040\",\n \"#b91c1c\",\n \"#c2410c\",\n \"#a16207\",\n \"#4d7c0f\",\n \"#047857\",\n \"#0369a1\",\n \"#4338ca\",\n \"#7e22ce\",\n \"#86198f\",\n \"#be123c\",\n] as const;\n"],"mappings":";;;;;;;;AAOA,MAAa,4BAA4B;CACvC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF"}