@form-engine-ts/storage-azure-table 6.7.0 → 6.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -4
- package/dist/index.cjs +77 -27
- package/dist/index.d.cts +42 -2
- package/dist/index.d.ts +42 -2
- package/dist/index.js +84 -28
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -20,7 +20,11 @@ const submissionsTableClient = TableClient.fromConnectionString(
|
|
|
20
20
|
process.env.AZURE_STORAGE_CONNECTION_STRING!,
|
|
21
21
|
"responses"
|
|
22
22
|
);
|
|
23
|
-
|
|
23
|
+
interface SubmissionMeta {
|
|
24
|
+
readonly tenantId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const storage = createAzureTableStorage<SubmissionMeta>({ schemasTableClient, submissionsTableClient });
|
|
24
28
|
|
|
25
29
|
const page = await storage.listSubmissionPage("contact", { pageSize: 500, locale: "ja" });
|
|
26
30
|
```
|
|
@@ -36,6 +40,14 @@ to fill the requested logical page after client-side filtering. `buildSubmission
|
|
|
36
40
|
The deprecated `client`, `submissionCodec`, and `toODataFilter` options remain available for compatibility. The caller
|
|
37
41
|
owns table creation, credentials, retries, and client lifecycle.
|
|
38
42
|
|
|
43
|
+
The default codec persists the canonical `FormSubmission.values` payload and never creates an `answers` property. Set
|
|
44
|
+
`rejectLegacyAnswers: true` to fail explicitly when a legacy entity is encountered; no legacy decoding or migration is
|
|
45
|
+
performed. `idempotentSubmissions: true` enables typed `created`, `duplicate`, and `conflict` results from
|
|
46
|
+
`saveSubmission(submission)`. Pass `validateAgainstSchema: true` to re-validate against the stored schema. Pass
|
|
47
|
+
`submissionSchema`, `submissionValidator`, or `validation` to validate every submission before it is written; the same
|
|
48
|
+
source can be supplied per call as `saveSubmission(submission, { validation })`. A source can be a FormSchema, a
|
|
49
|
+
Zod-compatible object with `safeParse`, or an async validator callback.
|
|
50
|
+
|
|
39
51
|
`listTextAnswerPage` accepts either the legacy single field ID or `TextAnswerPageQueryOptions.fieldIds`. Page size counts
|
|
40
52
|
emitted text items rather than entities. Its opaque Base64 JSON cursor retains the Azure continuation token plus entity
|
|
41
53
|
and field indexes, so a page can resume inside a multi-answer entity without gaps or duplicates. Empty answers do not
|
|
@@ -43,6 +55,5 @@ consume the item limit, and scanning remains bounded by `maxScanPages`. Cursor f
|
|
|
43
55
|
version, sorted fields, and a SHA-256 filter fingerprint. Reusing a cursor with different query context throws
|
|
44
56
|
`invalid_cursor_context`.
|
|
45
57
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
for migration tooling.
|
|
58
|
+
The legacy codec APIs are retained only as deprecated source symbols for existing applications; the standard factory does
|
|
59
|
+
not use them.
|
package/dist/index.cjs
CHANGED
|
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
createAzureTableStorage: () => createAzureTableStorage,
|
|
24
|
+
createAzureTableSubmissionCodec: () => createAzureTableSubmissionCodec,
|
|
24
25
|
createLegacyArrayAzureTableCodec: () => createLegacyArrayAzureTableCodec,
|
|
25
26
|
createLegacyAzureTableCodec: () => createLegacyAzureTableCodec,
|
|
26
27
|
defaultAzureTableSubmissionCodec: () => defaultAzureTableSubmissionCodec,
|
|
@@ -292,22 +293,25 @@ function scalarMetadata(metadata) {
|
|
|
292
293
|
})
|
|
293
294
|
);
|
|
294
295
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
296
|
+
function createAzureTableSubmissionCodec() {
|
|
297
|
+
return {
|
|
298
|
+
createEntity: (submission) => ({
|
|
299
|
+
...scalarMetadata(submission.metadata),
|
|
300
|
+
kind: "submission",
|
|
301
|
+
formVersion: submission.formVersion,
|
|
302
|
+
locale: submission.locale,
|
|
303
|
+
submittedAt: submission.submittedAt,
|
|
304
|
+
responseId: submission.id,
|
|
305
|
+
payload: JSON.stringify(submission)
|
|
306
|
+
}),
|
|
307
|
+
deserialize: (entity) => parseSubmission(entity.payload, "submission entity"),
|
|
308
|
+
matchesEntity: (entity) => entity.kind === "submission",
|
|
309
|
+
createPartitionKey: (submission) => submission.formId,
|
|
310
|
+
createPartitionKeyFromQuery: (formId) => formId,
|
|
311
|
+
createRowKey: defaultSubmissionRowKey
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
var defaultAzureTableSubmissionCodec = createAzureTableSubmissionCodec();
|
|
311
315
|
function legacyCodec(codec) {
|
|
312
316
|
return {
|
|
313
317
|
createEntity: (value) => ({
|
|
@@ -402,13 +406,13 @@ function submissionFilterToOData(filter, mapping) {
|
|
|
402
406
|
...filter.to === void 0 ? [] : [`${property} le ${valueToOData(filter.to)}`]
|
|
403
407
|
].join(" and ");
|
|
404
408
|
}
|
|
405
|
-
function defaultSubmissionFilter(codec, formId, options, legacyExtension, mapping) {
|
|
409
|
+
function defaultSubmissionFilter(codec, formId, options, legacyExtension, mapping, rejectLegacyAnswers = false) {
|
|
406
410
|
const partitionKey = codec.createPartitionKeyFromQuery(formId, options);
|
|
407
411
|
const ast = options.filter === void 0 || typeof options.filter === "function" ? void 0 : submissionFilterToOData(options.filter, mapping);
|
|
408
412
|
const keys = physicalKeyNames(mapping);
|
|
409
413
|
return [
|
|
410
414
|
...partitionKey === void 0 ? [] : [`${keys.partitionKey} eq '${escapeOData(partitionKey)}'`],
|
|
411
|
-
...codec === defaultAzureTableSubmissionCodec ? ["kind eq 'submission'"] : [],
|
|
415
|
+
...codec === defaultAzureTableSubmissionCodec && !rejectLegacyAnswers ? ["kind eq 'submission'"] : [],
|
|
412
416
|
...options.version === void 0 ? [] : [`${propertyName(mapping, "formVersion", "formVersion")} eq ${options.version}`],
|
|
413
417
|
...options.since === void 0 ? [] : [`${propertyName(mapping, "submittedAt", "submittedAt")} ge '${escapeOData(options.since)}'`],
|
|
414
418
|
...options.until === void 0 ? [] : [`${propertyName(mapping, "submittedAt", "submittedAt")} le '${escapeOData(options.until)}'`],
|
|
@@ -420,6 +424,10 @@ function defaultSubmissionFilter(codec, formId, options, legacyExtension, mappin
|
|
|
420
424
|
function isNotFound(error) {
|
|
421
425
|
return isRecord(error) && (error.statusCode === 404 || error.code === "ResourceNotFound" || error.code === "EntityNotFound");
|
|
422
426
|
}
|
|
427
|
+
function isDuplicateEntityError(error) {
|
|
428
|
+
const message = error instanceof Error ? error.message : isRecord(error) ? String(error.message ?? "") : "";
|
|
429
|
+
return isRecord(error) && (error.statusCode === 409 || error.code === "EntityAlreadyExists") || /alreadyexists|duplicate/iu.test(message);
|
|
430
|
+
}
|
|
423
431
|
function matchesBuiltInFilters(submission, formId, options) {
|
|
424
432
|
return submission.formId === formId && (options.version === void 0 || submission.formVersion === options.version) && (options.since === void 0 || submission.submittedAt >= options.since) && (options.until === void 0 || submission.submittedAt <= options.until) && (options.locale === void 0 || submission.locale === options.locale);
|
|
425
433
|
}
|
|
@@ -450,13 +458,22 @@ function createAzureTableStorage(options = {}) {
|
|
|
450
458
|
formId,
|
|
451
459
|
query,
|
|
452
460
|
options.toODataFilter?.(query) ?? metadataFiltersToOData(query, options.fieldMapping),
|
|
453
|
-
options.fieldMapping
|
|
461
|
+
options.fieldMapping,
|
|
462
|
+
options.rejectLegacyAnswers
|
|
454
463
|
);
|
|
455
464
|
};
|
|
456
|
-
const deserializeIfMatching = (entity) => codec.matchesEntity(entity) ? parseSubmissionEntity(entity, codec, options.fieldMapping, valueCodec) : void 0;
|
|
457
465
|
const ensureWritable = () => {
|
|
458
466
|
if (options.readOnly === true) throw new Error("Azure Table storage is read-only.");
|
|
459
467
|
};
|
|
468
|
+
const rejectLegacyEntity = (entity) => {
|
|
469
|
+
if (options.rejectLegacyAnswers === true && Object.hasOwn(entity, "answers")) {
|
|
470
|
+
throw new Error("Azure Table legacy answers column is not supported by the standard submission storage.");
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
const deserializeIfMatching = (entity) => {
|
|
474
|
+
rejectLegacyEntity(entity);
|
|
475
|
+
return codec.matchesEntity(entity) ? parseSubmissionEntity(entity, codec, options.fieldMapping, valueCodec) : void 0;
|
|
476
|
+
};
|
|
460
477
|
const listSubmissionCandidates = async (formId, query) => {
|
|
461
478
|
const client = await submissionClient(formId, query);
|
|
462
479
|
const found = [];
|
|
@@ -501,18 +518,50 @@ function createAzureTableStorage(options = {}) {
|
|
|
501
518
|
ensureWritable();
|
|
502
519
|
await (await schemaClient(formId)).deleteEntity(formId, schemaRowKey(formVersion));
|
|
503
520
|
},
|
|
504
|
-
async saveSubmission(submission) {
|
|
521
|
+
async saveSubmission(submission, saveOptions = {}) {
|
|
505
522
|
ensureWritable();
|
|
506
523
|
const stored = parseSubmission(submission, `input ${String(submission?.id)}`);
|
|
524
|
+
const explicitValidation = saveOptions.validator ?? saveOptions.validation;
|
|
525
|
+
const configuredValidation = options.submissionValidator ?? options.submissionSchema ?? options.validator ?? options.schema ?? options.validation;
|
|
526
|
+
if (explicitValidation !== void 0) await (0, import_core.assertValidFormSubmissionWith)(explicitValidation, stored);
|
|
527
|
+
if (explicitValidation === void 0 && configuredValidation !== void 0) {
|
|
528
|
+
await (0, import_core.assertValidFormSubmissionWith)(configuredValidation, stored);
|
|
529
|
+
}
|
|
530
|
+
if (options.validateSubmissions === true || saveOptions.validateAgainstSchema === true) {
|
|
531
|
+
const schema = parseSchemaEntity(
|
|
532
|
+
await (await schemaClient(stored.formId)).getEntity(stored.formId, schemaRowKey(stored.formVersion))
|
|
533
|
+
);
|
|
534
|
+
(0, import_core.assertValidFormSubmission)(schema, stored);
|
|
535
|
+
}
|
|
536
|
+
const payloadHash = await (0, import_core.hashFormSubmissionPayload)(stored);
|
|
507
537
|
const mappedEntity = mappedSubmissionEntity(codec.createEntity(stored), stored, options.fieldMapping, valueCodec);
|
|
508
538
|
const keys = physicalKeyNames(options.fieldMapping);
|
|
509
|
-
|
|
539
|
+
const partitionKey = codec.createPartitionKey(stored);
|
|
540
|
+
const rowKey = codec.createRowKey(stored);
|
|
541
|
+
const entity = {
|
|
510
542
|
...mappedEntity,
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
[keys.
|
|
515
|
-
|
|
543
|
+
payloadHash,
|
|
544
|
+
partitionKey,
|
|
545
|
+
rowKey,
|
|
546
|
+
[keys.partitionKey]: partitionKey,
|
|
547
|
+
[keys.rowKey]: rowKey
|
|
548
|
+
};
|
|
549
|
+
try {
|
|
550
|
+
await (await submissionClient(stored.formId)).createEntity(entity);
|
|
551
|
+
} catch (error) {
|
|
552
|
+
const idempotent = saveOptions.idempotent ?? options.idempotentSubmissions ?? options.idempotency ?? false;
|
|
553
|
+
if (!idempotent || !isDuplicateEntityError(error)) throw error;
|
|
554
|
+
const existingEntity = await (await submissionClient(stored.formId)).getEntity(partitionKey, rowKey);
|
|
555
|
+
const existing = parseSubmissionEntity(existingEntity, codec, options.fieldMapping, valueCodec);
|
|
556
|
+
const existingPayloadHash = typeof existingEntity.payloadHash === "string" ? existingEntity.payloadHash : await (0, import_core.hashFormSubmissionPayload)(existing);
|
|
557
|
+
if (existingPayloadHash === payloadHash) {
|
|
558
|
+
return { status: "duplicate", submission: existing, payloadHash };
|
|
559
|
+
}
|
|
560
|
+
return { status: "conflict", submissionId: stored.id, payloadHash, existingPayloadHash };
|
|
561
|
+
}
|
|
562
|
+
if (saveOptions.idempotent ?? options.idempotentSubmissions ?? options.idempotency ?? false) {
|
|
563
|
+
return { status: "created", submission: stored, payloadHash };
|
|
564
|
+
}
|
|
516
565
|
},
|
|
517
566
|
async listSubmissions(formId, formVersion, queryOptions = {}) {
|
|
518
567
|
return listSubmissionCandidates(formId, {
|
|
@@ -717,6 +766,7 @@ function createAzureTableStorage(options = {}) {
|
|
|
717
766
|
// Annotate the CommonJS export names for ESM import in node:
|
|
718
767
|
0 && (module.exports = {
|
|
719
768
|
createAzureTableStorage,
|
|
769
|
+
createAzureTableSubmissionCodec,
|
|
720
770
|
createLegacyArrayAzureTableCodec,
|
|
721
771
|
createLegacyAzureTableCodec,
|
|
722
772
|
defaultAzureTableSubmissionCodec,
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FormSubmission, BaseSubmissionMetadata, PagedSubmissionStorageAdapter, SubmissionPageQueryOptions, StrictFormSubmission, SubmissionFilter } from '@form-engine-ts/core';
|
|
1
|
+
import { FormSubmission, BaseSubmissionMetadata, PagedSubmissionStorageAdapter, SubmissionPageQueryOptions, FormSubmissionValidationSource, FormSubmissionValidator, StrictFormSubmission, SaveSubmissionOptions, SubmissionSaveResult, SubmissionFilter, JsonValue, TextAnswerPageQueryOptions, TypedTextAnswerPage } from '@form-engine-ts/core';
|
|
2
2
|
|
|
3
3
|
interface AzureTableListOptions {
|
|
4
4
|
readonly queryOptions?: {
|
|
@@ -104,6 +104,21 @@ interface AzureTableStorageOptions<T = FormSubmission> {
|
|
|
104
104
|
readonly maxScanPages?: number;
|
|
105
105
|
readonly fieldMapping?: AzureTableFieldMapping;
|
|
106
106
|
readonly readOnly?: boolean;
|
|
107
|
+
/** Detect legacy entities that expose an `answers` column and fail explicitly. */
|
|
108
|
+
readonly rejectLegacyAnswers?: boolean;
|
|
109
|
+
/** Enable typed duplicate/conflict results for submission IDs. */
|
|
110
|
+
readonly idempotentSubmissions?: boolean;
|
|
111
|
+
/** Alias for idempotentSubmissions. */
|
|
112
|
+
readonly idempotency?: boolean;
|
|
113
|
+
/** Re-validate a submission against the stored FormSchema before saving. */
|
|
114
|
+
readonly validateSubmissions?: boolean;
|
|
115
|
+
/** Validate every saved submission with an application-owned schema or callback. */
|
|
116
|
+
readonly submissionSchema?: FormSubmissionValidationSource;
|
|
117
|
+
readonly submissionValidator?: FormSubmissionValidator;
|
|
118
|
+
/** Alias for `submissionValidator`/`submissionSchema`. */
|
|
119
|
+
readonly validation?: FormSubmissionValidationSource;
|
|
120
|
+
readonly validator?: FormSubmissionValidationSource;
|
|
121
|
+
readonly schema?: FormSubmissionValidationSource;
|
|
107
122
|
}
|
|
108
123
|
interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSubmissionMetadata> extends PagedSubmissionStorageAdapter {
|
|
109
124
|
readonly fetchPage: (formId: string, options?: {
|
|
@@ -118,6 +133,29 @@ interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSu
|
|
|
118
133
|
readonly nextCursor?: string;
|
|
119
134
|
}>;
|
|
120
135
|
}
|
|
136
|
+
type TypedAzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata | undefined = undefined> = Omit<PagedSubmissionStorageAdapter, "saveSubmission" | "listSubmissionPage" | "listTextAnswerPage"> & {
|
|
137
|
+
readonly saveSubmission: (submission: FormSubmission<TMeta>, options?: SaveSubmissionOptions) => Promise<undefined | SubmissionSaveResult<TMeta>>;
|
|
138
|
+
readonly listSubmissionPage: (formId: string, options?: SubmissionPageQueryOptions & {
|
|
139
|
+
readonly filter?: SubmissionFilter | ((submission: FormSubmission<TMeta>) => boolean);
|
|
140
|
+
readonly metadataFilters?: TMeta extends BaseSubmissionMetadata ? Partial<TMeta> : Readonly<Record<string, JsonValue>>;
|
|
141
|
+
}) => Promise<{
|
|
142
|
+
readonly items: readonly FormSubmission<TMeta>[];
|
|
143
|
+
readonly nextCursor?: string;
|
|
144
|
+
readonly hasMore: boolean;
|
|
145
|
+
}>;
|
|
146
|
+
readonly fetchPage: (formId: string, options?: {
|
|
147
|
+
readonly pageSize?: number;
|
|
148
|
+
readonly fromSubmittedAt?: string;
|
|
149
|
+
readonly toSubmittedAt?: string;
|
|
150
|
+
readonly locale?: string;
|
|
151
|
+
readonly metadataFilters?: TMeta extends BaseSubmissionMetadata ? Partial<TMeta> : Readonly<Record<string, JsonValue>>;
|
|
152
|
+
readonly cursor?: string;
|
|
153
|
+
}) => Promise<{
|
|
154
|
+
readonly items: readonly FormSubmission<TMeta>[];
|
|
155
|
+
readonly nextCursor?: string;
|
|
156
|
+
}>;
|
|
157
|
+
readonly listTextAnswerPage: (formId: string, fieldIdOrOptions?: string | TextAnswerPageQueryOptions, options?: TextAnswerPageQueryOptions) => Promise<TypedTextAnswerPage<TMeta>>;
|
|
158
|
+
};
|
|
121
159
|
interface AzureTextAnswerCursorPayload {
|
|
122
160
|
readonly formatVersion: 1;
|
|
123
161
|
readonly formId: string;
|
|
@@ -128,9 +166,11 @@ interface AzureTextAnswerCursorPayload {
|
|
|
128
166
|
readonly entityIndex: number;
|
|
129
167
|
readonly fieldIndex: number;
|
|
130
168
|
}
|
|
169
|
+
declare function createAzureTableSubmissionCodec<TMeta extends BaseSubmissionMetadata | undefined = undefined>(): AzureTableSubmissionCodec<FormSubmission<TMeta>>;
|
|
131
170
|
declare const defaultAzureTableSubmissionCodec: AzureTableSubmissionCodec<FormSubmission>;
|
|
132
171
|
declare function metadataFiltersToOData(options: SubmissionPageQueryOptions, mapping?: AzureTableFieldMapping): string;
|
|
133
172
|
declare function submissionFilterToOData(filter: SubmissionFilter, mapping?: AzureTableFieldMapping): string | undefined;
|
|
134
173
|
declare function createAzureTableStorage(options?: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
|
|
174
|
+
declare function createAzureTableStorage<TMeta extends BaseSubmissionMetadata | undefined = undefined>(options?: AzureTableStorageOptions): TypedAzureTableStorageAdapter<TMeta>;
|
|
135
175
|
|
|
136
|
-
export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableFieldMapping, type AzureTableLegacyCodec, type AzureTableLegacyEntity, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageAdapter, type AzureTableStorageOptions, type AzureTableSubmissionCodec, type AzureTableValueCodec, type AzureTextAnswerCursorPayload, type LegacyAnswerArrayEntity, type LegacyArrayAzureTableCodec, createAzureTableStorage, createLegacyArrayAzureTableCodec, createLegacyAzureTableCodec, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
|
|
176
|
+
export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableFieldMapping, type AzureTableLegacyCodec, type AzureTableLegacyEntity, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageAdapter, type AzureTableStorageOptions, type AzureTableSubmissionCodec, type AzureTableValueCodec, type AzureTextAnswerCursorPayload, type LegacyAnswerArrayEntity, type LegacyArrayAzureTableCodec, type TypedAzureTableStorageAdapter, createAzureTableStorage, createAzureTableSubmissionCodec, createLegacyArrayAzureTableCodec, createLegacyAzureTableCodec, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FormSubmission, BaseSubmissionMetadata, PagedSubmissionStorageAdapter, SubmissionPageQueryOptions, StrictFormSubmission, SubmissionFilter } from '@form-engine-ts/core';
|
|
1
|
+
import { FormSubmission, BaseSubmissionMetadata, PagedSubmissionStorageAdapter, SubmissionPageQueryOptions, FormSubmissionValidationSource, FormSubmissionValidator, StrictFormSubmission, SaveSubmissionOptions, SubmissionSaveResult, SubmissionFilter, JsonValue, TextAnswerPageQueryOptions, TypedTextAnswerPage } from '@form-engine-ts/core';
|
|
2
2
|
|
|
3
3
|
interface AzureTableListOptions {
|
|
4
4
|
readonly queryOptions?: {
|
|
@@ -104,6 +104,21 @@ interface AzureTableStorageOptions<T = FormSubmission> {
|
|
|
104
104
|
readonly maxScanPages?: number;
|
|
105
105
|
readonly fieldMapping?: AzureTableFieldMapping;
|
|
106
106
|
readonly readOnly?: boolean;
|
|
107
|
+
/** Detect legacy entities that expose an `answers` column and fail explicitly. */
|
|
108
|
+
readonly rejectLegacyAnswers?: boolean;
|
|
109
|
+
/** Enable typed duplicate/conflict results for submission IDs. */
|
|
110
|
+
readonly idempotentSubmissions?: boolean;
|
|
111
|
+
/** Alias for idempotentSubmissions. */
|
|
112
|
+
readonly idempotency?: boolean;
|
|
113
|
+
/** Re-validate a submission against the stored FormSchema before saving. */
|
|
114
|
+
readonly validateSubmissions?: boolean;
|
|
115
|
+
/** Validate every saved submission with an application-owned schema or callback. */
|
|
116
|
+
readonly submissionSchema?: FormSubmissionValidationSource;
|
|
117
|
+
readonly submissionValidator?: FormSubmissionValidator;
|
|
118
|
+
/** Alias for `submissionValidator`/`submissionSchema`. */
|
|
119
|
+
readonly validation?: FormSubmissionValidationSource;
|
|
120
|
+
readonly validator?: FormSubmissionValidationSource;
|
|
121
|
+
readonly schema?: FormSubmissionValidationSource;
|
|
107
122
|
}
|
|
108
123
|
interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSubmissionMetadata> extends PagedSubmissionStorageAdapter {
|
|
109
124
|
readonly fetchPage: (formId: string, options?: {
|
|
@@ -118,6 +133,29 @@ interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSu
|
|
|
118
133
|
readonly nextCursor?: string;
|
|
119
134
|
}>;
|
|
120
135
|
}
|
|
136
|
+
type TypedAzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata | undefined = undefined> = Omit<PagedSubmissionStorageAdapter, "saveSubmission" | "listSubmissionPage" | "listTextAnswerPage"> & {
|
|
137
|
+
readonly saveSubmission: (submission: FormSubmission<TMeta>, options?: SaveSubmissionOptions) => Promise<undefined | SubmissionSaveResult<TMeta>>;
|
|
138
|
+
readonly listSubmissionPage: (formId: string, options?: SubmissionPageQueryOptions & {
|
|
139
|
+
readonly filter?: SubmissionFilter | ((submission: FormSubmission<TMeta>) => boolean);
|
|
140
|
+
readonly metadataFilters?: TMeta extends BaseSubmissionMetadata ? Partial<TMeta> : Readonly<Record<string, JsonValue>>;
|
|
141
|
+
}) => Promise<{
|
|
142
|
+
readonly items: readonly FormSubmission<TMeta>[];
|
|
143
|
+
readonly nextCursor?: string;
|
|
144
|
+
readonly hasMore: boolean;
|
|
145
|
+
}>;
|
|
146
|
+
readonly fetchPage: (formId: string, options?: {
|
|
147
|
+
readonly pageSize?: number;
|
|
148
|
+
readonly fromSubmittedAt?: string;
|
|
149
|
+
readonly toSubmittedAt?: string;
|
|
150
|
+
readonly locale?: string;
|
|
151
|
+
readonly metadataFilters?: TMeta extends BaseSubmissionMetadata ? Partial<TMeta> : Readonly<Record<string, JsonValue>>;
|
|
152
|
+
readonly cursor?: string;
|
|
153
|
+
}) => Promise<{
|
|
154
|
+
readonly items: readonly FormSubmission<TMeta>[];
|
|
155
|
+
readonly nextCursor?: string;
|
|
156
|
+
}>;
|
|
157
|
+
readonly listTextAnswerPage: (formId: string, fieldIdOrOptions?: string | TextAnswerPageQueryOptions, options?: TextAnswerPageQueryOptions) => Promise<TypedTextAnswerPage<TMeta>>;
|
|
158
|
+
};
|
|
121
159
|
interface AzureTextAnswerCursorPayload {
|
|
122
160
|
readonly formatVersion: 1;
|
|
123
161
|
readonly formId: string;
|
|
@@ -128,9 +166,11 @@ interface AzureTextAnswerCursorPayload {
|
|
|
128
166
|
readonly entityIndex: number;
|
|
129
167
|
readonly fieldIndex: number;
|
|
130
168
|
}
|
|
169
|
+
declare function createAzureTableSubmissionCodec<TMeta extends BaseSubmissionMetadata | undefined = undefined>(): AzureTableSubmissionCodec<FormSubmission<TMeta>>;
|
|
131
170
|
declare const defaultAzureTableSubmissionCodec: AzureTableSubmissionCodec<FormSubmission>;
|
|
132
171
|
declare function metadataFiltersToOData(options: SubmissionPageQueryOptions, mapping?: AzureTableFieldMapping): string;
|
|
133
172
|
declare function submissionFilterToOData(filter: SubmissionFilter, mapping?: AzureTableFieldMapping): string | undefined;
|
|
134
173
|
declare function createAzureTableStorage(options?: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
|
|
174
|
+
declare function createAzureTableStorage<TMeta extends BaseSubmissionMetadata | undefined = undefined>(options?: AzureTableStorageOptions): TypedAzureTableStorageAdapter<TMeta>;
|
|
135
175
|
|
|
136
|
-
export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableFieldMapping, type AzureTableLegacyCodec, type AzureTableLegacyEntity, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageAdapter, type AzureTableStorageOptions, type AzureTableSubmissionCodec, type AzureTableValueCodec, type AzureTextAnswerCursorPayload, type LegacyAnswerArrayEntity, type LegacyArrayAzureTableCodec, createAzureTableStorage, createLegacyArrayAzureTableCodec, createLegacyAzureTableCodec, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
|
|
176
|
+
export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableFieldMapping, type AzureTableLegacyCodec, type AzureTableLegacyEntity, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageAdapter, type AzureTableStorageOptions, type AzureTableSubmissionCodec, type AzureTableValueCodec, type AzureTextAnswerCursorPayload, type LegacyAnswerArrayEntity, type LegacyArrayAzureTableCodec, type TypedAzureTableStorageAdapter, createAzureTableStorage, createAzureTableSubmissionCodec, createLegacyArrayAzureTableCodec, createLegacyAzureTableCodec, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
assertValidFormSchema,
|
|
4
|
+
assertValidFormSubmission,
|
|
5
|
+
assertValidFormSubmissionWith,
|
|
6
|
+
hashFormSubmissionPayload,
|
|
7
|
+
matchesSubmissionPageFilters,
|
|
8
|
+
normalizeSubmissionPageSize
|
|
9
|
+
} from "@form-engine-ts/core";
|
|
3
10
|
function createLegacyArrayAzureTableCodec(options = {}) {
|
|
4
11
|
const defaultLocale = options.defaultLocale ?? "ja";
|
|
5
12
|
if (defaultLocale.trim().length === 0) throw new TypeError("defaultLocale must not be empty.");
|
|
@@ -263,22 +270,25 @@ function scalarMetadata(metadata) {
|
|
|
263
270
|
})
|
|
264
271
|
);
|
|
265
272
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
273
|
+
function createAzureTableSubmissionCodec() {
|
|
274
|
+
return {
|
|
275
|
+
createEntity: (submission) => ({
|
|
276
|
+
...scalarMetadata(submission.metadata),
|
|
277
|
+
kind: "submission",
|
|
278
|
+
formVersion: submission.formVersion,
|
|
279
|
+
locale: submission.locale,
|
|
280
|
+
submittedAt: submission.submittedAt,
|
|
281
|
+
responseId: submission.id,
|
|
282
|
+
payload: JSON.stringify(submission)
|
|
283
|
+
}),
|
|
284
|
+
deserialize: (entity) => parseSubmission(entity.payload, "submission entity"),
|
|
285
|
+
matchesEntity: (entity) => entity.kind === "submission",
|
|
286
|
+
createPartitionKey: (submission) => submission.formId,
|
|
287
|
+
createPartitionKeyFromQuery: (formId) => formId,
|
|
288
|
+
createRowKey: defaultSubmissionRowKey
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
var defaultAzureTableSubmissionCodec = createAzureTableSubmissionCodec();
|
|
282
292
|
function legacyCodec(codec) {
|
|
283
293
|
return {
|
|
284
294
|
createEntity: (value) => ({
|
|
@@ -373,13 +383,13 @@ function submissionFilterToOData(filter, mapping) {
|
|
|
373
383
|
...filter.to === void 0 ? [] : [`${property} le ${valueToOData(filter.to)}`]
|
|
374
384
|
].join(" and ");
|
|
375
385
|
}
|
|
376
|
-
function defaultSubmissionFilter(codec, formId, options, legacyExtension, mapping) {
|
|
386
|
+
function defaultSubmissionFilter(codec, formId, options, legacyExtension, mapping, rejectLegacyAnswers = false) {
|
|
377
387
|
const partitionKey = codec.createPartitionKeyFromQuery(formId, options);
|
|
378
388
|
const ast = options.filter === void 0 || typeof options.filter === "function" ? void 0 : submissionFilterToOData(options.filter, mapping);
|
|
379
389
|
const keys = physicalKeyNames(mapping);
|
|
380
390
|
return [
|
|
381
391
|
...partitionKey === void 0 ? [] : [`${keys.partitionKey} eq '${escapeOData(partitionKey)}'`],
|
|
382
|
-
...codec === defaultAzureTableSubmissionCodec ? ["kind eq 'submission'"] : [],
|
|
392
|
+
...codec === defaultAzureTableSubmissionCodec && !rejectLegacyAnswers ? ["kind eq 'submission'"] : [],
|
|
383
393
|
...options.version === void 0 ? [] : [`${propertyName(mapping, "formVersion", "formVersion")} eq ${options.version}`],
|
|
384
394
|
...options.since === void 0 ? [] : [`${propertyName(mapping, "submittedAt", "submittedAt")} ge '${escapeOData(options.since)}'`],
|
|
385
395
|
...options.until === void 0 ? [] : [`${propertyName(mapping, "submittedAt", "submittedAt")} le '${escapeOData(options.until)}'`],
|
|
@@ -391,6 +401,10 @@ function defaultSubmissionFilter(codec, formId, options, legacyExtension, mappin
|
|
|
391
401
|
function isNotFound(error) {
|
|
392
402
|
return isRecord(error) && (error.statusCode === 404 || error.code === "ResourceNotFound" || error.code === "EntityNotFound");
|
|
393
403
|
}
|
|
404
|
+
function isDuplicateEntityError(error) {
|
|
405
|
+
const message = error instanceof Error ? error.message : isRecord(error) ? String(error.message ?? "") : "";
|
|
406
|
+
return isRecord(error) && (error.statusCode === 409 || error.code === "EntityAlreadyExists") || /alreadyexists|duplicate/iu.test(message);
|
|
407
|
+
}
|
|
394
408
|
function matchesBuiltInFilters(submission, formId, options) {
|
|
395
409
|
return submission.formId === formId && (options.version === void 0 || submission.formVersion === options.version) && (options.since === void 0 || submission.submittedAt >= options.since) && (options.until === void 0 || submission.submittedAt <= options.until) && (options.locale === void 0 || submission.locale === options.locale);
|
|
396
410
|
}
|
|
@@ -421,13 +435,22 @@ function createAzureTableStorage(options = {}) {
|
|
|
421
435
|
formId,
|
|
422
436
|
query,
|
|
423
437
|
options.toODataFilter?.(query) ?? metadataFiltersToOData(query, options.fieldMapping),
|
|
424
|
-
options.fieldMapping
|
|
438
|
+
options.fieldMapping,
|
|
439
|
+
options.rejectLegacyAnswers
|
|
425
440
|
);
|
|
426
441
|
};
|
|
427
|
-
const deserializeIfMatching = (entity) => codec.matchesEntity(entity) ? parseSubmissionEntity(entity, codec, options.fieldMapping, valueCodec) : void 0;
|
|
428
442
|
const ensureWritable = () => {
|
|
429
443
|
if (options.readOnly === true) throw new Error("Azure Table storage is read-only.");
|
|
430
444
|
};
|
|
445
|
+
const rejectLegacyEntity = (entity) => {
|
|
446
|
+
if (options.rejectLegacyAnswers === true && Object.hasOwn(entity, "answers")) {
|
|
447
|
+
throw new Error("Azure Table legacy answers column is not supported by the standard submission storage.");
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
const deserializeIfMatching = (entity) => {
|
|
451
|
+
rejectLegacyEntity(entity);
|
|
452
|
+
return codec.matchesEntity(entity) ? parseSubmissionEntity(entity, codec, options.fieldMapping, valueCodec) : void 0;
|
|
453
|
+
};
|
|
431
454
|
const listSubmissionCandidates = async (formId, query) => {
|
|
432
455
|
const client = await submissionClient(formId, query);
|
|
433
456
|
const found = [];
|
|
@@ -472,18 +495,50 @@ function createAzureTableStorage(options = {}) {
|
|
|
472
495
|
ensureWritable();
|
|
473
496
|
await (await schemaClient(formId)).deleteEntity(formId, schemaRowKey(formVersion));
|
|
474
497
|
},
|
|
475
|
-
async saveSubmission(submission) {
|
|
498
|
+
async saveSubmission(submission, saveOptions = {}) {
|
|
476
499
|
ensureWritable();
|
|
477
500
|
const stored = parseSubmission(submission, `input ${String(submission?.id)}`);
|
|
501
|
+
const explicitValidation = saveOptions.validator ?? saveOptions.validation;
|
|
502
|
+
const configuredValidation = options.submissionValidator ?? options.submissionSchema ?? options.validator ?? options.schema ?? options.validation;
|
|
503
|
+
if (explicitValidation !== void 0) await assertValidFormSubmissionWith(explicitValidation, stored);
|
|
504
|
+
if (explicitValidation === void 0 && configuredValidation !== void 0) {
|
|
505
|
+
await assertValidFormSubmissionWith(configuredValidation, stored);
|
|
506
|
+
}
|
|
507
|
+
if (options.validateSubmissions === true || saveOptions.validateAgainstSchema === true) {
|
|
508
|
+
const schema = parseSchemaEntity(
|
|
509
|
+
await (await schemaClient(stored.formId)).getEntity(stored.formId, schemaRowKey(stored.formVersion))
|
|
510
|
+
);
|
|
511
|
+
assertValidFormSubmission(schema, stored);
|
|
512
|
+
}
|
|
513
|
+
const payloadHash = await hashFormSubmissionPayload(stored);
|
|
478
514
|
const mappedEntity = mappedSubmissionEntity(codec.createEntity(stored), stored, options.fieldMapping, valueCodec);
|
|
479
515
|
const keys = physicalKeyNames(options.fieldMapping);
|
|
480
|
-
|
|
516
|
+
const partitionKey = codec.createPartitionKey(stored);
|
|
517
|
+
const rowKey = codec.createRowKey(stored);
|
|
518
|
+
const entity = {
|
|
481
519
|
...mappedEntity,
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
[keys.
|
|
486
|
-
|
|
520
|
+
payloadHash,
|
|
521
|
+
partitionKey,
|
|
522
|
+
rowKey,
|
|
523
|
+
[keys.partitionKey]: partitionKey,
|
|
524
|
+
[keys.rowKey]: rowKey
|
|
525
|
+
};
|
|
526
|
+
try {
|
|
527
|
+
await (await submissionClient(stored.formId)).createEntity(entity);
|
|
528
|
+
} catch (error) {
|
|
529
|
+
const idempotent = saveOptions.idempotent ?? options.idempotentSubmissions ?? options.idempotency ?? false;
|
|
530
|
+
if (!idempotent || !isDuplicateEntityError(error)) throw error;
|
|
531
|
+
const existingEntity = await (await submissionClient(stored.formId)).getEntity(partitionKey, rowKey);
|
|
532
|
+
const existing = parseSubmissionEntity(existingEntity, codec, options.fieldMapping, valueCodec);
|
|
533
|
+
const existingPayloadHash = typeof existingEntity.payloadHash === "string" ? existingEntity.payloadHash : await hashFormSubmissionPayload(existing);
|
|
534
|
+
if (existingPayloadHash === payloadHash) {
|
|
535
|
+
return { status: "duplicate", submission: existing, payloadHash };
|
|
536
|
+
}
|
|
537
|
+
return { status: "conflict", submissionId: stored.id, payloadHash, existingPayloadHash };
|
|
538
|
+
}
|
|
539
|
+
if (saveOptions.idempotent ?? options.idempotentSubmissions ?? options.idempotency ?? false) {
|
|
540
|
+
return { status: "created", submission: stored, payloadHash };
|
|
541
|
+
}
|
|
487
542
|
},
|
|
488
543
|
async listSubmissions(formId, formVersion, queryOptions = {}) {
|
|
489
544
|
return listSubmissionCandidates(formId, {
|
|
@@ -687,6 +742,7 @@ function createAzureTableStorage(options = {}) {
|
|
|
687
742
|
}
|
|
688
743
|
export {
|
|
689
744
|
createAzureTableStorage,
|
|
745
|
+
createAzureTableSubmissionCodec,
|
|
690
746
|
createLegacyArrayAzureTableCodec,
|
|
691
747
|
createLegacyAzureTableCodec,
|
|
692
748
|
defaultAzureTableSubmissionCodec,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@form-engine-ts/storage-azure-table",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.9.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"typescript"
|
|
40
40
|
],
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@form-engine-ts/core": "6.
|
|
42
|
+
"@form-engine-ts/core": "6.9.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"@azure/data-tables": "^13.3.2"
|