@choiceapps/communityservices-sdk 0.2.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-11-03
4
+
5
+ - Compare journaled attachment and case-note intents, and shelter stay identities, by canonical hash instead of key-order-sensitive JSON, so an identical retry no longer conflicts when a store such as Postgres `jsonb` reorders keys. Existing journals remain valid.
6
+ - Add an optional `enrollmentSession` reader to `createCurrentLivingSituationRecorder` and `createCoordinatedEntryEventRecorder`. It reads the enrollment through GWT, so clients in household enrollments, whose REST enrollment reads fail with `42703`, can be recorded. Journal hashes match the REST path.
7
+ - Export `parseRestInstant`, which parses REST read timestamps such as `2026-11-20 10:00:00-07` the same way on every engine and host timezone, treating a missing offset as UTC. `Date.parse` can return NaN for that form and reads a missing offset as local time.
8
+ - Document that a mobile batch which returned HTTP 400 once saved one of its answers. The batch itself did not fail again in sandbox retests, including enrolled single-adult and household clients; the cause is unknown and the workflow is unchanged.
9
+
3
10
  ## 0.2.0 - 2026-11-02
4
11
 
5
12
  - Add opt-in private HTTP error-body capture and retain the original write failure as the cause of an uncertain enrollment stage. Failed writes still require reconciliation and are never replayed automatically.
@@ -3,9 +3,12 @@ import {createHash} from 'node:crypto';
3
3
  import {decodeMappedResponse} from './gwt-mapped.mjs';
4
4
  import {withPageLock} from './page-lock.mjs';
5
5
  import {AttachmentConflictError} from './attachment-conflict.mjs';
6
+ import {semanticHash} from '../dist/esm/index.js';
6
7
  const id=n=>{if(!Number.isSafeInteger(n)||n<=0)throw Error('Positive integer ID required');return n;};
7
8
  const hash=b=>createHash('sha256').update(b).digest('hex');
8
9
  const maxBytes=10_000_000;
10
+ // Key-order independent: stores such as Postgres jsonb do not preserve insertion order.
11
+ const sameIntent=async(a,b)=>await semanticHash(a)===await semanticHash(b);
9
12
 
10
13
  /** Browser-authenticated attachment access where the REST account cannot upload/read files.
11
14
  * Own this page exclusively. The store must persist before resolving and survive restarts. */
@@ -64,7 +67,7 @@ export function createClientWorkflows({page,store}) {
64
67
  await openClient(clientId);
65
68
  if(entityId&&!(await mapped(`SELECT g.goalId AS goalId FROM SpGoal AS g WHERE g.active = TRUE AND g.goalId = ${entityId} AND g.clientId = ${clientId}`)).length)throw Error('Goal does not belong to client');
66
69
  const records=await store.read(scope);if(!Array.isArray(records)||records.length>1)throw Error('Invalid upload journal');const previous=records[0];
67
- if(previous&&JSON.stringify(previous.intent)!==JSON.stringify(intent))throw new AttachmentConflictError('intent-changed','Attachment intent conflicts with prior attempt');
70
+ if(previous&&!(await sameIntent(previous.intent,intent)))throw new AttachmentConflictError('intent-changed','Attachment intent conflicts with prior attempt');
68
71
  const matching=async()=>{
69
72
  const matches=(await list(clientId)).filter(a=>a.entityType===entityType&&(a.entityId??null)===entityId&&a.name===file.name);
70
73
  if(matches.length>1)throw new AttachmentConflictError('duplicate-name','Duplicate attachment names');
@@ -94,7 +97,7 @@ export function createClientWorkflows({page,store}) {
94
97
  const scope=JSON.stringify(key===undefined?[base,'case-note',clientId,goalId,providerId,noteDate,hash(note)]:[base,'case-note-source',hash(key)]);
95
98
  return store.withLock(scope,async()=>{
96
99
  const records=await store.read(scope);if(!Array.isArray(records)||records.length>1)throw Error('Invalid case note journal');const previous=records[0];
97
- if(previous&&JSON.stringify(previous.intent)!==JSON.stringify(intent))throw Object.assign(Error('Case note source key conflicts with earlier intent'),{code:'CASE_NOTE_SOURCE_CONFLICT'});
100
+ if(previous&&!(await sameIntent(previous.intent,intent)))throw Object.assign(Error('Case note source key conflicts with earlier intent'),{code:'CASE_NOTE_SOURCE_CONFLICT'});
98
101
  return withPageLock(page,async()=>{
99
102
  await openClient(clientId);
100
103
  if(!(await mapped(`SELECT g.goalId AS goalId FROM SpGoal AS g WHERE g.active = TRUE AND g.goalId = ${goalId} AND g.clientId = ${clientId}`)).length)throw Error('Goal does not belong to client');
@@ -1,5 +1,5 @@
1
1
  import type {Answers, EncodingOptions, PicklistSelector, WellSkyClient} from '../dist/esm/index.js';
2
- import type {UiMutationStore} from './index.mjs';
2
+ import type {EnrollmentReader, UiMutationStore} from './index.mjs';
3
3
  /** Exact virtual fields from the target instrument's client-scoped metadata. No guessed codes. */
4
4
  export interface CoordinatedEntryEventFields {
5
5
  parentField: string;
@@ -75,4 +75,7 @@ export function createCoordinatedEntryEventRecorder(options: {
75
75
  api: WellSkyClient; store: UiMutationStore; fields: CoordinatedEntryEventFields;
76
76
  /** Tenant-verified labels for lookup answers, including the Event. */
77
77
  writeLabel: NonNullable<EncodingOptions['writeLabel']>;
78
+ /** Read the enrollment through this GWT reader instead of REST, which fails for grouped
79
+ * (household) enrollments. Use the reader `openEnrollmentSession` returns. */
80
+ enrollmentSession?: EnrollmentReader;
78
81
  }): CoordinatedEntryEventRecorder;
@@ -86,13 +86,13 @@ function compile(definition, fields, input, entryDate, tenant, writeLabel) {
86
86
  /** One new CE Event recordset per event key, written through the standard assessment endpoint,
87
87
  * which keeps provider and yes/no answers that mobile writes drop. Follow-up answers are
88
88
  * checked against the selected Event. */
89
- export function createCoordinatedEntryEventRecorder({api, store, fields: mapping, writeLabel}) {
89
+ export function createCoordinatedEntryEventRecorder({api, store, fields: mapping, writeLabel, enrollmentSession}) {
90
90
  if (!store?.withLock || !store?.read || !store?.write || typeof api?.assessments?.create !== 'function') fail('api-and-store-required');
91
91
  if (typeof writeLabel !== 'function') fail('write-label-required');
92
92
  exactFields(mapping, roles);
93
93
  const fields = structuredClone(mapping);
94
94
  if (roles.some(role => !nonempty(fields[role])) || new Set(Object.values(fields)).size !== roles.length) fail('invalid-field-mapping');
95
- const recorder = createRowRecorder({api, store, fail, kind: 'coordinated-entry-event', noun: 'event', dateKey: 'eventDate', parentField: fields.parentField, normalize,
95
+ const recorder = createRowRecorder({api, store, enrollmentSession, fail, kind: 'coordinated-entry-event', noun: 'event', dateKey: 'eventDate', parentField: fields.parentField, normalize,
96
96
  compile(definition, input, enrollment) {
97
97
  const {parent, sent, stored} = compile(definition, fields, input, enrollment.entryDate, api.tenant, writeLabel);
98
98
  // Hash option IDs, not labels, so a relabelled option does not invalidate verified keys.
@@ -1,5 +1,5 @@
1
1
  import type {Answers, AnswerValue, WellSkyClient} from '../dist/esm/index.js';
2
- import type {UiMutationStore} from './index.mjs';
2
+ import type {EnrollmentReader, UiMutationStore} from './index.mjs';
3
3
  /** Exact virtual fields from the target program's client-scoped metadata. No guessed codes. */
4
4
  export interface CurrentLivingSituationFields {
5
5
  parentField: string;
@@ -41,4 +41,7 @@ export class CurrentLivingSituationConflictError extends Error {
41
41
  /** REST orchestration. No browser launch, credential loading, or automatic interim. */
42
42
  export function createCurrentLivingSituationRecorder(options: {
43
43
  api: WellSkyClient; store: UiMutationStore; fields: CurrentLivingSituationFields;
44
+ /** Read the enrollment through this GWT reader instead of REST, which fails for grouped
45
+ * (household) enrollments. Use the reader `openEnrollmentSession` returns. */
46
+ enrollmentSession?: EnrollmentReader;
44
47
  }): CurrentLivingSituationRecorder;
@@ -36,12 +36,12 @@ function compile(definition, fields, input, entryDate, tenant) {
36
36
  }
37
37
 
38
38
  /** One new recordset per contact key, even when two contacts have identical answers. */
39
- export function createCurrentLivingSituationRecorder({api, store, fields: mapping}) {
39
+ export function createCurrentLivingSituationRecorder({api, store, fields: mapping, enrollmentSession}) {
40
40
  if (!store?.withLock || !store?.read || !store?.write) fail('api-and-store-required');
41
41
  exactFields(mapping, ['parentField', 'startDate', 'endDate', 'informationDate', 'livingSituation', 'verifiedBy']);
42
42
  const fields = structuredClone(Object.fromEntries(Object.entries(mapping).filter(([, value]) => value !== undefined)));
43
43
  if (['parentField', 'startDate', 'endDate', 'informationDate', 'livingSituation'].some(role => !nonempty(fields[role])) || fields.verifiedBy !== undefined && !nonempty(fields.verifiedBy) || new Set(Object.values(fields)).size !== Object.keys(fields).length) fail('invalid-field-mapping');
44
- return createRowRecorder({api, store, fail, kind: 'current-living-situation', noun: 'contact', dateKey: 'informationDate', parentField: fields.parentField, normalize,
44
+ return createRowRecorder({api, store, enrollmentSession, fail, kind: 'current-living-situation', noun: 'contact', dateKey: 'informationDate', parentField: fields.parentField, normalize,
45
45
  compile(definition, input, enrollment) {
46
46
  const {parent, encoded} = compile(definition, fields, input, enrollment.entryDate, api.tenant);
47
47
  return {expected: encoded, empty: [], intent: {fields, encoded},
@@ -2,7 +2,7 @@ import {apiScope} from './connection.mjs';
2
2
  import {validateBirthDate} from './date-of-birth.mjs';
3
3
  import {expectedExitDetails} from './exit-details.mjs';
4
4
  import {createHash} from 'node:crypto';
5
- import {readProgramCatalog,encodeAnswers,encodeNewSubassessmentRow,toItems} from '../dist/esm/index.js';
5
+ import {readProgramCatalog,encodeAnswers,encodeNewSubassessmentRow,toItems,parseRestInstant} from '../dist/esm/index.js';
6
6
 
7
7
  const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
8
8
  const positive=v=>Number.isSafeInteger(v)&&v>0;
@@ -16,7 +16,7 @@ function date(value){
16
16
  return new Date(value).toISOString();
17
17
  }
18
18
  // REST scalar/enrollment timestamps are emitted as UTC without a suffix.
19
- const restDate=value=>typeof value==='string'?Date.parse(value.replace(' ','T').replace(/([+-]\d\d)$/,'$1:00')+(/(?:Z|[+-]\d\d(?::?\d\d)?)$/.test(value)?'':'Z')):NaN;
19
+ const restDate=parseRestInstant;
20
20
  function key(value){if(typeof value!=='string'||!value.trim()||value.length>200)fail('stable key required');return value;}
21
21
  function fields(value,allowed){if(!object(value)||Object.keys(value).some(k=>!allowed.includes(k)))fail('unknown or invalid input fields');}
22
22
  function sameAnswer(expected,actual){
@@ -1,6 +1,6 @@
1
1
  import {apiScope} from './connection.mjs';
2
2
  import { createHash } from 'node:crypto';
3
- import { toItems } from '../dist/esm/index.js';
3
+ import { toItems, semanticHash } from '../dist/esm/index.js';
4
4
  const hash = v => createHash('sha256').update(JSON.stringify(v)).digest('hex');
5
5
  const id = v => {
6
6
  if (!Number.isSafeInteger(v) || v < 1)
@@ -75,7 +75,7 @@ export function createShelters({ api, ui, store, enrollmentHelpers }) {
75
75
  fail('stay-identity-changed');
76
76
  return { serviceId: r.serviceId, clientId: r.clientId, bedId: r.bedId, unitListId: r.unitListId };
77
77
  }).sort((a, b) => a.serviceId - b.serviceId);
78
- if (record.identities && JSON.stringify(normalized) !== JSON.stringify(record.identities))
78
+ if (record.identities && await semanticHash(normalized) !== await semanticHash(record.identities))
79
79
  fail('stay-identity-changed');
80
80
  record.identities = normalized;
81
81
  };
@@ -52,10 +52,25 @@ export function rowHelpers(fail) {
52
52
  * answers that must read back, fields that must read back empty, extra intent-hash content and
53
53
  * the write request. write(input, body) sends it once.
54
54
  */
55
- export function createRowRecorder({api, store, fail, kind, noun, dateKey, parentField, normalize, compile, write}) {
56
- const tenantScope = apiScope(api);
55
+ export function createRowRecorder({api, store, enrollmentSession, fail, kind, noun, dateKey, parentField, normalize, compile, write}) {
56
+ if (enrollmentSession !== undefined && typeof enrollmentSession?.readEnrollment !== 'function') fail('invalid-enrollment-session');
57
+ const tenantScope = apiScope(api, enrollmentSession);
57
58
  const {instant, valueOf} = rowHelpers(fail);
59
+ const checkDates = (input, entryDate, exitDate) => {
60
+ if (input[dateKey] < entryDate || exitDate && input[dateKey] > instant(exitDate, true)) fail(`${noun}-outside-enrollment`);
61
+ };
62
+ // REST enrollment reads fail for grouped enrollments (42703), so households need the GWT reader.
63
+ // It reads only active enrollments and throws when the target is missing.
58
64
  async function readEnrollment(input) {
65
+ if (enrollmentSession) {
66
+ const target = {clientId: input.clientId, enrollmentId: input.enrollmentId}, snapshot = await enrollmentSession.readEnrollment(target);
67
+ if (snapshot?.source !== 'gwt-rpc' || snapshot.tenant !== api.tenant || snapshot.clientId !== target.clientId || snapshot.enrollmentId !== target.enrollmentId || !positive(snapshot.providerId) || !Array.isArray(snapshot.exits)) fail('enrollment-unavailable');
68
+ const exits = snapshot.exits.filter(e => e?.clientId === target.clientId && e.enrollmentId === target.enrollmentId);
69
+ if (exits.length !== 1 || exits[0].projectExitDate !== null && typeof exits[0].projectExitDate !== 'string') fail('enrollment-unavailable');
70
+ const entryDate = instant(snapshot.projectStartDate, true);
71
+ checkDates(input, entryDate, exits[0].projectExitDate);
72
+ return {entryDate, providerId: snapshot.providerId};
73
+ }
59
74
  const rows = [], ids = new Set(); let total;
60
75
  for (let page = 1; page <= 100; page++) {
61
76
  const response = await api.enrollments.listForClient({path: {clientId: input.clientId}, query: {perPage: 100, startPage: page, total_count: true}});
@@ -69,7 +84,7 @@ export function createRowRecorder({api, store, fail, kind, noun, dateKey, parent
69
84
  const row = rows.find(row => row.enrollmentId === input.enrollmentId);
70
85
  if (!row || row.isActive !== true || !positive(row.enrollmentProvider)) fail('enrollment-unavailable');
71
86
  const entryDate = instant(row.projectStartDate, true);
72
- if (input[dateKey] < entryDate || row.projectExitDate && input[dateKey] > instant(row.projectExitDate, true)) fail(`${noun}-outside-enrollment`);
87
+ checkDates(input, entryDate, row.projectExitDate);
73
88
  return {entryDate, providerId: row.enrollmentProvider};
74
89
  }
75
90
  if (rows.length > total || !response.values.length) fail('incomplete-enrollment-read');
@@ -29,4 +29,9 @@ export declare function encodeAnswers(questions: readonly QuestionDefinition[],
29
29
  export declare function encodeNewSubassessmentRow(parent: QuestionDefinition, answers: Answers, options: EncodingOptions): {
30
30
  subAssessments: EncodedQuestion[];
31
31
  };
32
+ /** Milliseconds since epoch for a REST read timestamp, or NaN. Reads emit forms such as
33
+ * `2026-11-20 10:00:00-07` (space separator, hour-only offset) whose `Date.parse` result is
34
+ * engine-defined: NaN on some runtimes, and host-local time when the offset is missing.
35
+ * A value without an offset is UTC. */
36
+ export declare function parseRestInstant(value: unknown): number;
32
37
  //# sourceMappingURL=encoding.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"encoding.d.ts","sourceRoot":"","sources":["../../src/encoding.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAK1F,MAAM,MAAM,WAAW,GAAG,oBAAoB,GAAG,gBAAgB,GAAG,oBAAoB,CAAC;AACzF,MAAM,MAAM,WAAW,GAAG;IAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,EAAE,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,WAAW,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,gBAAgB,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,SAAS,gBAAgB,EAAE,CAAA;CAAE,CAAC;AAC7K,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AAClD,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,YAAY,GAAG,YAAY,GAAG,QAAQ,CAAC;IACjD,0GAA0G;IAC1G,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,kBAAkB,EAAE,MAAM,EAAE,cAAc,KAAK,MAAM,GAAG,SAAS,CAAC;IAC1F,wGAAwG;IACxG,UAAU,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;IAC7D,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,kBAAkB,EAAE,MAAM,EAAE,SAAS,MAAM,EAAE,KAAK,MAAM,CAAC;CAClF;AACD,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;CACtB;AA2DD,wBAAgB,aAAa,CAAC,SAAS,EAAE,SAAS,kBAAkB,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,GAAG,eAAe,EAAE,CAgBrI;AAED,iFAAiF;AACjF,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,kBAAkB,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,GAAG;IAAE,cAAc,EAAE,eAAe,EAAE,CAAA;CAAE,CAWvJ"}
1
+ {"version":3,"file":"encoding.d.ts","sourceRoot":"","sources":["../../src/encoding.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAK1F,MAAM,MAAM,WAAW,GAAG,oBAAoB,GAAG,gBAAgB,GAAG,oBAAoB,CAAC;AACzF,MAAM,MAAM,WAAW,GAAG;IAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,EAAE,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,WAAW,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,gBAAgB,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,SAAS,gBAAgB,EAAE,CAAA;CAAE,CAAC;AAC7K,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AAClD,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,YAAY,GAAG,YAAY,GAAG,QAAQ,CAAC;IACjD,0GAA0G;IAC1G,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,kBAAkB,EAAE,MAAM,EAAE,cAAc,KAAK,MAAM,GAAG,SAAS,CAAC;IAC1F,wGAAwG;IACxG,UAAU,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;IAC7D,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,kBAAkB,EAAE,MAAM,EAAE,SAAS,MAAM,EAAE,KAAK,MAAM,CAAC;CAClF;AACD,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;CACtB;AA2DD,wBAAgB,aAAa,CAAC,SAAS,EAAE,SAAS,kBAAkB,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,GAAG,eAAe,EAAE,CAgBrI;AAED,iFAAiF;AACjF,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,kBAAkB,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,GAAG;IAAE,cAAc,EAAE,eAAe,EAAE,CAAA;CAAE,CAWvJ;AAED;;;uCAGuC;AACvC,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAGvD"}
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.encodeAnswers = encodeAnswers;
4
4
  exports.encodeNewSubassessmentRow = encodeNewSubassessmentRow;
5
+ exports.parseRestInstant = parseRestInstant;
5
6
  const metadata_js_1 = require("./metadata.js");
6
7
  const errors_js_1 = require("./errors.js");
7
8
  const race_js_1 = require("./race.js");
@@ -119,4 +120,13 @@ function encodeNewSubassessmentRow(parent, answers, options) {
119
120
  }
120
121
  return { subAssessments: encoded };
121
122
  }
123
+ /** Milliseconds since epoch for a REST read timestamp, or NaN. Reads emit forms such as
124
+ * `2026-11-20 10:00:00-07` (space separator, hour-only offset) whose `Date.parse` result is
125
+ * engine-defined: NaN on some runtimes, and host-local time when the offset is missing.
126
+ * A value without an offset is UTC. */
127
+ function parseRestInstant(value) {
128
+ if (typeof value !== "string")
129
+ return NaN;
130
+ return Date.parse(value.replace(" ", "T").replace(/([+-]\d\d)$/, "$1:00") + (/(?:Z|[+-]\d\d(?::?\d\d)?)$/.test(value) ? "" : "Z"));
131
+ }
122
132
  //# sourceMappingURL=encoding.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"encoding.js","sourceRoot":"","sources":["../../src/encoding.ts"],"names":[],"mappings":"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","sourcesContent":["import type { PicklistOption, PicklistSelector, QuestionDefinition } from \"./metadata.js\";\nimport { resolvePicklistOption } from \"./metadata.js\";\nimport { ValidationError } from \"./errors.js\";\nimport { resolveRaceCombination } from \"./race.js\";\n\nexport type AnswerState = \"client_doesnt_know\" | \"client_refused\" | \"data_not_collected\";\nexport type AnswerValue = { value: string | number | boolean | string[] } | { state: AnswerState } | { option: PicklistSelector } | { options: readonly PicklistSelector[] };\nexport type Answers = Record<string, AnswerValue>;\nexport interface EncodingOptions {\n endpoint: \"standalone\" | \"enrollment\" | \"mobile\";\n /** Tenant-verified display labels, including exact whitespace/HUD suffixes, for label-based endpoints. */\n writeLabel?: (question: QuestionDefinition, option: PicklistOption) => string | undefined;\n /** Application configuration supplies the relevant option codes. No answer-state labels are guessed. */\n stateCodes?: Partial<Record<AnswerState, readonly string[]>>;\n multiValue?: (question: QuestionDefinition, values: readonly string[]) => string;\n}\nexport interface EncodedQuestion {\n question_id: number;\n question_type: string;\n virt_field_name: string;\n answer_value: string;\n}\n\nfunction encodeValue(question: QuestionDefinition, answer: AnswerValue, options: EncodingOptions): string | undefined {\n const field = question.fieldName ?? `#${question.questionId}`;\n if ([\"label\", \"blank\", \"sub_assessment\"].includes(question.questionType)) throw new ValidationError(\"assessment encoding\", [field]);\n if (question.questionType === \"provider\" && options.endpoint === \"mobile\") throw new ValidationError(\"mobile provider answers are unsupported\", [field]);\n if (\"state\" in answer) {\n if (question.questionType !== \"lookup\") return undefined;\n const codes = options.stateCodes?.[answer.state];\n if (!codes) throw new ValidationError(\"answer-state code configuration required\", [field]);\n const found = question.options.filter(option => option.code !== null && codes.includes(option.code));\n if (!found.length) return undefined;\n if (found.length !== 1 || !found[0]) throw new ValidationError(\"ambiguous answer-state option\", [field]);\n const option = question.fieldName === \"HUD_RACEETHNICITY\" && found[0].code !== null\n ? resolveRaceCombination(question, [{ code: found[0].code }]) : found[0];\n return lookupLabel(question, option, options);\n }\n if (question.questionType === \"lookup\") {\n if (question.fieldName === \"HUD_RACEETHNICITY\") {\n const selectors = \"options\" in answer ? answer.options : \"option\" in answer ? [answer.option]\n : typeof answer.value === \"string\" ? [{ name: answer.value }]\n : Array.isArray(answer.value) ? answer.value.map(name => ({ name })) : undefined;\n if (!selectors) throw new ValidationError(\"race requires category selectors\", [field]);\n return lookupLabel(question, resolveRaceCombination(question, selectors), options);\n }\n if (\"options\" in answer) throw new ValidationError(\"multiple options require a supported combination field\", [field]);\n if (\"option\" in answer) return lookupLabel(question, resolvePicklistOption(question, answer.option), options);\n if (typeof answer.value !== \"string\") throw new ValidationError(\"lookup requires a code or name selector\", [field]);\n return lookupLabel(question, resolvePicklistOption(question, { name: answer.value }), options);\n }\n if (!(\"value\" in answer)) throw new ValidationError(\"picklist selector on a non-lookup question\", [field]);\n const value = answer.value;\n if (Array.isArray(value)) {\n if (!options.multiValue) throw new ValidationError(\"multi-value encoding requires an explicit encoder\", [field]);\n return options.multiValue(question, value);\n }\n switch (question.questionType) {\n case \"yes_no\":\n if (value === true || value === \"Yes\") return \"Yes\";\n if (value === false || value === \"No\") return \"No\";\n throw new ValidationError(\"yes_no encoding\", [field]);\n case \"int\": case \"provider\":\n if ((typeof value !== \"number\" && typeof value !== \"string\") || value === \"\" || !Number.isSafeInteger(Number(value))) throw new ValidationError(\"integer encoding\", [field]);\n return String(value);\n case \"money\":\n if ((typeof value !== \"number\" && typeof value !== \"string\") || value === \"\" || !Number.isFinite(Number(value))) throw new ValidationError(\"money encoding\", [field]);\n return String(value);\n case \"date\": case \"textbox\": case \"textarea\": return String(value);\n default: throw new ValidationError(\"unsupported question type\", [field]);\n }\n}\n\nfunction lookupLabel(question: QuestionDefinition, option: PicklistOption, options: EncodingOptions): string {\n if (options.endpoint === \"mobile\") return String(option.id);\n const label = options.writeLabel?.(question, option);\n if (!label) throw new ValidationError(\"verified lookup write label required\", [question.fieldName ?? `#${question.questionId}`]);\n return label;\n}\n\nexport function encodeAnswers(questions: readonly QuestionDefinition[], answers: Answers, options: EncodingOptions): EncodedQuestion[] {\n const index = new Map<string, QuestionDefinition>();\n for (const question of questions) if (question.fieldName) {\n if (index.has(question.fieldName)) throw new ValidationError(\"ambiguous question field\", [question.fieldName]);\n index.set(question.fieldName, question);\n }\n const unknown = Object.keys(answers).filter(field => !index.has(field));\n if (unknown.length) throw new ValidationError(\"unknown assessment fields\", unknown);\n const result: EncodedQuestion[] = [];\n for (const [field, answer] of Object.entries(answers)) {\n const question = index.get(field);\n if (!question) throw new ValidationError(\"unknown assessment fields\", [field]);\n const value = encodeValue(question, answer, options);\n if (value !== undefined) result.push({ question_id: question.questionId, question_type: question.questionType, virt_field_name: field, answer_value: value });\n }\n return result;\n}\n\n/** Builds one new row only. Never accepts or echoes an existing recordset ID. */\nexport function encodeNewSubassessmentRow(parent: QuestionDefinition, answers: Answers, options: EncodingOptions): { subAssessments: EncodedQuestion[] } {\n if (parent.questionType !== \"sub_assessment\") throw new ValidationError(\"subassessment parent required\");\n const encoded = encodeAnswers(parent.children, answers, options);\n const included = new Set(encoded.map(q => q.virt_field_name));\n for (const question of parent.children) {\n // An unanswered provider child is omitted; answered ones are still refused for mobile in encodeValue.\n if (!question.fieldName || included.has(question.fieldName) || [\"label\", \"blank\", \"yes_no\"].includes(question.questionType)) continue;\n if (question.questionType === \"provider\" && options.endpoint === \"mobile\") continue;\n encoded.push({ question_id: question.questionId, question_type: question.questionType, virt_field_name: question.fieldName, answer_value: \"\" });\n }\n return { subAssessments: encoded };\n}\n"]}
1
+ {"version":3,"file":"encoding.js","sourceRoot":"","sources":["../../src/encoding.ts"],"names":[],"mappings":"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","sourcesContent":["import type { PicklistOption, PicklistSelector, QuestionDefinition } from \"./metadata.js\";\nimport { resolvePicklistOption } from \"./metadata.js\";\nimport { ValidationError } from \"./errors.js\";\nimport { resolveRaceCombination } from \"./race.js\";\n\nexport type AnswerState = \"client_doesnt_know\" | \"client_refused\" | \"data_not_collected\";\nexport type AnswerValue = { value: string | number | boolean | string[] } | { state: AnswerState } | { option: PicklistSelector } | { options: readonly PicklistSelector[] };\nexport type Answers = Record<string, AnswerValue>;\nexport interface EncodingOptions {\n endpoint: \"standalone\" | \"enrollment\" | \"mobile\";\n /** Tenant-verified display labels, including exact whitespace/HUD suffixes, for label-based endpoints. */\n writeLabel?: (question: QuestionDefinition, option: PicklistOption) => string | undefined;\n /** Application configuration supplies the relevant option codes. No answer-state labels are guessed. */\n stateCodes?: Partial<Record<AnswerState, readonly string[]>>;\n multiValue?: (question: QuestionDefinition, values: readonly string[]) => string;\n}\nexport interface EncodedQuestion {\n question_id: number;\n question_type: string;\n virt_field_name: string;\n answer_value: string;\n}\n\nfunction encodeValue(question: QuestionDefinition, answer: AnswerValue, options: EncodingOptions): string | undefined {\n const field = question.fieldName ?? `#${question.questionId}`;\n if ([\"label\", \"blank\", \"sub_assessment\"].includes(question.questionType)) throw new ValidationError(\"assessment encoding\", [field]);\n if (question.questionType === \"provider\" && options.endpoint === \"mobile\") throw new ValidationError(\"mobile provider answers are unsupported\", [field]);\n if (\"state\" in answer) {\n if (question.questionType !== \"lookup\") return undefined;\n const codes = options.stateCodes?.[answer.state];\n if (!codes) throw new ValidationError(\"answer-state code configuration required\", [field]);\n const found = question.options.filter(option => option.code !== null && codes.includes(option.code));\n if (!found.length) return undefined;\n if (found.length !== 1 || !found[0]) throw new ValidationError(\"ambiguous answer-state option\", [field]);\n const option = question.fieldName === \"HUD_RACEETHNICITY\" && found[0].code !== null\n ? resolveRaceCombination(question, [{ code: found[0].code }]) : found[0];\n return lookupLabel(question, option, options);\n }\n if (question.questionType === \"lookup\") {\n if (question.fieldName === \"HUD_RACEETHNICITY\") {\n const selectors = \"options\" in answer ? answer.options : \"option\" in answer ? [answer.option]\n : typeof answer.value === \"string\" ? [{ name: answer.value }]\n : Array.isArray(answer.value) ? answer.value.map(name => ({ name })) : undefined;\n if (!selectors) throw new ValidationError(\"race requires category selectors\", [field]);\n return lookupLabel(question, resolveRaceCombination(question, selectors), options);\n }\n if (\"options\" in answer) throw new ValidationError(\"multiple options require a supported combination field\", [field]);\n if (\"option\" in answer) return lookupLabel(question, resolvePicklistOption(question, answer.option), options);\n if (typeof answer.value !== \"string\") throw new ValidationError(\"lookup requires a code or name selector\", [field]);\n return lookupLabel(question, resolvePicklistOption(question, { name: answer.value }), options);\n }\n if (!(\"value\" in answer)) throw new ValidationError(\"picklist selector on a non-lookup question\", [field]);\n const value = answer.value;\n if (Array.isArray(value)) {\n if (!options.multiValue) throw new ValidationError(\"multi-value encoding requires an explicit encoder\", [field]);\n return options.multiValue(question, value);\n }\n switch (question.questionType) {\n case \"yes_no\":\n if (value === true || value === \"Yes\") return \"Yes\";\n if (value === false || value === \"No\") return \"No\";\n throw new ValidationError(\"yes_no encoding\", [field]);\n case \"int\": case \"provider\":\n if ((typeof value !== \"number\" && typeof value !== \"string\") || value === \"\" || !Number.isSafeInteger(Number(value))) throw new ValidationError(\"integer encoding\", [field]);\n return String(value);\n case \"money\":\n if ((typeof value !== \"number\" && typeof value !== \"string\") || value === \"\" || !Number.isFinite(Number(value))) throw new ValidationError(\"money encoding\", [field]);\n return String(value);\n case \"date\": case \"textbox\": case \"textarea\": return String(value);\n default: throw new ValidationError(\"unsupported question type\", [field]);\n }\n}\n\nfunction lookupLabel(question: QuestionDefinition, option: PicklistOption, options: EncodingOptions): string {\n if (options.endpoint === \"mobile\") return String(option.id);\n const label = options.writeLabel?.(question, option);\n if (!label) throw new ValidationError(\"verified lookup write label required\", [question.fieldName ?? `#${question.questionId}`]);\n return label;\n}\n\nexport function encodeAnswers(questions: readonly QuestionDefinition[], answers: Answers, options: EncodingOptions): EncodedQuestion[] {\n const index = new Map<string, QuestionDefinition>();\n for (const question of questions) if (question.fieldName) {\n if (index.has(question.fieldName)) throw new ValidationError(\"ambiguous question field\", [question.fieldName]);\n index.set(question.fieldName, question);\n }\n const unknown = Object.keys(answers).filter(field => !index.has(field));\n if (unknown.length) throw new ValidationError(\"unknown assessment fields\", unknown);\n const result: EncodedQuestion[] = [];\n for (const [field, answer] of Object.entries(answers)) {\n const question = index.get(field);\n if (!question) throw new ValidationError(\"unknown assessment fields\", [field]);\n const value = encodeValue(question, answer, options);\n if (value !== undefined) result.push({ question_id: question.questionId, question_type: question.questionType, virt_field_name: field, answer_value: value });\n }\n return result;\n}\n\n/** Builds one new row only. Never accepts or echoes an existing recordset ID. */\nexport function encodeNewSubassessmentRow(parent: QuestionDefinition, answers: Answers, options: EncodingOptions): { subAssessments: EncodedQuestion[] } {\n if (parent.questionType !== \"sub_assessment\") throw new ValidationError(\"subassessment parent required\");\n const encoded = encodeAnswers(parent.children, answers, options);\n const included = new Set(encoded.map(q => q.virt_field_name));\n for (const question of parent.children) {\n // An unanswered provider child is omitted; answered ones are still refused for mobile in encodeValue.\n if (!question.fieldName || included.has(question.fieldName) || [\"label\", \"blank\", \"yes_no\"].includes(question.questionType)) continue;\n if (question.questionType === \"provider\" && options.endpoint === \"mobile\") continue;\n encoded.push({ question_id: question.questionId, question_type: question.questionType, virt_field_name: question.fieldName, answer_value: \"\" });\n }\n return { subAssessments: encoded };\n}\n\n/** Milliseconds since epoch for a REST read timestamp, or NaN. Reads emit forms such as\n * `2026-11-20 10:00:00-07` (space separator, hour-only offset) whose `Date.parse` result is\n * engine-defined: NaN on some runtimes, and host-local time when the offset is missing.\n * A value without an offset is UTC. */\nexport function parseRestInstant(value: unknown): number {\n if (typeof value !== \"string\") return NaN;\n return Date.parse(value.replace(\" \", \"T\").replace(/([+-]\\d\\d)$/, \"$1:00\") + (/(?:Z|[+-]\\d\\d(?::?\\d\\d)?)$/.test(value) ? \"\" : \"Z\"));\n}\n"]}
@@ -29,4 +29,9 @@ export declare function encodeAnswers(questions: readonly QuestionDefinition[],
29
29
  export declare function encodeNewSubassessmentRow(parent: QuestionDefinition, answers: Answers, options: EncodingOptions): {
30
30
  subAssessments: EncodedQuestion[];
31
31
  };
32
+ /** Milliseconds since epoch for a REST read timestamp, or NaN. Reads emit forms such as
33
+ * `2026-11-20 10:00:00-07` (space separator, hour-only offset) whose `Date.parse` result is
34
+ * engine-defined: NaN on some runtimes, and host-local time when the offset is missing.
35
+ * A value without an offset is UTC. */
36
+ export declare function parseRestInstant(value: unknown): number;
32
37
  //# sourceMappingURL=encoding.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"encoding.d.ts","sourceRoot":"","sources":["../../src/encoding.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAK1F,MAAM,MAAM,WAAW,GAAG,oBAAoB,GAAG,gBAAgB,GAAG,oBAAoB,CAAC;AACzF,MAAM,MAAM,WAAW,GAAG;IAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,EAAE,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,WAAW,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,gBAAgB,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,SAAS,gBAAgB,EAAE,CAAA;CAAE,CAAC;AAC7K,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AAClD,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,YAAY,GAAG,YAAY,GAAG,QAAQ,CAAC;IACjD,0GAA0G;IAC1G,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,kBAAkB,EAAE,MAAM,EAAE,cAAc,KAAK,MAAM,GAAG,SAAS,CAAC;IAC1F,wGAAwG;IACxG,UAAU,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;IAC7D,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,kBAAkB,EAAE,MAAM,EAAE,SAAS,MAAM,EAAE,KAAK,MAAM,CAAC;CAClF;AACD,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;CACtB;AA2DD,wBAAgB,aAAa,CAAC,SAAS,EAAE,SAAS,kBAAkB,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,GAAG,eAAe,EAAE,CAgBrI;AAED,iFAAiF;AACjF,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,kBAAkB,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,GAAG;IAAE,cAAc,EAAE,eAAe,EAAE,CAAA;CAAE,CAWvJ"}
1
+ {"version":3,"file":"encoding.d.ts","sourceRoot":"","sources":["../../src/encoding.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAK1F,MAAM,MAAM,WAAW,GAAG,oBAAoB,GAAG,gBAAgB,GAAG,oBAAoB,CAAC;AACzF,MAAM,MAAM,WAAW,GAAG;IAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,EAAE,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,WAAW,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,gBAAgB,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,SAAS,gBAAgB,EAAE,CAAA;CAAE,CAAC;AAC7K,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AAClD,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,YAAY,GAAG,YAAY,GAAG,QAAQ,CAAC;IACjD,0GAA0G;IAC1G,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,kBAAkB,EAAE,MAAM,EAAE,cAAc,KAAK,MAAM,GAAG,SAAS,CAAC;IAC1F,wGAAwG;IACxG,UAAU,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;IAC7D,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,kBAAkB,EAAE,MAAM,EAAE,SAAS,MAAM,EAAE,KAAK,MAAM,CAAC;CAClF;AACD,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;CACtB;AA2DD,wBAAgB,aAAa,CAAC,SAAS,EAAE,SAAS,kBAAkB,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,GAAG,eAAe,EAAE,CAgBrI;AAED,iFAAiF;AACjF,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,kBAAkB,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,GAAG;IAAE,cAAc,EAAE,eAAe,EAAE,CAAA;CAAE,CAWvJ;AAED;;;uCAGuC;AACvC,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAGvD"}
@@ -115,4 +115,13 @@ export function encodeNewSubassessmentRow(parent, answers, options) {
115
115
  }
116
116
  return { subAssessments: encoded };
117
117
  }
118
+ /** Milliseconds since epoch for a REST read timestamp, or NaN. Reads emit forms such as
119
+ * `2026-11-20 10:00:00-07` (space separator, hour-only offset) whose `Date.parse` result is
120
+ * engine-defined: NaN on some runtimes, and host-local time when the offset is missing.
121
+ * A value without an offset is UTC. */
122
+ export function parseRestInstant(value) {
123
+ if (typeof value !== "string")
124
+ return NaN;
125
+ return Date.parse(value.replace(" ", "T").replace(/([+-]\d\d)$/, "$1:00") + (/(?:Z|[+-]\d\d(?::?\d\d)?)$/.test(value) ? "" : "Z"));
126
+ }
118
127
  //# sourceMappingURL=encoding.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"encoding.js","sourceRoot":"","sources":["../../src/encoding.ts"],"names":[],"mappings":"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","sourcesContent":["import type { PicklistOption, PicklistSelector, QuestionDefinition } from \"./metadata.js\";\nimport { resolvePicklistOption } from \"./metadata.js\";\nimport { ValidationError } from \"./errors.js\";\nimport { resolveRaceCombination } from \"./race.js\";\n\nexport type AnswerState = \"client_doesnt_know\" | \"client_refused\" | \"data_not_collected\";\nexport type AnswerValue = { value: string | number | boolean | string[] } | { state: AnswerState } | { option: PicklistSelector } | { options: readonly PicklistSelector[] };\nexport type Answers = Record<string, AnswerValue>;\nexport interface EncodingOptions {\n endpoint: \"standalone\" | \"enrollment\" | \"mobile\";\n /** Tenant-verified display labels, including exact whitespace/HUD suffixes, for label-based endpoints. */\n writeLabel?: (question: QuestionDefinition, option: PicklistOption) => string | undefined;\n /** Application configuration supplies the relevant option codes. No answer-state labels are guessed. */\n stateCodes?: Partial<Record<AnswerState, readonly string[]>>;\n multiValue?: (question: QuestionDefinition, values: readonly string[]) => string;\n}\nexport interface EncodedQuestion {\n question_id: number;\n question_type: string;\n virt_field_name: string;\n answer_value: string;\n}\n\nfunction encodeValue(question: QuestionDefinition, answer: AnswerValue, options: EncodingOptions): string | undefined {\n const field = question.fieldName ?? `#${question.questionId}`;\n if ([\"label\", \"blank\", \"sub_assessment\"].includes(question.questionType)) throw new ValidationError(\"assessment encoding\", [field]);\n if (question.questionType === \"provider\" && options.endpoint === \"mobile\") throw new ValidationError(\"mobile provider answers are unsupported\", [field]);\n if (\"state\" in answer) {\n if (question.questionType !== \"lookup\") return undefined;\n const codes = options.stateCodes?.[answer.state];\n if (!codes) throw new ValidationError(\"answer-state code configuration required\", [field]);\n const found = question.options.filter(option => option.code !== null && codes.includes(option.code));\n if (!found.length) return undefined;\n if (found.length !== 1 || !found[0]) throw new ValidationError(\"ambiguous answer-state option\", [field]);\n const option = question.fieldName === \"HUD_RACEETHNICITY\" && found[0].code !== null\n ? resolveRaceCombination(question, [{ code: found[0].code }]) : found[0];\n return lookupLabel(question, option, options);\n }\n if (question.questionType === \"lookup\") {\n if (question.fieldName === \"HUD_RACEETHNICITY\") {\n const selectors = \"options\" in answer ? answer.options : \"option\" in answer ? [answer.option]\n : typeof answer.value === \"string\" ? [{ name: answer.value }]\n : Array.isArray(answer.value) ? answer.value.map(name => ({ name })) : undefined;\n if (!selectors) throw new ValidationError(\"race requires category selectors\", [field]);\n return lookupLabel(question, resolveRaceCombination(question, selectors), options);\n }\n if (\"options\" in answer) throw new ValidationError(\"multiple options require a supported combination field\", [field]);\n if (\"option\" in answer) return lookupLabel(question, resolvePicklistOption(question, answer.option), options);\n if (typeof answer.value !== \"string\") throw new ValidationError(\"lookup requires a code or name selector\", [field]);\n return lookupLabel(question, resolvePicklistOption(question, { name: answer.value }), options);\n }\n if (!(\"value\" in answer)) throw new ValidationError(\"picklist selector on a non-lookup question\", [field]);\n const value = answer.value;\n if (Array.isArray(value)) {\n if (!options.multiValue) throw new ValidationError(\"multi-value encoding requires an explicit encoder\", [field]);\n return options.multiValue(question, value);\n }\n switch (question.questionType) {\n case \"yes_no\":\n if (value === true || value === \"Yes\") return \"Yes\";\n if (value === false || value === \"No\") return \"No\";\n throw new ValidationError(\"yes_no encoding\", [field]);\n case \"int\": case \"provider\":\n if ((typeof value !== \"number\" && typeof value !== \"string\") || value === \"\" || !Number.isSafeInteger(Number(value))) throw new ValidationError(\"integer encoding\", [field]);\n return String(value);\n case \"money\":\n if ((typeof value !== \"number\" && typeof value !== \"string\") || value === \"\" || !Number.isFinite(Number(value))) throw new ValidationError(\"money encoding\", [field]);\n return String(value);\n case \"date\": case \"textbox\": case \"textarea\": return String(value);\n default: throw new ValidationError(\"unsupported question type\", [field]);\n }\n}\n\nfunction lookupLabel(question: QuestionDefinition, option: PicklistOption, options: EncodingOptions): string {\n if (options.endpoint === \"mobile\") return String(option.id);\n const label = options.writeLabel?.(question, option);\n if (!label) throw new ValidationError(\"verified lookup write label required\", [question.fieldName ?? `#${question.questionId}`]);\n return label;\n}\n\nexport function encodeAnswers(questions: readonly QuestionDefinition[], answers: Answers, options: EncodingOptions): EncodedQuestion[] {\n const index = new Map<string, QuestionDefinition>();\n for (const question of questions) if (question.fieldName) {\n if (index.has(question.fieldName)) throw new ValidationError(\"ambiguous question field\", [question.fieldName]);\n index.set(question.fieldName, question);\n }\n const unknown = Object.keys(answers).filter(field => !index.has(field));\n if (unknown.length) throw new ValidationError(\"unknown assessment fields\", unknown);\n const result: EncodedQuestion[] = [];\n for (const [field, answer] of Object.entries(answers)) {\n const question = index.get(field);\n if (!question) throw new ValidationError(\"unknown assessment fields\", [field]);\n const value = encodeValue(question, answer, options);\n if (value !== undefined) result.push({ question_id: question.questionId, question_type: question.questionType, virt_field_name: field, answer_value: value });\n }\n return result;\n}\n\n/** Builds one new row only. Never accepts or echoes an existing recordset ID. */\nexport function encodeNewSubassessmentRow(parent: QuestionDefinition, answers: Answers, options: EncodingOptions): { subAssessments: EncodedQuestion[] } {\n if (parent.questionType !== \"sub_assessment\") throw new ValidationError(\"subassessment parent required\");\n const encoded = encodeAnswers(parent.children, answers, options);\n const included = new Set(encoded.map(q => q.virt_field_name));\n for (const question of parent.children) {\n // An unanswered provider child is omitted; answered ones are still refused for mobile in encodeValue.\n if (!question.fieldName || included.has(question.fieldName) || [\"label\", \"blank\", \"yes_no\"].includes(question.questionType)) continue;\n if (question.questionType === \"provider\" && options.endpoint === \"mobile\") continue;\n encoded.push({ question_id: question.questionId, question_type: question.questionType, virt_field_name: question.fieldName, answer_value: \"\" });\n }\n return { subAssessments: encoded };\n}\n"]}
1
+ {"version":3,"file":"encoding.js","sourceRoot":"","sources":["../../src/encoding.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AACtD,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAoBnD,SAAS,WAAW,CAAC,QAA4B,EAAE,MAAmB,EAAE,OAAwB;IAC9F,MAAM,KAAK,GAAG,QAAQ,CAAC,SAAS,IAAI,IAAI,QAAQ,CAAC,UAAU,EAAE,CAAC;IAC9D,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,gBAAgB,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,YAAY,CAAC;QAAE,MAAM,IAAI,eAAe,CAAC,qBAAqB,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;IACpI,IAAI,QAAQ,CAAC,YAAY,KAAK,UAAU,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ;QAAE,MAAM,IAAI,eAAe,CAAC,yCAAyC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;IACzJ,IAAI,OAAO,IAAI,MAAM,EAAE,CAAC;QACtB,IAAI,QAAQ,CAAC,YAAY,KAAK,QAAQ;YAAE,OAAO,SAAS,CAAC;QACzD,MAAM,KAAK,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACjD,IAAI,CAAC,KAAK;YAAE,MAAM,IAAI,eAAe,CAAC,0CAA0C,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;QAC3F,MAAM,KAAK,GAAG,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,IAAI,KAAK,IAAI,IAAI,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;QACrG,IAAI,CAAC,KAAK,CAAC,MAAM;YAAE,OAAO,SAAS,CAAC;QACpC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;YAAE,MAAM,IAAI,eAAe,CAAC,+BAA+B,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;QACzG,MAAM,MAAM,GAAG,QAAQ,CAAC,SAAS,KAAK,mBAAmB,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI;YACjF,CAAC,CAAC,sBAAsB,CAAC,QAAQ,EAAE,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAC3E,OAAO,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;IAChD,CAAC;IACD,IAAI,QAAQ,CAAC,YAAY,KAAK,QAAQ,EAAE,CAAC;QACvC,IAAI,QAAQ,CAAC,SAAS,KAAK,mBAAmB,EAAE,CAAC;YAC/C,MAAM,SAAS,GAAG,SAAS,IAAI,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC;gBAC3F,CAAC,CAAC,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC;oBAC7D,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;YACnF,IAAI,CAAC,SAAS;gBAAE,MAAM,IAAI,eAAe,CAAC,kCAAkC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;YACvF,OAAO,WAAW,CAAC,QAAQ,EAAE,sBAAsB,CAAC,QAAQ,EAAE,SAAS,CAAC,EAAE,OAAO,CAAC,CAAC;QACrF,CAAC;QACD,IAAI,SAAS,IAAI,MAAM;YAAE,MAAM,IAAI,eAAe,CAAC,wDAAwD,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;QACtH,IAAI,QAAQ,IAAI,MAAM;YAAE,OAAO,WAAW,CAAC,QAAQ,EAAE,qBAAqB,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC;QAC9G,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ;YAAE,MAAM,IAAI,eAAe,CAAC,yCAAyC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;QACpH,OAAO,WAAW,CAAC,QAAQ,EAAE,qBAAqB,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,EAAE,OAAO,CAAC,CAAC;IACjG,CAAC;IACD,IAAI,CAAC,CAAC,OAAO,IAAI,MAAM,CAAC;QAAE,MAAM,IAAI,eAAe,CAAC,4CAA4C,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;IAC3G,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;IAC3B,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,IAAI,CAAC,OAAO,CAAC,UAAU;YAAE,MAAM,IAAI,eAAe,CAAC,mDAAmD,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;QACjH,OAAO,OAAO,CAAC,UAAU,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IAC7C,CAAC;IACD,QAAQ,QAAQ,CAAC,YAAY,EAAE,CAAC;QAC9B,KAAK,QAAQ;YACX,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,KAAK;gBAAE,OAAO,KAAK,CAAC;YACpD,IAAI,KAAK,KAAK,KAAK,IAAI,KAAK,KAAK,IAAI;gBAAE,OAAO,IAAI,CAAC;YACnD,MAAM,IAAI,eAAe,CAAC,iBAAiB,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;QACxD,KAAK,KAAK,CAAC;QAAC,KAAK,UAAU;YACzB,IAAI,CAAC,OAAO,KAAK,KAAK,QAAQ,IAAI,OAAO,KAAK,KAAK,QAAQ,CAAC,IAAI,KAAK,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBAAE,MAAM,IAAI,eAAe,CAAC,kBAAkB,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;YAC7K,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;QACvB,KAAK,OAAO;YACV,IAAI,CAAC,OAAO,KAAK,KAAK,QAAQ,IAAI,OAAO,KAAK,KAAK,QAAQ,CAAC,IAAI,KAAK,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBAAE,MAAM,IAAI,eAAe,CAAC,gBAAgB,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;YACtK,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;QACvB,KAAK,MAAM,CAAC;QAAC,KAAK,SAAS,CAAC;QAAC,KAAK,UAAU,CAAC,CAAC,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;QACnE,OAAO,CAAC,CAAC,MAAM,IAAI,eAAe,CAAC,2BAA2B,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;IAC3E,CAAC;AACH,CAAC;AAED,SAAS,WAAW,CAAC,QAA4B,EAAE,MAAsB,EAAE,OAAwB;IACjG,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ;QAAE,OAAO,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IAC5D,MAAM,KAAK,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACrD,IAAI,CAAC,KAAK;QAAE,MAAM,IAAI,eAAe,CAAC,sCAAsC,EAAE,CAAC,QAAQ,CAAC,SAAS,IAAI,IAAI,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC;IACjI,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,SAAwC,EAAE,OAAgB,EAAE,OAAwB;IAChH,MAAM,KAAK,GAAG,IAAI,GAAG,EAA8B,CAAC;IACpD,KAAK,MAAM,QAAQ,IAAI,SAAS;QAAE,IAAI,QAAQ,CAAC,SAAS,EAAE,CAAC;YACzD,IAAI,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,SAAS,CAAC;gBAAE,MAAM,IAAI,eAAe,CAAC,0BAA0B,EAAE,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC;YAC/G,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QAC1C,CAAC;IACD,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC;IACxE,IAAI,OAAO,CAAC,MAAM;QAAE,MAAM,IAAI,eAAe,CAAC,2BAA2B,EAAE,OAAO,CAAC,CAAC;IACpF,MAAM,MAAM,GAAsB,EAAE,CAAC;IACrC,KAAK,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACtD,MAAM,QAAQ,GAAG,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAClC,IAAI,CAAC,QAAQ;YAAE,MAAM,IAAI,eAAe,CAAC,2BAA2B,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;QAC/E,MAAM,KAAK,GAAG,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;QACrD,IAAI,KAAK,KAAK,SAAS;YAAE,MAAM,CAAC,IAAI,CAAC,EAAE,WAAW,EAAE,QAAQ,CAAC,UAAU,EAAE,aAAa,EAAE,QAAQ,CAAC,YAAY,EAAE,eAAe,EAAE,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,CAAC,CAAC;IAChK,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,yBAAyB,CAAC,MAA0B,EAAE,OAAgB,EAAE,OAAwB;IAC9G,IAAI,MAAM,CAAC,YAAY,KAAK,gBAAgB;QAAE,MAAM,IAAI,eAAe,CAAC,+BAA+B,CAAC,CAAC;IACzG,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,CAAC,QAAQ,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IACjE,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC;IAC9D,KAAK,MAAM,QAAQ,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;QACvC,sGAAsG;QACtG,IAAI,CAAC,QAAQ,CAAC,SAAS,IAAI,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,YAAY,CAAC;YAAE,SAAS;QACtI,IAAI,QAAQ,CAAC,YAAY,KAAK,UAAU,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ;YAAE,SAAS;QACpF,OAAO,CAAC,IAAI,CAAC,EAAE,WAAW,EAAE,QAAQ,CAAC,UAAU,EAAE,aAAa,EAAE,QAAQ,CAAC,YAAY,EAAE,eAAe,EAAE,QAAQ,CAAC,SAAS,EAAE,YAAY,EAAE,EAAE,EAAE,CAAC,CAAC;IAClJ,CAAC;IACD,OAAO,EAAE,cAAc,EAAE,OAAO,EAAE,CAAC;AACrC,CAAC;AAED;;;uCAGuC;AACvC,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,GAAG,CAAC;IAC1C,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,aAAa,EAAE,OAAO,CAAC,GAAG,CAAC,4BAA4B,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;AACrI,CAAC","sourcesContent":["import type { PicklistOption, PicklistSelector, QuestionDefinition } from \"./metadata.js\";\nimport { resolvePicklistOption } from \"./metadata.js\";\nimport { ValidationError } from \"./errors.js\";\nimport { resolveRaceCombination } from \"./race.js\";\n\nexport type AnswerState = \"client_doesnt_know\" | \"client_refused\" | \"data_not_collected\";\nexport type AnswerValue = { value: string | number | boolean | string[] } | { state: AnswerState } | { option: PicklistSelector } | { options: readonly PicklistSelector[] };\nexport type Answers = Record<string, AnswerValue>;\nexport interface EncodingOptions {\n endpoint: \"standalone\" | \"enrollment\" | \"mobile\";\n /** Tenant-verified display labels, including exact whitespace/HUD suffixes, for label-based endpoints. */\n writeLabel?: (question: QuestionDefinition, option: PicklistOption) => string | undefined;\n /** Application configuration supplies the relevant option codes. No answer-state labels are guessed. */\n stateCodes?: Partial<Record<AnswerState, readonly string[]>>;\n multiValue?: (question: QuestionDefinition, values: readonly string[]) => string;\n}\nexport interface EncodedQuestion {\n question_id: number;\n question_type: string;\n virt_field_name: string;\n answer_value: string;\n}\n\nfunction encodeValue(question: QuestionDefinition, answer: AnswerValue, options: EncodingOptions): string | undefined {\n const field = question.fieldName ?? `#${question.questionId}`;\n if ([\"label\", \"blank\", \"sub_assessment\"].includes(question.questionType)) throw new ValidationError(\"assessment encoding\", [field]);\n if (question.questionType === \"provider\" && options.endpoint === \"mobile\") throw new ValidationError(\"mobile provider answers are unsupported\", [field]);\n if (\"state\" in answer) {\n if (question.questionType !== \"lookup\") return undefined;\n const codes = options.stateCodes?.[answer.state];\n if (!codes) throw new ValidationError(\"answer-state code configuration required\", [field]);\n const found = question.options.filter(option => option.code !== null && codes.includes(option.code));\n if (!found.length) return undefined;\n if (found.length !== 1 || !found[0]) throw new ValidationError(\"ambiguous answer-state option\", [field]);\n const option = question.fieldName === \"HUD_RACEETHNICITY\" && found[0].code !== null\n ? resolveRaceCombination(question, [{ code: found[0].code }]) : found[0];\n return lookupLabel(question, option, options);\n }\n if (question.questionType === \"lookup\") {\n if (question.fieldName === \"HUD_RACEETHNICITY\") {\n const selectors = \"options\" in answer ? answer.options : \"option\" in answer ? [answer.option]\n : typeof answer.value === \"string\" ? [{ name: answer.value }]\n : Array.isArray(answer.value) ? answer.value.map(name => ({ name })) : undefined;\n if (!selectors) throw new ValidationError(\"race requires category selectors\", [field]);\n return lookupLabel(question, resolveRaceCombination(question, selectors), options);\n }\n if (\"options\" in answer) throw new ValidationError(\"multiple options require a supported combination field\", [field]);\n if (\"option\" in answer) return lookupLabel(question, resolvePicklistOption(question, answer.option), options);\n if (typeof answer.value !== \"string\") throw new ValidationError(\"lookup requires a code or name selector\", [field]);\n return lookupLabel(question, resolvePicklistOption(question, { name: answer.value }), options);\n }\n if (!(\"value\" in answer)) throw new ValidationError(\"picklist selector on a non-lookup question\", [field]);\n const value = answer.value;\n if (Array.isArray(value)) {\n if (!options.multiValue) throw new ValidationError(\"multi-value encoding requires an explicit encoder\", [field]);\n return options.multiValue(question, value);\n }\n switch (question.questionType) {\n case \"yes_no\":\n if (value === true || value === \"Yes\") return \"Yes\";\n if (value === false || value === \"No\") return \"No\";\n throw new ValidationError(\"yes_no encoding\", [field]);\n case \"int\": case \"provider\":\n if ((typeof value !== \"number\" && typeof value !== \"string\") || value === \"\" || !Number.isSafeInteger(Number(value))) throw new ValidationError(\"integer encoding\", [field]);\n return String(value);\n case \"money\":\n if ((typeof value !== \"number\" && typeof value !== \"string\") || value === \"\" || !Number.isFinite(Number(value))) throw new ValidationError(\"money encoding\", [field]);\n return String(value);\n case \"date\": case \"textbox\": case \"textarea\": return String(value);\n default: throw new ValidationError(\"unsupported question type\", [field]);\n }\n}\n\nfunction lookupLabel(question: QuestionDefinition, option: PicklistOption, options: EncodingOptions): string {\n if (options.endpoint === \"mobile\") return String(option.id);\n const label = options.writeLabel?.(question, option);\n if (!label) throw new ValidationError(\"verified lookup write label required\", [question.fieldName ?? `#${question.questionId}`]);\n return label;\n}\n\nexport function encodeAnswers(questions: readonly QuestionDefinition[], answers: Answers, options: EncodingOptions): EncodedQuestion[] {\n const index = new Map<string, QuestionDefinition>();\n for (const question of questions) if (question.fieldName) {\n if (index.has(question.fieldName)) throw new ValidationError(\"ambiguous question field\", [question.fieldName]);\n index.set(question.fieldName, question);\n }\n const unknown = Object.keys(answers).filter(field => !index.has(field));\n if (unknown.length) throw new ValidationError(\"unknown assessment fields\", unknown);\n const result: EncodedQuestion[] = [];\n for (const [field, answer] of Object.entries(answers)) {\n const question = index.get(field);\n if (!question) throw new ValidationError(\"unknown assessment fields\", [field]);\n const value = encodeValue(question, answer, options);\n if (value !== undefined) result.push({ question_id: question.questionId, question_type: question.questionType, virt_field_name: field, answer_value: value });\n }\n return result;\n}\n\n/** Builds one new row only. Never accepts or echoes an existing recordset ID. */\nexport function encodeNewSubassessmentRow(parent: QuestionDefinition, answers: Answers, options: EncodingOptions): { subAssessments: EncodedQuestion[] } {\n if (parent.questionType !== \"sub_assessment\") throw new ValidationError(\"subassessment parent required\");\n const encoded = encodeAnswers(parent.children, answers, options);\n const included = new Set(encoded.map(q => q.virt_field_name));\n for (const question of parent.children) {\n // An unanswered provider child is omitted; answered ones are still refused for mobile in encodeValue.\n if (!question.fieldName || included.has(question.fieldName) || [\"label\", \"blank\", \"yes_no\"].includes(question.questionType)) continue;\n if (question.questionType === \"provider\" && options.endpoint === \"mobile\") continue;\n encoded.push({ question_id: question.questionId, question_type: question.questionType, virt_field_name: question.fieldName, answer_value: \"\" });\n }\n return { subAssessments: encoded };\n}\n\n/** Milliseconds since epoch for a REST read timestamp, or NaN. Reads emit forms such as\n * `2026-11-20 10:00:00-07` (space separator, hour-only offset) whose `Date.parse` result is\n * engine-defined: NaN on some runtimes, and host-local time when the offset is missing.\n * A value without an offset is UTC. */\nexport function parseRestInstant(value: unknown): number {\n if (typeof value !== \"string\") return NaN;\n return Date.parse(value.replace(\" \", \"T\").replace(/([+-]\\d\\d)$/, \"$1:00\") + (/(?:Z|[+-]\\d\\d(?::?\\d\\d)?)$/.test(value) ? \"\" : \"Z\"));\n}\n"]}
@@ -39,6 +39,7 @@ Reproduced on November 2, 2026 in a sandbox tenant with synthetic clients:
39
39
 
40
40
  - **Mobile `AddAssessmentAnswer` does not store yes/no values.** Requests with `"Yes"`, `"1"`/`"0"`, and `"true"`/`"false"` returned success and created a row at the intended effective date, but its value read back empty through both the generic and mobile reads. An integrator's 30-answer entry batch showed the same result.
41
41
  - **Mobile writes are insert-only per question and effective date.** A second mobile write to any question that already has a row at that date returned HTTP 400 `An error occurred while saving the entity changes`. This affected yes/no and integer questions alike, so a mobile retry cannot repair an empty yes/no row.
42
+ - **A failed mobile batch can still save part of its answers.** On November 2, 2026, an integrator's 15-answer batch returned HTTP 400 a few seconds after the client's household enrollment and entry answers were created. Afterwards one of the 15 answers was stored and the others were empty. Its error body was not captured, and the client's rows just before the send were not read. Later sends of the batch failed with the insert-only error above because that row existed. On November 3, the same byte-identical batch returned 200 and read back completely on fresh synthetic clients with no enrollment, an enrolled single adult, and an enrolled household head; batches with and without the score total question, and in either order, also succeeded. The cause is unknown; a race with enrollment creation is possible but unproven. Treat a failed mobile write as uncertain and reconcile it from a read, as the enrollment workflow does; never resend it.
42
43
  - **Standard `assessments.create` stores `"Yes"`/`"No"`.** It rejects `"true"`/`"false"`. It updates existing rows at the same effective date, including empty yes/no rows created by mobile writes, and changed only the answers it was sent in a full before/after comparison.
43
44
  - **Every standard write must include every mandatory answer**, even one already recorded at that date; otherwise it returns `All mandatory answers are not provided`. A conditionally mandatory lookup that does not apply can be sent as `""`; it is stored empty at that date.
44
45
  - **Generic reads omit yes/no answers at exactly their effective instant.** `assessments.get` with `dateEffective` equal to the saved instant returns `null` for those yes/no answers while other types read normally. One second later returns the saved value. The standard create response has the same omission. Mobile reads return the value, but they have no date filter and show only the latest row, so a later record hides the date being checked.
@@ -69,7 +70,7 @@ For a deployment with a measured mobile shift, configure `mobileAssessmentDateCo
69
70
 
70
71
  With correction enabled, supply an ISO timestamp with `Z` or an explicit numeric offset. Invalid calendar dates, ambiguous timestamps, and correction values outside integer minutes from -840 to 840 are rejected before authentication/network activity. Only the mobile write's `dateEffective` query is corrected. Read queries, standalone/enrollment writes, and date values inside answer bodies are unchanged. Do not pre-adjust dates as well as configuring the client.
71
72
 
72
- Verify `date_effective` through a generic assessment read and check UI history after writes. Revalidate the correction when upstream behavior changes, including seasonal clock changes; it is a measured compatibility setting, not automatic timezone handling. Mobile reads can omit UI-visible combination answers, so an empty mobile answer alone does not prove absence. Standard standalone writes are preferable for scalar answers when that endpoint fits the application; mobile remains necessary for verified in-place recordset updates. Mobile scalar writes only insert a row for a question and date; see [yes/no answer acceptance](#yesno-answer-acceptance).
73
+ Verify `date_effective` through a generic assessment read and check UI history after writes. Generic reads return date answers such as `2026-11-20 10:00:00-07`. `Date.parse` handles that form differently across JavaScript engines, returning NaN on some, and reads a value without an offset as host-local time. Compare instants with `parseRestInstant` from the package root, which treats a missing offset as UTC. Revalidate the correction when upstream behavior changes, including seasonal clock changes; it is a measured compatibility setting, not automatic timezone handling. Mobile reads can omit UI-visible combination answers, so an empty mobile answer alone does not prove absence. Standard standalone writes are preferable for scalar answers when that endpoint fits the application; mobile remains necessary for verified in-place recordset updates. Mobile scalar writes only insert a row for a question and date; see [yes/no answer acceptance](#yesno-answer-acceptance).
73
74
 
74
75
  ## Read verification records at enrollment
75
76
 
@@ -43,7 +43,7 @@ The generated client covers the pinned V2/V3 Swagger operations. The following f
43
43
 
44
44
  ## Known server gaps
45
45
 
46
- Grouped REST enrollment reads can return `42703: column v.entry_date_effective does not exist` after both UI and RPC saves. This is not specific to replay. A broken read is not an absent enrollment. The browser enrollment reader uses current UI/GWT evidence.
46
+ Grouped REST enrollment reads can return `42703: column v.entry_date_effective does not exist` after both UI and RPC saves. This is not specific to replay. A broken read is not an absent enrollment. The browser enrollment reader uses current UI/GWT evidence. The enrollment workflow and, given `enrollmentSession`, the Current Living Situation and Coordinated Entry Event recorders read household enrollments through it.
47
47
 
48
48
  The October 24 [V2/V3 comparison](vendor-issues/enrollment-effective-date-column.md) reproduced the missing-column error for all 16 tested grouped complete enrollments in each version. Individual-only client lists succeeded, and individual pages within a mixed client also succeeded. Switching to V2 does not fix the issue. One individual complete read had a separate `Non-static method requires a target.` error.
49
49
 
@@ -8,7 +8,7 @@ A Coordinated Entry Event records an access event, such as a referral to a crisi
8
8
 
9
9
  ## Fields
10
10
 
11
- Read `api.assessments.getDefinition({clientId, assessmentCode, refresh: true})` and supply the exact virtual fields. Readback queries one second after the later of now and the event date, because a read at exactly the event instant omits yes/no answers. A November 2, 2026 sandbox instrument exposed:
11
+ Read `api.assessments.getDefinition({clientId, assessmentCode, refresh: true})` and supply the exact virtual fields. For clients in household enrollments, pass `enrollmentSession`; the REST enrollment read fails for them, as for [Current Living Situation](current-living-situation.md#record-and-retry). Readback queries one second after the later of now and the event date, because a read at exactly the event instant omits yes/no answers. A November 2, 2026 sandbox instrument exposed:
12
12
 
13
13
  | Role | Virtual field | Type |
14
14
  | --- | --- | --- |
@@ -39,6 +39,8 @@ Use the [typed example](../examples/current-living-situation.mts) with an API cl
39
39
 
40
40
  Each input specifies an existing client and enrollment, the instrument, a stable contact event key, information date, and the selected living-situation answer. It does not accept a caller-supplied start date. The helper reads the counted enrollment list and derives start from the selected enrollment's `projectStartDate`. This matters for a later-entering household member: use that person's enrollment, not another member's entry. Contacts before entry or after a recorded exit are refused. Failed, incomplete, or ambiguous enrollment/assessment reads block writes.
41
41
 
42
+ REST enrollment reads fail for clients in grouped (household) enrollments with `42703: column v.entry_date_effective does not exist`; see [known server gaps](capabilities.md#known-server-gaps). For those clients, pass `enrollmentSession`, the reader `openEnrollmentSession` returns. The recorder then reads the selected enrollment, its provider, entry date and exit date through GWT instead of REST, under the same rules: a failed, mismatched or incomplete read blocks the write. The reader takes the page lock itself and needs the same tenant as the API client. Both read paths produce the same intent, so a key recorded through one verifies through the other.
43
+
42
44
  As with the existing enrollment workflow, date inputs use timezone-qualified ISO instants with whole-second precision. Supply the contact's intended date and time in the program's timezone, for example `2026-10-24T09:00:00-04:00`. The information-date answer and assessment effective-date query use that instant; the row start uses program entry. The helper does not use the host timezone or shift body dates. Only a separately configured, measured mobile query correction affects the query; see [date contracts](dates-and-answers.md). Date-only input is rejected rather than interpreted in an unknown timezone.
43
45
 
44
46
  For outreach, record each meaningful outreach contact with its own upstream event key and the situation reported that day. For night-by-night shelter, record each meaningful contact with a separate key; do not assume a bed-night automatically supplies a contact or the client's answer. Two distinct contact keys create two rows even if the date and answers are identical. Same-key retries verify the original recordset ID and never submit again. If a contact row is later end-dated, a same-key retry reports `verified-contact-changed`, because the recorder verifies the open end it wrote.
package/docs/reference.md CHANGED
@@ -8,7 +8,7 @@ Only the package export paths below are supported import entry points. Declarati
8
8
 
9
9
  Import from `@choiceapps/communityservices-sdk`, `@choiceapps/communityservices-sdk/v3`, or `@choiceapps/communityservices-sdk/v2`. The root selects V3 and also exports the testing helpers. Each version supplies its own generated HTTP models and fixed version header.
10
10
 
11
- Runtime exports at the root: `AmbiguousCommitError`, `AssessmentMetadata`, `AuthError`, `CooldownError`, `DefinitionDriftError`, `FakeControl`, `FakeWellSkyClient`, `GwtEnrollmentConflictError`, `GwtEnrollmentHelpers`, `SubassessmentEditor`, `TenantMismatchError`, `ThrottledError`, `UnknownPicklistValueError`, `UpstreamError`, `ValidationError`, `WellSkyClient`, `WellSkyError`, `apiVersion`, `buildAssessmentDefinition`, `compareDefinition`, `createFakeWellSkyClient`, `createInMemoryGwtMutationStore`, `createWellSkyClient`, `encodeAnswers`, `encodeNewSubassessmentRow`, `flattenQuestions`, `mapBulk`, `operationRoutes`, `readEnrollmentVerifications`, `readOutcome`, `readProgramCatalog`, `resolvePicklistOption`, `semanticHash`, `specSha256`, `toItems`, `verifyWrite`.
11
+ Runtime exports at the root: `AmbiguousCommitError`, `AssessmentMetadata`, `AuthError`, `CooldownError`, `DefinitionDriftError`, `FakeControl`, `FakeWellSkyClient`, `GwtEnrollmentConflictError`, `GwtEnrollmentHelpers`, `SubassessmentEditor`, `TenantMismatchError`, `ThrottledError`, `UnknownPicklistValueError`, `UpstreamError`, `ValidationError`, `WellSkyClient`, `WellSkyError`, `apiVersion`, `buildAssessmentDefinition`, `compareDefinition`, `createFakeWellSkyClient`, `createInMemoryGwtMutationStore`, `createWellSkyClient`, `encodeAnswers`, `encodeNewSubassessmentRow`, `flattenQuestions`, `mapBulk`, `operationRoutes`, `parseRestInstant`, `readEnrollmentVerifications`, `readOutcome`, `readProgramCatalog`, `resolvePicklistOption`, `semanticHash`, `specSha256`, `toItems`, `verifyWrite`.
12
12
 
13
13
  | Contract | Declaration | Guide |
14
14
  | --- | --- | --- |
@@ -31,7 +31,7 @@ The enrollment report includes a read-only reproduction using existing synthetic
31
31
  - [Client update semantics](write-contracts.md#omitting-ssn-from-a-client-update-cleared-it): omitting SSN cleared its value. Clarify replacement versus partial-update behavior before classifying the omission rule as a defect.
32
32
  - [Goal update and relationship contract](write-contracts.md#goal-updates-require-a-field-marked-optional): `clientId` was required despite an optional schema field; returned household rows did not match the inspected goal association.
33
33
  - [Mobile effective dates](date-semantics.md#mobile-answer-writes-shift-the-effective-date) and [DOB serialization](date-semantics.md#dob-serialization-can-disagree-with-the-ui-calendar-date): document timezone/date-only rules and resolve inconsistent stored or displayed values. These are separate from complete-entry filtering.
34
- - [Yes/no answer contract](yes-no-answers.md): mobile wire format for yes/no, insert-only mobile writes, mandatory answers on standard updates, and the generic read's effective-instant comparison.
34
+ - [Yes/no answer contract](yes-no-answers.md): mobile wire format for yes/no, insert-only mobile writes, a mobile batch that returned HTTP 400 yet saved one answer, mandatory answers on standard updates, and the generic read's effective-instant comparison.
35
35
  - [Filtering, empty reads, and response shapes](write-contracts.md#filtering-and-response-shapes-vary-by-operation): document per-endpoint syntax and align responses with schemas.
36
36
  - [Household head coverage](household-head-validation.md): the server accepted writes that left members enrolled after the original head exited. Clarify the intended validation and supported head-transfer/repair workflow; the SDK rejects that state.
37
37
 
@@ -33,3 +33,11 @@ A second mobile write for a question that already had a row at the same effectiv
33
33
  After a successful standard write, the generic assessment read with `dateEffective` equal to the saved instant returned `null` for the yes/no answers, while other types at the same instant read normally. A query one second later, and the mobile read, returned the saved values. The standard create response also returned `null` for them.
34
34
 
35
35
  **Ask WellSky:** Is the effective-date comparison for yes/no answers intended to exclude the queried instant?
36
+
37
+ ## A failed mobile batch saved one of its answers
38
+
39
+ Observed by an integrator on November 2, 2026, with synthetic records. A 15-answer `AddAssessmentAnswer` batch (1 `date`, 13 `lookup`, 1 `int`) was sent a few seconds after the client's household enrollment and entry-assessment answers were created. It returned HTTP 400; the error body was not captured. Afterwards the `int` answer was stored at the correct effective date and the other 14 were empty. The client's rows for this assessment were not read just before the send, so the absence of prior rows is inferred from the integration's own writes. Resends failed as in [the existing-row case](#mobile-writes-fail-for-an-existing-row).
40
+
41
+ On November 3, the byte-identical batch returned 200 and read back completely on fresh synthetic clients without an enrollment, an enrolled single adult, and an enrolled household head. Splitting the batch, reordering it, or omitting the `int` answer made no difference. The failure has not been reproduced.
42
+
43
+ **Ask WellSky:** Can a mobile write that returns HTTP 400 commit part of its batch? Is a write to an assessment assigned to the provider affected by work the server does after enrollment creation?
@@ -1,5 +1,5 @@
1
1
  import type {WellSkyClient, PicklistSelector} from '@choiceapps/communityservices-sdk';
2
- import {createCurrentLivingSituationRecorder, type CurrentLivingSituationFields, type UiMutationStore} from '@choiceapps/communityservices-sdk/browser';
2
+ import {createCurrentLivingSituationRecorder, type CurrentLivingSituationFields, type EnrollmentReader, type UiMutationStore} from '@choiceapps/communityservices-sdk/browser';
3
3
 
4
4
  /** Both outreach and night-by-night shelter callers supply actual contact events. */
5
5
  export async function recordMeaningfulContacts(
@@ -8,8 +8,10 @@ export async function recordMeaningfulContacts(
8
8
  fields: CurrentLivingSituationFields,
9
9
  target: {clientId: number; enrollmentId: number; assessmentCode: string},
10
10
  contacts: readonly {eventKey: string; informationDate: string; livingSituation: PicklistSelector; recordingProgramOption?: PicklistSelector}[],
11
+ /** Required for household enrollments, whose REST enrollment reads fail; from openEnrollmentSession. */
12
+ enrollmentSession?: EnrollmentReader,
11
13
  ) {
12
- const recorder = createCurrentLivingSituationRecorder({api, store, fields});
14
+ const recorder = createCurrentLivingSituationRecorder({api, store, fields, ...(enrollmentSession ? {enrollmentSession} : {})});
13
15
  const receipts = [];
14
16
  for (const contact of contacts) {
15
17
  receipts.push(await recorder.record({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@choiceapps/communityservices-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "SDK for WellSky Community Services (HMIS), formerly ServicePoint, Client API V2 and V3",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",