@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 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,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
- `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.
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
- 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,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
- await (await submissionClient(stored.formId)).createEntity({
533
+ const partitionKey = codec.createPartitionKey(stored);
534
+ const rowKey = codec.createRowKey(stored);
535
+ const entity = {
510
536
  ...mappedEntity,
511
- partitionKey: codec.createPartitionKey(stored),
512
- rowKey: codec.createRowKey(stored),
513
- [keys.partitionKey]: codec.createPartitionKey(stored),
514
- [keys.rowKey]: codec.createRowKey(stored)
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 { assertValidFormSchema, matchesSubmissionPageFilters, normalizeSubmissionPageSize } from "@form-engine-ts/core";
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
- 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
- };
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
- await (await submissionClient(stored.formId)).createEntity({
509
+ const partitionKey = codec.createPartitionKey(stored);
510
+ const rowKey = codec.createRowKey(stored);
511
+ const entity = {
481
512
  ...mappedEntity,
482
- partitionKey: codec.createPartitionKey(stored),
483
- rowKey: codec.createRowKey(stored),
484
- [keys.partitionKey]: codec.createPartitionKey(stored),
485
- [keys.rowKey]: codec.createRowKey(stored)
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.7.0",
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.7.0"
42
+ "@form-engine-ts/core": "6.8.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@azure/data-tables": "^13.3.2"