@form-engine-ts/storage-azure-table 6.6.0 → 6.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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 = [];
@@ -469,7 +486,7 @@ function createAzureTableStorage(options = {}) {
469
486
  (left, right) => left.submittedAt.localeCompare(right.submittedAt) || left.id.localeCompare(right.id)
470
487
  );
471
488
  };
472
- return {
489
+ const adapter = {
473
490
  async saveSchema(schema) {
474
491
  ensureWritable();
475
492
  (0, import_core.assertValidFormSchema)(schema);
@@ -501,18 +518,44 @@ function createAzureTableStorage(options = {}) {
501
518
  ensureWritable();
502
519
  await (await schemaClient(formId)).deleteEntity(formId, schemaRowKey(formVersion));
503
520
  },
504
- async saveSubmission(submission) {
521
+ async saveSubmission(submission, saveOptions = {}) {
505
522
  ensureWritable();
506
523
  const stored = parseSubmission(submission, `input ${String(submission?.id)}`);
524
+ if (options.validateSubmissions === true || saveOptions.validateAgainstSchema === true) {
525
+ const schema = parseSchemaEntity(
526
+ await (await schemaClient(stored.formId)).getEntity(stored.formId, schemaRowKey(stored.formVersion))
527
+ );
528
+ (0, import_core.assertValidFormSubmission)(schema, stored);
529
+ }
530
+ const payloadHash = await (0, import_core.hashFormSubmissionPayload)(stored);
507
531
  const mappedEntity = mappedSubmissionEntity(codec.createEntity(stored), stored, options.fieldMapping, valueCodec);
508
532
  const keys = physicalKeyNames(options.fieldMapping);
509
- 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, {
@@ -696,10 +739,28 @@ function createAzureTableStorage(options = {}) {
696
739
  }
697
740
  }
698
741
  };
742
+ return {
743
+ ...adapter,
744
+ async fetchPage(formId, options2) {
745
+ const page = await adapter.listSubmissionPage(formId, {
746
+ ...options2?.pageSize === void 0 ? {} : { pageSize: options2.pageSize },
747
+ ...options2?.fromSubmittedAt === void 0 ? {} : { since: options2.fromSubmittedAt },
748
+ ...options2?.toSubmittedAt === void 0 ? {} : { until: options2.toSubmittedAt },
749
+ ...options2?.locale === void 0 ? {} : { locale: options2.locale },
750
+ ...options2?.cursor === void 0 ? {} : { cursor: options2.cursor },
751
+ ...options2?.metadataFilters === void 0 ? {} : { metadataFilters: options2.metadataFilters }
752
+ });
753
+ return {
754
+ items: page.items,
755
+ ...page.nextCursor === void 0 ? {} : { nextCursor: page.nextCursor }
756
+ };
757
+ }
758
+ };
699
759
  }
700
760
  // Annotate the CommonJS export names for ESM import in node:
701
761
  0 && (module.exports = {
702
762
  createAzureTableStorage,
763
+ createAzureTableSubmissionCodec,
703
764
  createLegacyArrayAzureTableCodec,
704
765
  createLegacyAzureTableCodec,
705
766
  defaultAzureTableSubmissionCodec,
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { FormSubmission, SubmissionPageQueryOptions, BaseSubmissionMetadata, StrictFormSubmission, PagedSubmissionStorageAdapter, 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,7 +104,51 @@ interface AzureTableStorageOptions<T = FormSubmission> {
104
104
  readonly maxScanPages?: number;
105
105
  readonly fieldMapping?: AzureTableFieldMapping;
106
106
  readonly readOnly?: boolean;
107
+ /** Detect legacy entities that expose an `answers` column and fail explicitly. */
108
+ readonly rejectLegacyAnswers?: boolean;
109
+ /** Enable typed duplicate/conflict results for submission IDs. */
110
+ readonly idempotentSubmissions?: boolean;
111
+ /** Alias for idempotentSubmissions. */
112
+ readonly idempotency?: boolean;
113
+ /** Re-validate a submission against the stored FormSchema before saving. */
114
+ readonly validateSubmissions?: boolean;
107
115
  }
116
+ interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSubmissionMetadata> extends PagedSubmissionStorageAdapter {
117
+ readonly fetchPage: (formId: string, options?: {
118
+ readonly pageSize?: number;
119
+ readonly fromSubmittedAt?: string;
120
+ readonly toSubmittedAt?: string;
121
+ readonly locale?: string;
122
+ readonly metadataFilters?: Partial<TMeta>;
123
+ readonly cursor?: string;
124
+ }) => Promise<{
125
+ readonly items: readonly FormSubmission<TMeta>[];
126
+ readonly nextCursor?: string;
127
+ }>;
128
+ }
129
+ type TypedAzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata | undefined = undefined> = Omit<PagedSubmissionStorageAdapter, "saveSubmission" | "listSubmissionPage" | "listTextAnswerPage"> & {
130
+ readonly saveSubmission: (submission: FormSubmission<TMeta>, options?: SaveSubmissionOptions) => Promise<undefined | SubmissionSaveResult<TMeta>>;
131
+ readonly listSubmissionPage: (formId: string, options?: SubmissionPageQueryOptions & {
132
+ readonly filter?: SubmissionFilter | ((submission: FormSubmission<TMeta>) => boolean);
133
+ readonly metadataFilters?: TMeta extends BaseSubmissionMetadata ? Partial<TMeta> : Readonly<Record<string, JsonValue>>;
134
+ }) => Promise<{
135
+ readonly items: readonly FormSubmission<TMeta>[];
136
+ readonly nextCursor?: string;
137
+ readonly hasMore: boolean;
138
+ }>;
139
+ readonly fetchPage: (formId: string, options?: {
140
+ readonly pageSize?: number;
141
+ readonly fromSubmittedAt?: string;
142
+ readonly toSubmittedAt?: string;
143
+ readonly locale?: string;
144
+ readonly metadataFilters?: TMeta extends BaseSubmissionMetadata ? Partial<TMeta> : Readonly<Record<string, JsonValue>>;
145
+ readonly cursor?: string;
146
+ }) => Promise<{
147
+ readonly items: readonly FormSubmission<TMeta>[];
148
+ readonly nextCursor?: string;
149
+ }>;
150
+ readonly listTextAnswerPage: (formId: string, fieldIdOrOptions?: string | TextAnswerPageQueryOptions, options?: TextAnswerPageQueryOptions) => Promise<TypedTextAnswerPage<TMeta>>;
151
+ };
108
152
  interface AzureTextAnswerCursorPayload {
109
153
  readonly formatVersion: 1;
110
154
  readonly formId: string;
@@ -115,9 +159,11 @@ interface AzureTextAnswerCursorPayload {
115
159
  readonly entityIndex: number;
116
160
  readonly fieldIndex: number;
117
161
  }
162
+ declare function createAzureTableSubmissionCodec<TMeta extends BaseSubmissionMetadata | undefined = undefined>(): AzureTableSubmissionCodec<FormSubmission<TMeta>>;
118
163
  declare const defaultAzureTableSubmissionCodec: AzureTableSubmissionCodec<FormSubmission>;
119
164
  declare function metadataFiltersToOData(options: SubmissionPageQueryOptions, mapping?: AzureTableFieldMapping): string;
120
165
  declare function submissionFilterToOData(filter: SubmissionFilter, mapping?: AzureTableFieldMapping): string | undefined;
121
166
  declare function createAzureTableStorage(options?: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
167
+ declare function createAzureTableStorage<TMeta extends BaseSubmissionMetadata | undefined = undefined>(options?: AzureTableStorageOptions): TypedAzureTableStorageAdapter<TMeta>;
122
168
 
123
- export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableFieldMapping, type AzureTableLegacyCodec, type AzureTableLegacyEntity, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageOptions, type AzureTableSubmissionCodec, type AzureTableValueCodec, type AzureTextAnswerCursorPayload, type LegacyAnswerArrayEntity, type LegacyArrayAzureTableCodec, createAzureTableStorage, createLegacyArrayAzureTableCodec, createLegacyAzureTableCodec, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
169
+ export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableFieldMapping, type AzureTableLegacyCodec, type AzureTableLegacyEntity, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageAdapter, type AzureTableStorageOptions, type AzureTableSubmissionCodec, type AzureTableValueCodec, type AzureTextAnswerCursorPayload, type LegacyAnswerArrayEntity, type LegacyArrayAzureTableCodec, type TypedAzureTableStorageAdapter, createAzureTableStorage, createAzureTableSubmissionCodec, createLegacyArrayAzureTableCodec, createLegacyAzureTableCodec, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { FormSubmission, SubmissionPageQueryOptions, BaseSubmissionMetadata, StrictFormSubmission, PagedSubmissionStorageAdapter, 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,7 +104,51 @@ interface AzureTableStorageOptions<T = FormSubmission> {
104
104
  readonly maxScanPages?: number;
105
105
  readonly fieldMapping?: AzureTableFieldMapping;
106
106
  readonly readOnly?: boolean;
107
+ /** Detect legacy entities that expose an `answers` column and fail explicitly. */
108
+ readonly rejectLegacyAnswers?: boolean;
109
+ /** Enable typed duplicate/conflict results for submission IDs. */
110
+ readonly idempotentSubmissions?: boolean;
111
+ /** Alias for idempotentSubmissions. */
112
+ readonly idempotency?: boolean;
113
+ /** Re-validate a submission against the stored FormSchema before saving. */
114
+ readonly validateSubmissions?: boolean;
107
115
  }
116
+ interface AzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata = BaseSubmissionMetadata> extends PagedSubmissionStorageAdapter {
117
+ readonly fetchPage: (formId: string, options?: {
118
+ readonly pageSize?: number;
119
+ readonly fromSubmittedAt?: string;
120
+ readonly toSubmittedAt?: string;
121
+ readonly locale?: string;
122
+ readonly metadataFilters?: Partial<TMeta>;
123
+ readonly cursor?: string;
124
+ }) => Promise<{
125
+ readonly items: readonly FormSubmission<TMeta>[];
126
+ readonly nextCursor?: string;
127
+ }>;
128
+ }
129
+ type TypedAzureTableStorageAdapter<TMeta extends BaseSubmissionMetadata | undefined = undefined> = Omit<PagedSubmissionStorageAdapter, "saveSubmission" | "listSubmissionPage" | "listTextAnswerPage"> & {
130
+ readonly saveSubmission: (submission: FormSubmission<TMeta>, options?: SaveSubmissionOptions) => Promise<undefined | SubmissionSaveResult<TMeta>>;
131
+ readonly listSubmissionPage: (formId: string, options?: SubmissionPageQueryOptions & {
132
+ readonly filter?: SubmissionFilter | ((submission: FormSubmission<TMeta>) => boolean);
133
+ readonly metadataFilters?: TMeta extends BaseSubmissionMetadata ? Partial<TMeta> : Readonly<Record<string, JsonValue>>;
134
+ }) => Promise<{
135
+ readonly items: readonly FormSubmission<TMeta>[];
136
+ readonly nextCursor?: string;
137
+ readonly hasMore: boolean;
138
+ }>;
139
+ readonly fetchPage: (formId: string, options?: {
140
+ readonly pageSize?: number;
141
+ readonly fromSubmittedAt?: string;
142
+ readonly toSubmittedAt?: string;
143
+ readonly locale?: string;
144
+ readonly metadataFilters?: TMeta extends BaseSubmissionMetadata ? Partial<TMeta> : Readonly<Record<string, JsonValue>>;
145
+ readonly cursor?: string;
146
+ }) => Promise<{
147
+ readonly items: readonly FormSubmission<TMeta>[];
148
+ readonly nextCursor?: string;
149
+ }>;
150
+ readonly listTextAnswerPage: (formId: string, fieldIdOrOptions?: string | TextAnswerPageQueryOptions, options?: TextAnswerPageQueryOptions) => Promise<TypedTextAnswerPage<TMeta>>;
151
+ };
108
152
  interface AzureTextAnswerCursorPayload {
109
153
  readonly formatVersion: 1;
110
154
  readonly formId: string;
@@ -115,9 +159,11 @@ interface AzureTextAnswerCursorPayload {
115
159
  readonly entityIndex: number;
116
160
  readonly fieldIndex: number;
117
161
  }
162
+ declare function createAzureTableSubmissionCodec<TMeta extends BaseSubmissionMetadata | undefined = undefined>(): AzureTableSubmissionCodec<FormSubmission<TMeta>>;
118
163
  declare const defaultAzureTableSubmissionCodec: AzureTableSubmissionCodec<FormSubmission>;
119
164
  declare function metadataFiltersToOData(options: SubmissionPageQueryOptions, mapping?: AzureTableFieldMapping): string;
120
165
  declare function submissionFilterToOData(filter: SubmissionFilter, mapping?: AzureTableFieldMapping): string | undefined;
121
166
  declare function createAzureTableStorage(options?: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
167
+ declare function createAzureTableStorage<TMeta extends BaseSubmissionMetadata | undefined = undefined>(options?: AzureTableStorageOptions): TypedAzureTableStorageAdapter<TMeta>;
122
168
 
123
- export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableFieldMapping, type AzureTableLegacyCodec, type AzureTableLegacyEntity, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageOptions, type AzureTableSubmissionCodec, type AzureTableValueCodec, type AzureTextAnswerCursorPayload, type LegacyAnswerArrayEntity, type LegacyArrayAzureTableCodec, createAzureTableStorage, createLegacyArrayAzureTableCodec, createLegacyAzureTableCodec, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
169
+ export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableFieldMapping, type AzureTableLegacyCodec, type AzureTableLegacyEntity, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageAdapter, type AzureTableStorageOptions, type AzureTableSubmissionCodec, type AzureTableValueCodec, type AzureTextAnswerCursorPayload, type LegacyAnswerArrayEntity, type LegacyArrayAzureTableCodec, type TypedAzureTableStorageAdapter, createAzureTableStorage, createAzureTableSubmissionCodec, createLegacyArrayAzureTableCodec, createLegacyAzureTableCodec, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
package/dist/index.js CHANGED
@@ -1,5 +1,11 @@
1
1
  // src/index.ts
2
- import { 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 = [];
@@ -440,7 +462,7 @@ function createAzureTableStorage(options = {}) {
440
462
  (left, right) => left.submittedAt.localeCompare(right.submittedAt) || left.id.localeCompare(right.id)
441
463
  );
442
464
  };
443
- return {
465
+ const adapter = {
444
466
  async saveSchema(schema) {
445
467
  ensureWritable();
446
468
  assertValidFormSchema(schema);
@@ -472,18 +494,44 @@ function createAzureTableStorage(options = {}) {
472
494
  ensureWritable();
473
495
  await (await schemaClient(formId)).deleteEntity(formId, schemaRowKey(formVersion));
474
496
  },
475
- async saveSubmission(submission) {
497
+ async saveSubmission(submission, saveOptions = {}) {
476
498
  ensureWritable();
477
499
  const stored = parseSubmission(submission, `input ${String(submission?.id)}`);
500
+ if (options.validateSubmissions === true || saveOptions.validateAgainstSchema === true) {
501
+ const schema = parseSchemaEntity(
502
+ await (await schemaClient(stored.formId)).getEntity(stored.formId, schemaRowKey(stored.formVersion))
503
+ );
504
+ assertValidFormSubmission(schema, stored);
505
+ }
506
+ const payloadHash = await hashFormSubmissionPayload(stored);
478
507
  const mappedEntity = mappedSubmissionEntity(codec.createEntity(stored), stored, options.fieldMapping, valueCodec);
479
508
  const keys = physicalKeyNames(options.fieldMapping);
480
- 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, {
@@ -667,9 +715,27 @@ function createAzureTableStorage(options = {}) {
667
715
  }
668
716
  }
669
717
  };
718
+ return {
719
+ ...adapter,
720
+ async fetchPage(formId, options2) {
721
+ const page = await adapter.listSubmissionPage(formId, {
722
+ ...options2?.pageSize === void 0 ? {} : { pageSize: options2.pageSize },
723
+ ...options2?.fromSubmittedAt === void 0 ? {} : { since: options2.fromSubmittedAt },
724
+ ...options2?.toSubmittedAt === void 0 ? {} : { until: options2.toSubmittedAt },
725
+ ...options2?.locale === void 0 ? {} : { locale: options2.locale },
726
+ ...options2?.cursor === void 0 ? {} : { cursor: options2.cursor },
727
+ ...options2?.metadataFilters === void 0 ? {} : { metadataFilters: options2.metadataFilters }
728
+ });
729
+ return {
730
+ items: page.items,
731
+ ...page.nextCursor === void 0 ? {} : { nextCursor: page.nextCursor }
732
+ };
733
+ }
734
+ };
670
735
  }
671
736
  export {
672
737
  createAzureTableStorage,
738
+ createAzureTableSubmissionCodec,
673
739
  createLegacyArrayAzureTableCodec,
674
740
  createLegacyAzureTableCodec,
675
741
  defaultAzureTableSubmissionCodec,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/storage-azure-table",
3
- "version": "6.6.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.6.0"
42
+ "@form-engine-ts/core": "6.8.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@azure/data-tables": "^13.3.2"