@form-engine-ts/storage-azure-table 6.6.0 → 6.8.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 +12 -4
- package/dist/index.cjs +89 -28
- package/dist/index.d.cts +48 -2
- package/dist/index.d.ts +48 -2
- package/dist/index.js +95 -29
- 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,11 @@ 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.
|
|
47
|
+
|
|
39
48
|
`listTextAnswerPage` accepts either the legacy single field ID or `TextAnswerPageQueryOptions.fieldIds`. Page size counts
|
|
40
49
|
emitted text items rather than entities. Its opaque Base64 JSON cursor retains the Azure continuation token plus entity
|
|
41
50
|
and field indexes, so a page can resume inside a multi-answer entity without gaps or duplicates. Empty answers do not
|
|
@@ -43,6 +52,5 @@ consume the item limit, and scanning remains bounded by `maxScanPages`. Cursor f
|
|
|
43
52
|
version, sorted fields, and a SHA-256 filter fingerprint. Reusing a cursor with different query context throws
|
|
44
53
|
`invalid_cursor_context`.
|
|
45
54
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
for migration tooling.
|
|
55
|
+
The legacy codec APIs are retained only as deprecated source symbols for existing applications; the standard factory does
|
|
56
|
+
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 = [];
|
|
@@ -469,7 +486,7 @@ function createAzureTableStorage(options = {}) {
|
|
|
469
486
|
(left, right) => left.submittedAt.localeCompare(right.submittedAt) || left.id.localeCompare(right.id)
|
|
470
487
|
);
|
|
471
488
|
};
|
|
472
|
-
|
|
489
|
+
const adapter = {
|
|
473
490
|
async saveSchema(schema) {
|
|
474
491
|
ensureWritable();
|
|
475
492
|
(0, import_core.assertValidFormSchema)(schema);
|
|
@@ -501,18 +518,44 @@ 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
|
+
if (options.validateSubmissions === true || saveOptions.validateAgainstSchema === true) {
|
|
525
|
+
const schema = parseSchemaEntity(
|
|
526
|
+
await (await schemaClient(stored.formId)).getEntity(stored.formId, schemaRowKey(stored.formVersion))
|
|
527
|
+
);
|
|
528
|
+
(0, import_core.assertValidFormSubmission)(schema, stored);
|
|
529
|
+
}
|
|
530
|
+
const payloadHash = await (0, import_core.hashFormSubmissionPayload)(stored);
|
|
507
531
|
const mappedEntity = mappedSubmissionEntity(codec.createEntity(stored), stored, options.fieldMapping, valueCodec);
|
|
508
532
|
const keys = physicalKeyNames(options.fieldMapping);
|
|
509
|
-
|
|
533
|
+
const partitionKey = codec.createPartitionKey(stored);
|
|
534
|
+
const rowKey = codec.createRowKey(stored);
|
|
535
|
+
const entity = {
|
|
510
536
|
...mappedEntity,
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
[keys.
|
|
515
|
-
|
|
537
|
+
payloadHash,
|
|
538
|
+
partitionKey,
|
|
539
|
+
rowKey,
|
|
540
|
+
[keys.partitionKey]: partitionKey,
|
|
541
|
+
[keys.rowKey]: rowKey
|
|
542
|
+
};
|
|
543
|
+
try {
|
|
544
|
+
await (await submissionClient(stored.formId)).createEntity(entity);
|
|
545
|
+
} catch (error) {
|
|
546
|
+
const idempotent = saveOptions.idempotent ?? options.idempotentSubmissions ?? options.idempotency ?? false;
|
|
547
|
+
if (!idempotent || !isDuplicateEntityError(error)) throw error;
|
|
548
|
+
const existingEntity = await (await submissionClient(stored.formId)).getEntity(partitionKey, rowKey);
|
|
549
|
+
const existing = parseSubmissionEntity(existingEntity, codec, options.fieldMapping, valueCodec);
|
|
550
|
+
const existingPayloadHash = typeof existingEntity.payloadHash === "string" ? existingEntity.payloadHash : await (0, import_core.hashFormSubmissionPayload)(existing);
|
|
551
|
+
if (existingPayloadHash === payloadHash) {
|
|
552
|
+
return { status: "duplicate", submission: existing, payloadHash };
|
|
553
|
+
}
|
|
554
|
+
return { status: "conflict", submissionId: stored.id, payloadHash, existingPayloadHash };
|
|
555
|
+
}
|
|
556
|
+
if (saveOptions.idempotent ?? options.idempotentSubmissions ?? options.idempotency ?? false) {
|
|
557
|
+
return { status: "created", submission: stored, payloadHash };
|
|
558
|
+
}
|
|
516
559
|
},
|
|
517
560
|
async listSubmissions(formId, formVersion, queryOptions = {}) {
|
|
518
561
|
return listSubmissionCandidates(formId, {
|
|
@@ -696,10 +739,28 @@ function createAzureTableStorage(options = {}) {
|
|
|
696
739
|
}
|
|
697
740
|
}
|
|
698
741
|
};
|
|
742
|
+
return {
|
|
743
|
+
...adapter,
|
|
744
|
+
async fetchPage(formId, options2) {
|
|
745
|
+
const page = await adapter.listSubmissionPage(formId, {
|
|
746
|
+
...options2?.pageSize === void 0 ? {} : { pageSize: options2.pageSize },
|
|
747
|
+
...options2?.fromSubmittedAt === void 0 ? {} : { since: options2.fromSubmittedAt },
|
|
748
|
+
...options2?.toSubmittedAt === void 0 ? {} : { until: options2.toSubmittedAt },
|
|
749
|
+
...options2?.locale === void 0 ? {} : { locale: options2.locale },
|
|
750
|
+
...options2?.cursor === void 0 ? {} : { cursor: options2.cursor },
|
|
751
|
+
...options2?.metadataFilters === void 0 ? {} : { metadataFilters: options2.metadataFilters }
|
|
752
|
+
});
|
|
753
|
+
return {
|
|
754
|
+
items: page.items,
|
|
755
|
+
...page.nextCursor === void 0 ? {} : { nextCursor: page.nextCursor }
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
};
|
|
699
759
|
}
|
|
700
760
|
// Annotate the CommonJS export names for ESM import in node:
|
|
701
761
|
0 && (module.exports = {
|
|
702
762
|
createAzureTableStorage,
|
|
763
|
+
createAzureTableSubmissionCodec,
|
|
703
764
|
createLegacyArrayAzureTableCodec,
|
|
704
765
|
createLegacyAzureTableCodec,
|
|
705
766
|
defaultAzureTableSubmissionCodec,
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FormSubmission,
|
|
1
|
+
import { FormSubmission, BaseSubmissionMetadata, PagedSubmissionStorageAdapter, SubmissionPageQueryOptions, StrictFormSubmission, SaveSubmissionOptions, SubmissionSaveResult, SubmissionFilter, JsonValue, TextAnswerPageQueryOptions, TypedTextAnswerPage } from '@form-engine-ts/core';
|
|
2
2
|
|
|
3
3
|
interface AzureTableListOptions {
|
|
4
4
|
readonly queryOptions?: {
|
|
@@ -104,7 +104,51 @@ 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;
|
|
107
115
|
}
|
|
116
|
+
interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSubmissionMetadata> extends PagedSubmissionStorageAdapter {
|
|
117
|
+
readonly fetchPage: (formId: string, options?: {
|
|
118
|
+
readonly pageSize?: number;
|
|
119
|
+
readonly fromSubmittedAt?: string;
|
|
120
|
+
readonly toSubmittedAt?: string;
|
|
121
|
+
readonly locale?: string;
|
|
122
|
+
readonly metadataFilters?: Partial<TMeta>;
|
|
123
|
+
readonly cursor?: string;
|
|
124
|
+
}) => Promise<{
|
|
125
|
+
readonly items: readonly FormSubmission<TMeta>[];
|
|
126
|
+
readonly nextCursor?: string;
|
|
127
|
+
}>;
|
|
128
|
+
}
|
|
129
|
+
type TypedAzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata | undefined = undefined> = Omit<PagedSubmissionStorageAdapter, "saveSubmission" | "listSubmissionPage" | "listTextAnswerPage"> & {
|
|
130
|
+
readonly saveSubmission: (submission: FormSubmission<TMeta>, options?: SaveSubmissionOptions) => Promise<undefined | SubmissionSaveResult<TMeta>>;
|
|
131
|
+
readonly listSubmissionPage: (formId: string, options?: SubmissionPageQueryOptions & {
|
|
132
|
+
readonly filter?: SubmissionFilter | ((submission: FormSubmission<TMeta>) => boolean);
|
|
133
|
+
readonly metadataFilters?: TMeta extends BaseSubmissionMetadata ? Partial<TMeta> : Readonly<Record<string, JsonValue>>;
|
|
134
|
+
}) => Promise<{
|
|
135
|
+
readonly items: readonly FormSubmission<TMeta>[];
|
|
136
|
+
readonly nextCursor?: string;
|
|
137
|
+
readonly hasMore: boolean;
|
|
138
|
+
}>;
|
|
139
|
+
readonly fetchPage: (formId: string, options?: {
|
|
140
|
+
readonly pageSize?: number;
|
|
141
|
+
readonly fromSubmittedAt?: string;
|
|
142
|
+
readonly toSubmittedAt?: string;
|
|
143
|
+
readonly locale?: string;
|
|
144
|
+
readonly metadataFilters?: TMeta extends BaseSubmissionMetadata ? Partial<TMeta> : Readonly<Record<string, JsonValue>>;
|
|
145
|
+
readonly cursor?: string;
|
|
146
|
+
}) => Promise<{
|
|
147
|
+
readonly items: readonly FormSubmission<TMeta>[];
|
|
148
|
+
readonly nextCursor?: string;
|
|
149
|
+
}>;
|
|
150
|
+
readonly listTextAnswerPage: (formId: string, fieldIdOrOptions?: string | TextAnswerPageQueryOptions, options?: TextAnswerPageQueryOptions) => Promise<TypedTextAnswerPage<TMeta>>;
|
|
151
|
+
};
|
|
108
152
|
interface AzureTextAnswerCursorPayload {
|
|
109
153
|
readonly formatVersion: 1;
|
|
110
154
|
readonly formId: string;
|
|
@@ -115,9 +159,11 @@ interface AzureTextAnswerCursorPayload {
|
|
|
115
159
|
readonly entityIndex: number;
|
|
116
160
|
readonly fieldIndex: number;
|
|
117
161
|
}
|
|
162
|
+
declare function createAzureTableSubmissionCodec<TMeta extends BaseSubmissionMetadata | undefined = undefined>(): AzureTableSubmissionCodec<FormSubmission<TMeta>>;
|
|
118
163
|
declare const defaultAzureTableSubmissionCodec: AzureTableSubmissionCodec<FormSubmission>;
|
|
119
164
|
declare function metadataFiltersToOData(options: SubmissionPageQueryOptions, mapping?: AzureTableFieldMapping): string;
|
|
120
165
|
declare function submissionFilterToOData(filter: SubmissionFilter, mapping?: AzureTableFieldMapping): string | undefined;
|
|
121
166
|
declare function createAzureTableStorage(options?: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
|
|
167
|
+
declare function createAzureTableStorage<TMeta extends BaseSubmissionMetadata | undefined = undefined>(options?: AzureTableStorageOptions): TypedAzureTableStorageAdapter<TMeta>;
|
|
122
168
|
|
|
123
|
-
export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableFieldMapping, type AzureTableLegacyCodec, type AzureTableLegacyEntity, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageOptions, type AzureTableSubmissionCodec, type AzureTableValueCodec, type AzureTextAnswerCursorPayload, type LegacyAnswerArrayEntity, type LegacyArrayAzureTableCodec, createAzureTableStorage, createLegacyArrayAzureTableCodec, createLegacyAzureTableCodec, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
|
|
169
|
+
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,
|
|
1
|
+
import { FormSubmission, BaseSubmissionMetadata, PagedSubmissionStorageAdapter, SubmissionPageQueryOptions, StrictFormSubmission, SaveSubmissionOptions, SubmissionSaveResult, SubmissionFilter, JsonValue, TextAnswerPageQueryOptions, TypedTextAnswerPage } from '@form-engine-ts/core';
|
|
2
2
|
|
|
3
3
|
interface AzureTableListOptions {
|
|
4
4
|
readonly queryOptions?: {
|
|
@@ -104,7 +104,51 @@ 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;
|
|
107
115
|
}
|
|
116
|
+
interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSubmissionMetadata> extends PagedSubmissionStorageAdapter {
|
|
117
|
+
readonly fetchPage: (formId: string, options?: {
|
|
118
|
+
readonly pageSize?: number;
|
|
119
|
+
readonly fromSubmittedAt?: string;
|
|
120
|
+
readonly toSubmittedAt?: string;
|
|
121
|
+
readonly locale?: string;
|
|
122
|
+
readonly metadataFilters?: Partial<TMeta>;
|
|
123
|
+
readonly cursor?: string;
|
|
124
|
+
}) => Promise<{
|
|
125
|
+
readonly items: readonly FormSubmission<TMeta>[];
|
|
126
|
+
readonly nextCursor?: string;
|
|
127
|
+
}>;
|
|
128
|
+
}
|
|
129
|
+
type TypedAzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata | undefined = undefined> = Omit<PagedSubmissionStorageAdapter, "saveSubmission" | "listSubmissionPage" | "listTextAnswerPage"> & {
|
|
130
|
+
readonly saveSubmission: (submission: FormSubmission<TMeta>, options?: SaveSubmissionOptions) => Promise<undefined | SubmissionSaveResult<TMeta>>;
|
|
131
|
+
readonly listSubmissionPage: (formId: string, options?: SubmissionPageQueryOptions & {
|
|
132
|
+
readonly filter?: SubmissionFilter | ((submission: FormSubmission<TMeta>) => boolean);
|
|
133
|
+
readonly metadataFilters?: TMeta extends BaseSubmissionMetadata ? Partial<TMeta> : Readonly<Record<string, JsonValue>>;
|
|
134
|
+
}) => Promise<{
|
|
135
|
+
readonly items: readonly FormSubmission<TMeta>[];
|
|
136
|
+
readonly nextCursor?: string;
|
|
137
|
+
readonly hasMore: boolean;
|
|
138
|
+
}>;
|
|
139
|
+
readonly fetchPage: (formId: string, options?: {
|
|
140
|
+
readonly pageSize?: number;
|
|
141
|
+
readonly fromSubmittedAt?: string;
|
|
142
|
+
readonly toSubmittedAt?: string;
|
|
143
|
+
readonly locale?: string;
|
|
144
|
+
readonly metadataFilters?: TMeta extends BaseSubmissionMetadata ? Partial<TMeta> : Readonly<Record<string, JsonValue>>;
|
|
145
|
+
readonly cursor?: string;
|
|
146
|
+
}) => Promise<{
|
|
147
|
+
readonly items: readonly FormSubmission<TMeta>[];
|
|
148
|
+
readonly nextCursor?: string;
|
|
149
|
+
}>;
|
|
150
|
+
readonly listTextAnswerPage: (formId: string, fieldIdOrOptions?: string | TextAnswerPageQueryOptions, options?: TextAnswerPageQueryOptions) => Promise<TypedTextAnswerPage<TMeta>>;
|
|
151
|
+
};
|
|
108
152
|
interface AzureTextAnswerCursorPayload {
|
|
109
153
|
readonly formatVersion: 1;
|
|
110
154
|
readonly formId: string;
|
|
@@ -115,9 +159,11 @@ interface AzureTextAnswerCursorPayload {
|
|
|
115
159
|
readonly entityIndex: number;
|
|
116
160
|
readonly fieldIndex: number;
|
|
117
161
|
}
|
|
162
|
+
declare function createAzureTableSubmissionCodec<TMeta extends BaseSubmissionMetadata | undefined = undefined>(): AzureTableSubmissionCodec<FormSubmission<TMeta>>;
|
|
118
163
|
declare const defaultAzureTableSubmissionCodec: AzureTableSubmissionCodec<FormSubmission>;
|
|
119
164
|
declare function metadataFiltersToOData(options: SubmissionPageQueryOptions, mapping?: AzureTableFieldMapping): string;
|
|
120
165
|
declare function submissionFilterToOData(filter: SubmissionFilter, mapping?: AzureTableFieldMapping): string | undefined;
|
|
121
166
|
declare function createAzureTableStorage(options?: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
|
|
167
|
+
declare function createAzureTableStorage<TMeta extends BaseSubmissionMetadata | undefined = undefined>(options?: AzureTableStorageOptions): TypedAzureTableStorageAdapter<TMeta>;
|
|
122
168
|
|
|
123
|
-
export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableFieldMapping, type AzureTableLegacyCodec, type AzureTableLegacyEntity, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageOptions, type AzureTableSubmissionCodec, type AzureTableValueCodec, type AzureTextAnswerCursorPayload, type LegacyAnswerArrayEntity, type LegacyArrayAzureTableCodec, createAzureTableStorage, createLegacyArrayAzureTableCodec, createLegacyAzureTableCodec, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
|
|
169
|
+
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,11 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
assertValidFormSchema,
|
|
4
|
+
assertValidFormSubmission,
|
|
5
|
+
hashFormSubmissionPayload,
|
|
6
|
+
matchesSubmissionPageFilters,
|
|
7
|
+
normalizeSubmissionPageSize
|
|
8
|
+
} from "@form-engine-ts/core";
|
|
3
9
|
function createLegacyArrayAzureTableCodec(options = {}) {
|
|
4
10
|
const defaultLocale = options.defaultLocale ?? "ja";
|
|
5
11
|
if (defaultLocale.trim().length === 0) throw new TypeError("defaultLocale must not be empty.");
|
|
@@ -263,22 +269,25 @@ function scalarMetadata(metadata) {
|
|
|
263
269
|
})
|
|
264
270
|
);
|
|
265
271
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
272
|
+
function createAzureTableSubmissionCodec() {
|
|
273
|
+
return {
|
|
274
|
+
createEntity: (submission) => ({
|
|
275
|
+
...scalarMetadata(submission.metadata),
|
|
276
|
+
kind: "submission",
|
|
277
|
+
formVersion: submission.formVersion,
|
|
278
|
+
locale: submission.locale,
|
|
279
|
+
submittedAt: submission.submittedAt,
|
|
280
|
+
responseId: submission.id,
|
|
281
|
+
payload: JSON.stringify(submission)
|
|
282
|
+
}),
|
|
283
|
+
deserialize: (entity) => parseSubmission(entity.payload, "submission entity"),
|
|
284
|
+
matchesEntity: (entity) => entity.kind === "submission",
|
|
285
|
+
createPartitionKey: (submission) => submission.formId,
|
|
286
|
+
createPartitionKeyFromQuery: (formId) => formId,
|
|
287
|
+
createRowKey: defaultSubmissionRowKey
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
var defaultAzureTableSubmissionCodec = createAzureTableSubmissionCodec();
|
|
282
291
|
function legacyCodec(codec) {
|
|
283
292
|
return {
|
|
284
293
|
createEntity: (value) => ({
|
|
@@ -373,13 +382,13 @@ function submissionFilterToOData(filter, mapping) {
|
|
|
373
382
|
...filter.to === void 0 ? [] : [`${property} le ${valueToOData(filter.to)}`]
|
|
374
383
|
].join(" and ");
|
|
375
384
|
}
|
|
376
|
-
function defaultSubmissionFilter(codec, formId, options, legacyExtension, mapping) {
|
|
385
|
+
function defaultSubmissionFilter(codec, formId, options, legacyExtension, mapping, rejectLegacyAnswers = false) {
|
|
377
386
|
const partitionKey = codec.createPartitionKeyFromQuery(formId, options);
|
|
378
387
|
const ast = options.filter === void 0 || typeof options.filter === "function" ? void 0 : submissionFilterToOData(options.filter, mapping);
|
|
379
388
|
const keys = physicalKeyNames(mapping);
|
|
380
389
|
return [
|
|
381
390
|
...partitionKey === void 0 ? [] : [`${keys.partitionKey} eq '${escapeOData(partitionKey)}'`],
|
|
382
|
-
...codec === defaultAzureTableSubmissionCodec ? ["kind eq 'submission'"] : [],
|
|
391
|
+
...codec === defaultAzureTableSubmissionCodec && !rejectLegacyAnswers ? ["kind eq 'submission'"] : [],
|
|
383
392
|
...options.version === void 0 ? [] : [`${propertyName(mapping, "formVersion", "formVersion")} eq ${options.version}`],
|
|
384
393
|
...options.since === void 0 ? [] : [`${propertyName(mapping, "submittedAt", "submittedAt")} ge '${escapeOData(options.since)}'`],
|
|
385
394
|
...options.until === void 0 ? [] : [`${propertyName(mapping, "submittedAt", "submittedAt")} le '${escapeOData(options.until)}'`],
|
|
@@ -391,6 +400,10 @@ function defaultSubmissionFilter(codec, formId, options, legacyExtension, mappin
|
|
|
391
400
|
function isNotFound(error) {
|
|
392
401
|
return isRecord(error) && (error.statusCode === 404 || error.code === "ResourceNotFound" || error.code === "EntityNotFound");
|
|
393
402
|
}
|
|
403
|
+
function isDuplicateEntityError(error) {
|
|
404
|
+
const message = error instanceof Error ? error.message : isRecord(error) ? String(error.message ?? "") : "";
|
|
405
|
+
return isRecord(error) && (error.statusCode === 409 || error.code === "EntityAlreadyExists") || /alreadyexists|duplicate/iu.test(message);
|
|
406
|
+
}
|
|
394
407
|
function matchesBuiltInFilters(submission, formId, options) {
|
|
395
408
|
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
409
|
}
|
|
@@ -421,13 +434,22 @@ function createAzureTableStorage(options = {}) {
|
|
|
421
434
|
formId,
|
|
422
435
|
query,
|
|
423
436
|
options.toODataFilter?.(query) ?? metadataFiltersToOData(query, options.fieldMapping),
|
|
424
|
-
options.fieldMapping
|
|
437
|
+
options.fieldMapping,
|
|
438
|
+
options.rejectLegacyAnswers
|
|
425
439
|
);
|
|
426
440
|
};
|
|
427
|
-
const deserializeIfMatching = (entity) => codec.matchesEntity(entity) ? parseSubmissionEntity(entity, codec, options.fieldMapping, valueCodec) : void 0;
|
|
428
441
|
const ensureWritable = () => {
|
|
429
442
|
if (options.readOnly === true) throw new Error("Azure Table storage is read-only.");
|
|
430
443
|
};
|
|
444
|
+
const rejectLegacyEntity = (entity) => {
|
|
445
|
+
if (options.rejectLegacyAnswers === true && Object.hasOwn(entity, "answers")) {
|
|
446
|
+
throw new Error("Azure Table legacy answers column is not supported by the standard submission storage.");
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
const deserializeIfMatching = (entity) => {
|
|
450
|
+
rejectLegacyEntity(entity);
|
|
451
|
+
return codec.matchesEntity(entity) ? parseSubmissionEntity(entity, codec, options.fieldMapping, valueCodec) : void 0;
|
|
452
|
+
};
|
|
431
453
|
const listSubmissionCandidates = async (formId, query) => {
|
|
432
454
|
const client = await submissionClient(formId, query);
|
|
433
455
|
const found = [];
|
|
@@ -440,7 +462,7 @@ function createAzureTableStorage(options = {}) {
|
|
|
440
462
|
(left, right) => left.submittedAt.localeCompare(right.submittedAt) || left.id.localeCompare(right.id)
|
|
441
463
|
);
|
|
442
464
|
};
|
|
443
|
-
|
|
465
|
+
const adapter = {
|
|
444
466
|
async saveSchema(schema) {
|
|
445
467
|
ensureWritable();
|
|
446
468
|
assertValidFormSchema(schema);
|
|
@@ -472,18 +494,44 @@ function createAzureTableStorage(options = {}) {
|
|
|
472
494
|
ensureWritable();
|
|
473
495
|
await (await schemaClient(formId)).deleteEntity(formId, schemaRowKey(formVersion));
|
|
474
496
|
},
|
|
475
|
-
async saveSubmission(submission) {
|
|
497
|
+
async saveSubmission(submission, saveOptions = {}) {
|
|
476
498
|
ensureWritable();
|
|
477
499
|
const stored = parseSubmission(submission, `input ${String(submission?.id)}`);
|
|
500
|
+
if (options.validateSubmissions === true || saveOptions.validateAgainstSchema === true) {
|
|
501
|
+
const schema = parseSchemaEntity(
|
|
502
|
+
await (await schemaClient(stored.formId)).getEntity(stored.formId, schemaRowKey(stored.formVersion))
|
|
503
|
+
);
|
|
504
|
+
assertValidFormSubmission(schema, stored);
|
|
505
|
+
}
|
|
506
|
+
const payloadHash = await hashFormSubmissionPayload(stored);
|
|
478
507
|
const mappedEntity = mappedSubmissionEntity(codec.createEntity(stored), stored, options.fieldMapping, valueCodec);
|
|
479
508
|
const keys = physicalKeyNames(options.fieldMapping);
|
|
480
|
-
|
|
509
|
+
const partitionKey = codec.createPartitionKey(stored);
|
|
510
|
+
const rowKey = codec.createRowKey(stored);
|
|
511
|
+
const entity = {
|
|
481
512
|
...mappedEntity,
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
[keys.
|
|
486
|
-
|
|
513
|
+
payloadHash,
|
|
514
|
+
partitionKey,
|
|
515
|
+
rowKey,
|
|
516
|
+
[keys.partitionKey]: partitionKey,
|
|
517
|
+
[keys.rowKey]: rowKey
|
|
518
|
+
};
|
|
519
|
+
try {
|
|
520
|
+
await (await submissionClient(stored.formId)).createEntity(entity);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
const idempotent = saveOptions.idempotent ?? options.idempotentSubmissions ?? options.idempotency ?? false;
|
|
523
|
+
if (!idempotent || !isDuplicateEntityError(error)) throw error;
|
|
524
|
+
const existingEntity = await (await submissionClient(stored.formId)).getEntity(partitionKey, rowKey);
|
|
525
|
+
const existing = parseSubmissionEntity(existingEntity, codec, options.fieldMapping, valueCodec);
|
|
526
|
+
const existingPayloadHash = typeof existingEntity.payloadHash === "string" ? existingEntity.payloadHash : await hashFormSubmissionPayload(existing);
|
|
527
|
+
if (existingPayloadHash === payloadHash) {
|
|
528
|
+
return { status: "duplicate", submission: existing, payloadHash };
|
|
529
|
+
}
|
|
530
|
+
return { status: "conflict", submissionId: stored.id, payloadHash, existingPayloadHash };
|
|
531
|
+
}
|
|
532
|
+
if (saveOptions.idempotent ?? options.idempotentSubmissions ?? options.idempotency ?? false) {
|
|
533
|
+
return { status: "created", submission: stored, payloadHash };
|
|
534
|
+
}
|
|
487
535
|
},
|
|
488
536
|
async listSubmissions(formId, formVersion, queryOptions = {}) {
|
|
489
537
|
return listSubmissionCandidates(formId, {
|
|
@@ -667,9 +715,27 @@ function createAzureTableStorage(options = {}) {
|
|
|
667
715
|
}
|
|
668
716
|
}
|
|
669
717
|
};
|
|
718
|
+
return {
|
|
719
|
+
...adapter,
|
|
720
|
+
async fetchPage(formId, options2) {
|
|
721
|
+
const page = await adapter.listSubmissionPage(formId, {
|
|
722
|
+
...options2?.pageSize === void 0 ? {} : { pageSize: options2.pageSize },
|
|
723
|
+
...options2?.fromSubmittedAt === void 0 ? {} : { since: options2.fromSubmittedAt },
|
|
724
|
+
...options2?.toSubmittedAt === void 0 ? {} : { until: options2.toSubmittedAt },
|
|
725
|
+
...options2?.locale === void 0 ? {} : { locale: options2.locale },
|
|
726
|
+
...options2?.cursor === void 0 ? {} : { cursor: options2.cursor },
|
|
727
|
+
...options2?.metadataFilters === void 0 ? {} : { metadataFilters: options2.metadataFilters }
|
|
728
|
+
});
|
|
729
|
+
return {
|
|
730
|
+
items: page.items,
|
|
731
|
+
...page.nextCursor === void 0 ? {} : { nextCursor: page.nextCursor }
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
};
|
|
670
735
|
}
|
|
671
736
|
export {
|
|
672
737
|
createAzureTableStorage,
|
|
738
|
+
createAzureTableSubmissionCodec,
|
|
673
739
|
createLegacyArrayAzureTableCodec,
|
|
674
740
|
createLegacyAzureTableCodec,
|
|
675
741
|
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.8.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.8.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"@azure/data-tables": "^13.3.2"
|