Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Edits] Engine I/O support for editing submitted instances #349

Merged
merged 7 commits into from
Mar 20, 2025
Prev Previous commit
Next Next commit
engine: initial editInstance I/O support
Note that this has several known limitations, which are detailed in JSDoc `@todo`s on `resolveInstanceAttachmentMapSource`
eyelidlessness committed Mar 20, 2025
commit e599e8fac3e4674df5529812406d7f3aae3a6266
53 changes: 41 additions & 12 deletions packages/xforms-engine/src/instance/input/InitialInstanceState.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { getBlobText } from '@getodk/common/lib/web-compat/blob.ts';
import { INSTANCE_FILE_NAME } from '../../client/constants.ts';
import type { EditFormInstanceInput } from '../../client/form/EditFormInstance.ts';
import type {
EditFormInstanceInput,
ResolvableFormInstanceInput,
} from '../../client/form/EditFormInstance.ts';
import type { InstanceData } from '../../client/serialization/InstanceData.ts';
import { ErrorProductionDesignPendingError } from '../../error/ErrorProductionDesignPendingError.ts';
import type { StaticDocument } from '../../integration/xpath/static-dom/StaticDocument.ts';
@@ -16,6 +19,20 @@ interface InitialInstanceStateOptions {
readonly attachments: InstanceAttachmentMap;
}

const resolveInstanceXML = async (input: ResolvableFormInstanceInput): Promise<string> => {
const instanceResult = await input.resolveInstance();

if (typeof instanceResult === 'string') {
return instanceResult;
}

if (instanceResult instanceof Blob) {
return getBlobText(instanceResult);
}

return instanceResult.text();
};

const parseInstanceDocument = (model: ModelDefinition, instanceXML: string): StaticDocument => {
const doc = parseStaticDocumentFromXML(instanceXML);

@@ -45,16 +62,11 @@ const parseInstanceDocument = (model: ModelDefinition, instanceXML: string): Sta
export class InitialInstanceState {
static async from(
model: ModelDefinition,
sources: InitialInstanceStateSources
data: InitialInstanceStateSources
): Promise<InitialInstanceState> {
const [instanceData] = sources;
const instanceFile = instanceData.get(INSTANCE_FILE_NAME);
const instanceXML = await getBlobText(instanceFile);
const attachments = new InstanceAttachmentMap(sources);

return new this(model, {
instanceXML,
attachments,
return this.resolve(model, {
inputType: 'FORM_INSTANCE_INPUT_RESOLVED',
data,
});
}

@@ -63,10 +75,27 @@ export class InitialInstanceState {
input: EditFormInstanceInput
): Promise<InitialInstanceState> {
if (input.inputType === 'FORM_INSTANCE_INPUT_RESOLVED') {
return await this.from(model, input.data);
const { data } = input;
const [instanceData] = data;
const instanceFile = instanceData.get(INSTANCE_FILE_NAME);
const instanceXML = await getBlobText(instanceFile);
const attachments = InstanceAttachmentMap.from(data);

return new this(model, {
instanceXML,
attachments,
});
}

throw new Error(`TODO: complete I/O support for editing instance of ${model.form.title}`);
const [instanceXML, attachments] = await Promise.all([
resolveInstanceXML(input),
InstanceAttachmentMap.resolve(input.attachments),
]);

return new this(model, {
instanceXML,
attachments,
});
}

readonly document: StaticDocument;
77 changes: 74 additions & 3 deletions packages/xforms-engine/src/instance/input/InstanceAttachmentMap.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,65 @@
import { INSTANCE_FILE_NAME } from '../../client/constants.ts';
import type { InstanceData } from '../../client/serialization/InstanceData.ts';
import type { ResolvableInstanceAttachmentsMap } from '../../client/form/EditFormInstance.ts';
import { MalformedInstanceDataError } from '../../error/MalformedInstanceDataError.ts';

type InstanceAttachmentMapSources = readonly [InstanceData, ...InstanceData[]];
type InstanceAttachmentMapSourceEntry = readonly [key: string, value: FormDataEntryValue];

interface InstanceAttachmentMapSource {
entries(): Iterable<InstanceAttachmentMapSourceEntry>;
}

type InstanceAttachmentMapSources = readonly [
InstanceAttachmentMapSource,
...InstanceAttachmentMapSource[],
];

/**
* @todo This currently short-circuits if there are actually any instance
* attachments to resolve. As described below, much of the approach is pretty
* naive now anyway, and none of it is really "ready" until we have something to
* actually _use the instance attachments_ once they're resolved! When we are
* ready, the functionality can be unblocked as in
* {@link https://github.com/getodk/web-forms/commit/88ee1b91c1f68d53ce9ba551bab334852e1e60cd | this commit}.
*
* @todo Everything about this is incredibly naive! We should almost certainly
* do _at least_ the following:
*
* - Limit how many attachments we attempt to resolve concurrently
* - Lazy resolution of large attachments (i.e. probably streaming, maybe range
* requests, ?)
*
* @todo Once lazy resolution is a thing, we will **also** need a clear path
* from there to eager resolution (i.e. for offline caching: it doesn't make
* sense to cache a stream in progress, as it won't load the resource once the
* user actually is offline/lacks network access). This may be something we can
* evolve gradually!
*/
const resolveInstanceAttachmentMapSource = async (
input: ResolvableInstanceAttachmentsMap
): Promise<InstanceAttachmentMapSource> => {
const inputEntries = Array.from(input.entries());

if (inputEntries.length > 0) {
const fileNames = Array.from(input.keys());
const errors = fileNames.map((fileName) => {
return new Error(`Failed to resolve instance attachment with file name "${fileName}"`);
});

throw new AggregateError(errors, 'Not implemented: instance attachment resource resolution');
}

const entries = await Promise.all<InstanceAttachmentMapSourceEntry>(
inputEntries.map(async ([fileName, resolveAttachment]) => {
const response = await resolveAttachment();
const blob = await response.blob();
const value = new File([blob], fileName);

return [fileName, value] as const;
})
);

return { entries: () => entries };
};

interface KeyedInstanceDataFile<Key extends string> extends File {
readonly name: Key;
@@ -42,7 +99,21 @@ const assertInstanceDataEntry: AssertInstanceDataEntry = (entry) => {
};

export class InstanceAttachmentMap extends Map<string, File> {
constructor(sources: InstanceAttachmentMapSources) {
static from(sources: InstanceAttachmentMapSources): InstanceAttachmentMap {
return new this(sources);
}

/**
* @todo
* @see {@link resolveInstanceAttachmentMapSource}
*/
static async resolve(input: ResolvableInstanceAttachmentsMap): Promise<InstanceAttachmentMap> {
const source = await resolveInstanceAttachmentMapSource(input);

return new this([source]);
}

private constructor(sources: InstanceAttachmentMapSources) {
super();

for (const source of sources) {