@form-engine-ts/storage-azure-table 6.7.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 +71 -27
- package/dist/index.d.cts +35 -2
- package/dist/index.d.ts +35 -2
- package/dist/index.js +77 -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,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 = [];
|
|
@@ -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, {
|
|
@@ -717,6 +760,7 @@ function createAzureTableStorage(options = {}) {
|
|
|
717
760
|
// Annotate the CommonJS export names for ESM import in node:
|
|
718
761
|
0 && (module.exports = {
|
|
719
762
|
createAzureTableStorage,
|
|
763
|
+
createAzureTableSubmissionCodec,
|
|
720
764
|
createLegacyArrayAzureTableCodec,
|
|
721
765
|
createLegacyAzureTableCodec,
|
|
722
766
|
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, 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,14 @@ 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
|
}
|
|
108
116
|
interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSubmissionMetadata> extends PagedSubmissionStorageAdapter {
|
|
109
117
|
readonly fetchPage: (formId: string, options?: {
|
|
@@ -118,6 +126,29 @@ interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSu
|
|
|
118
126
|
readonly nextCursor?: string;
|
|
119
127
|
}>;
|
|
120
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
|
+
};
|
|
121
152
|
interface AzureTextAnswerCursorPayload {
|
|
122
153
|
readonly formatVersion: 1;
|
|
123
154
|
readonly formId: string;
|
|
@@ -128,9 +159,11 @@ interface AzureTextAnswerCursorPayload {
|
|
|
128
159
|
readonly entityIndex: number;
|
|
129
160
|
readonly fieldIndex: number;
|
|
130
161
|
}
|
|
162
|
+
declare function createAzureTableSubmissionCodec<TMeta extends BaseSubmissionMetadata | undefined = undefined>(): AzureTableSubmissionCodec<FormSubmission<TMeta>>;
|
|
131
163
|
declare const defaultAzureTableSubmissionCodec: AzureTableSubmissionCodec<FormSubmission>;
|
|
132
164
|
declare function metadataFiltersToOData(options: SubmissionPageQueryOptions, mapping?: AzureTableFieldMapping): string;
|
|
133
165
|
declare function submissionFilterToOData(filter: SubmissionFilter, mapping?: AzureTableFieldMapping): string | undefined;
|
|
134
166
|
declare function createAzureTableStorage(options?: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
|
|
167
|
+
declare function createAzureTableStorage<TMeta extends BaseSubmissionMetadata | undefined = undefined>(options?: AzureTableStorageOptions): TypedAzureTableStorageAdapter<TMeta>;
|
|
135
168
|
|
|
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 };
|
|
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, BaseSubmissionMetadata, PagedSubmissionStorageAdapter, SubmissionPageQueryOptions, StrictFormSubmission, SubmissionFilter } from '@form-engine-ts/core';
|
|
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,6 +104,14 @@ 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
|
}
|
|
108
116
|
interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSubmissionMetadata> extends PagedSubmissionStorageAdapter {
|
|
109
117
|
readonly fetchPage: (formId: string, options?: {
|
|
@@ -118,6 +126,29 @@ interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSu
|
|
|
118
126
|
readonly nextCursor?: string;
|
|
119
127
|
}>;
|
|
120
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
|
+
};
|
|
121
152
|
interface AzureTextAnswerCursorPayload {
|
|
122
153
|
readonly formatVersion: 1;
|
|
123
154
|
readonly formId: string;
|
|
@@ -128,9 +159,11 @@ interface AzureTextAnswerCursorPayload {
|
|
|
128
159
|
readonly entityIndex: number;
|
|
129
160
|
readonly fieldIndex: number;
|
|
130
161
|
}
|
|
162
|
+
declare function createAzureTableSubmissionCodec<TMeta extends BaseSubmissionMetadata | undefined = undefined>(): AzureTableSubmissionCodec<FormSubmission<TMeta>>;
|
|
131
163
|
declare const defaultAzureTableSubmissionCodec: AzureTableSubmissionCodec<FormSubmission>;
|
|
132
164
|
declare function metadataFiltersToOData(options: SubmissionPageQueryOptions, mapping?: AzureTableFieldMapping): string;
|
|
133
165
|
declare function submissionFilterToOData(filter: SubmissionFilter, mapping?: AzureTableFieldMapping): string | undefined;
|
|
134
166
|
declare function createAzureTableStorage(options?: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
|
|
167
|
+
declare function createAzureTableStorage<TMeta extends BaseSubmissionMetadata | undefined = undefined>(options?: AzureTableStorageOptions): TypedAzureTableStorageAdapter<TMeta>;
|
|
135
168
|
|
|
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 };
|
|
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 = [];
|
|
@@ -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, {
|
|
@@ -687,6 +735,7 @@ function createAzureTableStorage(options = {}) {
|
|
|
687
735
|
}
|
|
688
736
|
export {
|
|
689
737
|
createAzureTableStorage,
|
|
738
|
+
createAzureTableSubmissionCodec,
|
|
690
739
|
createLegacyArrayAzureTableCodec,
|
|
691
740
|
createLegacyAzureTableCodec,
|
|
692
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"
|