@form-engine-ts/storage-azure-table 2.5.1 → 2.6.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
@@ -15,12 +15,19 @@ pnpm add @form-engine-ts/core @form-engine-ts/storage-azure-table @azure/data-ta
15
15
  import { TableClient } from "@azure/data-tables";
16
16
  import { createAzureTableStorage } from "@form-engine-ts/storage-azure-table";
17
17
 
18
- const client = TableClient.fromConnectionString(process.env.AZURE_STORAGE_CONNECTION_STRING!, "forms");
19
- const storage = createAzureTableStorage({ client });
18
+ const schemasTableClient = TableClient.fromConnectionString(process.env.AZURE_STORAGE_CONNECTION_STRING!, "forms");
19
+ const submissionsTableClient = TableClient.fromConnectionString(
20
+ process.env.AZURE_STORAGE_CONNECTION_STRING!,
21
+ "responses"
22
+ );
23
+ const storage = createAzureTableStorage({ schemasTableClient, submissionsTableClient });
20
24
 
21
25
  const page = await storage.listSubmissionPage("contact", { pageSize: 500, locale: "ja" });
22
26
  ```
23
27
 
24
28
  Submission entities use `formId` as `PartitionKey` and `submittedAt_responseId` as `RowKey`. Built-in date, locale,
25
- version, and cursor constraints are sent as OData filters. Custom predicates and metadata filters are applied before page
26
- sizing. The caller owns table creation, credentials, retries, and the client lifecycle.
29
+ version, and cursor constraints are sent as OData filters. `listSubmissionPage` calls the Azure iterator's
30
+ `.byPage({ maxPageSize, continuationToken })` and consumes exactly one native page per request; the returned service token
31
+ remains opaque. Scalar metadata filters can be converted to OData with `metadataFiltersToOData` or a custom
32
+ `toODataFilter`. Supply `submissionCodec` for custom entity layouts. The deprecated single `client` option remains
33
+ available for compatibility. The caller owns table creation, credentials, retries, and the client lifecycle.
package/dist/index.cjs CHANGED
@@ -20,7 +20,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
- createAzureTableStorage: () => createAzureTableStorage
23
+ createAzureTableStorage: () => createAzureTableStorage,
24
+ defaultAzureTableSubmissionCodec: () => defaultAzureTableSubmissionCodec,
25
+ metadataFiltersToOData: () => metadataFiltersToOData
24
26
  });
25
27
  module.exports = __toCommonJS(index_exports);
26
28
  var import_core = require("@form-engine-ts/core");
@@ -42,127 +44,154 @@ function parseJson(value, location) {
42
44
  }
43
45
  }
44
46
  function parseSubmission(value, location) {
45
- const parsed = parseJson(value, location);
47
+ const parsed = typeof value === "string" ? parseJson(value, location) : value;
46
48
  if (!isRecord(parsed) || typeof parsed.id !== "string" || typeof parsed.formId !== "string" || !Number.isInteger(parsed.formVersion) || typeof parsed.locale !== "string" || typeof parsed.submittedAt !== "string" || !isRecord(parsed.values) || !Object.values(parsed.values).every(isFormValue)) {
47
49
  throw new Error(`Azure Table ${location} submission is invalid.`);
48
50
  }
49
51
  return cloneJson(parsed);
50
52
  }
51
- function parseEntity(value) {
52
- if (typeof value.partitionKey !== "string" || typeof value.rowKey !== "string" || value.kind !== "schema" && value.kind !== "submission" || !Number.isInteger(value.formVersion) || typeof value.payload !== "string") {
53
- throw new Error("Azure Table entity is invalid.");
54
- }
55
- return value;
53
+ function schemaRowKey(version) {
54
+ return `schema_${version}`;
55
+ }
56
+ function defaultSubmissionRowKey(submission) {
57
+ return `${submission.submittedAt}_${submission.id}`;
58
+ }
59
+ function scalarMetadata(metadata) {
60
+ if (metadata === void 0) return {};
61
+ return Object.fromEntries(
62
+ Object.entries(metadata).filter((entry) => {
63
+ const value = entry[1];
64
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
65
+ })
66
+ );
56
67
  }
68
+ var defaultAzureTableSubmissionCodec = {
69
+ createPartitionKey: (submission) => submission.formId,
70
+ createPartitionKeyFromFormId: (formId) => formId,
71
+ createRowKey: defaultSubmissionRowKey,
72
+ serialize: (submission) => ({ ...scalarMetadata(submission.metadata), payload: JSON.stringify(submission) }),
73
+ deserialize: (entity) => parseSubmission(entity.payload, "submission entity")
74
+ };
57
75
  function parseSchemaEntity(value) {
58
- const entity = parseEntity(value);
59
- const schema = parseJson(entity.payload, `schema ${entity.partitionKey}/${entity.rowKey}`);
76
+ if (typeof value.partitionKey !== "string" || typeof value.rowKey !== "string" || value.kind !== "schema" || !Number.isInteger(value.formVersion) || typeof value.payload !== "string") {
77
+ throw new Error("Azure Table schema entity is invalid.");
78
+ }
79
+ const schema = parseJson(value.payload, `schema ${value.partitionKey}/${value.rowKey}`);
60
80
  (0, import_core.assertValidFormSchema)(schema);
61
- if (entity.kind !== "schema" || schema.id !== entity.partitionKey || schema.version !== entity.formVersion) {
81
+ if (schema.id !== value.partitionKey || schema.version !== value.formVersion) {
62
82
  throw new Error("Azure Table schema entity has inconsistent metadata.");
63
83
  }
64
84
  return cloneJson(schema);
65
85
  }
66
- function parseSubmissionEntity(value) {
67
- const entity = parseEntity(value);
68
- const submission = parseSubmission(entity.payload, `submission ${entity.partitionKey}/${entity.rowKey}`);
69
- if (entity.kind !== "submission" || entity.partitionKey !== submission.formId || entity.rowKey !== submissionRowKey(submission) || entity.formVersion !== submission.formVersion || entity.locale !== submission.locale || entity.submittedAt !== submission.submittedAt || entity.responseId !== submission.id) {
86
+ function parseSubmissionEntity(value, codec) {
87
+ if (typeof value.partitionKey !== "string" || typeof value.rowKey !== "string" || value.kind !== "submission") {
88
+ throw new Error("Azure Table submission entity is invalid.");
89
+ }
90
+ const submission = parseSubmission(codec.deserialize(value), `submission ${value.partitionKey}/${value.rowKey}`);
91
+ if (codec.createPartitionKey(submission) !== value.partitionKey || codec.createRowKey(submission) !== value.rowKey || value.formVersion !== submission.formVersion || value.locale !== submission.locale || value.submittedAt !== submission.submittedAt || value.responseId !== submission.id) {
70
92
  throw new Error("Azure Table submission entity has inconsistent metadata.");
71
93
  }
72
94
  return submission;
73
95
  }
74
- function schemaRowKey(version) {
75
- return `schema_${version}`;
76
- }
77
- function submissionRowKey(submission) {
78
- return `${submission.submittedAt}_${submission.id}`;
79
- }
80
96
  function escapeOData(value) {
81
97
  return value.replaceAll("'", "''");
82
98
  }
83
- function odataFilter(formId, options = {}) {
84
- const filters = [
99
+ function metadataValueToOData(value) {
100
+ if (value === null) return "null";
101
+ if (typeof value === "string") return `'${escapeOData(value)}'`;
102
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
103
+ if (typeof value === "boolean") return String(value);
104
+ throw new TypeError("Azure Table metadata OData filters support only scalar JSON values.");
105
+ }
106
+ function metadataFiltersToOData(options) {
107
+ return Object.entries(options.metadataFilters ?? {}).map(([key, value]) => {
108
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new TypeError(`Invalid Azure Table property name: ${key}`);
109
+ return `${key} eq ${metadataValueToOData(value)}`;
110
+ }).join(" and ");
111
+ }
112
+ function odataFilter(formId, options, extension) {
113
+ return [
85
114
  ...formId === void 0 ? [] : [`PartitionKey eq '${escapeOData(formId)}'`],
86
115
  "kind eq 'submission'",
87
116
  ...options.version === void 0 ? [] : [`formVersion eq ${options.version}`],
88
117
  ...options.since === void 0 ? [] : [`submittedAt ge '${escapeOData(options.since)}'`],
89
118
  ...options.until === void 0 ? [] : [`submittedAt le '${escapeOData(options.until)}'`],
90
- ...options.locale === void 0 ? [] : [`locale eq '${escapeOData(options.locale)}'`]
91
- ];
92
- if (options.cursor !== void 0) {
93
- const cursor = (0, import_core.decodeSubmissionCursor)(options.cursor);
94
- filters.push(`RowKey gt '${escapeOData(`${cursor.submittedAt}_${cursor.responseId}`)}'`);
95
- }
96
- return filters.join(" and ");
119
+ ...options.locale === void 0 ? [] : [`locale eq '${escapeOData(options.locale)}'`],
120
+ ...extension.trim().length === 0 ? [] : [`(${extension})`]
121
+ ].join(" and ");
97
122
  }
98
123
  function isNotFound(error) {
99
124
  return isRecord(error) && (error.statusCode === 404 || error.code === "ResourceNotFound" || error.code === "EntityNotFound");
100
125
  }
101
126
  function matchesBuiltInFilters(submission, formId, options) {
102
- const cursor = options.cursor === void 0 ? void 0 : (0, import_core.decodeSubmissionCursor)(options.cursor);
103
- 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) && (cursor === void 0 || submission.submittedAt > cursor.submittedAt || submission.submittedAt === cursor.submittedAt && submission.id > cursor.responseId);
127
+ 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);
128
+ }
129
+ function resolveClient(preferred, fallback, name) {
130
+ const client = preferred ?? fallback;
131
+ if (client === void 0) throw new TypeError(`${name} is required.`);
132
+ return client;
104
133
  }
105
134
  function createAzureTableStorage(options) {
106
- if (options?.client === void 0) throw new TypeError("client is required.");
107
- const { client } = options;
135
+ const schemas = resolveClient(options?.schemasTableClient, options?.client, "schemasTableClient");
136
+ const submissions = resolveClient(options?.submissionsTableClient, options?.client, "submissionsTableClient");
137
+ const codec = options.submissionCodec ?? defaultAzureTableSubmissionCodec;
138
+ const toODataFilter = options.toODataFilter ?? metadataFiltersToOData;
139
+ const partitionKeyFromFormId = codec.createPartitionKeyFromFormId ?? ((formId) => formId);
140
+ const queryFilter = (formId, query) => odataFilter(formId === void 0 ? void 0 : partitionKeyFromFormId(formId), query, toODataFilter(query));
108
141
  const listSubmissionCandidates = async (formId, query) => {
109
- const submissions = [];
110
- for await (const raw of client.listEntities({ queryOptions: { filter: odataFilter(formId, query) } })) {
111
- const entity = parseEntity(raw);
112
- if (entity.kind !== "submission") continue;
113
- const submission = parseSubmissionEntity(entity);
142
+ const found = [];
143
+ for await (const raw of submissions.listEntities({ queryOptions: { filter: queryFilter(formId, query) } })) {
144
+ if (raw.kind !== "submission") continue;
145
+ const submission = parseSubmissionEntity(raw, codec);
114
146
  if (!matchesBuiltInFilters(submission, formId, query)) continue;
115
147
  if (!(0, import_core.matchesSubmissionPageFilters)(submission, query)) continue;
116
- submissions.push(submission);
148
+ found.push(submission);
117
149
  }
118
- return submissions.sort(
150
+ return found.sort(
119
151
  (left, right) => left.submittedAt.localeCompare(right.submittedAt) || left.id.localeCompare(right.id)
120
152
  );
121
153
  };
122
154
  return {
123
155
  async saveSchema(schema) {
124
156
  (0, import_core.assertValidFormSchema)(schema);
125
- await client.upsertEntity(
126
- {
127
- partitionKey: schema.id,
128
- rowKey: schemaRowKey(schema.version),
129
- kind: "schema",
130
- formVersion: schema.version,
131
- payload: JSON.stringify(schema)
132
- },
133
- "Replace"
134
- );
157
+ const entity = {
158
+ partitionKey: schema.id,
159
+ rowKey: schemaRowKey(schema.version),
160
+ kind: "schema",
161
+ formVersion: schema.version,
162
+ payload: JSON.stringify(schema)
163
+ };
164
+ await schemas.upsertEntity(entity, "Replace");
135
165
  },
136
166
  async getSchema(formId, formVersion) {
137
167
  try {
138
- return parseSchemaEntity(await client.getEntity(formId, schemaRowKey(formVersion)));
168
+ return parseSchemaEntity(await schemas.getEntity(formId, schemaRowKey(formVersion)));
139
169
  } catch (error) {
140
170
  if (isNotFound(error)) return null;
141
171
  throw error;
142
172
  }
143
173
  },
144
174
  async listSchemas() {
145
- const schemas = [];
146
- for await (const raw of client.listEntities({ queryOptions: { filter: "kind eq 'schema'" } })) {
147
- const entity = parseEntity(raw);
148
- if (entity.kind === "schema") schemas.push(parseSchemaEntity(entity));
175
+ const found = [];
176
+ for await (const raw of schemas.listEntities({ queryOptions: { filter: "kind eq 'schema'" } })) {
177
+ if (raw.kind === "schema") found.push(parseSchemaEntity(raw));
149
178
  }
150
- return schemas.sort((left, right) => left.id.localeCompare(right.id) || left.version - right.version);
179
+ return found.sort((left, right) => left.id.localeCompare(right.id) || left.version - right.version);
151
180
  },
152
181
  async deleteSchema(formId, formVersion) {
153
- await client.deleteEntity(formId, schemaRowKey(formVersion));
182
+ await schemas.deleteEntity(formId, schemaRowKey(formVersion));
154
183
  },
155
184
  async saveSubmission(submission) {
156
- const stored = parseSubmission(JSON.stringify(submission), `input ${String(submission?.id)}`);
157
- await client.createEntity({
158
- partitionKey: stored.formId,
159
- rowKey: submissionRowKey(stored),
185
+ const stored = parseSubmission(submission, `input ${String(submission?.id)}`);
186
+ await submissions.createEntity({
187
+ ...codec.serialize(stored),
188
+ partitionKey: codec.createPartitionKey(stored),
189
+ rowKey: codec.createRowKey(stored),
160
190
  kind: "submission",
161
191
  formVersion: stored.formVersion,
162
192
  locale: stored.locale,
163
193
  submittedAt: stored.submittedAt,
164
- responseId: stored.id,
165
- payload: JSON.stringify(stored)
194
+ responseId: stored.id
166
195
  });
167
196
  },
168
197
  async listSubmissions(formId, formVersion, queryOptions = {}) {
@@ -173,44 +202,58 @@ function createAzureTableStorage(options) {
173
202
  },
174
203
  async listSubmissionPage(formId, query = {}) {
175
204
  const pageSize = (0, import_core.normalizeSubmissionPageSize)(query.pageSize);
176
- const candidates = await listSubmissionCandidates(formId, query);
177
- const hasMore = candidates.length > pageSize;
178
- const items = candidates.slice(0, pageSize);
179
- const last = items.at(-1);
205
+ const iterator = submissions.listEntities({ queryOptions: { filter: queryFilter(formId, query) } }).byPage({ maxPageSize: pageSize, ...query.cursor === void 0 ? {} : { continuationToken: query.cursor } });
206
+ const result = await iterator.next();
207
+ if (result.done === true) return { items: [], hasMore: false };
208
+ const items = result.value.filter((raw) => raw.kind === "submission").map((raw) => parseSubmissionEntity(raw, codec)).filter(
209
+ (submission) => matchesBuiltInFilters(submission, formId, query) && (0, import_core.matchesSubmissionPageFilters)(submission, query)
210
+ );
211
+ const nextCursor = result.value.continuationToken;
180
212
  return {
181
213
  items,
182
- hasMore,
183
- ...hasMore && last !== void 0 ? { nextCursor: (0, import_core.encodeSubmissionCursor)({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
214
+ hasMore: nextCursor !== void 0 && nextCursor.length > 0,
215
+ ...nextCursor === void 0 || nextCursor.length === 0 ? {} : { nextCursor }
184
216
  };
185
217
  },
186
218
  async deleteSubmission(submissionId) {
187
- for await (const raw of client.listEntities({ queryOptions: { filter: "kind eq 'submission'" } })) {
188
- const entity = parseEntity(raw);
189
- if (entity.kind === "submission" && entity.responseId === submissionId) {
190
- await client.deleteEntity(entity.partitionKey, entity.rowKey);
219
+ for await (const raw of submissions.listEntities({ queryOptions: { filter: "kind eq 'submission'" } })) {
220
+ if (raw.kind === "submission" && raw.responseId === submissionId) {
221
+ if (typeof raw.partitionKey !== "string" || typeof raw.rowKey !== "string") {
222
+ throw new Error("Azure Table submission entity is invalid.");
223
+ }
224
+ await submissions.deleteEntity(raw.partitionKey, raw.rowKey);
191
225
  return;
192
226
  }
193
227
  }
194
228
  },
195
229
  async clearResponses(formId) {
196
- for await (const raw of client.listEntities({
197
- queryOptions: { filter: `PartitionKey eq '${escapeOData(formId)}' and kind eq 'submission'` }
230
+ const partitionKey = partitionKeyFromFormId(formId);
231
+ for await (const raw of submissions.listEntities({
232
+ queryOptions: { filter: `PartitionKey eq '${escapeOData(partitionKey)}' and kind eq 'submission'` }
198
233
  })) {
199
- const entity = parseEntity(raw);
200
- if (entity.partitionKey === formId && entity.kind === "submission") {
201
- await client.deleteEntity(entity.partitionKey, entity.rowKey);
234
+ if (raw.partitionKey === partitionKey && raw.kind === "submission" && typeof raw.rowKey === "string") {
235
+ await submissions.deleteEntity(partitionKey, raw.rowKey);
202
236
  }
203
237
  }
204
238
  },
205
239
  async clear() {
206
- for await (const raw of client.listEntities()) {
207
- const entity = parseEntity(raw);
208
- await client.deleteEntity(entity.partitionKey, entity.rowKey);
240
+ for await (const raw of schemas.listEntities()) {
241
+ if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
242
+ await schemas.deleteEntity(raw.partitionKey, raw.rowKey);
243
+ }
244
+ }
245
+ if (submissions === schemas) return;
246
+ for await (const raw of submissions.listEntities()) {
247
+ if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
248
+ await submissions.deleteEntity(raw.partitionKey, raw.rowKey);
249
+ }
209
250
  }
210
251
  }
211
252
  };
212
253
  }
213
254
  // Annotate the CommonJS export names for ESM import in node:
214
255
  0 && (module.exports = {
215
- createAzureTableStorage
256
+ createAzureTableStorage,
257
+ defaultAzureTableSubmissionCodec,
258
+ metadataFiltersToOData
216
259
  });
package/dist/index.d.cts CHANGED
@@ -1,20 +1,44 @@
1
- import { PagedSubmissionStorageAdapter } from '@form-engine-ts/core';
1
+ import { FormSubmission, SubmissionPageQueryOptions, PagedSubmissionStorageAdapter } from '@form-engine-ts/core';
2
2
 
3
3
  interface AzureTableListOptions {
4
4
  readonly queryOptions?: {
5
5
  readonly filter?: string;
6
6
  };
7
7
  }
8
+ interface AzureTablePageSettings {
9
+ readonly maxPageSize?: number;
10
+ readonly continuationToken?: string;
11
+ }
12
+ interface AzureTableEntityPage extends ReadonlyArray<Record<string, unknown>> {
13
+ readonly continuationToken?: string;
14
+ }
15
+ interface AzureTableEntityIterator extends AsyncIterable<Record<string, unknown>> {
16
+ byPage(settings?: AzureTablePageSettings): AsyncIterableIterator<AzureTableEntityPage>;
17
+ }
8
18
  interface AzureTableClientLike {
9
19
  createEntity(entity: Record<string, unknown>): Promise<unknown>;
10
20
  upsertEntity(entity: Record<string, unknown>, mode?: "Merge" | "Replace"): Promise<unknown>;
11
21
  getEntity(partitionKey: string, rowKey: string): Promise<Record<string, unknown>>;
12
- listEntities(options?: AzureTableListOptions): AsyncIterable<Record<string, unknown>>;
22
+ listEntities(options?: AzureTableListOptions): AzureTableEntityIterator;
13
23
  deleteEntity(partitionKey: string, rowKey: string): Promise<unknown>;
14
24
  }
25
+ interface AzureTableEntityCodec<T> {
26
+ readonly createPartitionKey: (submission: T) => string;
27
+ readonly createPartitionKeyFromFormId?: (formId: string) => string;
28
+ readonly createRowKey: (submission: T) => string;
29
+ readonly serialize: (submission: T) => Record<string, unknown>;
30
+ readonly deserialize: (entity: Record<string, unknown>) => T;
31
+ }
15
32
  interface AzureTableStorageOptions {
16
- readonly client: AzureTableClientLike;
33
+ /** @deprecated Use schemasTableClient and submissionsTableClient. */
34
+ readonly client?: AzureTableClientLike;
35
+ readonly schemasTableClient?: AzureTableClientLike;
36
+ readonly submissionsTableClient?: AzureTableClientLike;
37
+ readonly submissionCodec?: AzureTableEntityCodec<FormSubmission>;
38
+ readonly toODataFilter?: (options: SubmissionPageQueryOptions) => string;
17
39
  }
40
+ declare const defaultAzureTableSubmissionCodec: AzureTableEntityCodec<FormSubmission>;
41
+ declare function metadataFiltersToOData(options: SubmissionPageQueryOptions): string;
18
42
  declare function createAzureTableStorage(options: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
19
43
 
20
- export { type AzureTableClientLike, type AzureTableListOptions, type AzureTableStorageOptions, createAzureTableStorage };
44
+ export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageOptions, createAzureTableStorage, defaultAzureTableSubmissionCodec, metadataFiltersToOData };
package/dist/index.d.ts CHANGED
@@ -1,20 +1,44 @@
1
- import { PagedSubmissionStorageAdapter } from '@form-engine-ts/core';
1
+ import { FormSubmission, SubmissionPageQueryOptions, PagedSubmissionStorageAdapter } from '@form-engine-ts/core';
2
2
 
3
3
  interface AzureTableListOptions {
4
4
  readonly queryOptions?: {
5
5
  readonly filter?: string;
6
6
  };
7
7
  }
8
+ interface AzureTablePageSettings {
9
+ readonly maxPageSize?: number;
10
+ readonly continuationToken?: string;
11
+ }
12
+ interface AzureTableEntityPage extends ReadonlyArray<Record<string, unknown>> {
13
+ readonly continuationToken?: string;
14
+ }
15
+ interface AzureTableEntityIterator extends AsyncIterable<Record<string, unknown>> {
16
+ byPage(settings?: AzureTablePageSettings): AsyncIterableIterator<AzureTableEntityPage>;
17
+ }
8
18
  interface AzureTableClientLike {
9
19
  createEntity(entity: Record<string, unknown>): Promise<unknown>;
10
20
  upsertEntity(entity: Record<string, unknown>, mode?: "Merge" | "Replace"): Promise<unknown>;
11
21
  getEntity(partitionKey: string, rowKey: string): Promise<Record<string, unknown>>;
12
- listEntities(options?: AzureTableListOptions): AsyncIterable<Record<string, unknown>>;
22
+ listEntities(options?: AzureTableListOptions): AzureTableEntityIterator;
13
23
  deleteEntity(partitionKey: string, rowKey: string): Promise<unknown>;
14
24
  }
25
+ interface AzureTableEntityCodec<T> {
26
+ readonly createPartitionKey: (submission: T) => string;
27
+ readonly createPartitionKeyFromFormId?: (formId: string) => string;
28
+ readonly createRowKey: (submission: T) => string;
29
+ readonly serialize: (submission: T) => Record<string, unknown>;
30
+ readonly deserialize: (entity: Record<string, unknown>) => T;
31
+ }
15
32
  interface AzureTableStorageOptions {
16
- readonly client: AzureTableClientLike;
33
+ /** @deprecated Use schemasTableClient and submissionsTableClient. */
34
+ readonly client?: AzureTableClientLike;
35
+ readonly schemasTableClient?: AzureTableClientLike;
36
+ readonly submissionsTableClient?: AzureTableClientLike;
37
+ readonly submissionCodec?: AzureTableEntityCodec<FormSubmission>;
38
+ readonly toODataFilter?: (options: SubmissionPageQueryOptions) => string;
17
39
  }
40
+ declare const defaultAzureTableSubmissionCodec: AzureTableEntityCodec<FormSubmission>;
41
+ declare function metadataFiltersToOData(options: SubmissionPageQueryOptions): string;
18
42
  declare function createAzureTableStorage(options: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
19
43
 
20
- export { type AzureTableClientLike, type AzureTableListOptions, type AzureTableStorageOptions, createAzureTableStorage };
44
+ export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageOptions, createAzureTableStorage, defaultAzureTableSubmissionCodec, metadataFiltersToOData };
package/dist/index.js CHANGED
@@ -1,11 +1,5 @@
1
1
  // src/index.ts
2
- import {
3
- assertValidFormSchema,
4
- decodeSubmissionCursor,
5
- encodeSubmissionCursor,
6
- matchesSubmissionPageFilters,
7
- normalizeSubmissionPageSize
8
- } from "@form-engine-ts/core";
2
+ import { assertValidFormSchema, matchesSubmissionPageFilters, normalizeSubmissionPageSize } from "@form-engine-ts/core";
9
3
  function cloneJson(value) {
10
4
  return JSON.parse(JSON.stringify(value));
11
5
  }
@@ -24,127 +18,154 @@ function parseJson(value, location) {
24
18
  }
25
19
  }
26
20
  function parseSubmission(value, location) {
27
- const parsed = parseJson(value, location);
21
+ const parsed = typeof value === "string" ? parseJson(value, location) : value;
28
22
  if (!isRecord(parsed) || typeof parsed.id !== "string" || typeof parsed.formId !== "string" || !Number.isInteger(parsed.formVersion) || typeof parsed.locale !== "string" || typeof parsed.submittedAt !== "string" || !isRecord(parsed.values) || !Object.values(parsed.values).every(isFormValue)) {
29
23
  throw new Error(`Azure Table ${location} submission is invalid.`);
30
24
  }
31
25
  return cloneJson(parsed);
32
26
  }
33
- function parseEntity(value) {
34
- if (typeof value.partitionKey !== "string" || typeof value.rowKey !== "string" || value.kind !== "schema" && value.kind !== "submission" || !Number.isInteger(value.formVersion) || typeof value.payload !== "string") {
35
- throw new Error("Azure Table entity is invalid.");
36
- }
37
- return value;
27
+ function schemaRowKey(version) {
28
+ return `schema_${version}`;
29
+ }
30
+ function defaultSubmissionRowKey(submission) {
31
+ return `${submission.submittedAt}_${submission.id}`;
32
+ }
33
+ function scalarMetadata(metadata) {
34
+ if (metadata === void 0) return {};
35
+ return Object.fromEntries(
36
+ Object.entries(metadata).filter((entry) => {
37
+ const value = entry[1];
38
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
39
+ })
40
+ );
38
41
  }
42
+ var defaultAzureTableSubmissionCodec = {
43
+ createPartitionKey: (submission) => submission.formId,
44
+ createPartitionKeyFromFormId: (formId) => formId,
45
+ createRowKey: defaultSubmissionRowKey,
46
+ serialize: (submission) => ({ ...scalarMetadata(submission.metadata), payload: JSON.stringify(submission) }),
47
+ deserialize: (entity) => parseSubmission(entity.payload, "submission entity")
48
+ };
39
49
  function parseSchemaEntity(value) {
40
- const entity = parseEntity(value);
41
- const schema = parseJson(entity.payload, `schema ${entity.partitionKey}/${entity.rowKey}`);
50
+ if (typeof value.partitionKey !== "string" || typeof value.rowKey !== "string" || value.kind !== "schema" || !Number.isInteger(value.formVersion) || typeof value.payload !== "string") {
51
+ throw new Error("Azure Table schema entity is invalid.");
52
+ }
53
+ const schema = parseJson(value.payload, `schema ${value.partitionKey}/${value.rowKey}`);
42
54
  assertValidFormSchema(schema);
43
- if (entity.kind !== "schema" || schema.id !== entity.partitionKey || schema.version !== entity.formVersion) {
55
+ if (schema.id !== value.partitionKey || schema.version !== value.formVersion) {
44
56
  throw new Error("Azure Table schema entity has inconsistent metadata.");
45
57
  }
46
58
  return cloneJson(schema);
47
59
  }
48
- function parseSubmissionEntity(value) {
49
- const entity = parseEntity(value);
50
- const submission = parseSubmission(entity.payload, `submission ${entity.partitionKey}/${entity.rowKey}`);
51
- if (entity.kind !== "submission" || entity.partitionKey !== submission.formId || entity.rowKey !== submissionRowKey(submission) || entity.formVersion !== submission.formVersion || entity.locale !== submission.locale || entity.submittedAt !== submission.submittedAt || entity.responseId !== submission.id) {
60
+ function parseSubmissionEntity(value, codec) {
61
+ if (typeof value.partitionKey !== "string" || typeof value.rowKey !== "string" || value.kind !== "submission") {
62
+ throw new Error("Azure Table submission entity is invalid.");
63
+ }
64
+ const submission = parseSubmission(codec.deserialize(value), `submission ${value.partitionKey}/${value.rowKey}`);
65
+ if (codec.createPartitionKey(submission) !== value.partitionKey || codec.createRowKey(submission) !== value.rowKey || value.formVersion !== submission.formVersion || value.locale !== submission.locale || value.submittedAt !== submission.submittedAt || value.responseId !== submission.id) {
52
66
  throw new Error("Azure Table submission entity has inconsistent metadata.");
53
67
  }
54
68
  return submission;
55
69
  }
56
- function schemaRowKey(version) {
57
- return `schema_${version}`;
58
- }
59
- function submissionRowKey(submission) {
60
- return `${submission.submittedAt}_${submission.id}`;
61
- }
62
70
  function escapeOData(value) {
63
71
  return value.replaceAll("'", "''");
64
72
  }
65
- function odataFilter(formId, options = {}) {
66
- const filters = [
73
+ function metadataValueToOData(value) {
74
+ if (value === null) return "null";
75
+ if (typeof value === "string") return `'${escapeOData(value)}'`;
76
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
77
+ if (typeof value === "boolean") return String(value);
78
+ throw new TypeError("Azure Table metadata OData filters support only scalar JSON values.");
79
+ }
80
+ function metadataFiltersToOData(options) {
81
+ return Object.entries(options.metadataFilters ?? {}).map(([key, value]) => {
82
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new TypeError(`Invalid Azure Table property name: ${key}`);
83
+ return `${key} eq ${metadataValueToOData(value)}`;
84
+ }).join(" and ");
85
+ }
86
+ function odataFilter(formId, options, extension) {
87
+ return [
67
88
  ...formId === void 0 ? [] : [`PartitionKey eq '${escapeOData(formId)}'`],
68
89
  "kind eq 'submission'",
69
90
  ...options.version === void 0 ? [] : [`formVersion eq ${options.version}`],
70
91
  ...options.since === void 0 ? [] : [`submittedAt ge '${escapeOData(options.since)}'`],
71
92
  ...options.until === void 0 ? [] : [`submittedAt le '${escapeOData(options.until)}'`],
72
- ...options.locale === void 0 ? [] : [`locale eq '${escapeOData(options.locale)}'`]
73
- ];
74
- if (options.cursor !== void 0) {
75
- const cursor = decodeSubmissionCursor(options.cursor);
76
- filters.push(`RowKey gt '${escapeOData(`${cursor.submittedAt}_${cursor.responseId}`)}'`);
77
- }
78
- return filters.join(" and ");
93
+ ...options.locale === void 0 ? [] : [`locale eq '${escapeOData(options.locale)}'`],
94
+ ...extension.trim().length === 0 ? [] : [`(${extension})`]
95
+ ].join(" and ");
79
96
  }
80
97
  function isNotFound(error) {
81
98
  return isRecord(error) && (error.statusCode === 404 || error.code === "ResourceNotFound" || error.code === "EntityNotFound");
82
99
  }
83
100
  function matchesBuiltInFilters(submission, formId, options) {
84
- const cursor = options.cursor === void 0 ? void 0 : decodeSubmissionCursor(options.cursor);
85
- 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) && (cursor === void 0 || submission.submittedAt > cursor.submittedAt || submission.submittedAt === cursor.submittedAt && submission.id > cursor.responseId);
101
+ 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);
102
+ }
103
+ function resolveClient(preferred, fallback, name) {
104
+ const client = preferred ?? fallback;
105
+ if (client === void 0) throw new TypeError(`${name} is required.`);
106
+ return client;
86
107
  }
87
108
  function createAzureTableStorage(options) {
88
- if (options?.client === void 0) throw new TypeError("client is required.");
89
- const { client } = options;
109
+ const schemas = resolveClient(options?.schemasTableClient, options?.client, "schemasTableClient");
110
+ const submissions = resolveClient(options?.submissionsTableClient, options?.client, "submissionsTableClient");
111
+ const codec = options.submissionCodec ?? defaultAzureTableSubmissionCodec;
112
+ const toODataFilter = options.toODataFilter ?? metadataFiltersToOData;
113
+ const partitionKeyFromFormId = codec.createPartitionKeyFromFormId ?? ((formId) => formId);
114
+ const queryFilter = (formId, query) => odataFilter(formId === void 0 ? void 0 : partitionKeyFromFormId(formId), query, toODataFilter(query));
90
115
  const listSubmissionCandidates = async (formId, query) => {
91
- const submissions = [];
92
- for await (const raw of client.listEntities({ queryOptions: { filter: odataFilter(formId, query) } })) {
93
- const entity = parseEntity(raw);
94
- if (entity.kind !== "submission") continue;
95
- const submission = parseSubmissionEntity(entity);
116
+ const found = [];
117
+ for await (const raw of submissions.listEntities({ queryOptions: { filter: queryFilter(formId, query) } })) {
118
+ if (raw.kind !== "submission") continue;
119
+ const submission = parseSubmissionEntity(raw, codec);
96
120
  if (!matchesBuiltInFilters(submission, formId, query)) continue;
97
121
  if (!matchesSubmissionPageFilters(submission, query)) continue;
98
- submissions.push(submission);
122
+ found.push(submission);
99
123
  }
100
- return submissions.sort(
124
+ return found.sort(
101
125
  (left, right) => left.submittedAt.localeCompare(right.submittedAt) || left.id.localeCompare(right.id)
102
126
  );
103
127
  };
104
128
  return {
105
129
  async saveSchema(schema) {
106
130
  assertValidFormSchema(schema);
107
- await client.upsertEntity(
108
- {
109
- partitionKey: schema.id,
110
- rowKey: schemaRowKey(schema.version),
111
- kind: "schema",
112
- formVersion: schema.version,
113
- payload: JSON.stringify(schema)
114
- },
115
- "Replace"
116
- );
131
+ const entity = {
132
+ partitionKey: schema.id,
133
+ rowKey: schemaRowKey(schema.version),
134
+ kind: "schema",
135
+ formVersion: schema.version,
136
+ payload: JSON.stringify(schema)
137
+ };
138
+ await schemas.upsertEntity(entity, "Replace");
117
139
  },
118
140
  async getSchema(formId, formVersion) {
119
141
  try {
120
- return parseSchemaEntity(await client.getEntity(formId, schemaRowKey(formVersion)));
142
+ return parseSchemaEntity(await schemas.getEntity(formId, schemaRowKey(formVersion)));
121
143
  } catch (error) {
122
144
  if (isNotFound(error)) return null;
123
145
  throw error;
124
146
  }
125
147
  },
126
148
  async listSchemas() {
127
- const schemas = [];
128
- for await (const raw of client.listEntities({ queryOptions: { filter: "kind eq 'schema'" } })) {
129
- const entity = parseEntity(raw);
130
- if (entity.kind === "schema") schemas.push(parseSchemaEntity(entity));
149
+ const found = [];
150
+ for await (const raw of schemas.listEntities({ queryOptions: { filter: "kind eq 'schema'" } })) {
151
+ if (raw.kind === "schema") found.push(parseSchemaEntity(raw));
131
152
  }
132
- return schemas.sort((left, right) => left.id.localeCompare(right.id) || left.version - right.version);
153
+ return found.sort((left, right) => left.id.localeCompare(right.id) || left.version - right.version);
133
154
  },
134
155
  async deleteSchema(formId, formVersion) {
135
- await client.deleteEntity(formId, schemaRowKey(formVersion));
156
+ await schemas.deleteEntity(formId, schemaRowKey(formVersion));
136
157
  },
137
158
  async saveSubmission(submission) {
138
- const stored = parseSubmission(JSON.stringify(submission), `input ${String(submission?.id)}`);
139
- await client.createEntity({
140
- partitionKey: stored.formId,
141
- rowKey: submissionRowKey(stored),
159
+ const stored = parseSubmission(submission, `input ${String(submission?.id)}`);
160
+ await submissions.createEntity({
161
+ ...codec.serialize(stored),
162
+ partitionKey: codec.createPartitionKey(stored),
163
+ rowKey: codec.createRowKey(stored),
142
164
  kind: "submission",
143
165
  formVersion: stored.formVersion,
144
166
  locale: stored.locale,
145
167
  submittedAt: stored.submittedAt,
146
- responseId: stored.id,
147
- payload: JSON.stringify(stored)
168
+ responseId: stored.id
148
169
  });
149
170
  },
150
171
  async listSubmissions(formId, formVersion, queryOptions = {}) {
@@ -155,43 +176,57 @@ function createAzureTableStorage(options) {
155
176
  },
156
177
  async listSubmissionPage(formId, query = {}) {
157
178
  const pageSize = normalizeSubmissionPageSize(query.pageSize);
158
- const candidates = await listSubmissionCandidates(formId, query);
159
- const hasMore = candidates.length > pageSize;
160
- const items = candidates.slice(0, pageSize);
161
- const last = items.at(-1);
179
+ const iterator = submissions.listEntities({ queryOptions: { filter: queryFilter(formId, query) } }).byPage({ maxPageSize: pageSize, ...query.cursor === void 0 ? {} : { continuationToken: query.cursor } });
180
+ const result = await iterator.next();
181
+ if (result.done === true) return { items: [], hasMore: false };
182
+ const items = result.value.filter((raw) => raw.kind === "submission").map((raw) => parseSubmissionEntity(raw, codec)).filter(
183
+ (submission) => matchesBuiltInFilters(submission, formId, query) && matchesSubmissionPageFilters(submission, query)
184
+ );
185
+ const nextCursor = result.value.continuationToken;
162
186
  return {
163
187
  items,
164
- hasMore,
165
- ...hasMore && last !== void 0 ? { nextCursor: encodeSubmissionCursor({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
188
+ hasMore: nextCursor !== void 0 && nextCursor.length > 0,
189
+ ...nextCursor === void 0 || nextCursor.length === 0 ? {} : { nextCursor }
166
190
  };
167
191
  },
168
192
  async deleteSubmission(submissionId) {
169
- for await (const raw of client.listEntities({ queryOptions: { filter: "kind eq 'submission'" } })) {
170
- const entity = parseEntity(raw);
171
- if (entity.kind === "submission" && entity.responseId === submissionId) {
172
- await client.deleteEntity(entity.partitionKey, entity.rowKey);
193
+ for await (const raw of submissions.listEntities({ queryOptions: { filter: "kind eq 'submission'" } })) {
194
+ if (raw.kind === "submission" && raw.responseId === submissionId) {
195
+ if (typeof raw.partitionKey !== "string" || typeof raw.rowKey !== "string") {
196
+ throw new Error("Azure Table submission entity is invalid.");
197
+ }
198
+ await submissions.deleteEntity(raw.partitionKey, raw.rowKey);
173
199
  return;
174
200
  }
175
201
  }
176
202
  },
177
203
  async clearResponses(formId) {
178
- for await (const raw of client.listEntities({
179
- queryOptions: { filter: `PartitionKey eq '${escapeOData(formId)}' and kind eq 'submission'` }
204
+ const partitionKey = partitionKeyFromFormId(formId);
205
+ for await (const raw of submissions.listEntities({
206
+ queryOptions: { filter: `PartitionKey eq '${escapeOData(partitionKey)}' and kind eq 'submission'` }
180
207
  })) {
181
- const entity = parseEntity(raw);
182
- if (entity.partitionKey === formId && entity.kind === "submission") {
183
- await client.deleteEntity(entity.partitionKey, entity.rowKey);
208
+ if (raw.partitionKey === partitionKey && raw.kind === "submission" && typeof raw.rowKey === "string") {
209
+ await submissions.deleteEntity(partitionKey, raw.rowKey);
184
210
  }
185
211
  }
186
212
  },
187
213
  async clear() {
188
- for await (const raw of client.listEntities()) {
189
- const entity = parseEntity(raw);
190
- await client.deleteEntity(entity.partitionKey, entity.rowKey);
214
+ for await (const raw of schemas.listEntities()) {
215
+ if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
216
+ await schemas.deleteEntity(raw.partitionKey, raw.rowKey);
217
+ }
218
+ }
219
+ if (submissions === schemas) return;
220
+ for await (const raw of submissions.listEntities()) {
221
+ if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
222
+ await submissions.deleteEntity(raw.partitionKey, raw.rowKey);
223
+ }
191
224
  }
192
225
  }
193
226
  };
194
227
  }
195
228
  export {
196
- createAzureTableStorage
229
+ createAzureTableStorage,
230
+ defaultAzureTableSubmissionCodec,
231
+ metadataFiltersToOData
197
232
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/storage-azure-table",
3
- "version": "2.5.1",
3
+ "version": "2.6.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": "2.5.1"
42
+ "@form-engine-ts/core": "2.6.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@azure/data-tables": "^13.3.2"