@form-engine-ts/storage-azure-table 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,9 +25,18 @@ const storage = createAzureTableStorage({ schemasTableClient, submissionsTableCl
25
25
  const page = await storage.listSubmissionPage("contact", { pageSize: 500, locale: "ja" });
26
26
  ```
27
27
 
28
- Submission entities use `formId` as `PartitionKey` and `submittedAt_responseId` as `RowKey`. Built-in date, locale,
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.
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.
38
+
39
+ `listTextAnswerPage` accepts either the legacy single field ID or `TextAnswerPageQueryOptions.fieldIds`. Page size counts
40
+ emitted text items rather than entities. Its opaque Base64 JSON cursor retains the Azure continuation token plus entity
41
+ and field indexes, so a page can resume inside a multi-answer entity without gaps or duplicates. Empty answers do not
42
+ consume the item limit, and scanning remains bounded by `maxScanPages`.
package/dist/index.cjs CHANGED
@@ -22,10 +22,75 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  createAzureTableStorage: () => createAzureTableStorage,
24
24
  defaultAzureTableSubmissionCodec: () => defaultAzureTableSubmissionCodec,
25
- metadataFiltersToOData: () => metadataFiltersToOData
25
+ metadataFiltersToOData: () => metadataFiltersToOData,
26
+ submissionFilterToOData: () => submissionFilterToOData
26
27
  });
27
28
  module.exports = __toCommonJS(index_exports);
28
29
  var import_core = require("@form-engine-ts/core");
30
+ var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
31
+ function encodeBase64(bytes) {
32
+ let result = "";
33
+ for (let index = 0; index < bytes.length; index += 3) {
34
+ const first = bytes[index] ?? 0;
35
+ const second = bytes[index + 1] ?? 0;
36
+ const third = bytes[index + 2] ?? 0;
37
+ const combined = first << 16 | second << 8 | third;
38
+ result += BASE64_ALPHABET[combined >> 18 & 63] ?? "";
39
+ result += BASE64_ALPHABET[combined >> 12 & 63] ?? "";
40
+ result += index + 1 < bytes.length ? BASE64_ALPHABET[combined >> 6 & 63] ?? "" : "=";
41
+ result += index + 2 < bytes.length ? BASE64_ALPHABET[combined & 63] ?? "" : "=";
42
+ }
43
+ return result;
44
+ }
45
+ function decodeBase64(value) {
46
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
47
+ throw new TypeError("cursor must be a valid Base64 token.");
48
+ }
49
+ const bytes = [];
50
+ for (let index = 0; index < value.length; index += 4) {
51
+ const characters = value.slice(index, index + 4);
52
+ const sextets = [...characters].map((character) => character === "=" ? 0 : BASE64_ALPHABET.indexOf(character));
53
+ const combined = (sextets[0] ?? 0) << 18 | (sextets[1] ?? 0) << 12 | (sextets[2] ?? 0) << 6 | (sextets[3] ?? 0);
54
+ bytes.push(combined >> 16 & 255);
55
+ if (characters[2] !== "=") bytes.push(combined >> 8 & 255);
56
+ if (characters[3] !== "=") bytes.push(combined & 255);
57
+ }
58
+ return new Uint8Array(bytes);
59
+ }
60
+ function encodeAzureTextAnswerCursor(value) {
61
+ return encodeBase64(new TextEncoder().encode(JSON.stringify(value)));
62
+ }
63
+ function decodeAzureTextAnswerCursor(cursor) {
64
+ try {
65
+ const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(cursor)));
66
+ if (!isRecord(value) || value.tableContinuationToken !== null && typeof value.tableContinuationToken !== "string" || !Number.isSafeInteger(value.entityIndex) || value.entityIndex < 0 || !Number.isSafeInteger(value.fieldIndex) || value.fieldIndex < 0) {
67
+ throw new TypeError("Azure text answer cursor payload is invalid.");
68
+ }
69
+ return {
70
+ tableContinuationToken: value.tableContinuationToken,
71
+ entityIndex: value.entityIndex,
72
+ fieldIndex: value.fieldIndex
73
+ };
74
+ } catch (cause) {
75
+ if (cause instanceof TypeError && cause.message === "Azure text answer cursor payload is invalid.") throw cause;
76
+ throw new TypeError("cursor must be a valid Azure text answer cursor.", { cause });
77
+ }
78
+ }
79
+ function textAnswerQuery(fieldIdOrOptions, options) {
80
+ const query = typeof fieldIdOrOptions === "string" ? options ?? {} : fieldIdOrOptions ?? {};
81
+ const requested = typeof fieldIdOrOptions === "string" ? [fieldIdOrOptions] : query.fieldIds;
82
+ if (requested?.some((fieldId) => fieldId.trim().length === 0)) {
83
+ throw new TypeError("fieldIds must not contain empty values.");
84
+ }
85
+ const fieldIds = requested === void 0 ? void 0 : [...new Set(requested)];
86
+ return { query, ...fieldIds === void 0 ? {} : { fieldIds } };
87
+ }
88
+ function submissionTextAnswers(submission, fieldIds) {
89
+ const entries = fieldIds === void 0 ? Object.entries(submission.values) : fieldIds.map((id) => [id, submission.values[id]]);
90
+ return entries.flatMap(
91
+ ([fieldId, value]) => typeof value === "string" && value.length > 0 ? [{ fieldId, text: value }] : []
92
+ );
93
+ }
29
94
  function cloneJson(value) {
30
95
  return JSON.parse(JSON.stringify(value));
31
96
  }
@@ -66,12 +131,38 @@ function scalarMetadata(metadata) {
66
131
  );
67
132
  }
68
133
  var defaultAzureTableSubmissionCodec = {
134
+ createEntity: (submission) => ({
135
+ ...scalarMetadata(submission.metadata),
136
+ kind: "submission",
137
+ formVersion: submission.formVersion,
138
+ locale: submission.locale,
139
+ submittedAt: submission.submittedAt,
140
+ responseId: submission.id,
141
+ payload: JSON.stringify(submission)
142
+ }),
143
+ deserialize: (entity) => parseSubmission(entity.payload, "submission entity"),
144
+ matchesEntity: (entity) => entity.kind === "submission",
69
145
  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")
146
+ createPartitionKeyFromQuery: (formId) => formId,
147
+ createRowKey: defaultSubmissionRowKey
74
148
  };
149
+ function legacyCodec(codec) {
150
+ return {
151
+ createEntity: (value) => ({
152
+ ...codec.serialize(value),
153
+ kind: "submission",
154
+ formVersion: value.formVersion,
155
+ locale: value.locale,
156
+ submittedAt: value.submittedAt,
157
+ responseId: value.id
158
+ }),
159
+ deserialize: codec.deserialize,
160
+ matchesEntity: (entity) => entity.kind === "submission",
161
+ createPartitionKey: codec.createPartitionKey,
162
+ createPartitionKeyFromQuery: (formId) => codec.createPartitionKeyFromFormId?.(formId) ?? formId,
163
+ createRowKey: codec.createRowKey
164
+ };
165
+ }
75
166
  function parseSchemaEntity(value) {
76
167
  if (typeof value.partitionKey !== "string" || typeof value.rowKey !== "string" || value.kind !== "schema" || !Number.isInteger(value.formVersion) || typeof value.payload !== "string") {
77
168
  throw new Error("Azure Table schema entity is invalid.");
@@ -84,40 +175,73 @@ function parseSchemaEntity(value) {
84
175
  return cloneJson(schema);
85
176
  }
86
177
  function parseSubmissionEntity(value, codec) {
87
- if (typeof value.partitionKey !== "string" || typeof value.rowKey !== "string" || value.kind !== "submission") {
178
+ if (typeof value.partitionKey !== "string" || typeof value.rowKey !== "string" || !codec.matchesEntity(value)) {
88
179
  throw new Error("Azure Table submission entity is invalid.");
89
180
  }
90
181
  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) {
92
- throw new Error("Azure Table submission entity has inconsistent metadata.");
182
+ if (codec.createPartitionKey(submission) !== value.partitionKey || codec.createRowKey(submission) !== value.rowKey) {
183
+ throw new Error("Azure Table submission entity has inconsistent keys.");
93
184
  }
94
185
  return submission;
95
186
  }
96
187
  function escapeOData(value) {
97
188
  return value.replaceAll("'", "''");
98
189
  }
99
- function metadataValueToOData(value) {
190
+ function valueToOData(value) {
100
191
  if (value === null) return "null";
101
192
  if (typeof value === "string") return `'${escapeOData(value)}'`;
102
193
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
103
194
  if (typeof value === "boolean") return String(value);
104
- throw new TypeError("Azure Table metadata OData filters support only scalar JSON values.");
195
+ throw new TypeError("Azure Table OData filters support only scalar JSON values.");
105
196
  }
106
197
  function metadataFiltersToOData(options) {
107
198
  return Object.entries(options.metadataFilters ?? {}).map(([key, value]) => {
108
199
  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)}`;
200
+ return `${key} eq ${valueToOData(value)}`;
110
201
  }).join(" and ");
111
202
  }
112
- function odataFilter(formId, options, extension) {
203
+ function odataProperty(path) {
204
+ if (path === "id" || path === "responseId") return "responseId";
205
+ if (["formVersion", "locale", "submittedAt"].includes(path)) return path;
206
+ if (path.startsWith("metadata.")) {
207
+ const property = path.slice("metadata.".length);
208
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(property) ? property : void 0;
209
+ }
210
+ return void 0;
211
+ }
212
+ function submissionFilterToOData(filter) {
213
+ if (filter.op === "and" || filter.op === "or") {
214
+ const converted = filter.filters.map(submissionFilterToOData);
215
+ if (filter.op === "or" && converted.some((value) => value === void 0)) return void 0;
216
+ const available = converted.filter((value) => value !== void 0 && value.length > 0);
217
+ if (available.length === 0) return void 0;
218
+ return available.map((value) => `(${value})`).join(filter.op === "and" ? " and " : " or ");
219
+ }
220
+ const property = odataProperty(filter.path);
221
+ if (property === void 0) return void 0;
222
+ if (filter.op === "eq") return `${property} eq ${valueToOData(filter.value)}`;
223
+ if (filter.op === "in") {
224
+ if (filter.values.length === 0) return "false";
225
+ return filter.values.map((value) => `${property} eq ${valueToOData(value)}`).join(" or ");
226
+ }
227
+ if (filter.op === "exists") return `${property} ${filter.value ? "ne" : "eq"} null`;
113
228
  return [
114
- ...formId === void 0 ? [] : [`PartitionKey eq '${escapeOData(formId)}'`],
115
- "kind eq 'submission'",
229
+ ...filter.from === void 0 ? [] : [`${property} ge ${valueToOData(filter.from)}`],
230
+ ...filter.to === void 0 ? [] : [`${property} le ${valueToOData(filter.to)}`]
231
+ ].join(" and ");
232
+ }
233
+ function defaultSubmissionFilter(codec, formId, options, legacyExtension) {
234
+ const partitionKey = codec.createPartitionKeyFromQuery(formId, options);
235
+ const ast = options.filter === void 0 || typeof options.filter === "function" ? void 0 : submissionFilterToOData(options.filter);
236
+ return [
237
+ ...partitionKey === void 0 ? [] : [`PartitionKey eq '${escapeOData(partitionKey)}'`],
238
+ ...codec === defaultAzureTableSubmissionCodec ? ["kind eq 'submission'"] : [],
116
239
  ...options.version === void 0 ? [] : [`formVersion eq ${options.version}`],
117
240
  ...options.since === void 0 ? [] : [`submittedAt ge '${escapeOData(options.since)}'`],
118
241
  ...options.until === void 0 ? [] : [`submittedAt le '${escapeOData(options.until)}'`],
119
242
  ...options.locale === void 0 ? [] : [`locale eq '${escapeOData(options.locale)}'`],
120
- ...extension.trim().length === 0 ? [] : [`(${extension})`]
243
+ ...ast === void 0 || ast.length === 0 ? [] : [`(${ast})`],
244
+ ...legacyExtension.trim().length === 0 ? [] : [`(${legacyExtension})`]
121
245
  ].join(" and ");
122
246
  }
123
247
  function isNotFound(error) {
@@ -126,26 +250,41 @@ function isNotFound(error) {
126
250
  function matchesBuiltInFilters(submission, formId, options) {
127
251
  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
252
  }
129
- function resolveClient(preferred, fallback, name) {
130
- const client = preferred ?? fallback;
253
+ function requireClient(client, name) {
131
254
  if (client === void 0) throw new TypeError(`${name} is required.`);
132
255
  return client;
133
256
  }
134
- function createAzureTableStorage(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));
257
+ function createAzureTableStorage(options = {}) {
258
+ const staticSchemas = options.schemasTableClient ?? options.client;
259
+ const staticSubmissions = options.submissionsTableClient ?? options.client;
260
+ const codec = options.codec ?? (options.submissionCodec === void 0 ? defaultAzureTableSubmissionCodec : legacyCodec(options.submissionCodec));
261
+ const maxScanPages = options.maxScanPages ?? 5;
262
+ if (!Number.isSafeInteger(maxScanPages) || maxScanPages < 1) {
263
+ throw new TypeError("maxScanPages must be a positive safe integer.");
264
+ }
265
+ const resolveDynamicClient = async (formId, query) => options.clientResolver?.({ formId, ...query === void 0 ? {} : { query } });
266
+ const schemaClient = async (formId) => requireClient(staticSchemas ?? await resolveDynamicClient(formId), "schemasTableClient or clientResolver");
267
+ const submissionClient = async (formId, query) => requireClient(
268
+ await resolveDynamicClient(formId, query) ?? staticSubmissions,
269
+ "submissionsTableClient or clientResolver"
270
+ );
271
+ const queryFilter = (formId, query) => {
272
+ if (options.buildSubmissionFilter !== void 0) return options.buildSubmissionFilter(formId, query);
273
+ return defaultSubmissionFilter(
274
+ codec,
275
+ formId,
276
+ query,
277
+ options.toODataFilter?.(query) ?? metadataFiltersToOData(query)
278
+ );
279
+ };
280
+ const deserializeIfMatching = (entity) => codec.matchesEntity(entity) ? parseSubmissionEntity(entity, codec) : void 0;
141
281
  const listSubmissionCandidates = async (formId, query) => {
282
+ const client = await submissionClient(formId, query);
142
283
  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);
146
- if (!matchesBuiltInFilters(submission, formId, query)) continue;
147
- if (!(0, import_core.matchesSubmissionPageFilters)(submission, query)) continue;
148
- found.push(submission);
284
+ for await (const raw of client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } })) {
285
+ const submission = deserializeIfMatching(raw);
286
+ if (submission === void 0 || !matchesBuiltInFilters(submission, formId, query)) continue;
287
+ if ((0, import_core.matchesSubmissionPageFilters)(submission, query)) found.push(submission);
149
288
  }
150
289
  return found.sort(
151
290
  (left, right) => left.submittedAt.localeCompare(right.submittedAt) || left.id.localeCompare(right.id)
@@ -161,11 +300,11 @@ function createAzureTableStorage(options) {
161
300
  formVersion: schema.version,
162
301
  payload: JSON.stringify(schema)
163
302
  };
164
- await schemas.upsertEntity(entity, "Replace");
303
+ await (await schemaClient(schema.id)).upsertEntity(entity, "Replace");
165
304
  },
166
305
  async getSchema(formId, formVersion) {
167
306
  try {
168
- return parseSchemaEntity(await schemas.getEntity(formId, schemaRowKey(formVersion)));
307
+ return parseSchemaEntity(await (await schemaClient(formId)).getEntity(formId, schemaRowKey(formVersion)));
169
308
  } catch (error) {
170
309
  if (isNotFound(error)) return null;
171
310
  throw error;
@@ -173,25 +312,20 @@ function createAzureTableStorage(options) {
173
312
  },
174
313
  async listSchemas() {
175
314
  const found = [];
176
- for await (const raw of schemas.listEntities({ queryOptions: { filter: "kind eq 'schema'" } })) {
315
+ for await (const raw of (await schemaClient("")).listEntities({ queryOptions: { filter: "kind eq 'schema'" } })) {
177
316
  if (raw.kind === "schema") found.push(parseSchemaEntity(raw));
178
317
  }
179
318
  return found.sort((left, right) => left.id.localeCompare(right.id) || left.version - right.version);
180
319
  },
181
320
  async deleteSchema(formId, formVersion) {
182
- await schemas.deleteEntity(formId, schemaRowKey(formVersion));
321
+ await (await schemaClient(formId)).deleteEntity(formId, schemaRowKey(formVersion));
183
322
  },
184
323
  async saveSubmission(submission) {
185
324
  const stored = parseSubmission(submission, `input ${String(submission?.id)}`);
186
- await submissions.createEntity({
187
- ...codec.serialize(stored),
325
+ await (await submissionClient(stored.formId)).createEntity({
326
+ ...codec.createEntity(stored),
188
327
  partitionKey: codec.createPartitionKey(stored),
189
- rowKey: codec.createRowKey(stored),
190
- kind: "submission",
191
- formVersion: stored.formVersion,
192
- locale: stored.locale,
193
- submittedAt: stored.submittedAt,
194
- responseId: stored.id
328
+ rowKey: codec.createRowKey(stored)
195
329
  });
196
330
  },
197
331
  async listSubmissions(formId, formVersion, queryOptions = {}) {
@@ -202,46 +336,147 @@ function createAzureTableStorage(options) {
202
336
  },
203
337
  async listSubmissionPage(formId, query = {}) {
204
338
  const pageSize = (0, import_core.normalizeSubmissionPageSize)(query.pageSize);
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;
339
+ const client = await submissionClient(formId, query);
340
+ const items = [];
341
+ let continuationToken = query.cursor;
342
+ let scannedPages = 0;
343
+ do {
344
+ const iterator = client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } }).byPage({
345
+ maxPageSize: Math.max(1, pageSize - items.length),
346
+ ...continuationToken === void 0 ? {} : { continuationToken }
347
+ });
348
+ const result = await iterator.next();
349
+ if (result.done === true) {
350
+ continuationToken = void 0;
351
+ break;
352
+ }
353
+ scannedPages += 1;
354
+ for (const raw of result.value) {
355
+ const submission = deserializeIfMatching(raw);
356
+ if (submission === void 0 || !matchesBuiltInFilters(submission, formId, query)) continue;
357
+ if ((0, import_core.matchesSubmissionPageFilters)(submission, query)) items.push(submission);
358
+ }
359
+ continuationToken = result.value.continuationToken;
360
+ } while (items.length < pageSize && continuationToken !== void 0 && scannedPages < maxScanPages);
212
361
  return {
213
362
  items,
214
- hasMore: nextCursor !== void 0 && nextCursor.length > 0,
215
- ...nextCursor === void 0 || nextCursor.length === 0 ? {} : { nextCursor }
363
+ hasMore: continuationToken !== void 0 && continuationToken.length > 0,
364
+ ...continuationToken === void 0 || continuationToken.length === 0 ? {} : { nextCursor: continuationToken }
216
365
  };
217
366
  },
218
- async deleteSubmission(submissionId) {
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.");
367
+ async listTextAnswerPage(formId, fieldIdOrOptions, providedOptions) {
368
+ const { query, fieldIds } = textAnswerQuery(fieldIdOrOptions, providedOptions);
369
+ const pageSize = (0, import_core.normalizeSubmissionPageSize)(query.pageSize);
370
+ const cursor = query.cursor === void 0 ? void 0 : decodeAzureTextAnswerCursor(query.cursor);
371
+ const client = await submissionClient(formId, query);
372
+ const items = [];
373
+ let tableContinuationToken = cursor?.tableContinuationToken ?? void 0;
374
+ let entityStartIndex = cursor?.entityIndex ?? 0;
375
+ let fieldStartIndex = cursor?.fieldIndex ?? 0;
376
+ let scannedPages = 0;
377
+ while (scannedPages < maxScanPages) {
378
+ const requestToken = tableContinuationToken;
379
+ const iterator = client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } }).byPage({
380
+ maxPageSize: pageSize,
381
+ ...requestToken === void 0 ? {} : { continuationToken: requestToken }
382
+ });
383
+ const result = await iterator.next();
384
+ if (result.done === true) break;
385
+ scannedPages += 1;
386
+ const page = result.value;
387
+ for (let entityIndex = entityStartIndex; entityIndex < page.length; entityIndex += 1) {
388
+ const raw = page[entityIndex];
389
+ if (raw === void 0) continue;
390
+ const submission = deserializeIfMatching(raw);
391
+ if (submission === void 0 || !matchesBuiltInFilters(submission, formId, query) || !(0, import_core.matchesSubmissionPageFilters)(submission, query)) {
392
+ fieldStartIndex = 0;
393
+ continue;
223
394
  }
224
- await submissions.deleteEntity(raw.partitionKey, raw.rowKey);
225
- return;
395
+ const answers = submissionTextAnswers(submission, fieldIds);
396
+ for (let fieldIndex = entityIndex === entityStartIndex ? fieldStartIndex : 0; fieldIndex < answers.length; fieldIndex += 1) {
397
+ const answer = answers[fieldIndex];
398
+ if (answer === void 0) continue;
399
+ items.push({
400
+ responseId: submission.id,
401
+ formId: submission.formId,
402
+ formVersion: submission.formVersion,
403
+ fieldId: answer.fieldId,
404
+ text: answer.text,
405
+ locale: submission.locale,
406
+ submittedAt: submission.submittedAt,
407
+ ...submission.metadata === void 0 ? {} : { metadata: submission.metadata }
408
+ });
409
+ if (items.length < pageSize) continue;
410
+ const nextFieldIndex = fieldIndex + 1;
411
+ const hasFieldsInEntity = nextFieldIndex < answers.length;
412
+ const hasEntitiesInPage = entityIndex + 1 < page.length;
413
+ const nextPageToken = page.continuationToken;
414
+ const hasMore = hasFieldsInEntity || hasEntitiesInPage || nextPageToken !== void 0;
415
+ if (!hasMore) return { items, hasMore: false };
416
+ const nextCursor = hasFieldsInEntity ? {
417
+ tableContinuationToken: requestToken ?? null,
418
+ entityIndex,
419
+ fieldIndex: nextFieldIndex
420
+ } : hasEntitiesInPage ? {
421
+ tableContinuationToken: requestToken ?? null,
422
+ entityIndex: entityIndex + 1,
423
+ fieldIndex: 0
424
+ } : {
425
+ tableContinuationToken: nextPageToken ?? null,
426
+ entityIndex: 0,
427
+ fieldIndex: 0
428
+ };
429
+ return { items, hasMore: true, nextCursor: encodeAzureTextAnswerCursor(nextCursor) };
430
+ }
431
+ fieldStartIndex = 0;
432
+ }
433
+ tableContinuationToken = page.continuationToken;
434
+ entityStartIndex = 0;
435
+ fieldStartIndex = 0;
436
+ if (tableContinuationToken === void 0) break;
437
+ }
438
+ if (tableContinuationToken === void 0) return { items, hasMore: false };
439
+ return {
440
+ items,
441
+ hasMore: true,
442
+ nextCursor: encodeAzureTextAnswerCursor({
443
+ tableContinuationToken,
444
+ entityIndex: 0,
445
+ fieldIndex: 0
446
+ })
447
+ };
448
+ },
449
+ async deleteSubmission(submissionId) {
450
+ const client = await submissionClient("");
451
+ for await (const raw of client.listEntities()) {
452
+ const submission = deserializeIfMatching(raw);
453
+ if (submission?.id !== submissionId) continue;
454
+ if (typeof raw.partitionKey !== "string" || typeof raw.rowKey !== "string") {
455
+ throw new Error("Azure Table submission entity is invalid.");
226
456
  }
457
+ await client.deleteEntity(raw.partitionKey, raw.rowKey);
458
+ return;
227
459
  }
228
460
  },
229
461
  async clearResponses(formId) {
230
- const partitionKey = partitionKeyFromFormId(formId);
231
- for await (const raw of submissions.listEntities({
232
- queryOptions: { filter: `PartitionKey eq '${escapeOData(partitionKey)}' and kind eq 'submission'` }
233
- })) {
234
- if (raw.partitionKey === partitionKey && raw.kind === "submission" && typeof raw.rowKey === "string") {
235
- await submissions.deleteEntity(partitionKey, raw.rowKey);
462
+ const query = {};
463
+ const client = await submissionClient(formId, query);
464
+ for await (const raw of client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } })) {
465
+ const submission = deserializeIfMatching(raw);
466
+ if (submission?.formId !== formId) continue;
467
+ if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
468
+ await client.deleteEntity(raw.partitionKey, raw.rowKey);
236
469
  }
237
470
  }
238
471
  },
239
472
  async clear() {
473
+ const schemas = await schemaClient("");
240
474
  for await (const raw of schemas.listEntities()) {
241
475
  if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
242
476
  await schemas.deleteEntity(raw.partitionKey, raw.rowKey);
243
477
  }
244
478
  }
479
+ const submissions = await submissionClient("");
245
480
  if (submissions === schemas) return;
246
481
  for await (const raw of submissions.listEntities()) {
247
482
  if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
@@ -255,5 +490,6 @@ function createAzureTableStorage(options) {
255
490
  0 && (module.exports = {
256
491
  createAzureTableStorage,
257
492
  defaultAzureTableSubmissionCodec,
258
- metadataFiltersToOData
493
+ metadataFiltersToOData,
494
+ submissionFilterToOData
259
495
  });
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { FormSubmission, SubmissionPageQueryOptions, 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?: {
@@ -22,6 +22,7 @@ interface AzureTableClientLike {
22
22
  listEntities(options?: AzureTableListOptions): AzureTableEntityIterator;
23
23
  deleteEntity(partitionKey: string, rowKey: string): Promise<unknown>;
24
24
  }
25
+ /** @deprecated Use AzureTableSubmissionCodec. */
25
26
  interface AzureTableEntityCodec<T> {
26
27
  readonly createPartitionKey: (submission: T) => string;
27
28
  readonly createPartitionKeyFromFormId?: (formId: string) => string;
@@ -29,16 +30,34 @@ interface AzureTableEntityCodec<T> {
29
30
  readonly serialize: (submission: T) => Record<string, unknown>;
30
31
  readonly deserialize: (entity: Record<string, unknown>) => T;
31
32
  }
32
- interface AzureTableStorageOptions {
33
- /** @deprecated Use schemasTableClient and submissionsTableClient. */
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. */
34
43
  readonly client?: AzureTableClientLike;
35
44
  readonly schemasTableClient?: AzureTableClientLike;
36
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. */
37
52
  readonly submissionCodec?: AzureTableEntityCodec<FormSubmission>;
53
+ readonly buildSubmissionFilter?: (formId: string, query: SubmissionPageQueryOptions) => string;
54
+ /** @deprecated Use buildSubmissionFilter. */
38
55
  readonly toODataFilter?: (options: SubmissionPageQueryOptions) => string;
56
+ readonly maxScanPages?: number;
39
57
  }
40
- declare const defaultAzureTableSubmissionCodec: AzureTableEntityCodec<FormSubmission>;
58
+ declare const defaultAzureTableSubmissionCodec: AzureTableSubmissionCodec<FormSubmission>;
41
59
  declare function metadataFiltersToOData(options: SubmissionPageQueryOptions): string;
42
- declare function createAzureTableStorage(options: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
60
+ declare function submissionFilterToOData(filter: SubmissionFilter): string | undefined;
61
+ declare function createAzureTableStorage(options?: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
43
62
 
44
- export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageOptions, createAzureTableStorage, defaultAzureTableSubmissionCodec, metadataFiltersToOData };
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,4 +1,4 @@
1
- import { FormSubmission, SubmissionPageQueryOptions, 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?: {
@@ -22,6 +22,7 @@ interface AzureTableClientLike {
22
22
  listEntities(options?: AzureTableListOptions): AzureTableEntityIterator;
23
23
  deleteEntity(partitionKey: string, rowKey: string): Promise<unknown>;
24
24
  }
25
+ /** @deprecated Use AzureTableSubmissionCodec. */
25
26
  interface AzureTableEntityCodec<T> {
26
27
  readonly createPartitionKey: (submission: T) => string;
27
28
  readonly createPartitionKeyFromFormId?: (formId: string) => string;
@@ -29,16 +30,34 @@ interface AzureTableEntityCodec<T> {
29
30
  readonly serialize: (submission: T) => Record<string, unknown>;
30
31
  readonly deserialize: (entity: Record<string, unknown>) => T;
31
32
  }
32
- interface AzureTableStorageOptions {
33
- /** @deprecated Use schemasTableClient and submissionsTableClient. */
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. */
34
43
  readonly client?: AzureTableClientLike;
35
44
  readonly schemasTableClient?: AzureTableClientLike;
36
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. */
37
52
  readonly submissionCodec?: AzureTableEntityCodec<FormSubmission>;
53
+ readonly buildSubmissionFilter?: (formId: string, query: SubmissionPageQueryOptions) => string;
54
+ /** @deprecated Use buildSubmissionFilter. */
38
55
  readonly toODataFilter?: (options: SubmissionPageQueryOptions) => string;
56
+ readonly maxScanPages?: number;
39
57
  }
40
- declare const defaultAzureTableSubmissionCodec: AzureTableEntityCodec<FormSubmission>;
58
+ declare const defaultAzureTableSubmissionCodec: AzureTableSubmissionCodec<FormSubmission>;
41
59
  declare function metadataFiltersToOData(options: SubmissionPageQueryOptions): string;
42
- declare function createAzureTableStorage(options: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
60
+ declare function submissionFilterToOData(filter: SubmissionFilter): string | undefined;
61
+ declare function createAzureTableStorage(options?: AzureTableStorageOptions): PagedSubmissionStorageAdapter;
43
62
 
44
- export { type AzureTableClientLike, type AzureTableEntityCodec, type AzureTableEntityIterator, type AzureTableEntityPage, type AzureTableListOptions, type AzureTablePageSettings, type AzureTableStorageOptions, createAzureTableStorage, defaultAzureTableSubmissionCodec, metadataFiltersToOData };
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,5 +1,69 @@
1
1
  // src/index.ts
2
2
  import { assertValidFormSchema, matchesSubmissionPageFilters, normalizeSubmissionPageSize } from "@form-engine-ts/core";
3
+ var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
4
+ function encodeBase64(bytes) {
5
+ let result = "";
6
+ for (let index = 0; index < bytes.length; index += 3) {
7
+ const first = bytes[index] ?? 0;
8
+ const second = bytes[index + 1] ?? 0;
9
+ const third = bytes[index + 2] ?? 0;
10
+ const combined = first << 16 | second << 8 | third;
11
+ result += BASE64_ALPHABET[combined >> 18 & 63] ?? "";
12
+ result += BASE64_ALPHABET[combined >> 12 & 63] ?? "";
13
+ result += index + 1 < bytes.length ? BASE64_ALPHABET[combined >> 6 & 63] ?? "" : "=";
14
+ result += index + 2 < bytes.length ? BASE64_ALPHABET[combined & 63] ?? "" : "=";
15
+ }
16
+ return result;
17
+ }
18
+ function decodeBase64(value) {
19
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
20
+ throw new TypeError("cursor must be a valid Base64 token.");
21
+ }
22
+ const bytes = [];
23
+ for (let index = 0; index < value.length; index += 4) {
24
+ const characters = value.slice(index, index + 4);
25
+ const sextets = [...characters].map((character) => character === "=" ? 0 : BASE64_ALPHABET.indexOf(character));
26
+ const combined = (sextets[0] ?? 0) << 18 | (sextets[1] ?? 0) << 12 | (sextets[2] ?? 0) << 6 | (sextets[3] ?? 0);
27
+ bytes.push(combined >> 16 & 255);
28
+ if (characters[2] !== "=") bytes.push(combined >> 8 & 255);
29
+ if (characters[3] !== "=") bytes.push(combined & 255);
30
+ }
31
+ return new Uint8Array(bytes);
32
+ }
33
+ function encodeAzureTextAnswerCursor(value) {
34
+ return encodeBase64(new TextEncoder().encode(JSON.stringify(value)));
35
+ }
36
+ function decodeAzureTextAnswerCursor(cursor) {
37
+ try {
38
+ const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(cursor)));
39
+ if (!isRecord(value) || value.tableContinuationToken !== null && typeof value.tableContinuationToken !== "string" || !Number.isSafeInteger(value.entityIndex) || value.entityIndex < 0 || !Number.isSafeInteger(value.fieldIndex) || value.fieldIndex < 0) {
40
+ throw new TypeError("Azure text answer cursor payload is invalid.");
41
+ }
42
+ return {
43
+ tableContinuationToken: value.tableContinuationToken,
44
+ entityIndex: value.entityIndex,
45
+ fieldIndex: value.fieldIndex
46
+ };
47
+ } catch (cause) {
48
+ if (cause instanceof TypeError && cause.message === "Azure text answer cursor payload is invalid.") throw cause;
49
+ throw new TypeError("cursor must be a valid Azure text answer cursor.", { cause });
50
+ }
51
+ }
52
+ function textAnswerQuery(fieldIdOrOptions, options) {
53
+ const query = typeof fieldIdOrOptions === "string" ? options ?? {} : fieldIdOrOptions ?? {};
54
+ const requested = typeof fieldIdOrOptions === "string" ? [fieldIdOrOptions] : query.fieldIds;
55
+ if (requested?.some((fieldId) => fieldId.trim().length === 0)) {
56
+ throw new TypeError("fieldIds must not contain empty values.");
57
+ }
58
+ const fieldIds = requested === void 0 ? void 0 : [...new Set(requested)];
59
+ return { query, ...fieldIds === void 0 ? {} : { fieldIds } };
60
+ }
61
+ function submissionTextAnswers(submission, fieldIds) {
62
+ const entries = fieldIds === void 0 ? Object.entries(submission.values) : fieldIds.map((id) => [id, submission.values[id]]);
63
+ return entries.flatMap(
64
+ ([fieldId, value]) => typeof value === "string" && value.length > 0 ? [{ fieldId, text: value }] : []
65
+ );
66
+ }
3
67
  function cloneJson(value) {
4
68
  return JSON.parse(JSON.stringify(value));
5
69
  }
@@ -40,12 +104,38 @@ function scalarMetadata(metadata) {
40
104
  );
41
105
  }
42
106
  var defaultAzureTableSubmissionCodec = {
107
+ createEntity: (submission) => ({
108
+ ...scalarMetadata(submission.metadata),
109
+ kind: "submission",
110
+ formVersion: submission.formVersion,
111
+ locale: submission.locale,
112
+ submittedAt: submission.submittedAt,
113
+ responseId: submission.id,
114
+ payload: JSON.stringify(submission)
115
+ }),
116
+ deserialize: (entity) => parseSubmission(entity.payload, "submission entity"),
117
+ matchesEntity: (entity) => entity.kind === "submission",
43
118
  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")
119
+ createPartitionKeyFromQuery: (formId) => formId,
120
+ createRowKey: defaultSubmissionRowKey
48
121
  };
122
+ function legacyCodec(codec) {
123
+ return {
124
+ createEntity: (value) => ({
125
+ ...codec.serialize(value),
126
+ kind: "submission",
127
+ formVersion: value.formVersion,
128
+ locale: value.locale,
129
+ submittedAt: value.submittedAt,
130
+ responseId: value.id
131
+ }),
132
+ deserialize: codec.deserialize,
133
+ matchesEntity: (entity) => entity.kind === "submission",
134
+ createPartitionKey: codec.createPartitionKey,
135
+ createPartitionKeyFromQuery: (formId) => codec.createPartitionKeyFromFormId?.(formId) ?? formId,
136
+ createRowKey: codec.createRowKey
137
+ };
138
+ }
49
139
  function parseSchemaEntity(value) {
50
140
  if (typeof value.partitionKey !== "string" || typeof value.rowKey !== "string" || value.kind !== "schema" || !Number.isInteger(value.formVersion) || typeof value.payload !== "string") {
51
141
  throw new Error("Azure Table schema entity is invalid.");
@@ -58,40 +148,73 @@ function parseSchemaEntity(value) {
58
148
  return cloneJson(schema);
59
149
  }
60
150
  function parseSubmissionEntity(value, codec) {
61
- if (typeof value.partitionKey !== "string" || typeof value.rowKey !== "string" || value.kind !== "submission") {
151
+ if (typeof value.partitionKey !== "string" || typeof value.rowKey !== "string" || !codec.matchesEntity(value)) {
62
152
  throw new Error("Azure Table submission entity is invalid.");
63
153
  }
64
154
  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) {
66
- throw new Error("Azure Table submission entity has inconsistent metadata.");
155
+ if (codec.createPartitionKey(submission) !== value.partitionKey || codec.createRowKey(submission) !== value.rowKey) {
156
+ throw new Error("Azure Table submission entity has inconsistent keys.");
67
157
  }
68
158
  return submission;
69
159
  }
70
160
  function escapeOData(value) {
71
161
  return value.replaceAll("'", "''");
72
162
  }
73
- function metadataValueToOData(value) {
163
+ function valueToOData(value) {
74
164
  if (value === null) return "null";
75
165
  if (typeof value === "string") return `'${escapeOData(value)}'`;
76
166
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
77
167
  if (typeof value === "boolean") return String(value);
78
- throw new TypeError("Azure Table metadata OData filters support only scalar JSON values.");
168
+ throw new TypeError("Azure Table OData filters support only scalar JSON values.");
79
169
  }
80
170
  function metadataFiltersToOData(options) {
81
171
  return Object.entries(options.metadataFilters ?? {}).map(([key, value]) => {
82
172
  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)}`;
173
+ return `${key} eq ${valueToOData(value)}`;
84
174
  }).join(" and ");
85
175
  }
86
- function odataFilter(formId, options, extension) {
176
+ function odataProperty(path) {
177
+ if (path === "id" || path === "responseId") return "responseId";
178
+ if (["formVersion", "locale", "submittedAt"].includes(path)) return path;
179
+ if (path.startsWith("metadata.")) {
180
+ const property = path.slice("metadata.".length);
181
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(property) ? property : void 0;
182
+ }
183
+ return void 0;
184
+ }
185
+ function submissionFilterToOData(filter) {
186
+ if (filter.op === "and" || filter.op === "or") {
187
+ const converted = filter.filters.map(submissionFilterToOData);
188
+ if (filter.op === "or" && converted.some((value) => value === void 0)) return void 0;
189
+ const available = converted.filter((value) => value !== void 0 && value.length > 0);
190
+ if (available.length === 0) return void 0;
191
+ return available.map((value) => `(${value})`).join(filter.op === "and" ? " and " : " or ");
192
+ }
193
+ const property = odataProperty(filter.path);
194
+ if (property === void 0) return void 0;
195
+ if (filter.op === "eq") return `${property} eq ${valueToOData(filter.value)}`;
196
+ if (filter.op === "in") {
197
+ if (filter.values.length === 0) return "false";
198
+ return filter.values.map((value) => `${property} eq ${valueToOData(value)}`).join(" or ");
199
+ }
200
+ if (filter.op === "exists") return `${property} ${filter.value ? "ne" : "eq"} null`;
87
201
  return [
88
- ...formId === void 0 ? [] : [`PartitionKey eq '${escapeOData(formId)}'`],
89
- "kind eq 'submission'",
202
+ ...filter.from === void 0 ? [] : [`${property} ge ${valueToOData(filter.from)}`],
203
+ ...filter.to === void 0 ? [] : [`${property} le ${valueToOData(filter.to)}`]
204
+ ].join(" and ");
205
+ }
206
+ function defaultSubmissionFilter(codec, formId, options, legacyExtension) {
207
+ const partitionKey = codec.createPartitionKeyFromQuery(formId, options);
208
+ const ast = options.filter === void 0 || typeof options.filter === "function" ? void 0 : submissionFilterToOData(options.filter);
209
+ return [
210
+ ...partitionKey === void 0 ? [] : [`PartitionKey eq '${escapeOData(partitionKey)}'`],
211
+ ...codec === defaultAzureTableSubmissionCodec ? ["kind eq 'submission'"] : [],
90
212
  ...options.version === void 0 ? [] : [`formVersion eq ${options.version}`],
91
213
  ...options.since === void 0 ? [] : [`submittedAt ge '${escapeOData(options.since)}'`],
92
214
  ...options.until === void 0 ? [] : [`submittedAt le '${escapeOData(options.until)}'`],
93
215
  ...options.locale === void 0 ? [] : [`locale eq '${escapeOData(options.locale)}'`],
94
- ...extension.trim().length === 0 ? [] : [`(${extension})`]
216
+ ...ast === void 0 || ast.length === 0 ? [] : [`(${ast})`],
217
+ ...legacyExtension.trim().length === 0 ? [] : [`(${legacyExtension})`]
95
218
  ].join(" and ");
96
219
  }
97
220
  function isNotFound(error) {
@@ -100,26 +223,41 @@ function isNotFound(error) {
100
223
  function matchesBuiltInFilters(submission, formId, options) {
101
224
  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
225
  }
103
- function resolveClient(preferred, fallback, name) {
104
- const client = preferred ?? fallback;
226
+ function requireClient(client, name) {
105
227
  if (client === void 0) throw new TypeError(`${name} is required.`);
106
228
  return client;
107
229
  }
108
- function createAzureTableStorage(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));
230
+ function createAzureTableStorage(options = {}) {
231
+ const staticSchemas = options.schemasTableClient ?? options.client;
232
+ const staticSubmissions = options.submissionsTableClient ?? options.client;
233
+ const codec = options.codec ?? (options.submissionCodec === void 0 ? defaultAzureTableSubmissionCodec : legacyCodec(options.submissionCodec));
234
+ const maxScanPages = options.maxScanPages ?? 5;
235
+ if (!Number.isSafeInteger(maxScanPages) || maxScanPages < 1) {
236
+ throw new TypeError("maxScanPages must be a positive safe integer.");
237
+ }
238
+ const resolveDynamicClient = async (formId, query) => options.clientResolver?.({ formId, ...query === void 0 ? {} : { query } });
239
+ const schemaClient = async (formId) => requireClient(staticSchemas ?? await resolveDynamicClient(formId), "schemasTableClient or clientResolver");
240
+ const submissionClient = async (formId, query) => requireClient(
241
+ await resolveDynamicClient(formId, query) ?? staticSubmissions,
242
+ "submissionsTableClient or clientResolver"
243
+ );
244
+ const queryFilter = (formId, query) => {
245
+ if (options.buildSubmissionFilter !== void 0) return options.buildSubmissionFilter(formId, query);
246
+ return defaultSubmissionFilter(
247
+ codec,
248
+ formId,
249
+ query,
250
+ options.toODataFilter?.(query) ?? metadataFiltersToOData(query)
251
+ );
252
+ };
253
+ const deserializeIfMatching = (entity) => codec.matchesEntity(entity) ? parseSubmissionEntity(entity, codec) : void 0;
115
254
  const listSubmissionCandidates = async (formId, query) => {
255
+ const client = await submissionClient(formId, query);
116
256
  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);
120
- if (!matchesBuiltInFilters(submission, formId, query)) continue;
121
- if (!matchesSubmissionPageFilters(submission, query)) continue;
122
- found.push(submission);
257
+ for await (const raw of client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } })) {
258
+ const submission = deserializeIfMatching(raw);
259
+ if (submission === void 0 || !matchesBuiltInFilters(submission, formId, query)) continue;
260
+ if (matchesSubmissionPageFilters(submission, query)) found.push(submission);
123
261
  }
124
262
  return found.sort(
125
263
  (left, right) => left.submittedAt.localeCompare(right.submittedAt) || left.id.localeCompare(right.id)
@@ -135,11 +273,11 @@ function createAzureTableStorage(options) {
135
273
  formVersion: schema.version,
136
274
  payload: JSON.stringify(schema)
137
275
  };
138
- await schemas.upsertEntity(entity, "Replace");
276
+ await (await schemaClient(schema.id)).upsertEntity(entity, "Replace");
139
277
  },
140
278
  async getSchema(formId, formVersion) {
141
279
  try {
142
- return parseSchemaEntity(await schemas.getEntity(formId, schemaRowKey(formVersion)));
280
+ return parseSchemaEntity(await (await schemaClient(formId)).getEntity(formId, schemaRowKey(formVersion)));
143
281
  } catch (error) {
144
282
  if (isNotFound(error)) return null;
145
283
  throw error;
@@ -147,25 +285,20 @@ function createAzureTableStorage(options) {
147
285
  },
148
286
  async listSchemas() {
149
287
  const found = [];
150
- for await (const raw of schemas.listEntities({ queryOptions: { filter: "kind eq 'schema'" } })) {
288
+ for await (const raw of (await schemaClient("")).listEntities({ queryOptions: { filter: "kind eq 'schema'" } })) {
151
289
  if (raw.kind === "schema") found.push(parseSchemaEntity(raw));
152
290
  }
153
291
  return found.sort((left, right) => left.id.localeCompare(right.id) || left.version - right.version);
154
292
  },
155
293
  async deleteSchema(formId, formVersion) {
156
- await schemas.deleteEntity(formId, schemaRowKey(formVersion));
294
+ await (await schemaClient(formId)).deleteEntity(formId, schemaRowKey(formVersion));
157
295
  },
158
296
  async saveSubmission(submission) {
159
297
  const stored = parseSubmission(submission, `input ${String(submission?.id)}`);
160
- await submissions.createEntity({
161
- ...codec.serialize(stored),
298
+ await (await submissionClient(stored.formId)).createEntity({
299
+ ...codec.createEntity(stored),
162
300
  partitionKey: codec.createPartitionKey(stored),
163
- rowKey: codec.createRowKey(stored),
164
- kind: "submission",
165
- formVersion: stored.formVersion,
166
- locale: stored.locale,
167
- submittedAt: stored.submittedAt,
168
- responseId: stored.id
301
+ rowKey: codec.createRowKey(stored)
169
302
  });
170
303
  },
171
304
  async listSubmissions(formId, formVersion, queryOptions = {}) {
@@ -176,46 +309,147 @@ function createAzureTableStorage(options) {
176
309
  },
177
310
  async listSubmissionPage(formId, query = {}) {
178
311
  const pageSize = normalizeSubmissionPageSize(query.pageSize);
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;
312
+ const client = await submissionClient(formId, query);
313
+ const items = [];
314
+ let continuationToken = query.cursor;
315
+ let scannedPages = 0;
316
+ do {
317
+ const iterator = client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } }).byPage({
318
+ maxPageSize: Math.max(1, pageSize - items.length),
319
+ ...continuationToken === void 0 ? {} : { continuationToken }
320
+ });
321
+ const result = await iterator.next();
322
+ if (result.done === true) {
323
+ continuationToken = void 0;
324
+ break;
325
+ }
326
+ scannedPages += 1;
327
+ for (const raw of result.value) {
328
+ const submission = deserializeIfMatching(raw);
329
+ if (submission === void 0 || !matchesBuiltInFilters(submission, formId, query)) continue;
330
+ if (matchesSubmissionPageFilters(submission, query)) items.push(submission);
331
+ }
332
+ continuationToken = result.value.continuationToken;
333
+ } while (items.length < pageSize && continuationToken !== void 0 && scannedPages < maxScanPages);
186
334
  return {
187
335
  items,
188
- hasMore: nextCursor !== void 0 && nextCursor.length > 0,
189
- ...nextCursor === void 0 || nextCursor.length === 0 ? {} : { nextCursor }
336
+ hasMore: continuationToken !== void 0 && continuationToken.length > 0,
337
+ ...continuationToken === void 0 || continuationToken.length === 0 ? {} : { nextCursor: continuationToken }
190
338
  };
191
339
  },
192
- async deleteSubmission(submissionId) {
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.");
340
+ async listTextAnswerPage(formId, fieldIdOrOptions, providedOptions) {
341
+ const { query, fieldIds } = textAnswerQuery(fieldIdOrOptions, providedOptions);
342
+ const pageSize = normalizeSubmissionPageSize(query.pageSize);
343
+ const cursor = query.cursor === void 0 ? void 0 : decodeAzureTextAnswerCursor(query.cursor);
344
+ const client = await submissionClient(formId, query);
345
+ const items = [];
346
+ let tableContinuationToken = cursor?.tableContinuationToken ?? void 0;
347
+ let entityStartIndex = cursor?.entityIndex ?? 0;
348
+ let fieldStartIndex = cursor?.fieldIndex ?? 0;
349
+ let scannedPages = 0;
350
+ while (scannedPages < maxScanPages) {
351
+ const requestToken = tableContinuationToken;
352
+ const iterator = client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } }).byPage({
353
+ maxPageSize: pageSize,
354
+ ...requestToken === void 0 ? {} : { continuationToken: requestToken }
355
+ });
356
+ const result = await iterator.next();
357
+ if (result.done === true) break;
358
+ scannedPages += 1;
359
+ const page = result.value;
360
+ for (let entityIndex = entityStartIndex; entityIndex < page.length; entityIndex += 1) {
361
+ const raw = page[entityIndex];
362
+ if (raw === void 0) continue;
363
+ const submission = deserializeIfMatching(raw);
364
+ if (submission === void 0 || !matchesBuiltInFilters(submission, formId, query) || !matchesSubmissionPageFilters(submission, query)) {
365
+ fieldStartIndex = 0;
366
+ continue;
197
367
  }
198
- await submissions.deleteEntity(raw.partitionKey, raw.rowKey);
199
- return;
368
+ const answers = submissionTextAnswers(submission, fieldIds);
369
+ for (let fieldIndex = entityIndex === entityStartIndex ? fieldStartIndex : 0; fieldIndex < answers.length; fieldIndex += 1) {
370
+ const answer = answers[fieldIndex];
371
+ if (answer === void 0) continue;
372
+ items.push({
373
+ responseId: submission.id,
374
+ formId: submission.formId,
375
+ formVersion: submission.formVersion,
376
+ fieldId: answer.fieldId,
377
+ text: answer.text,
378
+ locale: submission.locale,
379
+ submittedAt: submission.submittedAt,
380
+ ...submission.metadata === void 0 ? {} : { metadata: submission.metadata }
381
+ });
382
+ if (items.length < pageSize) continue;
383
+ const nextFieldIndex = fieldIndex + 1;
384
+ const hasFieldsInEntity = nextFieldIndex < answers.length;
385
+ const hasEntitiesInPage = entityIndex + 1 < page.length;
386
+ const nextPageToken = page.continuationToken;
387
+ const hasMore = hasFieldsInEntity || hasEntitiesInPage || nextPageToken !== void 0;
388
+ if (!hasMore) return { items, hasMore: false };
389
+ const nextCursor = hasFieldsInEntity ? {
390
+ tableContinuationToken: requestToken ?? null,
391
+ entityIndex,
392
+ fieldIndex: nextFieldIndex
393
+ } : hasEntitiesInPage ? {
394
+ tableContinuationToken: requestToken ?? null,
395
+ entityIndex: entityIndex + 1,
396
+ fieldIndex: 0
397
+ } : {
398
+ tableContinuationToken: nextPageToken ?? null,
399
+ entityIndex: 0,
400
+ fieldIndex: 0
401
+ };
402
+ return { items, hasMore: true, nextCursor: encodeAzureTextAnswerCursor(nextCursor) };
403
+ }
404
+ fieldStartIndex = 0;
405
+ }
406
+ tableContinuationToken = page.continuationToken;
407
+ entityStartIndex = 0;
408
+ fieldStartIndex = 0;
409
+ if (tableContinuationToken === void 0) break;
410
+ }
411
+ if (tableContinuationToken === void 0) return { items, hasMore: false };
412
+ return {
413
+ items,
414
+ hasMore: true,
415
+ nextCursor: encodeAzureTextAnswerCursor({
416
+ tableContinuationToken,
417
+ entityIndex: 0,
418
+ fieldIndex: 0
419
+ })
420
+ };
421
+ },
422
+ async deleteSubmission(submissionId) {
423
+ const client = await submissionClient("");
424
+ for await (const raw of client.listEntities()) {
425
+ const submission = deserializeIfMatching(raw);
426
+ if (submission?.id !== submissionId) continue;
427
+ if (typeof raw.partitionKey !== "string" || typeof raw.rowKey !== "string") {
428
+ throw new Error("Azure Table submission entity is invalid.");
200
429
  }
430
+ await client.deleteEntity(raw.partitionKey, raw.rowKey);
431
+ return;
201
432
  }
202
433
  },
203
434
  async clearResponses(formId) {
204
- const partitionKey = partitionKeyFromFormId(formId);
205
- for await (const raw of submissions.listEntities({
206
- queryOptions: { filter: `PartitionKey eq '${escapeOData(partitionKey)}' and kind eq 'submission'` }
207
- })) {
208
- if (raw.partitionKey === partitionKey && raw.kind === "submission" && typeof raw.rowKey === "string") {
209
- await submissions.deleteEntity(partitionKey, raw.rowKey);
435
+ const query = {};
436
+ const client = await submissionClient(formId, query);
437
+ for await (const raw of client.listEntities({ queryOptions: { filter: queryFilter(formId, query) } })) {
438
+ const submission = deserializeIfMatching(raw);
439
+ if (submission?.formId !== formId) continue;
440
+ if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
441
+ await client.deleteEntity(raw.partitionKey, raw.rowKey);
210
442
  }
211
443
  }
212
444
  },
213
445
  async clear() {
446
+ const schemas = await schemaClient("");
214
447
  for await (const raw of schemas.listEntities()) {
215
448
  if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
216
449
  await schemas.deleteEntity(raw.partitionKey, raw.rowKey);
217
450
  }
218
451
  }
452
+ const submissions = await submissionClient("");
219
453
  if (submissions === schemas) return;
220
454
  for await (const raw of submissions.listEntities()) {
221
455
  if (typeof raw.partitionKey === "string" && typeof raw.rowKey === "string") {
@@ -228,5 +462,6 @@ function createAzureTableStorage(options) {
228
462
  export {
229
463
  createAzureTableStorage,
230
464
  defaultAzureTableSubmissionCodec,
231
- metadataFiltersToOData
465
+ metadataFiltersToOData,
466
+ submissionFilterToOData
232
467
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/storage-azure-table",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -39,7 +39,7 @@
39
39
  "typescript"
40
40
  ],
41
41
  "dependencies": {
42
- "@form-engine-ts/core": "2.6.0"
42
+ "@form-engine-ts/core": "2.8.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@azure/data-tables": "^13.3.2"