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