@appweaver/data 0.0.0-stage → 2.0.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.
Files changed (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -2
  3. package/config.d.ts +6 -0
  4. package/config.js +22 -0
  5. package/database/create-client.d.ts +11 -0
  6. package/database/create-client.js +145 -0
  7. package/database/database-config.d.ts +14 -0
  8. package/database/database-config.js +40 -0
  9. package/database/database-error.d.ts +49 -0
  10. package/database/database-error.js +216 -0
  11. package/database/index.d.ts +3 -0
  12. package/database/index.js +19 -0
  13. package/database/prisma-database.d.ts +33 -0
  14. package/database/prisma-database.js +178 -0
  15. package/database/run-transaction.d.ts +21 -0
  16. package/database/run-transaction.js +15 -0
  17. package/export/export-config.d.ts +7 -0
  18. package/export/export-config.js +17 -0
  19. package/export/export-service.d.ts +22 -0
  20. package/export/export-service.js +188 -0
  21. package/export/index.d.ts +1 -0
  22. package/export/index.js +17 -0
  23. package/factory/create-model.d.ts +11 -0
  24. package/factory/create-model.js +479 -0
  25. package/factory/create-policy.d.ts +12 -0
  26. package/factory/create-policy.js +20 -0
  27. package/factory/create-service.d.ts +14 -0
  28. package/factory/create-service.js +116 -0
  29. package/index.d.ts +22 -0
  30. package/index.js +38 -0
  31. package/internal.d.ts +2 -0
  32. package/internal.js +6 -0
  33. package/module.d.ts +3 -0
  34. package/module.js +81 -0
  35. package/package.json +67 -3
  36. package/prisma/client/browser.d.ts +39 -0
  37. package/prisma/client/browser.js +56 -0
  38. package/prisma/client/client.d.ts +58 -0
  39. package/prisma/client/client.js +70 -0
  40. package/prisma/client/commonInputTypes.d.ts +345 -0
  41. package/prisma/client/commonInputTypes.js +11 -0
  42. package/prisma/client/enums.d.ts +1 -0
  43. package/prisma/client/enums.js +11 -0
  44. package/prisma/client/internal/class.d.ts +198 -0
  45. package/prisma/client/internal/class.js +85 -0
  46. package/prisma/client/internal/prismaNamespace.d.ts +1125 -0
  47. package/prisma/client/internal/prismaNamespace.js +204 -0
  48. package/prisma/client/internal/prismaNamespaceBrowser.d.ts +137 -0
  49. package/prisma/client/internal/prismaNamespaceBrowser.js +175 -0
  50. package/prisma/client/models/ApiKey.d.ts +1167 -0
  51. package/prisma/client/models/ApiKey.js +2 -0
  52. package/prisma/client/models/ConnectedAccount.d.ts +1094 -0
  53. package/prisma/client/models/ConnectedAccount.js +2 -0
  54. package/prisma/client/models/File.d.ts +1291 -0
  55. package/prisma/client/models/File.js +2 -0
  56. package/prisma/client/models/OneTimeToken.d.ts +1055 -0
  57. package/prisma/client/models/OneTimeToken.js +2 -0
  58. package/prisma/client/models/Permission.d.ts +1209 -0
  59. package/prisma/client/models/Permission.js +2 -0
  60. package/prisma/client/models/Role.d.ts +1209 -0
  61. package/prisma/client/models/Role.js +2 -0
  62. package/prisma/client/models/Seeder.d.ts +1023 -0
  63. package/prisma/client/models/Seeder.js +2 -0
  64. package/prisma/client/models.d.ts +8 -0
  65. package/prisma/client/models.js +2 -0
  66. package/resource/load-data-resources.d.ts +13 -0
  67. package/resource/load-data-resources.js +37 -0
  68. package/resource/model-schema.d.ts +34 -0
  69. package/resource/model-schema.js +54 -0
  70. package/resource/resource-config.d.ts +16 -0
  71. package/resource/resource-config.js +62 -0
  72. package/resource/resource-loader.d.ts +25 -0
  73. package/resource/resource-loader.js +138 -0
  74. package/resource/resource-service.d.ts +462 -0
  75. package/resource/resource-service.js +1042 -0
  76. package/resource/schemas/index.d.ts +3 -0
  77. package/resource/schemas/index.js +19 -0
  78. package/resource/schemas/resource-aggregate-schema.d.ts +53 -0
  79. package/resource/schemas/resource-aggregate-schema.js +130 -0
  80. package/resource/schemas/resource-filter-schema.d.ts +39 -0
  81. package/resource/schemas/resource-filter-schema.js +153 -0
  82. package/resource/schemas/resource-sort-schema.d.ts +38 -0
  83. package/resource/schemas/resource-sort-schema.js +113 -0
  84. package/resource/utils/aggregate-util.d.ts +113 -0
  85. package/resource/utils/aggregate-util.js +322 -0
  86. package/resource/utils/cursor-util.d.ts +60 -0
  87. package/resource/utils/cursor-util.js +116 -0
  88. package/resource/utils/date-util.d.ts +22 -0
  89. package/resource/utils/date-util.js +112 -0
  90. package/resource/utils/delete-util.d.ts +131 -0
  91. package/resource/utils/delete-util.js +385 -0
  92. package/resource/utils/filter-util.d.ts +24 -0
  93. package/resource/utils/filter-util.js +289 -0
  94. package/resource/utils/prune-util.d.ts +11 -0
  95. package/resource/utils/prune-util.js +56 -0
  96. package/resource/utils/relation-util.d.ts +75 -0
  97. package/resource/utils/relation-util.js +400 -0
  98. package/resource/utils/sort-util.d.ts +31 -0
  99. package/resource/utils/sort-util.js +235 -0
  100. package/resource/utils/text-search-util.d.ts +14 -0
  101. package/resource/utils/text-search-util.js +39 -0
  102. package/resources.d.ts +10 -0
  103. package/resources.js +26 -0
  104. package/security/auth-config.d.ts +23 -0
  105. package/security/auth-config.js +43 -0
  106. package/security/create-auth-resources.d.ts +28 -0
  107. package/security/create-auth-resources.js +163 -0
  108. package/security/helper.d.ts +109 -0
  109. package/security/helper.js +298 -0
  110. package/security/resources/api-key/model.d.ts +2 -0
  111. package/security/resources/api-key/model.js +67 -0
  112. package/security/resources/api-key/policy.d.ts +2 -0
  113. package/security/resources/api-key/policy.js +27 -0
  114. package/security/resources/api-key/service.d.ts +3 -0
  115. package/security/resources/api-key/service.js +34 -0
  116. package/security/resources/connected-account/model.d.ts +2 -0
  117. package/security/resources/connected-account/model.js +57 -0
  118. package/security/resources/connected-account/service.d.ts +2 -0
  119. package/security/resources/connected-account/service.js +7 -0
  120. package/security/resources/one-time-token/model.d.ts +2 -0
  121. package/security/resources/one-time-token/model.js +36 -0
  122. package/security/resources/permission/model.d.ts +2 -0
  123. package/security/resources/permission/model.js +16 -0
  124. package/security/resources/role/model.d.ts +2 -0
  125. package/security/resources/role/model.js +22 -0
  126. package/security/store/database-security-store.d.ts +5 -0
  127. package/security/store/database-security-store.js +54 -0
  128. package/seeder/create-seeder.d.ts +29 -0
  129. package/seeder/create-seeder.js +28 -0
  130. package/seeder/index.d.ts +2 -0
  131. package/seeder/index.js +18 -0
  132. package/seeder/resources/seeder/model.d.ts +2 -0
  133. package/seeder/resources/seeder/model.js +38 -0
  134. package/seeder/seeder.d.ts +29 -0
  135. package/seeder/seeder.js +197 -0
  136. package/storage/file-service.d.ts +133 -0
  137. package/storage/file-service.js +586 -0
  138. package/storage/image-config.d.ts +3 -0
  139. package/storage/image-config.js +11 -0
  140. package/storage/resources/file/model.d.ts +2 -0
  141. package/storage/resources/file/model.js +80 -0
  142. package/types/auth.d.ts +23 -0
  143. package/types/auth.js +2 -0
  144. package/types/generated.d.ts +366 -0
  145. package/types/generated.js +3 -0
  146. package/types/storage.d.ts +35 -0
  147. package/types/storage.js +2 -0
  148. package/utils/export-util.d.ts +8 -0
  149. package/utils/export-util.js +26 -0
  150. package/utils/file-util.d.ts +106 -0
  151. package/utils/file-util.js +254 -0
  152. package/utils/model-util.d.ts +36 -0
  153. package/utils/model-util.js +154 -0
  154. package/utils/policy-util.d.ts +18 -0
  155. package/utils/policy-util.js +28 -0
  156. package/utils/virtual-util.d.ts +11 -0
  157. package/utils/virtual-util.js +53 -0
@@ -0,0 +1,113 @@
1
+ import { AggregateSelect, AggregateValue, ResourceClient, ResourceModel } from '@appweaver/common';
2
+ /** The kind of value an aggregatable field holds. */
3
+ export type AggregateFieldType = 'numeric' | 'date';
4
+ /**
5
+ * The operations a selection is resolved by: the arguments of a single database aggregation, and the fields read off
6
+ * the earliest and the latest record of the aggregated range.
7
+ */
8
+ export type AggregationOperations = {
9
+ /** The Prisma aggregation arguments, keyed by the prefixed operator name */
10
+ aggregate: Record<string, any>;
11
+ /** The fields whose value is read off the earliest record of the range */
12
+ first: string[];
13
+ /** The fields whose value is read off the latest record of the range */
14
+ last: string[];
15
+ };
16
+ /** A single aggregation period, labeled with the median date of its range. */
17
+ export interface AggregationRange {
18
+ from: Date;
19
+ to: Date;
20
+ median: Date;
21
+ }
22
+ /** The resolved aggregation date range together with its periods. */
23
+ export interface AggregationPeriods {
24
+ fromDate: Date;
25
+ toDate: Date;
26
+ ranges: AggregationRange[];
27
+ }
28
+ /**
29
+ * Resolves an aggregation date range from its ISO string bounds and splits it into equally sized periods.
30
+ *
31
+ * @param {string} [from] - The ISO date string of the range start. Defaults to seven days before the range end.
32
+ * @param {string} [to] - The ISO date string of the range end. Defaults to the current date and time.
33
+ * @param {number} [step] - The size of a single period in units of the automatically selected time unit. If not
34
+ * provided, one unit is used and the unit is derived from the range length.
35
+ * @param {boolean} [safeIncrement=true] - Whether the period increments use the derived time unit and stay consistent
36
+ * across daylight saving time changes. When false, the step is interpreted in seconds.
37
+ * @return {AggregationPeriods} The resolved range bounds and one range per period, each labeled with its median date.
38
+ */
39
+ export declare function buildAggregationPeriods(from?: string, to?: string, step?: number, safeIncrement?: boolean): AggregationPeriods;
40
+ /**
41
+ * Maps an aggregation selection to the operations that resolve it, splitting the operators the database aggregates in
42
+ * a single query from the `first` and `last` operators, which read the boundary records of the range instead. Every
43
+ * field is validated against the model it is aggregated on, and every operator against the kind of value its field
44
+ * holds, since only the numeric fields can be summed and averaged.
45
+ *
46
+ * @param {AggregateSelect<Object>} select - The operations to perform per field, with the operators of a field nested
47
+ * inside it. The operators set to false are left out.
48
+ * @param {string} resourceName - The name of the model the selection is aggregated on.
49
+ * @return {AggregationOperations} The Prisma aggregation arguments, keyed by the prefixed operator name with the
50
+ * selected fields nested inside, together with the fields read off the earliest and the latest record of the range.
51
+ * The arguments always count the records of the range when a boundary field was selected, so the boundary queries can
52
+ * be skipped for the empty ranges.
53
+ * @throws {ResourceError} `RESOURCE_INVALID_AGGREGATE` if the selection is not an object, selects no operation at all, names a field the model
54
+ * cannot aggregate, or applies an operator the kind of value of its field does not support.
55
+ */
56
+ export declare function mapAggregationSelect<T>(select: AggregateSelect<T>, resourceName: string): AggregationOperations;
57
+ /**
58
+ * Maps a Prisma aggregation result back to the response format by swapping the operator and field nesting, so a
59
+ * `{ _count: { views: 3 } }` result becomes the `{ views: { count: 3 } }` response value. The boundary record values
60
+ * are mapped the same way when they are merged into the result under their own `_first` and `_last` keys.
61
+ *
62
+ * @param {Object} result - The Prisma aggregation result, keyed by the prefixed operator name with the aggregated
63
+ * fields nested inside.
64
+ * @return {AggregateValue<Object>} The aggregated values keyed by field name, with the operator results nested inside.
65
+ * The record count of the range is left out, since it counts no field of its own.
66
+ */
67
+ export declare function mapAggregationResult<T>(result: Record<string, Record<string, any>>): AggregateValue<T>;
68
+ /**
69
+ * Reads the `first` and `last` values of an aggregated range off its boundary records. The earliest and the latest
70
+ * record of the range are looked up by the same date field the range is sliced by, with the record id breaking the
71
+ * ties, and only the selected fields are read off them. The values of an empty range are resolved to null without
72
+ * querying for them, so a range the aggregation already counted as empty costs nothing.
73
+ *
74
+ * @param {ResourceClient} client - The model client the boundary records are read with, which is the transaction
75
+ * client of the aggregation.
76
+ * @param {Object} where - The database query of the aggregated range, applied unchanged to the boundary lookups.
77
+ * @param {string} dateField - The date field the range is sliced by, which the boundary records are ordered by.
78
+ * @param {AggregationOperations} operations - The operations holding the fields to read off each boundary record.
79
+ * @param {number} [recordCount] - The number of records in the range, used to skip the lookups of an empty range. When
80
+ * omitted, the lookups are always performed.
81
+ * @return {Promise<Object>} The boundary values keyed by the prefixed operator name (`_first` and `_last`) with the
82
+ * read fields nested inside, ready to be merged into the aggregation result of the range. Empty when no boundary field
83
+ * was selected.
84
+ */
85
+ export declare function readAggregationBoundaries(client: ResourceClient, where: any, dateField: string, operations: AggregationOperations, recordCount?: number): Promise<Record<string, any>>;
86
+ /**
87
+ * Reads the record count a Prisma aggregation result holds for its range, as counted by the `_all` selection that
88
+ * {@link mapAggregationSelect} adds whenever a boundary field is selected.
89
+ *
90
+ * @param {Object} result - The Prisma aggregation result to read the count off.
91
+ * @return {number|undefined} The number of records in the range, or undefined when the result does not count them.
92
+ */
93
+ export declare function aggregationRecordCount(result: Record<string, any>): number | undefined;
94
+ /**
95
+ * Validates the date field the aggregated range is applied on against the model it is aggregated on.
96
+ *
97
+ * @param {string} dateField - The name of the date field to validate.
98
+ * @param {string} resourceName - The name of the model the range is applied on.
99
+ * @return {string} The validated date field name.
100
+ * @throws {ResourceError} `RESOURCE_INVALID_AGGREGATE` if the model has no date field under that name.
101
+ */
102
+ export declare function checkAggregationDateField(dateField: string, resourceName: string): string;
103
+ /**
104
+ * Lists the aggregatable fields of a resource model together with the kind of value they hold. The id is aggregatable
105
+ * when it is numeric, the audit fields follow the audit configuration of the model, and the scalars are aggregatable
106
+ * when they hold a single numeric or date value and are not hidden. The virtual fields are never aggregatable, since
107
+ * they have no column of their own.
108
+ *
109
+ * @param {ResourceModel} model - The resource model whose fields are listed.
110
+ * @return {Array} The name and kind (`numeric` or `date`) of every aggregatable field, in the order the model declares
111
+ * them.
112
+ */
113
+ export declare function aggregateFields(model: ResourceModel): [field: string, type: AggregateFieldType][];
@@ -0,0 +1,322 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildAggregationPeriods = buildAggregationPeriods;
4
+ exports.mapAggregationSelect = mapAggregationSelect;
5
+ exports.mapAggregationResult = mapAggregationResult;
6
+ exports.readAggregationBoundaries = readAggregationBoundaries;
7
+ exports.aggregationRecordCount = aggregationRecordCount;
8
+ exports.checkAggregationDateField = checkAggregationDateField;
9
+ exports.aggregateFields = aggregateFields;
10
+ const date_util_1 = require("./date-util");
11
+ const common_1 = require("@appweaver/common");
12
+ const core_1 = require("@appweaver/core");
13
+ /** The scalar types holding a numeric value, which every operator applies to. */
14
+ const NUMERIC_TYPES = ['int', 'bigInt', 'float'];
15
+ /** The scalar types holding a date value, which cannot be summed or averaged. */
16
+ const DATE_TYPES = ['dateTime'];
17
+ /** The operators resolved by reading a boundary record of the range instead of aggregating it. */
18
+ const BOUNDARY_OPERATORS = ['first', 'last'];
19
+ /** The Prisma selection counting every record of a range, regardless of the fields it holds. */
20
+ const COUNT_ALL_FIELD = '_all';
21
+ /** The aggregation operators applicable to a field, per kind of value. */
22
+ const OPERATORS = {
23
+ numeric: ['count', 'sum', 'avg', 'min', 'max', ...BOUNDARY_OPERATORS],
24
+ date: ['count', 'min', 'max', ...BOUNDARY_OPERATORS]
25
+ };
26
+ /**
27
+ * Resolves an aggregation date range from its ISO string bounds and splits it into equally sized periods.
28
+ *
29
+ * @param {string} [from] - The ISO date string of the range start. Defaults to seven days before the range end.
30
+ * @param {string} [to] - The ISO date string of the range end. Defaults to the current date and time.
31
+ * @param {number} [step] - The size of a single period in units of the automatically selected time unit. If not
32
+ * provided, one unit is used and the unit is derived from the range length.
33
+ * @param {boolean} [safeIncrement=true] - Whether the period increments use the derived time unit and stay consistent
34
+ * across daylight saving time changes. When false, the step is interpreted in seconds.
35
+ * @return {AggregationPeriods} The resolved range bounds and one range per period, each labeled with its median date.
36
+ */
37
+ function buildAggregationPeriods(from, to, step, safeIncrement = true) {
38
+ const toDate = (0, date_util_1.parseIsoDate)(to ?? new Date().toISOString());
39
+ const fromDate = from ? (0, date_util_1.parseIsoDate)(from) : (0, date_util_1.addDays)(toDate, -7);
40
+ const iterator = makeAggregationIterator(fromDate, toDate, step, safeIncrement);
41
+ const ranges = [];
42
+ let currentDate = new Date(fromDate.getTime());
43
+ while (currentDate < toDate) {
44
+ const date = currentDate;
45
+ const median = iterator.addPeriod(date, (iterator.step - 1) / 2);
46
+ currentDate = iterator.addPeriod(currentDate, iterator.step);
47
+ ranges.push({ from: date, to: currentDate, median });
48
+ }
49
+ return { fromDate, toDate, ranges };
50
+ }
51
+ /**
52
+ * Maps an aggregation selection to the operations that resolve it, splitting the operators the database aggregates in
53
+ * a single query from the `first` and `last` operators, which read the boundary records of the range instead. Every
54
+ * field is validated against the model it is aggregated on, and every operator against the kind of value its field
55
+ * holds, since only the numeric fields can be summed and averaged.
56
+ *
57
+ * @param {AggregateSelect<Object>} select - The operations to perform per field, with the operators of a field nested
58
+ * inside it. The operators set to false are left out.
59
+ * @param {string} resourceName - The name of the model the selection is aggregated on.
60
+ * @return {AggregationOperations} The Prisma aggregation arguments, keyed by the prefixed operator name with the
61
+ * selected fields nested inside, together with the fields read off the earliest and the latest record of the range.
62
+ * The arguments always count the records of the range when a boundary field was selected, so the boundary queries can
63
+ * be skipped for the empty ranges.
64
+ * @throws {ResourceError} `RESOURCE_INVALID_AGGREGATE` if the selection is not an object, selects no operation at all, names a field the model
65
+ * cannot aggregate, or applies an operator the kind of value of its field does not support.
66
+ */
67
+ function mapAggregationSelect(select, resourceName) {
68
+ if (!(0, common_1.isPlainObject)(select)) {
69
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidAggregate, `${resourceName} aggregate select must be an object of fields to aggregate`, { model: resourceName });
70
+ }
71
+ const model = (0, core_1.injectModel)(resourceName);
72
+ const fieldTypes = new Map(aggregateFields(model));
73
+ const operations = {
74
+ aggregate: {},
75
+ first: [],
76
+ last: []
77
+ };
78
+ for (const [field, operators] of Object.entries(select)) {
79
+ const fieldType = fieldTypes.get(field);
80
+ if (!fieldType) {
81
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidAggregate, `Cannot aggregate the '${field}' field, it is not a numeric or date field of the ${model.name} model`, { model: model.name, field });
82
+ }
83
+ if (!(0, common_1.isPlainObject)(operators)) {
84
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidAggregate, `Cannot aggregate the '${field}' field, its value must be an object of aggregation operators`, { model: model.name, field });
85
+ }
86
+ for (const [operator, enabled] of Object.entries(operators)) {
87
+ if (!OPERATORS[fieldType].includes(operator)) {
88
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidAggregate, `Cannot apply the '${operator}' operator to the ${fieldType} field '${field}', ` +
89
+ `expected one of: ${OPERATORS[fieldType].join(', ')}`, { model: model.name, field, operator });
90
+ }
91
+ if (!enabled) {
92
+ continue;
93
+ }
94
+ if (isBoundaryOperator(operator)) {
95
+ operations[operator].push(field);
96
+ }
97
+ else {
98
+ (0, common_1.setValue)(operations.aggregate, `_${operator}.${field}`, true);
99
+ }
100
+ }
101
+ }
102
+ const hasBoundary = operations.first.length + operations.last.length > 0;
103
+ if (Object.keys(operations.aggregate).length === 0 && !hasBoundary) {
104
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidAggregate, `${resourceName} aggregate requires at least one field with a selected aggregation operator`, { model: resourceName });
105
+ }
106
+ // The record count of a range decides whether its boundary records have to be
107
+ // read at all, and it is also the only aggregation left to perform when the
108
+ // selection holds nothing but the boundary operators
109
+ if (hasBoundary) {
110
+ (0, common_1.setValue)(operations.aggregate, `_count.${COUNT_ALL_FIELD}`, true);
111
+ }
112
+ return operations;
113
+ }
114
+ /**
115
+ * Maps a Prisma aggregation result back to the response format by swapping the operator and field nesting, so a
116
+ * `{ _count: { views: 3 } }` result becomes the `{ views: { count: 3 } }` response value. The boundary record values
117
+ * are mapped the same way when they are merged into the result under their own `_first` and `_last` keys.
118
+ *
119
+ * @param {Object} result - The Prisma aggregation result, keyed by the prefixed operator name with the aggregated
120
+ * fields nested inside.
121
+ * @return {AggregateValue<Object>} The aggregated values keyed by field name, with the operator results nested inside.
122
+ * The record count of the range is left out, since it counts no field of its own.
123
+ */
124
+ function mapAggregationResult(result) {
125
+ const aggregationMap = {};
126
+ for (const operator in result) {
127
+ const fields = result[operator];
128
+ for (const field in fields) {
129
+ if (field === COUNT_ALL_FIELD) {
130
+ continue;
131
+ }
132
+ (0, common_1.setValue)(aggregationMap, `${field}.${operator.substring(1)}`, fields[field]);
133
+ }
134
+ }
135
+ return aggregationMap;
136
+ }
137
+ /**
138
+ * Reads the `first` and `last` values of an aggregated range off its boundary records. The earliest and the latest
139
+ * record of the range are looked up by the same date field the range is sliced by, with the record id breaking the
140
+ * ties, and only the selected fields are read off them. The values of an empty range are resolved to null without
141
+ * querying for them, so a range the aggregation already counted as empty costs nothing.
142
+ *
143
+ * @param {ResourceClient} client - The model client the boundary records are read with, which is the transaction
144
+ * client of the aggregation.
145
+ * @param {Object} where - The database query of the aggregated range, applied unchanged to the boundary lookups.
146
+ * @param {string} dateField - The date field the range is sliced by, which the boundary records are ordered by.
147
+ * @param {AggregationOperations} operations - The operations holding the fields to read off each boundary record.
148
+ * @param {number} [recordCount] - The number of records in the range, used to skip the lookups of an empty range. When
149
+ * omitted, the lookups are always performed.
150
+ * @return {Promise<Object>} The boundary values keyed by the prefixed operator name (`_first` and `_last`) with the
151
+ * read fields nested inside, ready to be merged into the aggregation result of the range. Empty when no boundary field
152
+ * was selected.
153
+ */
154
+ async function readAggregationBoundaries(client, where, dateField, operations, recordCount) {
155
+ const boundaries = {};
156
+ const readBoundary = async (operator, direction) => {
157
+ const fields = operations[operator];
158
+ if (fields.length === 0) {
159
+ return;
160
+ }
161
+ // An empty range holds no record to read the values off, and its fields are
162
+ // resolved to null the same way the database aggregations resolve theirs
163
+ const record = recordCount === 0
164
+ ? undefined
165
+ : await client.findFirst({
166
+ where,
167
+ // The id breaks the ties of the records sharing the same date, so
168
+ // the boundary of a range stays the same across identical requests
169
+ orderBy: [{ [dateField]: direction }, { id: direction }],
170
+ select: Object.fromEntries(fields.map((field) => [field, true]))
171
+ });
172
+ boundaries[`_${operator}`] = Object.fromEntries(fields.map((field) => [field, record?.[field] ?? null]));
173
+ };
174
+ await Promise.all([
175
+ readBoundary('first', 'asc'),
176
+ readBoundary('last', 'desc')
177
+ ]);
178
+ return boundaries;
179
+ }
180
+ /**
181
+ * Reads the record count a Prisma aggregation result holds for its range, as counted by the `_all` selection that
182
+ * {@link mapAggregationSelect} adds whenever a boundary field is selected.
183
+ *
184
+ * @param {Object} result - The Prisma aggregation result to read the count off.
185
+ * @return {number|undefined} The number of records in the range, or undefined when the result does not count them.
186
+ */
187
+ function aggregationRecordCount(result) {
188
+ return (0, common_1.isPlainObject)(result?._count)
189
+ ? result._count[COUNT_ALL_FIELD]
190
+ : undefined;
191
+ }
192
+ /** Determines whether an operator reads a boundary record instead of aggregating the range. */
193
+ function isBoundaryOperator(operator) {
194
+ return BOUNDARY_OPERATORS.includes(operator);
195
+ }
196
+ /**
197
+ * Validates the date field the aggregated range is applied on against the model it is aggregated on.
198
+ *
199
+ * @param {string} dateField - The name of the date field to validate.
200
+ * @param {string} resourceName - The name of the model the range is applied on.
201
+ * @return {string} The validated date field name.
202
+ * @throws {ResourceError} `RESOURCE_INVALID_AGGREGATE` if the model has no date field under that name.
203
+ */
204
+ function checkAggregationDateField(dateField, resourceName) {
205
+ const model = (0, core_1.injectModel)(resourceName);
206
+ const dateFields = aggregateFields(model)
207
+ .filter(([, type]) => type === 'date')
208
+ .map(([field]) => field);
209
+ if (!dateFields.includes(dateField)) {
210
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidAggregate, `Cannot aggregate over the '${dateField}' field, it is not a date field of the ${model.name} model` +
211
+ (dateFields.length > 0
212
+ ? `, expected one of: ${dateFields.join(', ')}`
213
+ : ''), { model: model.name, field: dateField });
214
+ }
215
+ return dateField;
216
+ }
217
+ /**
218
+ * Lists the aggregatable fields of a resource model together with the kind of value they hold. The id is aggregatable
219
+ * when it is numeric, the audit fields follow the audit configuration of the model, and the scalars are aggregatable
220
+ * when they hold a single numeric or date value and are not hidden. The virtual fields are never aggregatable, since
221
+ * they have no column of their own.
222
+ *
223
+ * @param {ResourceModel} model - The resource model whose fields are listed.
224
+ * @return {Array} The name and kind (`numeric` or `date`) of every aggregatable field, in the order the model declares
225
+ * them.
226
+ */
227
+ function aggregateFields(model) {
228
+ const fields = [];
229
+ if ((model.config.id?.type ?? 'int') !== 'string') {
230
+ fields.push(['id', 'numeric']);
231
+ }
232
+ const auditFields = {
233
+ updatedAt: true,
234
+ createdAt: true,
235
+ createdById: true,
236
+ ...(model.config.audit ?? {})
237
+ };
238
+ for (const [fieldName, included] of Object.entries(auditFields)) {
239
+ if (included) {
240
+ fields.push([
241
+ fieldName,
242
+ fieldName === 'createdById' ? 'numeric' : 'date'
243
+ ]);
244
+ }
245
+ }
246
+ for (const [fieldName, scalar] of Object.entries(model.config.scalars ?? {})) {
247
+ if (scalar.hidden || scalar.array) {
248
+ continue;
249
+ }
250
+ if (NUMERIC_TYPES.includes(scalar.type)) {
251
+ fields.push([fieldName, 'numeric']);
252
+ }
253
+ else if (DATE_TYPES.includes(scalar.type)) {
254
+ fields.push([fieldName, 'date']);
255
+ }
256
+ }
257
+ return fields;
258
+ }
259
+ /**
260
+ * Builds the period iterator used to split an aggregation date range into equally sized periods. When no step is
261
+ * provided, a step of one is used with the time unit derived from the range length, and the returned increment
262
+ * function compensates for daylight saving time offset changes so every period keeps the time zone offset of its
263
+ * start date.
264
+ *
265
+ * @param {Date} fromDate - The start of the aggregated date range.
266
+ * @param {Date} toDate - The end of the aggregated date range.
267
+ * @param {number} [step] - The size of a single period in units of the selected time unit. If not provided, a step of
268
+ * one is used with the time unit derived from the range length (seconds up to a minute, minutes up to an hour, hours
269
+ * up to a day, days up to a month, months up to a year, and years beyond that).
270
+ * @param {boolean} [safeIncrement=true] - Whether the increments use the derived time unit. When false, the step is
271
+ * interpreted in seconds.
272
+ * @return {{addPeriod: PeriodIncrementFn, step: number}} The iterator holding the resolved step amount and the
273
+ * `addPeriod` function that adds a number of periods to a date while preserving the time zone offset of that date.
274
+ */
275
+ function makeAggregationIterator(fromDate, toDate, step, safeIncrement = true) {
276
+ let stepAmount = step;
277
+ let incrementFn = date_util_1.addSeconds;
278
+ if (!stepAmount) {
279
+ const diffInSeconds = (0, date_util_1.differenceInSeconds)(toDate, fromDate);
280
+ const diffInMonths = (0, date_util_1.differenceInMonths)(toDate, fromDate);
281
+ const diffInYears = (0, date_util_1.differenceInYears)(toDate, fromDate);
282
+ stepAmount = 1;
283
+ // 1-second step if the difference is less than or equal to 1 minute
284
+ if (diffInSeconds <= 60) {
285
+ incrementFn = date_util_1.addSeconds;
286
+ }
287
+ // 1-minute step if the difference is less than or equal to 1 hour
288
+ else if (diffInSeconds <= 3600) {
289
+ incrementFn = date_util_1.addMinutes;
290
+ }
291
+ // 1-hour step if the difference is less than or equal to 1 day
292
+ else if (diffInSeconds <= 86400) {
293
+ incrementFn = date_util_1.addHours;
294
+ }
295
+ // 1-day step if the difference is less than or equal to 1 month
296
+ else if (diffInMonths <= 1) {
297
+ incrementFn = date_util_1.addDays;
298
+ }
299
+ // 1-month step if the difference is less than or equal to 1 year
300
+ else if (diffInYears <= 1) {
301
+ incrementFn = date_util_1.addMonths;
302
+ }
303
+ // 1-year step if the difference is equal to 1 year or more
304
+ else {
305
+ incrementFn = date_util_1.addYears;
306
+ }
307
+ }
308
+ // A higher-order function that adjusts date increments to account for
309
+ // changes in daylight saving time (DST). When incrementing dates in time
310
+ // zones that observe DST, this function ensures that the resulting date
311
+ // remains consistent with the original date's time zone offset.
312
+ const dstAgnosticFn = (fn) => {
313
+ return (date, amount) => {
314
+ const endDate = fn(date, amount);
315
+ return (0, date_util_1.addMinutes)(endDate, date.getTimezoneOffset() - endDate.getTimezoneOffset());
316
+ };
317
+ };
318
+ return {
319
+ addPeriod: dstAgnosticFn(safeIncrement ? incrementFn : date_util_1.addSeconds),
320
+ step: stepAmount
321
+ };
322
+ }
@@ -0,0 +1,60 @@
1
+ import { QueryResponse, ResourceId } from '@appweaver/common';
2
+ /** The cursors a query response carries, kept in step with the response contract. */
3
+ export type PageCursors = Pick<QueryResponse<unknown>, 'nextCursor' | 'prevCursor'>;
4
+ /** A decoded cursor, holding the record it points at and the direction to page in. */
5
+ export type DecodedCursor = {
6
+ id: ResourceId;
7
+ /** True when the cursor pages towards the preceding records */
8
+ backward: boolean;
9
+ };
10
+ /**
11
+ * Builds the fingerprint identifying the query a cursor belongs to. A cursor
12
+ * only yields the intended records while the resource, the query, and the order
13
+ * stay the same, so it carries the fingerprint and is rejected on mismatch.
14
+ *
15
+ * @param {string} resourceName - The name of the queried model.
16
+ * @param {Object} query - The mapped database query the cursor was issued for.
17
+ * @param {Object[]} orderBy - The mapped order entries the cursor was issued for.
18
+ * @return {string} The fingerprint of the query.
19
+ */
20
+ export declare function queryFingerprint(resourceName: string, query: any, orderBy: any[]): string;
21
+ /**
22
+ * Encodes the cursor of a page adjacent to a query result. The direction belongs
23
+ * to the cursor rather than to the request, so a caller cannot pair one with a
24
+ * direction it was not issued for.
25
+ *
26
+ * @param {ResourceId} id - The primary key of the record the page continues from.
27
+ * @param {string} fingerprint - The fingerprint of the query, as built by
28
+ * {@link queryFingerprint}.
29
+ * @param {boolean} [backward] - Whether the cursor pages towards the preceding
30
+ * records.
31
+ * @return {string} The encoded cursor.
32
+ */
33
+ export declare function encodeCursor(id: ResourceId, fingerprint: string, backward?: boolean): string;
34
+ /**
35
+ * Builds the cursors of the pages adjacent to a returned page, each addressing
36
+ * the record of this page it continues from. A page that has no neighbour in a
37
+ * direction, and an empty page, yield null rather than an absent cursor.
38
+ *
39
+ * @param {Object[]} resources - The records of the returned page, in the order
40
+ * they were queried in.
41
+ * @param {string} fingerprint - The fingerprint of the query the page belongs to.
42
+ * @param {boolean} hasNext - Whether a page follows the returned one.
43
+ * @param {boolean} hasPrev - Whether a page precedes the returned one.
44
+ * @return {PageCursors} The cursors of the existing adjacent pages.
45
+ */
46
+ export declare function pageCursors<T>(resources: T[], fingerprint: string, hasNext: boolean, hasPrev: boolean): PageCursors;
47
+ /**
48
+ * Decodes the cursor a query request carries, resolving the record the page
49
+ * continues from and the direction it runs in.
50
+ *
51
+ * @param {string} [cursor] - The cursor of the request, which may be the null
52
+ * one of a page that does not exist.
53
+ * @param {string} fingerprint - The fingerprint of the query the cursor is used
54
+ * on, as built by {@link queryFingerprint}.
55
+ * @return {DecodedCursor | undefined} The decoded cursor, or `undefined` when the
56
+ * request carries none.
57
+ * @throws {ResourceError} `RESOURCE_INVALID_CURSOR` if the cursor is malformed, or was issued for another
58
+ * resource, filter, or sort order.
59
+ */
60
+ export declare function decodeCursor(cursor: string | null | undefined, fingerprint: string): DecodedCursor | undefined;
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.queryFingerprint = queryFingerprint;
4
+ exports.encodeCursor = encodeCursor;
5
+ exports.pageCursors = pageCursors;
6
+ exports.decodeCursor = decodeCursor;
7
+ const common_1 = require("@appweaver/common");
8
+ /**
9
+ * Builds the fingerprint identifying the query a cursor belongs to. A cursor
10
+ * only yields the intended records while the resource, the query, and the order
11
+ * stay the same, so it carries the fingerprint and is rejected on mismatch.
12
+ *
13
+ * @param {string} resourceName - The name of the queried model.
14
+ * @param {Object} query - The mapped database query the cursor was issued for.
15
+ * @param {Object[]} orderBy - The mapped order entries the cursor was issued for.
16
+ * @return {string} The fingerprint of the query.
17
+ */
18
+ function queryFingerprint(resourceName, query, orderBy) {
19
+ const serialized = stableStringify([resourceName, query ?? {}, orderBy]);
20
+ return (0, common_1.makeHash)(serialized, 'sha256', 'base64url').slice(0, 16);
21
+ }
22
+ /**
23
+ * Encodes the cursor of a page adjacent to a query result. The direction belongs
24
+ * to the cursor rather than to the request, so a caller cannot pair one with a
25
+ * direction it was not issued for.
26
+ *
27
+ * @param {ResourceId} id - The primary key of the record the page continues from.
28
+ * @param {string} fingerprint - The fingerprint of the query, as built by
29
+ * {@link queryFingerprint}.
30
+ * @param {boolean} [backward] - Whether the cursor pages towards the preceding
31
+ * records.
32
+ * @return {string} The encoded cursor.
33
+ */
34
+ function encodeCursor(id, fingerprint, backward = false) {
35
+ const payload = { i: id, f: fingerprint };
36
+ if (backward) {
37
+ payload.b = true;
38
+ }
39
+ return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
40
+ }
41
+ /**
42
+ * Builds the cursors of the pages adjacent to a returned page, each addressing
43
+ * the record of this page it continues from. A page that has no neighbour in a
44
+ * direction, and an empty page, yield null rather than an absent cursor.
45
+ *
46
+ * @param {Object[]} resources - The records of the returned page, in the order
47
+ * they were queried in.
48
+ * @param {string} fingerprint - The fingerprint of the query the page belongs to.
49
+ * @param {boolean} hasNext - Whether a page follows the returned one.
50
+ * @param {boolean} hasPrev - Whether a page precedes the returned one.
51
+ * @return {PageCursors} The cursors of the existing adjacent pages.
52
+ */
53
+ function pageCursors(resources, fingerprint, hasNext, hasPrev) {
54
+ // A queried record always carries its primary key, which a model type need
55
+ // not declare
56
+ const first = resources[0];
57
+ const last = resources[resources.length - 1];
58
+ if (!first || !last) {
59
+ return { nextCursor: null, prevCursor: null };
60
+ }
61
+ return {
62
+ nextCursor: hasNext ? encodeCursor(last.id, fingerprint) : null,
63
+ prevCursor: hasPrev ? encodeCursor(first.id, fingerprint, true) : null
64
+ };
65
+ }
66
+ /**
67
+ * Decodes the cursor a query request carries, resolving the record the page
68
+ * continues from and the direction it runs in.
69
+ *
70
+ * @param {string} [cursor] - The cursor of the request, which may be the null
71
+ * one of a page that does not exist.
72
+ * @param {string} fingerprint - The fingerprint of the query the cursor is used
73
+ * on, as built by {@link queryFingerprint}.
74
+ * @return {DecodedCursor | undefined} The decoded cursor, or `undefined` when the
75
+ * request carries none.
76
+ * @throws {ResourceError} `RESOURCE_INVALID_CURSOR` if the cursor is malformed, or was issued for another
77
+ * resource, filter, or sort order.
78
+ */
79
+ function decodeCursor(cursor, fingerprint) {
80
+ if (!cursor) {
81
+ return undefined;
82
+ }
83
+ let payload;
84
+ try {
85
+ payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
86
+ }
87
+ catch (e) {
88
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidCursor, 'Invalid pagination cursor', {}, { cause: e });
89
+ }
90
+ if (!(0, common_1.isPlainObject)(payload) || payload.i === undefined) {
91
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidCursor, 'Invalid pagination cursor');
92
+ }
93
+ if (payload.f !== fingerprint) {
94
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidCursor, 'Pagination cursor does not match the filter and sort of this query');
95
+ }
96
+ return { id: payload.i, backward: payload.b === true };
97
+ }
98
+ /**
99
+ * Serializes a value with its object keys in a stable order, so two equal
100
+ * queries fingerprint identically whatever order their properties arrived in.
101
+ *
102
+ * @param {any} value - The value to serialize.
103
+ * @return {string} The serialized value.
104
+ */
105
+ function stableStringify(value) {
106
+ if ((0, common_1.isArray)(value)) {
107
+ return `[${value.map(stableStringify).join(',')}]`;
108
+ }
109
+ if ((0, common_1.isPlainObject)(value)) {
110
+ const entries = Object.keys(value)
111
+ .sort()
112
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`);
113
+ return `{${entries.join(',')}}`;
114
+ }
115
+ return JSON.stringify(value) ?? 'null';
116
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Parses an ISO 8601 date string. A date without a time is the local midnight of that day, a date and time without an
3
+ * offset is local, and one with an offset is that exact instant.
4
+ *
5
+ * @param {string} value - The ISO date string.
6
+ * @return {Date} The parsed date, invalid when the string is not a date.
7
+ */
8
+ export declare function parseIsoDate(value: string): Date;
9
+ export declare function addSeconds(date: Date, amount: number): Date;
10
+ export declare function addMinutes(date: Date, amount: number): Date;
11
+ export declare function addHours(date: Date, amount: number): Date;
12
+ /** Adds whole local days, keeping the time of day across daylight saving time changes. */
13
+ export declare function addDays(date: Date, amount: number): Date;
14
+ /** Adds whole local months, clamping the day to the last day of a shorter month (January 31 plus one is February 28). */
15
+ export declare function addMonths(date: Date, amount: number): Date;
16
+ export declare function addYears(date: Date, amount: number): Date;
17
+ /** Returns the whole seconds between two dates, truncated toward zero. */
18
+ export declare function differenceInSeconds(laterDate: Date, earlierDate: Date): number;
19
+ /** Returns the full local months between two dates, negative when the first date is the earlier one. */
20
+ export declare function differenceInMonths(laterDate: Date, earlierDate: Date): number;
21
+ /** Returns the full local years between two dates, negative when the first date is the earlier one. */
22
+ export declare function differenceInYears(laterDate: Date, earlierDate: Date): number;