@form-engine-ts/storage-azure-table 2.5.1 → 2.7.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,23 @@ 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
- 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.
28
+ The default codec uses `formId` as `PartitionKey` and `submittedAt_responseId` as `RowKey`. Supply an
29
+ `AzureTableSubmissionCodec` to define a completely different entity layout, key strategy, deserializer, and client-side
30
+ matcher; custom entities are not polluted with default discriminator fields. `clientResolver` can select a table client
31
+ per form and operation. Date, locale, version, cursor, metadata, and supported filter-AST constraints are sent as OData;
32
+ unsupported expressions remain client-filtered with identical semantics.
33
+
34
+ `listSubmissionPage` follows opaque Azure continuation tokens and scans at most `maxScanPages` native pages (default 5)
35
+ to fill the requested logical page after client-side filtering. `buildSubmissionFilter` can replace filter generation.
36
+ The deprecated `client`, `submissionCodec`, and `toODataFilter` options remain available for compatibility. The caller
37
+ owns table creation, credentials, retries, and client lifecycle.
package/dist/index.cjs CHANGED
@@ -20,7 +20,10 @@ 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,
26
+ submissionFilterToOData: () => submissionFilterToOData
24
27
  });
25
28
  module.exports = __toCommonJS(index_exports);
26
29
  var import_core = require("@form-engine-ts/core");
@@ -42,127 +45,223 @@ function parseJson(value, location) {
42
45
  }
43
46
  }
44
47
  function parseSubmission(value, location) {
45
- const parsed = parseJson(value, location);
48
+ const parsed = typeof value === "string" ? parseJson(value, location) : value;
46
49
  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
50
  throw new Error(`Azure Table ${location} submission is invalid.`);
48
51
  }
49
52
  return cloneJson(parsed);
50
53
  }
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;
54
+ function schemaRowKey(version) {
55
+ return `schema_${version}`;
56
+ }
57
+ function defaultSubmissionRowKey(submission) {
58
+ return `${submission.submittedAt}_${submission.id}`;
59
+ }
60
+ function scalarMetadata(metadata) {
61
+ if (metadata === void 0) return {};
62
+ return Object.fromEntries(
63
+ Object.entries(metadata).filter((entry) => {
64
+ const value = entry[1];
65
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
66
+ })
67
+ );
68
+ }
69
+ var defaultAzureTableSubmissionCodec = {
70
+ createEntity: (submission) => ({
71
+ ...scalarMetadata(submission.metadata),
72
+ kind: "submission",
73
+ formVersion: submission.formVersion,
74
+ locale: submission.locale,
75
+ submittedAt: submission.submittedAt,
76
+ responseId: submission.id,
77
+ payload: JSON.stringify(submission)
78
+ }),
79
+ deserialize: (entity) => parseSubmission(entity.payload, "submission entity"),
80
+ matchesEntity: (entity) => entity.kind === "submission",
81
+ createPartitionKey: (submission) => submission.formId,
82
+ createPartitionKeyFromQuery: (formId) => formId,
83
+ createRowKey: defaultSubmissionRowKey
84
+ };
85
+ function legacyCodec(codec) {
86
+ return {
87
+ createEntity: (value) => ({
88
+ ...codec.serialize(value),
89
+ kind: "submission",
90
+ formVersion: value.formVersion,
91
+ locale: value.locale,
92
+ submittedAt: value.submittedAt,
93
+ responseId: value.id
94
+ }),
95
+ deserialize: codec.deserialize,
96
+ matchesEntity: (entity) => entity.kind === "submission",
97
+ createPartitionKey: codec.createPartitionKey,
98
+ createPartitionKeyFromQuery: (formId) => codec.createPartitionKeyFromFormId?.(formId) ?? formId,
99
+ createRowKey: codec.createRowKey
100
+ };
56
101
  }
57
102
  function parseSchemaEntity(value) {
58
- const entity = parseEntity(value);
59
- const schema = parseJson(entity.payload, `schema ${entity.partitionKey}/${entity.rowKey}`);
103
+ if (typeof value.partitionKey !== "string" || typeof value.rowKey !== "string" || value.kind !== "schema" || !Number.isInteger(value.formVersion) || typeof value.payload !== "string") {
104
+ throw new Error("Azure Table schema entity is invalid.");
105
+ }
106
+ const schema = parseJson(value.payload, `schema ${value.partitionKey}/${value.rowKey}`);
60
107
  (0, import_core.assertValidFormSchema)(schema);
61
- if (entity.kind !== "schema" || schema.id !== entity.partitionKey || schema.version !== entity.formVersion) {
108
+ if (schema.id !== value.partitionKey || schema.version !== value.formVersion) {
62
109
  throw new Error("Azure Table schema entity has inconsistent metadata.");
63
110
  }
64
111
  return cloneJson(schema);
65
112
  }
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) {
70
- throw new Error("Azure Table submission entity has inconsistent metadata.");
113
+ function parseSubmissionEntity(value, codec) {
114
+ if (typeof value.partitionKey !== "string" || typeof value.rowKey !== "string" || !codec.matchesEntity(value)) {
115
+ throw new Error("Azure Table submission entity is invalid.");
116
+ }
117
+ const submission = parseSubmission(codec.deserialize(value), `submission ${value.partitionKey}/${value.rowKey}`);
118
+ if (codec.createPartitionKey(submission) !== value.partitionKey || codec.createRowKey(submission) !== value.rowKey) {
119
+ throw new Error("Azure Table submission entity has inconsistent keys.");
71
120
  }
72
121
  return submission;
73
122
  }
74
- function schemaRowKey(version) {
75
- return `schema_${version}`;
76
- }
77
- function submissionRowKey(submission) {
78
- return `${submission.submittedAt}_${submission.id}`;
79
- }
80
123
  function escapeOData(value) {
81
124
  return value.replaceAll("'", "''");
82
125
  }
83
- function odataFilter(formId, options = {}) {
84
- const filters = [
85
- ...formId === void 0 ? [] : [`PartitionKey eq '${escapeOData(formId)}'`],
86
- "kind eq 'submission'",
126
+ function valueToOData(value) {
127
+ if (value === null) return "null";
128
+ if (typeof value === "string") return `'${escapeOData(value)}'`;
129
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
130
+ if (typeof value === "boolean") return String(value);
131
+ throw new TypeError("Azure Table OData filters support only scalar JSON values.");
132
+ }
133
+ function metadataFiltersToOData(options) {
134
+ return Object.entries(options.metadataFilters ?? {}).map(([key, value]) => {
135
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new TypeError(`Invalid Azure Table property name: ${key}`);
136
+ return `${key} eq ${valueToOData(value)}`;
137
+ }).join(" and ");
138
+ }
139
+ function odataProperty(path) {
140
+ if (path === "id" || path === "responseId") return "responseId";
141
+ if (["formVersion", "locale", "submittedAt"].includes(path)) return path;
142
+ if (path.startsWith("metadata.")) {
143
+ const property = path.slice("metadata.".length);
144
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(property) ? property : void 0;
145
+ }
146
+ return void 0;
147
+ }
148
+ function submissionFilterToOData(filter) {
149
+ if (filter.op === "and" || filter.op === "or") {
150
+ const converted = filter.filters.map(submissionFilterToOData);
151
+ if (filter.op === "or" && converted.some((value) => value === void 0)) return void 0;
152
+ const available = converted.filter((value) => value !== void 0 && value.length > 0);
153
+ if (available.length === 0) return void 0;
154
+ return available.map((value) => `(${value})`).join(filter.op === "and" ? " and " : " or ");
155
+ }
156
+ const property = odataProperty(filter.path);
157
+ if (property === void 0) return void 0;
158
+ if (filter.op === "eq") return `${property} eq ${valueToOData(filter.value)}`;
159
+ if (filter.op === "in") {
160
+ if (filter.values.length === 0) return "false";
161
+ return filter.values.map((value) => `${property} eq ${valueToOData(value)}`).join(" or ");
162
+ }
163
+ if (filter.op === "exists") return `${property} ${filter.value ? "ne" : "eq"} null`;
164
+ return [
165
+ ...filter.from === void 0 ? [] : [`${property} ge ${valueToOData(filter.from)}`],
166
+ ...filter.to === void 0 ? [] : [`${property} le ${valueToOData(filter.to)}`]
167
+ ].join(" and ");
168
+ }
169
+ function defaultSubmissionFilter(codec, formId, options, legacyExtension) {
170
+ const partitionKey = codec.createPartitionKeyFromQuery(formId, options);
171
+ const ast = options.filter === void 0 || typeof options.filter === "function" ? void 0 : submissionFilterToOData(options.filter);
172
+ return [
173
+ ...partitionKey === void 0 ? [] : [`PartitionKey eq '${escapeOData(partitionKey)}'`],
174
+ ...codec === defaultAzureTableSubmissionCodec ? ["kind eq 'submission'"] : [],
87
175
  ...options.version === void 0 ? [] : [`formVersion eq ${options.version}`],
88
176
  ...options.since === void 0 ? [] : [`submittedAt ge '${escapeOData(options.since)}'`],
89
177
  ...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 ");
178
+ ...options.locale === void 0 ? [] : [`locale eq '${escapeOData(options.locale)}'`],
179
+ ...ast === void 0 || ast.length === 0 ? [] : [`(${ast})`],
180
+ ...legacyExtension.trim().length === 0 ? [] : [`(${legacyExtension})`]
181
+ ].join(" and ");
97
182
  }
98
183
  function isNotFound(error) {
99
184
  return isRecord(error) && (error.statusCode === 404 || error.code === "ResourceNotFound" || error.code === "EntityNotFound");
100
185
  }
101
186
  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);
187
+ 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);
188
+ }
189
+ function requireClient(client, name) {
190
+ if (client === void 0) throw new TypeError(`${name} is required.`);
191
+ return client;
104
192
  }
105
- function createAzureTableStorage(options) {
106
- if (options?.client === void 0) throw new TypeError("client is required.");
107
- const { client } = options;
193
+ function createAzureTableStorage(options = {}) {
194
+ const staticSchemas = options.schemasTableClient ?? options.client;
195
+ const staticSubmissions = options.submissionsTableClient ?? options.client;
196
+ const codec = options.codec ?? (options.submissionCodec === void 0 ? defaultAzureTableSubmissionCodec : legacyCodec(options.submissionCodec));
197
+ const maxScanPages = options.maxScanPages ?? 5;
198
+ if (!Number.isSafeInteger(maxScanPages) || maxScanPages < 1) {
199
+ throw new TypeError("maxScanPages must be a positive safe integer.");
200
+ }
201
+ const resolveDynamicClient = async (formId, query) => options.clientResolver?.({ formId, ...query === void 0 ? {} : { query } });
202
+ const schemaClient = async (formId) => requireClient(staticSchemas ?? await resolveDynamicClient(formId), "schemasTableClient or clientResolver");
203
+ const submissionClient = async (formId, query) => requireClient(
204
+ await resolveDynamicClient(formId, query) ?? staticSubmissions,
205
+ "submissionsTableClient or clientResolver"
206
+ );
207
+ const queryFilter = (formId, query) => {
208
+ if (options.buildSubmissionFilter !== void 0) return options.buildSubmissionFilter(formId, query);
209
+ return defaultSubmissionFilter(
210
+ codec,
211
+ formId,
212
+ query,
213
+ options.toODataFilter?.(query) ?? metadataFiltersToOData(query)
214
+ );
215
+ };
216
+ const deserializeIfMatching = (entity) => codec.matchesEntity(entity) ? parseSubmissionEntity(entity, codec) : void 0;
108
217
  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);
114
- if (!matchesBuiltInFilters(submission, formId, query)) continue;
115
- if (!(0, import_core.matchesSubmissionPageFilters)(submission, query)) continue;
116
- submissions.push(submission);
218
+ const client = await submissionClient(formId, query);
219
+ const found = [];
220
+ for await (const raw of client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } })) {
221
+ const submission = deserializeIfMatching(raw);
222
+ if (submission === void 0 || !matchesBuiltInFilters(submission, formId, query)) continue;
223
+ if ((0, import_core.matchesSubmissionPageFilters)(submission, query)) found.push(submission);
117
224
  }
118
- return submissions.sort(
225
+ return found.sort(
119
226
  (left, right) => left.submittedAt.localeCompare(right.submittedAt) || left.id.localeCompare(right.id)
120
227
  );
121
228
  };
122
229
  return {
123
230
  async saveSchema(schema) {
124
231
  (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
- );
232
+ const entity = {
233
+ partitionKey: schema.id,
234
+ rowKey: schemaRowKey(schema.version),
235
+ kind: "schema",
236
+ formVersion: schema.version,
237
+ payload: JSON.stringify(schema)
238
+ };
239
+ await (await schemaClient(schema.id)).upsertEntity(entity, "Replace");
135
240
  },
136
241
  async getSchema(formId, formVersion) {
137
242
  try {
138
- return parseSchemaEntity(await client.getEntity(formId, schemaRowKey(formVersion)));
243
+ return parseSchemaEntity(await (await schemaClient(formId)).getEntity(formId, schemaRowKey(formVersion)));
139
244
  } catch (error) {
140
245
  if (isNotFound(error)) return null;
141
246
  throw error;
142
247
  }
143
248
  },
144
249
  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));
250
+ const found = [];
251
+ for await (const raw of (await schemaClient("")).listEntities({ queryOptions: { filter: "kind eq 'schema'" } })) {
252
+ if (raw.kind === "schema") found.push(parseSchemaEntity(raw));
149
253
  }
150
- return schemas.sort((left, right) => left.id.localeCompare(right.id) || left.version - right.version);
254
+ return found.sort((left, right) => left.id.localeCompare(right.id) || left.version - right.version);
151
255
  },
152
256
  async deleteSchema(formId, formVersion) {
153
- await client.deleteEntity(formId, schemaRowKey(formVersion));
257
+ await (await schemaClient(formId)).deleteEntity(formId, schemaRowKey(formVersion));
154
258
  },
155
259
  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),
160
- kind: "submission",
161
- formVersion: stored.formVersion,
162
- locale: stored.locale,
163
- submittedAt: stored.submittedAt,
164
- responseId: stored.id,
165
- payload: JSON.stringify(stored)
260
+ const stored = parseSubmission(submission, `input ${String(submission?.id)}`);
261
+ await (await submissionClient(stored.formId)).createEntity({
262
+ ...codec.createEntity(stored),
263
+ partitionKey: codec.createPartitionKey(stored),
264
+ rowKey: codec.createRowKey(stored)
166
265
  });
167
266
  },
168
267
  async listSubmissions(formId, formVersion, queryOptions = {}) {
@@ -173,44 +272,78 @@ function createAzureTableStorage(options) {
173
272
  },
174
273
  async listSubmissionPage(formId, query = {}) {
175
274
  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);
275
+ const client = await submissionClient(formId, query);
276
+ const items = [];
277
+ let continuationToken = query.cursor;
278
+ let scannedPages = 0;
279
+ do {
280
+ const iterator = client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } }).byPage({
281
+ maxPageSize: Math.max(1, pageSize - items.length),
282
+ ...continuationToken === void 0 ? {} : { continuationToken }
283
+ });
284
+ const result = await iterator.next();
285
+ if (result.done === true) {
286
+ continuationToken = void 0;
287
+ break;
288
+ }
289
+ scannedPages += 1;
290
+ for (const raw of result.value) {
291
+ const submission = deserializeIfMatching(raw);
292
+ if (submission === void 0 || !matchesBuiltInFilters(submission, formId, query)) continue;
293
+ if ((0, import_core.matchesSubmissionPageFilters)(submission, query)) items.push(submission);
294
+ }
295
+ continuationToken = result.value.continuationToken;
296
+ } while (items.length < pageSize && continuationToken !== void 0 && scannedPages < maxScanPages);
180
297
  return {
181
298
  items,
182
- hasMore,
183
- ...hasMore && last !== void 0 ? { nextCursor: (0, import_core.encodeSubmissionCursor)({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
299
+ hasMore: continuationToken !== void 0 && continuationToken.length > 0,
300
+ ...continuationToken === void 0 || continuationToken.length === 0 ? {} : { nextCursor: continuationToken }
184
301
  };
185
302
  },
186
303
  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);
191
- return;
304
+ const client = await submissionClient("");
305
+ for await (const raw of client.listEntities()) {
306
+ const submission = deserializeIfMatching(raw);
307
+ if (submission?.id !== submissionId) continue;
308
+ if (typeof raw.partitionKey !== "string" || typeof raw.rowKey !== "string") {
309
+ throw new Error("Azure Table submission entity is invalid.");
192
310
  }
311
+ await client.deleteEntity(raw.partitionKey, raw.rowKey);
312
+ return;
193
313
  }
194
314
  },
195
315
  async clearResponses(formId) {
196
- for await (const raw of client.listEntities({
197
- queryOptions: { filter: `PartitionKey eq '${escapeOData(formId)}' and kind eq 'submission'` }
198
- })) {
199
- const entity = parseEntity(raw);
200
- if (entity.partitionKey === formId && entity.kind === "submission") {
201
- await client.deleteEntity(entity.partitionKey, entity.rowKey);
316
+ const query = {};
317
+ const client = await submissionClient(formId, query);
318
+ for await (const raw of client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } })) {
319
+ const submission = deserializeIfMatching(raw);
320
+ if (submission?.formId !== formId) continue;
321
+ if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
322
+ await client.deleteEntity(raw.partitionKey, raw.rowKey);
202
323
  }
203
324
  }
204
325
  },
205
326
  async clear() {
206
- for await (const raw of client.listEntities()) {
207
- const entity = parseEntity(raw);
208
- await client.deleteEntity(entity.partitionKey, entity.rowKey);
327
+ const schemas = await schemaClient("");
328
+ for await (const raw of schemas.listEntities()) {
329
+ if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
330
+ await schemas.deleteEntity(raw.partitionKey, raw.rowKey);
331
+ }
332
+ }
333
+ const submissions = await submissionClient("");
334
+ if (submissions === schemas) return;
335
+ for await (const raw of submissions.listEntities()) {
336
+ if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
337
+ await submissions.deleteEntity(raw.partitionKey, raw.rowKey);
338
+ }
209
339
  }
210
340
  }
211
341
  };
212
342
  }
213
343
  // Annotate the CommonJS export names for ESM import in node:
214
344
  0 && (module.exports = {
215
- createAzureTableStorage
345
+ createAzureTableStorage,
346
+ defaultAzureTableSubmissionCodec,
347
+ metadataFiltersToOData,
348
+ submissionFilterToOData
216
349
  });
package/dist/index.d.cts CHANGED
@@ -1,20 +1,63 @@
1
- import { PagedSubmissionStorageAdapter } from '@form-engine-ts/core';
1
+ import { FormSubmission, SubmissionPageQueryOptions, PagedSubmissionStorageAdapter, SubmissionFilter } 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
  }
15
- interface AzureTableStorageOptions {
16
- readonly client: AzureTableClientLike;
25
+ /** @deprecated Use AzureTableSubmissionCodec. */
26
+ interface AzureTableEntityCodec<T> {
27
+ readonly createPartitionKey: (submission: T) => string;
28
+ readonly createPartitionKeyFromFormId?: (formId: string) => string;
29
+ readonly createRowKey: (submission: T) => string;
30
+ readonly serialize: (submission: T) => Record<string, unknown>;
31
+ readonly deserialize: (entity: Record<string, unknown>) => T;
32
+ }
33
+ interface AzureTableSubmissionCodec<T = FormSubmission> {
34
+ readonly createEntity: (value: T) => Record<string, unknown>;
35
+ readonly deserialize: (entity: Record<string, unknown>) => T;
36
+ readonly matchesEntity: (entity: Record<string, unknown>) => boolean;
37
+ readonly createPartitionKey: (value: T) => string;
38
+ readonly createPartitionKeyFromQuery: (formId: string, query: SubmissionPageQueryOptions) => string | undefined;
39
+ readonly createRowKey: (value: T) => string;
40
+ }
41
+ interface AzureTableStorageOptions<T = FormSubmission> {
42
+ /** @deprecated Use schemasTableClient, submissionsTableClient, or clientResolver. */
43
+ readonly client?: AzureTableClientLike;
44
+ readonly schemasTableClient?: AzureTableClientLike;
45
+ readonly submissionsTableClient?: AzureTableClientLike;
46
+ readonly clientResolver?: (context: {
47
+ readonly formId: string;
48
+ readonly query?: SubmissionPageQueryOptions;
49
+ }) => AzureTableClientLike | Promise<AzureTableClientLike>;
50
+ readonly codec?: AzureTableSubmissionCodec<T>;
51
+ /** @deprecated Use codec. */
52
+ readonly submissionCodec?: AzureTableEntityCodec<FormSubmission>;
53
+ readonly buildSubmissionFilter?: (formId: string, query: SubmissionPageQueryOptions) => string;
54
+ /** @deprecated Use buildSubmissionFilter. */
55
+ readonly toODataFilter?: (options: SubmissionPageQueryOptions) => string;
56
+ readonly maxScanPages?: number;
17
57
  }
18
- declare function createAzureTableStorage(options: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
58
+ declare const defaultAzureTableSubmissionCodec: AzureTableSubmissionCodec<FormSubmission>;
59
+ declare function metadataFiltersToOData(options: SubmissionPageQueryOptions): string;
60
+ declare function submissionFilterToOData(filter: SubmissionFilter): string | undefined;
61
+ declare function createAzureTableStorage(options?: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
19
62
 
20
- export { type AzureTableClientLike, type AzureTableListOptions, type AzureTableStorageOptions, createAzureTableStorage };
63
+ export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageOptions, type AzureTableSubmissionCodec, createAzureTableStorage, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
package/dist/index.d.ts CHANGED
@@ -1,20 +1,63 @@
1
- import { PagedSubmissionStorageAdapter } from '@form-engine-ts/core';
1
+ import { FormSubmission, SubmissionPageQueryOptions, PagedSubmissionStorageAdapter, SubmissionFilter } 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
  }
15
- interface AzureTableStorageOptions {
16
- readonly client: AzureTableClientLike;
25
+ /** @deprecated Use AzureTableSubmissionCodec. */
26
+ interface AzureTableEntityCodec<T> {
27
+ readonly createPartitionKey: (submission: T) => string;
28
+ readonly createPartitionKeyFromFormId?: (formId: string) => string;
29
+ readonly createRowKey: (submission: T) => string;
30
+ readonly serialize: (submission: T) => Record<string, unknown>;
31
+ readonly deserialize: (entity: Record<string, unknown>) => T;
32
+ }
33
+ interface AzureTableSubmissionCodec<T = FormSubmission> {
34
+ readonly createEntity: (value: T) => Record<string, unknown>;
35
+ readonly deserialize: (entity: Record<string, unknown>) => T;
36
+ readonly matchesEntity: (entity: Record<string, unknown>) => boolean;
37
+ readonly createPartitionKey: (value: T) => string;
38
+ readonly createPartitionKeyFromQuery: (formId: string, query: SubmissionPageQueryOptions) => string | undefined;
39
+ readonly createRowKey: (value: T) => string;
40
+ }
41
+ interface AzureTableStorageOptions<T = FormSubmission> {
42
+ /** @deprecated Use schemasTableClient, submissionsTableClient, or clientResolver. */
43
+ readonly client?: AzureTableClientLike;
44
+ readonly schemasTableClient?: AzureTableClientLike;
45
+ readonly submissionsTableClient?: AzureTableClientLike;
46
+ readonly clientResolver?: (context: {
47
+ readonly formId: string;
48
+ readonly query?: SubmissionPageQueryOptions;
49
+ }) => AzureTableClientLike | Promise<AzureTableClientLike>;
50
+ readonly codec?: AzureTableSubmissionCodec<T>;
51
+ /** @deprecated Use codec. */
52
+ readonly submissionCodec?: AzureTableEntityCodec<FormSubmission>;
53
+ readonly buildSubmissionFilter?: (formId: string, query: SubmissionPageQueryOptions) => string;
54
+ /** @deprecated Use buildSubmissionFilter. */
55
+ readonly toODataFilter?: (options: SubmissionPageQueryOptions) => string;
56
+ readonly maxScanPages?: number;
17
57
  }
18
- declare function createAzureTableStorage(options: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
58
+ declare const defaultAzureTableSubmissionCodec: AzureTableSubmissionCodec<FormSubmission>;
59
+ declare function metadataFiltersToOData(options: SubmissionPageQueryOptions): string;
60
+ declare function submissionFilterToOData(filter: SubmissionFilter): string | undefined;
61
+ declare function createAzureTableStorage(options?: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
19
62
 
20
- export { type AzureTableClientLike, type AzureTableListOptions, type AzureTableStorageOptions, createAzureTableStorage };
63
+ export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageOptions, type AzureTableSubmissionCodec, createAzureTableStorage, defaultAzureTableSubmissionCodec, metadataFiltersToOData, submissionFilterToOData };
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,223 @@ 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
+ );
41
+ }
42
+ var defaultAzureTableSubmissionCodec = {
43
+ createEntity: (submission) => ({
44
+ ...scalarMetadata(submission.metadata),
45
+ kind: "submission",
46
+ formVersion: submission.formVersion,
47
+ locale: submission.locale,
48
+ submittedAt: submission.submittedAt,
49
+ responseId: submission.id,
50
+ payload: JSON.stringify(submission)
51
+ }),
52
+ deserialize: (entity) => parseSubmission(entity.payload, "submission entity"),
53
+ matchesEntity: (entity) => entity.kind === "submission",
54
+ createPartitionKey: (submission) => submission.formId,
55
+ createPartitionKeyFromQuery: (formId) => formId,
56
+ createRowKey: defaultSubmissionRowKey
57
+ };
58
+ function legacyCodec(codec) {
59
+ return {
60
+ createEntity: (value) => ({
61
+ ...codec.serialize(value),
62
+ kind: "submission",
63
+ formVersion: value.formVersion,
64
+ locale: value.locale,
65
+ submittedAt: value.submittedAt,
66
+ responseId: value.id
67
+ }),
68
+ deserialize: codec.deserialize,
69
+ matchesEntity: (entity) => entity.kind === "submission",
70
+ createPartitionKey: codec.createPartitionKey,
71
+ createPartitionKeyFromQuery: (formId) => codec.createPartitionKeyFromFormId?.(formId) ?? formId,
72
+ createRowKey: codec.createRowKey
73
+ };
38
74
  }
39
75
  function parseSchemaEntity(value) {
40
- const entity = parseEntity(value);
41
- 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}`);
42
80
  assertValidFormSchema(schema);
43
- if (entity.kind !== "schema" || schema.id !== entity.partitionKey || schema.version !== entity.formVersion) {
81
+ if (schema.id !== value.partitionKey || schema.version !== value.formVersion) {
44
82
  throw new Error("Azure Table schema entity has inconsistent metadata.");
45
83
  }
46
84
  return cloneJson(schema);
47
85
  }
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) {
52
- throw new Error("Azure Table submission entity has inconsistent metadata.");
86
+ function parseSubmissionEntity(value, codec) {
87
+ if (typeof value.partitionKey !== "string" || typeof value.rowKey !== "string" || !codec.matchesEntity(value)) {
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) {
92
+ throw new Error("Azure Table submission entity has inconsistent keys.");
53
93
  }
54
94
  return submission;
55
95
  }
56
- function schemaRowKey(version) {
57
- return `schema_${version}`;
58
- }
59
- function submissionRowKey(submission) {
60
- return `${submission.submittedAt}_${submission.id}`;
61
- }
62
96
  function escapeOData(value) {
63
97
  return value.replaceAll("'", "''");
64
98
  }
65
- function odataFilter(formId, options = {}) {
66
- const filters = [
67
- ...formId === void 0 ? [] : [`PartitionKey eq '${escapeOData(formId)}'`],
68
- "kind eq 'submission'",
99
+ function valueToOData(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 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 ${valueToOData(value)}`;
110
+ }).join(" and ");
111
+ }
112
+ function odataProperty(path) {
113
+ if (path === "id" || path === "responseId") return "responseId";
114
+ if (["formVersion", "locale", "submittedAt"].includes(path)) return path;
115
+ if (path.startsWith("metadata.")) {
116
+ const property = path.slice("metadata.".length);
117
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(property) ? property : void 0;
118
+ }
119
+ return void 0;
120
+ }
121
+ function submissionFilterToOData(filter) {
122
+ if (filter.op === "and" || filter.op === "or") {
123
+ const converted = filter.filters.map(submissionFilterToOData);
124
+ if (filter.op === "or" && converted.some((value) => value === void 0)) return void 0;
125
+ const available = converted.filter((value) => value !== void 0 && value.length > 0);
126
+ if (available.length === 0) return void 0;
127
+ return available.map((value) => `(${value})`).join(filter.op === "and" ? " and " : " or ");
128
+ }
129
+ const property = odataProperty(filter.path);
130
+ if (property === void 0) return void 0;
131
+ if (filter.op === "eq") return `${property} eq ${valueToOData(filter.value)}`;
132
+ if (filter.op === "in") {
133
+ if (filter.values.length === 0) return "false";
134
+ return filter.values.map((value) => `${property} eq ${valueToOData(value)}`).join(" or ");
135
+ }
136
+ if (filter.op === "exists") return `${property} ${filter.value ? "ne" : "eq"} null`;
137
+ return [
138
+ ...filter.from === void 0 ? [] : [`${property} ge ${valueToOData(filter.from)}`],
139
+ ...filter.to === void 0 ? [] : [`${property} le ${valueToOData(filter.to)}`]
140
+ ].join(" and ");
141
+ }
142
+ function defaultSubmissionFilter(codec, formId, options, legacyExtension) {
143
+ const partitionKey = codec.createPartitionKeyFromQuery(formId, options);
144
+ const ast = options.filter === void 0 || typeof options.filter === "function" ? void 0 : submissionFilterToOData(options.filter);
145
+ return [
146
+ ...partitionKey === void 0 ? [] : [`PartitionKey eq '${escapeOData(partitionKey)}'`],
147
+ ...codec === defaultAzureTableSubmissionCodec ? ["kind eq 'submission'"] : [],
69
148
  ...options.version === void 0 ? [] : [`formVersion eq ${options.version}`],
70
149
  ...options.since === void 0 ? [] : [`submittedAt ge '${escapeOData(options.since)}'`],
71
150
  ...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 ");
151
+ ...options.locale === void 0 ? [] : [`locale eq '${escapeOData(options.locale)}'`],
152
+ ...ast === void 0 || ast.length === 0 ? [] : [`(${ast})`],
153
+ ...legacyExtension.trim().length === 0 ? [] : [`(${legacyExtension})`]
154
+ ].join(" and ");
79
155
  }
80
156
  function isNotFound(error) {
81
157
  return isRecord(error) && (error.statusCode === 404 || error.code === "ResourceNotFound" || error.code === "EntityNotFound");
82
158
  }
83
159
  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);
160
+ 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);
161
+ }
162
+ function requireClient(client, name) {
163
+ if (client === void 0) throw new TypeError(`${name} is required.`);
164
+ return client;
86
165
  }
87
- function createAzureTableStorage(options) {
88
- if (options?.client === void 0) throw new TypeError("client is required.");
89
- const { client } = options;
166
+ function createAzureTableStorage(options = {}) {
167
+ const staticSchemas = options.schemasTableClient ?? options.client;
168
+ const staticSubmissions = options.submissionsTableClient ?? options.client;
169
+ const codec = options.codec ?? (options.submissionCodec === void 0 ? defaultAzureTableSubmissionCodec : legacyCodec(options.submissionCodec));
170
+ const maxScanPages = options.maxScanPages ?? 5;
171
+ if (!Number.isSafeInteger(maxScanPages) || maxScanPages < 1) {
172
+ throw new TypeError("maxScanPages must be a positive safe integer.");
173
+ }
174
+ const resolveDynamicClient = async (formId, query) => options.clientResolver?.({ formId, ...query === void 0 ? {} : { query } });
175
+ const schemaClient = async (formId) => requireClient(staticSchemas ?? await resolveDynamicClient(formId), "schemasTableClient or clientResolver");
176
+ const submissionClient = async (formId, query) => requireClient(
177
+ await resolveDynamicClient(formId, query) ?? staticSubmissions,
178
+ "submissionsTableClient or clientResolver"
179
+ );
180
+ const queryFilter = (formId, query) => {
181
+ if (options.buildSubmissionFilter !== void 0) return options.buildSubmissionFilter(formId, query);
182
+ return defaultSubmissionFilter(
183
+ codec,
184
+ formId,
185
+ query,
186
+ options.toODataFilter?.(query) ?? metadataFiltersToOData(query)
187
+ );
188
+ };
189
+ const deserializeIfMatching = (entity) => codec.matchesEntity(entity) ? parseSubmissionEntity(entity, codec) : void 0;
90
190
  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);
96
- if (!matchesBuiltInFilters(submission, formId, query)) continue;
97
- if (!matchesSubmissionPageFilters(submission, query)) continue;
98
- submissions.push(submission);
191
+ const client = await submissionClient(formId, query);
192
+ const found = [];
193
+ for await (const raw of client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } })) {
194
+ const submission = deserializeIfMatching(raw);
195
+ if (submission === void 0 || !matchesBuiltInFilters(submission, formId, query)) continue;
196
+ if (matchesSubmissionPageFilters(submission, query)) found.push(submission);
99
197
  }
100
- return submissions.sort(
198
+ return found.sort(
101
199
  (left, right) => left.submittedAt.localeCompare(right.submittedAt) || left.id.localeCompare(right.id)
102
200
  );
103
201
  };
104
202
  return {
105
203
  async saveSchema(schema) {
106
204
  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
- );
205
+ const entity = {
206
+ partitionKey: schema.id,
207
+ rowKey: schemaRowKey(schema.version),
208
+ kind: "schema",
209
+ formVersion: schema.version,
210
+ payload: JSON.stringify(schema)
211
+ };
212
+ await (await schemaClient(schema.id)).upsertEntity(entity, "Replace");
117
213
  },
118
214
  async getSchema(formId, formVersion) {
119
215
  try {
120
- return parseSchemaEntity(await client.getEntity(formId, schemaRowKey(formVersion)));
216
+ return parseSchemaEntity(await (await schemaClient(formId)).getEntity(formId, schemaRowKey(formVersion)));
121
217
  } catch (error) {
122
218
  if (isNotFound(error)) return null;
123
219
  throw error;
124
220
  }
125
221
  },
126
222
  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));
223
+ const found = [];
224
+ for await (const raw of (await schemaClient("")).listEntities({ queryOptions: { filter: "kind eq 'schema'" } })) {
225
+ if (raw.kind === "schema") found.push(parseSchemaEntity(raw));
131
226
  }
132
- return schemas.sort((left, right) => left.id.localeCompare(right.id) || left.version - right.version);
227
+ return found.sort((left, right) => left.id.localeCompare(right.id) || left.version - right.version);
133
228
  },
134
229
  async deleteSchema(formId, formVersion) {
135
- await client.deleteEntity(formId, schemaRowKey(formVersion));
230
+ await (await schemaClient(formId)).deleteEntity(formId, schemaRowKey(formVersion));
136
231
  },
137
232
  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),
142
- kind: "submission",
143
- formVersion: stored.formVersion,
144
- locale: stored.locale,
145
- submittedAt: stored.submittedAt,
146
- responseId: stored.id,
147
- payload: JSON.stringify(stored)
233
+ const stored = parseSubmission(submission, `input ${String(submission?.id)}`);
234
+ await (await submissionClient(stored.formId)).createEntity({
235
+ ...codec.createEntity(stored),
236
+ partitionKey: codec.createPartitionKey(stored),
237
+ rowKey: codec.createRowKey(stored)
148
238
  });
149
239
  },
150
240
  async listSubmissions(formId, formVersion, queryOptions = {}) {
@@ -155,43 +245,77 @@ function createAzureTableStorage(options) {
155
245
  },
156
246
  async listSubmissionPage(formId, query = {}) {
157
247
  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);
248
+ const client = await submissionClient(formId, query);
249
+ const items = [];
250
+ let continuationToken = query.cursor;
251
+ let scannedPages = 0;
252
+ do {
253
+ const iterator = client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } }).byPage({
254
+ maxPageSize: Math.max(1, pageSize - items.length),
255
+ ...continuationToken === void 0 ? {} : { continuationToken }
256
+ });
257
+ const result = await iterator.next();
258
+ if (result.done === true) {
259
+ continuationToken = void 0;
260
+ break;
261
+ }
262
+ scannedPages += 1;
263
+ for (const raw of result.value) {
264
+ const submission = deserializeIfMatching(raw);
265
+ if (submission === void 0 || !matchesBuiltInFilters(submission, formId, query)) continue;
266
+ if (matchesSubmissionPageFilters(submission, query)) items.push(submission);
267
+ }
268
+ continuationToken = result.value.continuationToken;
269
+ } while (items.length < pageSize && continuationToken !== void 0 && scannedPages < maxScanPages);
162
270
  return {
163
271
  items,
164
- hasMore,
165
- ...hasMore && last !== void 0 ? { nextCursor: encodeSubmissionCursor({ submittedAt: last.submittedAt, responseId: last.id }) } : {}
272
+ hasMore: continuationToken !== void 0 && continuationToken.length > 0,
273
+ ...continuationToken === void 0 || continuationToken.length === 0 ? {} : { nextCursor: continuationToken }
166
274
  };
167
275
  },
168
276
  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);
173
- return;
277
+ const client = await submissionClient("");
278
+ for await (const raw of client.listEntities()) {
279
+ const submission = deserializeIfMatching(raw);
280
+ if (submission?.id !== submissionId) continue;
281
+ if (typeof raw.partitionKey !== "string" || typeof raw.rowKey !== "string") {
282
+ throw new Error("Azure Table submission entity is invalid.");
174
283
  }
284
+ await client.deleteEntity(raw.partitionKey, raw.rowKey);
285
+ return;
175
286
  }
176
287
  },
177
288
  async clearResponses(formId) {
178
- for await (const raw of client.listEntities({
179
- queryOptions: { filter: `PartitionKey eq '${escapeOData(formId)}' and kind eq 'submission'` }
180
- })) {
181
- const entity = parseEntity(raw);
182
- if (entity.partitionKey === formId && entity.kind === "submission") {
183
- await client.deleteEntity(entity.partitionKey, entity.rowKey);
289
+ const query = {};
290
+ const client = await submissionClient(formId, query);
291
+ for await (const raw of client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } })) {
292
+ const submission = deserializeIfMatching(raw);
293
+ if (submission?.formId !== formId) continue;
294
+ if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
295
+ await client.deleteEntity(raw.partitionKey, raw.rowKey);
184
296
  }
185
297
  }
186
298
  },
187
299
  async clear() {
188
- for await (const raw of client.listEntities()) {
189
- const entity = parseEntity(raw);
190
- await client.deleteEntity(entity.partitionKey, entity.rowKey);
300
+ const schemas = await schemaClient("");
301
+ for await (const raw of schemas.listEntities()) {
302
+ if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
303
+ await schemas.deleteEntity(raw.partitionKey, raw.rowKey);
304
+ }
305
+ }
306
+ const submissions = await submissionClient("");
307
+ if (submissions === schemas) return;
308
+ for await (const raw of submissions.listEntities()) {
309
+ if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
310
+ await submissions.deleteEntity(raw.partitionKey, raw.rowKey);
311
+ }
191
312
  }
192
313
  }
193
314
  };
194
315
  }
195
316
  export {
196
- createAzureTableStorage
317
+ createAzureTableStorage,
318
+ defaultAzureTableSubmissionCodec,
319
+ metadataFiltersToOData,
320
+ submissionFilterToOData
197
321
  };
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.7.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.7.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@azure/data-tables": "^13.3.2"