@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 CHANGED
@@ -20,7 +20,11 @@ const submissionsTableClient = TableClient.fromConnectionString(
20
20
  process.env.AZURE_STORAGE_CONNECTION_STRING!,
21
21
  "responses"
22
22
  );
23
- const storage = createAzureTableStorage({ schemasTableClient, submissionsTableClient });
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
- `createLegacyAzureTableCodec()` decodes older entities using `PartitionKey`, `RowKey`, `answers`, `answeredAt`, and
47
- `surveyVersion` into the canonical `FormSubmission` shape. Custom partition- and row-key generators can be supplied
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
- var defaultAzureTableSubmissionCodec = {
296
- createEntity: (submission) => ({
297
- ...scalarMetadata(submission.metadata),
298
- kind: "submission",
299
- formVersion: submission.formVersion,
300
- locale: submission.locale,
301
- submittedAt: submission.submittedAt,
302
- responseId: submission.id,
303
- payload: JSON.stringify(submission)
304
- }),
305
- deserialize: (entity) => parseSubmission(entity.payload, "submission entity"),
306
- matchesEntity: (entity) => entity.kind === "submission",
307
- createPartitionKey: (submission) => submission.formId,
308
- createPartitionKeyFromQuery: (formId) => formId,
309
- createRowKey: defaultSubmissionRowKey
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
- await (await submissionClient(stored.formId)).createEntity({
539
+ const partitionKey = codec.createPartitionKey(stored);
540
+ const rowKey = codec.createRowKey(stored);
541
+ const entity = {
510
542
  ...mappedEntity,
511
- partitionKey: codec.createPartitionKey(stored),
512
- rowKey: codec.createRowKey(stored),
513
- [keys.partitionKey]: codec.createPartitionKey(stored),
514
- [keys.rowKey]: codec.createRowKey(stored)
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 { assertValidFormSchema, matchesSubmissionPageFilters, normalizeSubmissionPageSize } from "@form-engine-ts/core";
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
- var defaultAzureTableSubmissionCodec = {
267
- createEntity: (submission) => ({
268
- ...scalarMetadata(submission.metadata),
269
- kind: "submission",
270
- formVersion: submission.formVersion,
271
- locale: submission.locale,
272
- submittedAt: submission.submittedAt,
273
- responseId: submission.id,
274
- payload: JSON.stringify(submission)
275
- }),
276
- deserialize: (entity) => parseSubmission(entity.payload, "submission entity"),
277
- matchesEntity: (entity) => entity.kind === "submission",
278
- createPartitionKey: (submission) => submission.formId,
279
- createPartitionKeyFromQuery: (formId) => formId,
280
- createRowKey: defaultSubmissionRowKey
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
- await (await submissionClient(stored.formId)).createEntity({
516
+ const partitionKey = codec.createPartitionKey(stored);
517
+ const rowKey = codec.createRowKey(stored);
518
+ const entity = {
481
519
  ...mappedEntity,
482
- partitionKey: codec.createPartitionKey(stored),
483
- rowKey: codec.createRowKey(stored),
484
- [keys.partitionKey]: codec.createPartitionKey(stored),
485
- [keys.rowKey]: codec.createRowKey(stored)
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.7.0",
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.7.0"
42
+ "@form-engine-ts/core": "6.9.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@azure/data-tables": "^13.3.2"