@likerts/react-native 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ import type {Answer, Question} from './index';
2
+
3
+ /** Selected option IDs have the same meaning across legacy and structured answers. */
4
+ export function selectedChoices(value: unknown): string[] {
5
+ if (typeof value === 'string') return value ? [value] : [];
6
+ if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string');
7
+ if (value && typeof value === 'object' && 'selected' in value && Array.isArray(value.selected)) return value.selected.filter((v): v is string => typeof v === 'string');
8
+ return [];
9
+ }
10
+ export function otherTexts(value: unknown): Record<string,string> {
11
+ if (!value || typeof value !== 'object' || !('otherText' in value) || !value.otherText || typeof value.otherText !== 'object' || Array.isArray(value.otherText)) return {};
12
+ return Object.fromEntries(Object.entries(value.otherText).filter((entry): entry is [string,string] => typeof entry[1] === 'string'));
13
+ }
14
+ export function choiceAnswer(q: Question, selected: string[], text: Record<string,string> = {}): Answer {
15
+ if (q.options?.some(o => o.other !== undefined)) return {selected:[...selected],otherText:Object.fromEntries((q.options ?? []).filter(o => o.other !== undefined && selected.includes(o.id)).map(o => [o.id,text[o.id] ?? '']))};
16
+ return q.type === 'single_choice' ? selected[0] ?? '' : [...selected];
17
+ }
18
+ export function toggleChoice(q: Question, value: unknown, id: string): Answer {
19
+ const option=q.options?.find(o=>o.id===id); if (!option) throw new TypeError('Unknown choice');
20
+ const current=selectedChoices(value);
21
+ const selected=q.type==='single_choice' ? [id] : current.includes(id) ? current.filter(v=>v!==id) : option.exclusive ? [id] : [...current.filter(v=>!q.options?.find(o=>o.id===v)?.exclusive),id];
22
+ return choiceAnswer(q,selected,otherTexts(value));
23
+ }
24
+ export function setOtherText(q: Question, value: unknown, id: string, text: string): Answer {
25
+ return choiceAnswer(q,selectedChoices(value),{...otherTexts(value),[id]:text});
26
+ }
27
+ /** Local feedback only; the server validates every accepted answer again. */
28
+ export function choiceError(q: Question, value: unknown): 'required'|'range'|'other'|'invalid'|undefined {
29
+ if (value === undefined || value === '') return q.required ? 'required' : undefined;
30
+ const hasOther=q.options?.some(o=>o.other !== undefined);
31
+ if (hasOther) {
32
+ if (!value || typeof value!=='object' || Array.isArray(value) || Object.keys(value).length!==2 || !('selected' in value) || !Array.isArray(value.selected) || !value.selected.every(v=>typeof v==='string') || !('otherText' in value) || !value.otherText || typeof value.otherText!=='object' || Array.isArray(value.otherText)) return 'invalid';
33
+ } else if (q.type==='single_choice' ? typeof value!=='string' : !Array.isArray(value) || !value.every(v=>typeof v==='string')) return 'invalid';
34
+ const selected=selectedChoices(value);
35
+ if (new Set(selected).size!==selected.length || selected.some(id=>!q.options?.some(o=>o.id===id))) return 'invalid';
36
+ const exclusive=selected.some(id=>q.options?.some(o=>o.id===id && o.exclusive));
37
+ if (exclusive && selected.length!==1) return 'invalid';
38
+ if (q.type==='single_choice' ? selected.length!==1 : selected.length>(q.maxSelections??q.options?.length??0) || (!exclusive && selected.length<Math.max(q.required?1:0,q.minSelections??0))) return 'range';
39
+ if (hasOther) {
40
+ const text=otherTexts(value),expected=(q.options??[]).filter(o=>o.other!==undefined && selected.includes(o.id));
41
+ if (Object.keys((value as {otherText:object}).otherText).length!==expected.length) return 'other';
42
+ if (expected.some(o=>typeof text[o.id]!=='string' || !text[o.id].trim() || Array.from(text[o.id]).length>o.other!.maxLength)) return 'other';
43
+ }
44
+ return undefined;
45
+ }
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ export interface StructuredChoiceAnswer {selected:string[];otherText:Record<string,string>}
2
+ export type Answer = string | number | string[] | StructuredChoiceAnswer | Record<string,string|string[]> | Record<string,number>;
3
+ export type QuestionType = 'single_choice' | 'multiple_choice' | 'scale' | 'text' | 'number' | 'date' | 'ranking' | 'matrix' | 'constant_sum';
4
+ export type VisibilityOperator='equals'|'not_equals'|'includes'|'not_includes'|'answered'|'not_answered';
5
+ export interface VisibilityCondition {questionId:string;operator:VisibilityOperator;value?:string|number}
6
+ export interface PageBranch {when:VisibilityCondition;goToPageId:string}
7
+ export interface SurveyPage {id:string;title?:string;questionIds:string[];branches?:PageBranch[]}
8
+ export interface Choice {id:string;label:string;other?:{maxLength:number};exclusive?:true}
9
+ export interface PromptItem{id:string;label:string}
10
+ export interface Question { id: string; type: QuestionType; label: string; required?: boolean; options?: Choice[]; min?: number; max?: number; maxLength?: number; preset?: 'nps' | 'yes_no'; labels?: Record<string, string>; minSelections?: number; maxSelections?: number; visibleWhen?:VisibilityCondition; presentation?:'stars'|'dropdown';rows?:PromptItem[];columns?:Choice[];matrixMode?:'single'|'multiple';items?:PromptItem[];total?:number }
11
+ export interface Collection { id: string; surveyId: string; version: number; placement: string; schema: { schemaVersion: 1 | 2 | 3 | 4 | 5; title: string; questions: Question[]; pages?:SurveyPage[] } }
12
+ export interface Submission { idempotencyKey: string; answers: Record<string, Answer>; metadata: Record<string, unknown> }
13
+ export interface Receipt { responseId: string; collectionId: string; accepted: true }
14
+ export interface RequestOptions { signal?: AbortSignal; timeoutMs?: number }
15
+ export interface CollectionRequestOptions extends RequestOptions { refresh?: boolean }
16
+ export const LIKERTS_SDK_CAPABILITY = Object.freeze({target:'react_native' as const,sdkVersion:'0.0.3',schemaVersions:[1,2,3,4,5] as const});
17
+ export class LikertsError extends Error { constructor(public status: number, public response: string) { super(`Likerts request failed (${status})`); } }
18
+ function safeBaseURL(value: string): string {
19
+ const url = new URL(value); const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]';
20
+ if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.search || url.hash) throw new TypeError('Likerts base URL must use HTTPS (HTTP is limited to loopback development)');
21
+ return value.replace(/\/$/, '');
22
+ }
23
+ /** Only a public collection credential belongs here. No administrative credentials. */
24
+ export class LikertsClient {
25
+ private baseURL: string;
26
+ private transport: typeof fetch;
27
+ private collectionCache = new Map<string,{value:Collection;storedAt:number}>();
28
+ constructor(baseURL: string, private token: string, transport: typeof fetch | undefined = undefined, private defaultTimeoutMs = 15000, private cacheMaxAgeMs = 300000) { this.baseURL = safeBaseURL(baseURL);this.transport=transport??((input,init)=>globalThis.fetch(input,init)); if (!Number.isFinite(defaultTimeoutMs) || defaultTimeoutMs <= 0) throw new TypeError('timeoutMs must be positive'); if (!Number.isFinite(cacheMaxAgeMs) || cacheMaxAgeMs < 0) throw new TypeError('cacheMaxAgeMs must not be negative'); }
29
+ private async request<T>(path: string, body?: Submission, options: RequestOptions = {}): Promise<T> {
30
+ const timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs; if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new TypeError('timeoutMs must be positive');
31
+ const controller = new AbortController(); const abort = () => controller.abort(options.signal?.reason);
32
+ if (options.signal?.aborted) abort(); else options.signal?.addEventListener('abort', abort, {once: true});
33
+ const timer = setTimeout(() => controller.abort(new Error('Likerts request timed out')), timeoutMs);
34
+ try {
35
+ const response = await this.transport(`${this.baseURL}${path}`, { method: body ? 'POST' : 'GET', redirect: 'error', signal: controller.signal, headers: { Authorization: `Bearer ${this.token}`, ...(body ? {'Content-Type': 'application/json'} : {}) }, ...(body ? {body: JSON.stringify(body)} : {}) });
36
+ if (!response.ok) throw new LikertsError(response.status, await response.text());
37
+ return await response.json() as T;
38
+ } finally { clearTimeout(timer); options.signal?.removeEventListener('abort', abort); }
39
+ }
40
+ async collection(id: string, options: CollectionRequestOptions = {}): Promise<Collection> { const cached=this.collectionCache.get(id);if(!options.refresh&&cached&&Date.now()-cached.storedAt<this.cacheMaxAgeMs)return cached.value;
41
+ try { const c=await this.request<Collection>(`/v1/collections/${encodeURIComponent(id)}`,undefined,options);if(![1,2,3,4,5].includes(c.schema.schemaVersion))throw new Error('Unsupported survey schema version');if(cached&&(cached.value.id!==c.id||cached.value.surveyId!==c.surveyId||cached.value.version!==c.version||cached.value.schema.schemaVersion!==c.schema.schemaVersion)){this.collectionCache.delete(id);throw new Error('Collection binding changed');}this.collectionCache.set(id,{value:c,storedAt:Date.now()});return c;}catch(error){this.collectionCache.delete(id);throw error;} }
42
+ clearCollectionCache(id?:string):void{if(id)this.collectionCache.delete(id);else this.collectionCache.clear();}
43
+ /** Retrying requires the same submission object, including its idempotencyKey. */
44
+ async submit(id: string, submission: Submission, options?: RequestOptions): Promise<Receipt> { const receipt = await this.request<Receipt>(`/v1/collections/${encodeURIComponent(id)}/responses`, submission, options); if (receipt.accepted !== true) throw new Error('Invalid Likerts receipt'); return receipt; }
45
+ }
46
+ export function conditionMatches(condition:VisibilityCondition,answer:Answer|undefined):boolean{const selected=answer&&typeof answer==='object'&&!Array.isArray(answer)&&'selected'in answer&&Array.isArray(answer.selected)?answer.selected as string[]:undefined;const objectAnswer=answer&&typeof answer==='object'&&!Array.isArray(answer);const hasAnswer=answer!==undefined&&(typeof answer!=='string'||answer.trim()!=='')&&(!Array.isArray(answer)||answer.length>0)&&(!objectAnswer||(selected?selected.length>0:Object.keys(answer).length>0));if(condition.operator==='answered')return hasAnswer;if(condition.operator==='not_answered')return !hasAnswer;if(answer===undefined||answer==='')return false;const scalar=selected?.length===1?selected[0]:answer;if(condition.operator==='equals')return scalar===condition.value;if(condition.operator==='not_equals')return scalar!==condition.value;const values=selected??(Array.isArray(answer)?answer:undefined);if(!values)return false;if(condition.operator==='includes')return values.includes(String(condition.value));if(condition.operator==='not_includes')return !values.includes(String(condition.value));throw new Error('Unsupported visibility operator')}
47
+ export function visibleQuestionIds(questions:Question[],answers:Record<string,Answer>):Set<string>{const byId=new Map(questions.map(q=>[q.id,q]));const memo=new Map<string,boolean>();const active=new Set<string>();const visible=(q:Question):boolean=>{const prior=memo.get(q.id);if(prior!==undefined)return prior;if(active.has(q.id))throw new Error('Conditional visibility cycle');active.add(q.id);let result=true;if(q.visibleWhen){const source=byId.get(q.visibleWhen.questionId);if(!source)throw new Error('Conditional visibility references an unknown question');result=conditionMatches(q.visibleWhen,visible(source)?answers[source.id]:undefined)}active.delete(q.id);memo.set(q.id,result);return result};return new Set(questions.filter(visible).map(q=>q.id))}
48
+ export function visibleAnswers(questions:Question[],answers:Record<string,Answer>):Record<string,Answer>{const visible=visibleQuestionIds(questions,answers);return Object.fromEntries(Object.entries(answers).filter(([id])=>visible.has(id)))}
49
+ export {SurveyFlow,pageRoute,routedAnswers} from './branching';
50
+ export type {BranchingSchema,SurveyProgress} from './branching';
51
+ export {Survey} from './Survey';
52
+ export type {SurveyMessages, SurveyProps, SurveyStyles} from './Survey';
53
+ export {SurveyHost} from './SurveyHost';
54
+ export type {SurveyHostMessages, SurveyHostProps} from './SurveyHost';
package/src/offline.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type {Receipt,Submission} from './index';
2
+
3
+ export type OfflineReason='invalid'|'conflict'|'unauthorized'|'revoked'|'deleted'|'expired';
4
+ export type OfflineState='pending'|'blocked'|'expired_local';
5
+ export interface OfflineLimits{maxRecords:number;maxBytes:number;maxAgeSeconds:number}
6
+ export interface OfflineStatus{pending:number;blockedByReason:Partial<Record<OfflineReason,number>>;expiredLocal:number;quarantined:number;bytes:number}
7
+ export interface OfflineOutcome{recordId:string;outcome:'accepted'|'retry'|'blocked'|'expired_local'|'quarantined'|'credential_unavailable';reason?:OfflineReason;retryAfterSeconds?:number}
8
+ export interface FlushReport{attempted:number;accepted:number;pending:number;blocked:number;expiredLocal:number;quarantined:number;cancelled:boolean;outcomes:OfflineOutcome[]}
9
+ export interface OpaqueQueueStore{load():Promise<Uint8Array[]>;replace(records:readonly Uint8Array[]):Promise<void>}
10
+ export interface QueueCipher{seal(cleartext:Uint8Array):Promise<Uint8Array>;open(ciphertext:Uint8Array):Promise<Uint8Array>}
11
+ export interface OfflineSendResult{status:number;receipt?:Receipt;retryAfterSeconds?:number}
12
+ export type OfflineSender=(collectionId:string,credential:string,submission:Submission,signal?:AbortSignal)=>Promise<OfflineSendResult>;
13
+ type RecordData={id:string;collectionId:string;submissionText:string;createdAt:number;byteSize:number;attemptCount:number;state:OfflineState;reason?:OfflineReason};
14
+ const DEFAULTS:OfflineLimits={maxRecords:1000,maxBytes:10*1024*1024,maxAgeSeconds:7*86400},HARD={maxRecords:10000,maxBytes:100*1024*1024,maxAgeSeconds:30*86400},MAX_RECORD=65536;
15
+ const encoder=new TextEncoder(),decoder=new TextDecoder();
16
+ const canonical=(value:unknown):string=>JSON.stringify(value&&typeof value==='object'&&!Array.isArray(value)?Object.fromEntries(Object.entries(value as Record<string,unknown>).sort(([a],[b])=>a.localeCompare(b)).map(([k,v])=>[k,JSON.parse(canonical(v))])):Array.isArray(value)?value.map(v=>JSON.parse(canonical(v))):value);
17
+ const reason=(status:number):OfflineReason|undefined=>({400:'invalid',409:'conflict',401:'unauthorized',403:'revoked',404:'deleted',410:'expired'} as Record<number,OfflineReason>)[status]??(status>=400&&status<500&&![408,425,429].includes(status)?'invalid':undefined);
18
+ const retryable=(status:number)=>[408,425,429].includes(status)||status>=500;
19
+ const limits=(value:Partial<OfflineLimits>):OfflineLimits=>{const result={...DEFAULTS,...value};if(!Number.isSafeInteger(result.maxRecords)||result.maxRecords<1||result.maxRecords>HARD.maxRecords||!Number.isSafeInteger(result.maxBytes)||result.maxBytes<1||result.maxBytes>HARD.maxBytes||!Number.isSafeInteger(result.maxAgeSeconds)||result.maxAgeSeconds<1||result.maxAgeSeconds>HARD.maxAgeSeconds)throw new RangeError('Offline queue limits exceed hard bounds');return result};
20
+
21
+ export interface NativeOfflineAdapter extends OpaqueQueueStore,QueueCipher { readonly secureKeyProfile:'keychain'|'android_keystore'; readonly encryptedStorage:true;createRecordId():string }
22
+ export function openNativeOfflineQueue(adapter:NativeOfflineAdapter,sender:OfflineSender,configuration:Partial<OfflineLimits>={}):OfflineQueue{if(adapter.encryptedStorage!==true||!['keychain','android_keystore'].includes(adapter.secureKeyProfile))throw new Error('A native encrypted storage and secure key adapter is required');return new OfflineQueue(adapter,adapter,sender,configuration,undefined,()=>adapter.createRecordId())}
23
+
24
+ export class OfflineQueue{
25
+ private readonly configuration:OfflineLimits;private flushing=false;
26
+ constructor(private store:OpaqueQueueStore,private cipher:QueueCipher,private sender:OfflineSender,configuration:Partial<OfflineLimits>={},private now:()=>number=()=>Date.now(),private randomId:()=>string=()=>{throw new Error('A native secure random record ID adapter is required')}){this.configuration=limits(configuration)}
27
+ private async read(){const good:{record:RecordData;ciphertext:Uint8Array}[]=[],bad:Uint8Array[]=[];for(const ciphertext of await this.store.load()){try{good.push({record:JSON.parse(decoder.decode(await this.cipher.open(ciphertext))),ciphertext})}catch{bad.push(ciphertext)}}return{good,bad}}
28
+ private async write(records:RecordData[],bad:Uint8Array[]){await this.store.replace([...bad,...await Promise.all(records.map(r=>this.cipher.seal(encoder.encode(canonical(r)))) )])}
29
+ async enqueue(collectionId:string,submission:Submission){if(!collectionId||!submission.idempotencyKey)throw new TypeError('collectionId and idempotencyKey are required');const submissionText=canonical(submission),byteSize=encoder.encode(submissionText).byteLength;if(byteSize>MAX_RECORD)throw new RangeError('Offline record exceeds 64 KiB');const {good,bad}=await this.read(),records=good.map(v=>v.record),prior=records.find(r=>JSON.parse(r.submissionText).idempotencyKey===submission.idempotencyKey);if(prior){if(prior.collectionId!==collectionId||prior.submissionText!==submissionText)throw new Error('Offline idempotency conflict');return prior.id}if(records.length>=this.configuration.maxRecords||records.reduce((sum,r)=>sum+r.byteSize,0)+byteSize>this.configuration.maxBytes)throw new RangeError('Offline queue capacity exceeded');const record={id:this.randomId(),collectionId,submissionText,createdAt:this.now(),byteSize,attemptCount:0,state:'pending' as const};await this.write([...records,record],bad);return record.id}
30
+ async snapshot():Promise<OfflineStatus>{const {good,bad}=await this.read(),status:OfflineStatus={pending:0,blockedByReason:{},expiredLocal:0,quarantined:bad.length,bytes:0};for(const {record:r} of good){status.bytes+=r.byteSize;if(r.state==='pending')status.pending++;else if(r.state==='expired_local')status.expiredLocal++;else if(r.reason)status.blockedByReason[r.reason]=(status.blockedByReason[r.reason]??0)+1}return status}
31
+ async delete(recordId:string){const {good,bad}=await this.read();await this.write(good.map(v=>v.record).filter(r=>r.id!==recordId),bad)}
32
+ async deleteCollection(collectionId:string){const {good,bad}=await this.read(),kept=good.map(v=>v.record).filter(r=>r.collectionId!==collectionId);const count=good.length-kept.length;await this.write(kept,bad);return count}
33
+ async purgeQuarantined(){const {good,bad}=await this.read();await this.write(good.map(v=>v.record),[]);return bad.length}
34
+ async flush(resolveCredential:(collectionId:string)=>Promise<string|undefined>,signal?:AbortSignal):Promise<FlushReport>{if(this.flushing)throw new Error('Offline flush already active');this.flushing=true;const report:FlushReport={attempted:0,accepted:0,pending:0,blocked:0,expiredLocal:0,quarantined:0,cancelled:false,outcomes:[]};try{const initial=await this.read(),initialRecords=initial.good.map(v=>v.record);let expiredChanged=false;for(const record of initialRecords){if(record.state!=='expired_local'&&this.now()-record.createdAt>this.configuration.maxAgeSeconds*1000){record.state='expired_local';delete record.reason;expiredChanged=true}}if(expiredChanged)await this.write(initialRecords,initial.bad);const ids=initialRecords.filter(r=>r.state==='pending').sort((a,b)=>a.createdAt-b.createdAt||a.id.localeCompare(b.id)).map(r=>r.id);for(const id of ids){if(signal?.aborted){report.cancelled=true;break}const current=await this.read(),records=current.good.map(v=>v.record),record=records.find(r=>r.id===id);if(!record||record.state!=='pending')continue;const credential=await resolveCredential(record.collectionId);if(!credential){report.pending++;report.outcomes.push({recordId:id,outcome:'credential_unavailable'});continue}report.attempted++;let result:OfflineSendResult|undefined;try{result=await this.sender(record.collectionId,credential,JSON.parse(record.submissionText),signal)}catch{}const fresh=await this.read(),updated=fresh.good.map(v=>v.record),target=updated.find(r=>r.id===id);if(!target)continue;const receipt=result?.receipt;if(result&&result.status>=200&&result.status<300&&receipt?.accepted===true&&receipt.collectionId===target.collectionId&&receipt.responseId.length>0){await this.write(updated.filter(r=>r.id!==id),fresh.bad);report.accepted++;report.outcomes.push({recordId:id,outcome:'accepted'});continue}target.attemptCount++;const terminal=result&&reason(result.status);if(terminal){target.state='blocked';target.reason=terminal;report.blocked++;report.outcomes.push({recordId:id,outcome:'blocked',reason:terminal})}else{report.pending++;report.outcomes.push({recordId:id,outcome:'retry',...(result?.retryAfterSeconds!==undefined&&retryable(result.status)?{retryAfterSeconds:Math.max(0,Math.min(86400,Math.floor(result.retryAfterSeconds)))}:{})})}await this.write(updated,fresh.bad)}}finally{this.flushing=false}const final=await this.snapshot();report.pending=final.pending;report.quarantined=final.quarantined;report.expiredLocal=final.expiredLocal;report.blocked=Object.values(final.blockedByReason).reduce((a,b)=>a+(b??0),0);return report}
35
+ }