json-rest-api 1.0.20 → 1.0.22
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/audit/00-charter.md +103 -0
- package/audit/01-scope-map.md +84 -0
- package/audit/02-coverage-matrix.csv +174 -0
- package/audit/03-invariants.md +123 -0
- package/audit/04-standards-rubric.md +56 -0
- package/audit/05-competitor-rubric.md +77 -0
- package/audit/06-findings.md +160 -0
- package/audit/07-risk-register.csv +10 -0
- package/audit/08-remediation-roadmap.md +177 -0
- package/audit/09-signoff-check.md +23 -0
- package/audit/10-final-synthesis.md +29 -0
- package/audit/evidence/code/all-files-json-rest-api.txt +168 -0
- package/audit/evidence/code/all-files-json-rest-schema.txt +12 -0
- package/audit/evidence/code/boundary-leak-checks.txt +72 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__bulk-operations-plugin.js.txt +480 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__express-plugin.js.txt +532 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__lib__busboy-detector.js.txt +175 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__lib__formidable-detector.js.txt +138 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__lib__request-helpers.js.txt +222 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__file-handling-plugin.js.txt +342 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__dbTablesOperations.js.txt +304 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__fractional-positioning.js.txt +188 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__compile-schemas.js.txt +336 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__connectors-query-parser.js.txt +165 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__database-capabilities.js.txt +265 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__database-value-normalizers.js.txt +369 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__default-data-helpers.js.txt +72 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__field-utils.js.txt +139 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__knex-constants.js.txt +45 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__knex-field-helpers.js.txt +396 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__knex-json-api-transformers.js.txt +162 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__payload-validators.js.txt +1314 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__schema-helpers.js.txt +488 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__scope-validations.js.txt +484 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__simplified-helpers.js.txt +857 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-cross-table-search.js.txt +565 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-json-api-transformers-querying.js.txt +485 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-pagination-helpers.js.txt +560 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-process-includes.js.txt +167 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-query-helpers-base.js.txt +109 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-query-helpers.js.txt +1064 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-relationship-includes.js.txt +1983 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-window-queries.js.txt +328 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__url-helpers.js.txt +119 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__storage__storage-adapter.js.txt +160 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__writing__many-to-many-manipulations.js.txt +300 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__writing__relationship-processor.js.txt +245 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-access.js.txt +459 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-cors-plugin.js.txt +237 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-knex-plugin.js.txt +1135 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-multihome-plugin.js.txt +314 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__compile-resource-schemas.js.txt +7 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__register-relationship-routes.js.txt +71 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__register-scope-routes.js.txt +102 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__turn-scope-init-into-vars.js.txt +49 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__validate-include-configurations.js.txt +39 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__add-route.js.txt +23 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__check-permissions.js.txt +13 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__common.js.txt +639 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__delete-relationship.js.txt +117 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__delete.js.txt +104 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__enrich-attributes.js.txt +199 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__get-related.js.txt +353 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__get-relationship.js.txt +61 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__get.js.txt +184 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__patch-relationship.js.txt +55 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__patch.js.txt +191 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__post-relationship.js.txt +110 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__post.js.txt +168 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__put.js.txt +253 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__query.js.txt +207 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__release.js.txt +12 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin.js.txt +165 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-positioning-plugin.js.txt +668 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__socketio-plugin.js.txt +621 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__storage__local-storage.js.txt +218 -0
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__storage__s3-storage.js.txt +124 -0
- package/audit/evidence/code/critical-pass1/json-rest-schema__src__CorePlugin.js.txt +269 -0
- package/audit/evidence/code/critical-pass1/json-rest-schema__src__Schema.js.txt +242 -0
- package/audit/evidence/code/critical-pass1/json-rest-schema__src__index.js.txt +77 -0
- package/audit/evidence/code/critical-pass1-risk-keywords.txt +238 -0
- package/audit/evidence/code/critical-pass1-summary.csv +67 -0
- package/audit/evidence/code/critical-pass2-summary.csv +67 -0
- package/audit/evidence/code/early-silent-zones-no-test-token.txt +57 -0
- package/audit/evidence/code/error-model-signals.txt +98 -0
- package/audit/evidence/code/git-files-json-rest-api.txt +171 -0
- package/audit/evidence/code/git-files-json-rest-schema.txt +27 -0
- package/audit/evidence/code/hook-flow-signals.txt +282 -0
- package/audit/evidence/code/idproperty-signals.txt +157 -0
- package/audit/evidence/code/import-cycles-non-anyapi.txt +3 -0
- package/audit/evidence/code/import-hotspots-non-anyapi.txt +96 -0
- package/audit/evidence/code/imports-json-rest-api.txt +354 -0
- package/audit/evidence/code/imports-json-rest-schema.txt +15 -0
- package/audit/evidence/code/in-scope-files-json-rest-api.txt +160 -0
- package/audit/evidence/code/in-scope-files-json-rest-schema.txt +13 -0
- package/audit/evidence/code/non-anyapi-core-files.txt +101 -0
- package/audit/evidence/code/permission-signals.txt +193 -0
- package/audit/evidence/code/query-include-signals.txt +687 -0
- package/audit/evidence/code/transaction-signals.txt +28 -0
- package/audit/evidence/code/validation-signals.txt +650 -0
- package/audit/evidence/code/write-path-signals.txt +61 -0
- package/audit/evidence/docs/docs-claims-signals.txt +1075 -0
- package/audit/evidence/docs/schema-docs-claims-signals.txt +179 -0
- package/audit/evidence/external/corpus-freeze.md +21 -0
- package/audit/evidence/external/npm-competitor-metadata.txt +70 -0
- package/audit/evidence/external/npm-download-signals.txt +23 -0
- package/audit/evidence/external/npm-jsonapi-server.txt +8 -0
- package/audit/evidence/external/npm-modern-jsonapi-metadata.txt +38 -0
- package/audit/evidence/external/readme-fortune-signals.txt +26 -0
- package/audit/evidence/external/readme-fortune.md +165 -0
- package/audit/evidence/external/readme-json-api-serializer-signals.txt +80 -0
- package/audit/evidence/external/readme-json-api-serializer.md +690 -0
- package/audit/evidence/external/readme-jsonapi-serde-server-signals.txt +0 -0
- package/audit/evidence/external/readme-jsonapi-serde-server.md +22 -0
- package/audit/evidence/external/readme-jsonapi-serializer-signals.txt +63 -0
- package/audit/evidence/external/readme-jsonapi-serializer.md +315 -0
- package/audit/evidence/external/readme-jsonapi-server-signals.txt +19 -0
- package/audit/evidence/external/readme-jsonapi-server.md +104 -0
- package/audit/evidence/external/readme-nestjs-json-api-signals.txt +4 -0
- package/audit/evidence/external/readme-nestjs-json-api.md +94 -0
- package/audit/evidence/external/readme-yaser-json-api-nestjs-signals.txt +47 -0
- package/audit/evidence/external/readme-yaser-json-api-nestjs.md +447 -0
- package/audit/evidence/external/standard-jsonapi-snippets.txt +84 -0
- package/audit/evidence/external/standard-rfc-snippets.txt +168 -0
- package/audit/evidence/external/standards-sources.txt +6 -0
- package/audit/evidence/runtime/baseline-commits.txt +2 -0
- package/audit/evidence/runtime/db-adapter-assumptions.txt +3 -0
- package/audit/evidence/runtime/environment.txt +4 -0
- package/audit/evidence/runtime/invariant-runtime-mapping.md +38 -0
- package/audit/evidence/runtime/probe-patch-relationship-error.exitcode +1 -0
- package/audit/evidence/runtime/probe-patch-relationship-error.log +76 -0
- package/audit/evidence/runtime/probe-patch-relationship-error.mjs +67 -0
- package/audit/evidence/runtime/probe-patch-relationship-referenceerror.exitcode +1 -0
- package/audit/evidence/runtime/probe-patch-relationship-referenceerror.log +62 -0
- package/audit/evidence/runtime/probe-patch-relationship-referenceerror.mjs +47 -0
- package/audit/evidence/runtime/test-exitcodes.txt +4 -0
- package/audit/evidence/runtime/test-summary.md +107 -0
- package/audit/evidence/runtime/trace-anchors.txt +61 -0
- package/audit/evidence/tests/access-multitenancy-test-signals.txt +125 -0
- package/audit/evidence/tests/failing-tests-summary.txt +25 -0
- package/audit/evidence/tests/npm-test-json-rest-api.exitcode +1 -0
- package/audit/evidence/tests/npm-test-json-rest-api.log +5885 -0
- package/audit/evidence/tests/npm-test-json-rest-schema.exitcode +1 -0
- package/audit/evidence/tests/npm-test-json-rest-schema.log +356 -0
- package/audit/evidence/tests/query-include-test-signals.txt +948 -0
- package/audit/evidence/tests/relationship-test-signals.txt +223 -0
- package/audit/evidence/tests/skipped-tests.txt +10 -0
- package/audit/evidence/tests/test-suite-list-non-anyapi.txt +30 -0
- package/audit/evidence/tests/test-suite-list.txt +32 -0
- package/audit/subsystems/docs-drift.md +49 -0
- package/audit/subsystems/pass2-cross-review.md +131 -0
- package/audit/subsystems/plugins-access.md +51 -0
- package/audit/subsystems/plugins-bulk.md +50 -0
- package/audit/subsystems/plugins-cors.md +48 -0
- package/audit/subsystems/plugins-file.md +53 -0
- package/audit/subsystems/plugins-multihome.md +50 -0
- package/audit/subsystems/plugins-positioning.md +54 -0
- package/audit/subsystems/plugins-socketio.md +50 -0
- package/audit/subsystems/query-engine.md +61 -0
- package/audit/subsystems/relationships.md +55 -0
- package/audit/subsystems/rest-orchestration.md +66 -0
- package/audit/subsystems/schema-core.md +64 -0
- package/audit/subsystems/storage-knex.md +57 -0
- package/audit/subsystems/tests.md +48 -0
- package/audit/subsystems/transport-express.md +55 -0
- package/audit/traces/trace-read-get.md +22 -0
- package/audit/traces/trace-read-query.md +25 -0
- package/audit/traces/trace-relationship-endpoints.md +31 -0
- package/audit/traces/trace-write-delete.md +17 -0
- package/audit/traces/trace-write-patch.md +26 -0
- package/audit/traces/trace-write-post.md +22 -0
- package/audit/traces/trace-write-put.md +23 -0
- package/package.json +5 -5
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
1 import { requirePackage } from 'hooked-api'
|
|
2
|
+
2
|
|
3
|
+
3 let generateKeyBetween
|
|
4
|
+
4 try {
|
|
5
|
+
5 ({ generateKeyBetween } = await import('fractional-indexing'))
|
|
6
|
+
6 } catch (e) {
|
|
7
|
+
7 requirePackage('fractional-indexing', 'positioning',
|
|
8
|
+
8 'Fractional indexing is required for the positioning plugin to generate sortable position keys. This is a peer dependency.')
|
|
9
|
+
9 }
|
|
10
|
+
10
|
|
11
|
+
11 /**
|
|
12
|
+
12 * Helper functions for fractional positioning
|
|
13
|
+
13 * Uses the fractional-indexing library for generating position keys
|
|
14
|
+
14 */
|
|
15
|
+
15
|
|
16
|
+
16 /**
|
|
17
|
+
17 * Calculate position for a new/moved item based on beforeId
|
|
18
|
+
18 * @param {Array} items - Array of items with position field
|
|
19
|
+
19 * @param {string} beforeId - ID to position before (null = last)
|
|
20
|
+
20 * @param {string} idField - Name of the ID field
|
|
21
|
+
21 * @param {string} positionField - Name of the position field
|
|
22
|
+
22 * @returns {string} New position key
|
|
23
|
+
23 */
|
|
24
|
+
24 export function calculatePosition (items, beforeId, idField, positionField) {
|
|
25
|
+
25 // Sort items by position using simple string comparison
|
|
26
|
+
26 // Important: fractional-indexing expects ASCII/Unicode ordering, not locale-specific
|
|
27
|
+
27 const sorted = [...items].sort((a, b) => {
|
|
28
|
+
28 const posA = a[positionField] || ''
|
|
29
|
+
29 const posB = b[positionField] || ''
|
|
30
|
+
30 return posA < posB ? -1 : posA > posB ? 1 : 0
|
|
31
|
+
31 })
|
|
32
|
+
32
|
|
33
|
+
33 // If no items, start in the middle
|
|
34
|
+
34 if (sorted.length === 0) {
|
|
35
|
+
35 return generateKeyBetween(null, null) // Returns 'a0'
|
|
36
|
+
36 }
|
|
37
|
+
37
|
|
38
|
+
38 // Position at end
|
|
39
|
+
39 if (beforeId === null || beforeId === undefined) {
|
|
40
|
+
40 const lastItem = sorted[sorted.length - 1]
|
|
41
|
+
41 return generateKeyBetween(lastItem[positionField], null)
|
|
42
|
+
42 }
|
|
43
|
+
43
|
|
44
|
+
44 // Find the item to position before
|
|
45
|
+
45 const beforeIndex = sorted.findIndex(item => String(item[idField]) === String(beforeId))
|
|
46
|
+
46
|
|
47
|
+
47 // If beforeId not found, position at end
|
|
48
|
+
48 if (beforeIndex === -1) {
|
|
49
|
+
49 const lastItem = sorted[sorted.length - 1]
|
|
50
|
+
50 return generateKeyBetween(lastItem[positionField], null)
|
|
51
|
+
51 }
|
|
52
|
+
52
|
|
53
|
+
53 // Position before the found item
|
|
54
|
+
54 const beforeItem = sorted[beforeIndex]
|
|
55
|
+
55 const prevItem = beforeIndex > 0 ? sorted[beforeIndex - 1] : null
|
|
56
|
+
56
|
|
57
|
+
57 const result = generateKeyBetween(
|
|
58
|
+
58 prevItem ? prevItem[positionField] : null,
|
|
59
|
+
59 beforeItem[positionField]
|
|
60
|
+
60 )
|
|
61
|
+
61
|
|
62
|
+
62 return result
|
|
63
|
+
63 }
|
|
64
|
+
64
|
|
65
|
+
65 /**
|
|
66
|
+
66 * Initialize position for first item in a group
|
|
67
|
+
67 * @returns {string} Initial position key
|
|
68
|
+
68 */
|
|
69
|
+
69 export function getInitialPosition () {
|
|
70
|
+
70 return generateKeyBetween(null, null) // Returns 'a0'
|
|
71
|
+
71 }
|
|
72
|
+
72
|
|
73
|
+
73 /**
|
|
74
|
+
74 * Rebalance positions if they get too long (optional maintenance)
|
|
75
|
+
75 * @param {Array} items - Array of items to rebalance
|
|
76
|
+
76 * @param {string} positionField - Name of the position field
|
|
77
|
+
77 * @param {number} maxLength - Maximum position string length before rebalancing
|
|
78
|
+
78 * @returns {Array} Items with new positions (or original if no rebalance needed)
|
|
79
|
+
79 */
|
|
80
|
+
80 export function rebalancePositions (items, positionField, maxLength = 50) {
|
|
81
|
+
81 // Check if rebalancing is needed
|
|
82
|
+
82 const needsRebalance = items.some(item =>
|
|
83
|
+
83 item[positionField] && item[positionField].length > maxLength
|
|
84
|
+
84 )
|
|
85
|
+
85
|
|
86
|
+
86 if (!needsRebalance) {
|
|
87
|
+
87 return items
|
|
88
|
+
88 }
|
|
89
|
+
89
|
|
90
|
+
90 // Sort by current position
|
|
91
|
+
91 const sorted = [...items].sort((a, b) => {
|
|
92
|
+
92 const posA = a[positionField] || ''
|
|
93
|
+
93 const posB = b[positionField] || ''
|
|
94
|
+
94 return posA < posB ? -1 : posA > posB ? 1 : 0
|
|
95
|
+
95 })
|
|
96
|
+
96
|
|
97
|
+
97 // Generate evenly spaced positions
|
|
98
|
+
98 const rebalanced = []
|
|
99
|
+
99 let prevKey = null
|
|
100
|
+
100
|
|
101
|
+
101 for (let i = 0; i < sorted.length; i++) {
|
|
102
|
+
102 // For even spacing, we generate keys sequentially
|
|
103
|
+
103 const newPosition = generateKeyBetween(prevKey, null)
|
|
104
|
+
104
|
|
105
|
+
105 rebalanced.push({
|
|
106
|
+
106 ...sorted[i],
|
|
107
|
+
107 [positionField]: newPosition
|
|
108
|
+
108 })
|
|
109
|
+
109
|
|
110
|
+
110 prevKey = newPosition
|
|
111
|
+
111 }
|
|
112
|
+
112
|
|
113
|
+
113 return rebalanced
|
|
114
|
+
114 }
|
|
115
|
+
115
|
|
116
|
+
116 /**
|
|
117
|
+
117 * Check if a position value is valid
|
|
118
|
+
118 * @param {string} position - Position value to check
|
|
119
|
+
119 * @returns {boolean} True if valid
|
|
120
|
+
120 */
|
|
121
|
+
121 export function isValidPosition (position) {
|
|
122
|
+
122 if (!position || typeof position !== 'string') {
|
|
123
|
+
123 return false
|
|
124
|
+
124 }
|
|
125
|
+
125
|
|
126
|
+
126 // Fractional keys should match pattern: lowercase letters and digits
|
|
127
|
+
127 return /^[a-z0-9]+$/i.test(position)
|
|
128
|
+
128 }
|
|
129
|
+
129
|
|
130
|
+
130 /**
|
|
131
|
+
131 * Get items that need position assignment (for migration)
|
|
132
|
+
132 * @param {Array} items - Array of items
|
|
133
|
+
133 * @param {string} positionField - Name of the position field
|
|
134
|
+
134 * @returns {Array} Items without valid positions
|
|
135
|
+
135 */
|
|
136
|
+
136 export function getUnpositionedItems (items, positionField) {
|
|
137
|
+
137 return items.filter(item => !isValidPosition(item[positionField]))
|
|
138
|
+
138 }
|
|
139
|
+
139
|
|
140
|
+
140 /**
|
|
141
|
+
141 * Assign initial positions to items that don't have them
|
|
142
|
+
142 * @param {Array} items - Array of items
|
|
143
|
+
143 * @param {string} positionField - Name of the position field
|
|
144
|
+
144 * @param {string} orderByField - Optional field to order by initially
|
|
145
|
+
145 * @returns {Array} Items with positions assigned
|
|
146
|
+
146 */
|
|
147
|
+
147 export function assignInitialPositions (items, positionField, orderByField = null) {
|
|
148
|
+
148 const positioned = items.filter(item => isValidPosition(item[positionField]))
|
|
149
|
+
149 const unpositioned = items.filter(item => !isValidPosition(item[positionField]))
|
|
150
|
+
150
|
|
151
|
+
151 if (unpositioned.length === 0) {
|
|
152
|
+
152 return items
|
|
153
|
+
153 }
|
|
154
|
+
154
|
|
155
|
+
155 // Sort unpositioned items if orderBy field provided
|
|
156
|
+
156 if (orderByField) {
|
|
157
|
+
157 unpositioned.sort((a, b) => {
|
|
158
|
+
158 const valA = a[orderByField]
|
|
159
|
+
159 const valB = b[orderByField]
|
|
160
|
+
160 if (valA < valB) return -1
|
|
161
|
+
161 if (valA > valB) return 1
|
|
162
|
+
162 return 0
|
|
163
|
+
163 })
|
|
164
|
+
164 }
|
|
165
|
+
165
|
|
166
|
+
166 // Get the last positioned item
|
|
167
|
+
167 const sortedPositioned = [...positioned].sort((a, b) => {
|
|
168
|
+
168 const posA = a[positionField] || ''
|
|
169
|
+
169 const posB = b[positionField] || ''
|
|
170
|
+
170 return posA < posB ? -1 : posA > posB ? 1 : 0
|
|
171
|
+
171 })
|
|
172
|
+
172 const lastPositioned = sortedPositioned[sortedPositioned.length - 1]
|
|
173
|
+
173
|
|
174
|
+
174 let lastKey = lastPositioned ? lastPositioned[positionField] : null
|
|
175
|
+
175
|
|
176
|
+
176 // Assign positions to unpositioned items
|
|
177
|
+
177 const newlyPositioned = unpositioned.map(item => {
|
|
178
|
+
178 const newKey = generateKeyBetween(lastKey, null)
|
|
179
|
+
179 lastKey = newKey
|
|
180
|
+
180
|
|
181
|
+
181 return {
|
|
182
|
+
182 ...item,
|
|
183
|
+
183 [positionField]: newKey
|
|
184
|
+
184 }
|
|
185
|
+
185 })
|
|
186
|
+
186
|
|
187
|
+
187 return [...positioned, ...newlyPositioned]
|
|
188
|
+
188 }
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
1 import { createSchema } from 'json-rest-schema'
|
|
2
|
+
2 import { ensureSearchFieldsAreIndexed, generateSearchSchemaFromSchema, sortFieldsByDependencies } from './schema-helpers.js'
|
|
3
|
+
3
|
|
4
|
+
4 /**
|
|
5
|
+
5 * Compiles and enriches schemas for a resource scope
|
|
6
|
+
6 *
|
|
7
|
+
7 * @param {Object} scope - Scope containing raw schema options
|
|
8
|
+
8 * @param {Object} deps - Dependencies with context and hooks
|
|
9
|
+
9 * @returns {Promise<void>} Resolves when compilation complete
|
|
10
|
+
10 *
|
|
11
|
+
11 * @example
|
|
12
|
+
12 * // Input: Raw schema with computed field
|
|
13
|
+
13 * const rawFields = {
|
|
14
|
+
14 * title: { type: 'string', required: true },
|
|
15
|
+
15 * content: { type: 'string' },
|
|
16
|
+
16 * author_id: { belongsTo: 'users', as: 'author' }, // Missing type
|
|
17
|
+
17 * word_count: {
|
|
18
|
+
18 * type: 'number',
|
|
19
|
+
19 * computed: true,
|
|
20
|
+
20 * compute: (record) => record.content.split(' ').length
|
|
21
|
+
21 * }
|
|
22
|
+
22 * };
|
|
23
|
+
23 *
|
|
24
|
+
24 * await compileSchemas(scope, deps);
|
|
25
|
+
25 *
|
|
26
|
+
26 * // Output in scope.vars.schemaInfo:
|
|
27
|
+
27 * // {
|
|
28
|
+
28 * // schema: Schema {}, // json-rest-schema instance
|
|
29
|
+
29 * // schemaStructure: {
|
|
30
|
+
30 * // title: { type: 'string', required: true },
|
|
31
|
+
31 * // content: { type: 'string' },
|
|
32
|
+
32 * // author_id: { type: 'id', belongsTo: 'users', as: 'author' } // Type added!
|
|
33
|
+
33 * // // Note: word_count removed (it's computed)
|
|
34
|
+
34 * // },
|
|
35
|
+
35 * // computed: {
|
|
36
|
+
36 * // word_count: { type: 'number', computed: true, compute: [Function] }
|
|
37
|
+
37 * // }
|
|
38
|
+
38 * // }
|
|
39
|
+
39 *
|
|
40
|
+
40 * @example
|
|
41
|
+
41 * // Input: Schema with search fields
|
|
42
|
+
42 * const rawFields = {
|
|
43
|
+
43 * title: { type: 'string', search: true }, // Searchable
|
|
44
|
+
44 * status: { type: 'string' }, // Not searchable
|
|
45
|
+
45 * author_id: { belongsTo: 'users', as: 'author' }
|
|
46
|
+
46 * };
|
|
47
|
+
47 *
|
|
48
|
+
48 * // Output: Auto-generated searchSchema
|
|
49
|
+
49 * // searchSchemaObject contains:
|
|
50
|
+
50 * // {
|
|
51
|
+
51 * // title: {
|
|
52
|
+
52 * // type: 'string',
|
|
53
|
+
53 * // indexed: true, // Added for DB optimization
|
|
54
|
+
54 * // filterOperator: '=' // Default operator
|
|
55
|
+
55 * // }
|
|
56
|
+
56 * // }
|
|
57
|
+
57 *
|
|
58
|
+
58 * @example
|
|
59
|
+
59 * // Input: Pivot table auto-detection
|
|
60
|
+
60 * const pivotSchema = {
|
|
61
|
+
61 * article_id: { belongsTo: 'articles', as: 'article' },
|
|
62
|
+
62 * tag_id: { belongsTo: 'tags', as: 'tag' },
|
|
63
|
+
63 * display_order: { type: 'number' }
|
|
64
|
+
64 * };
|
|
65
|
+
65 *
|
|
66
|
+
66 * // Output: Both foreign keys become searchable
|
|
67
|
+
67 * // schemaStructure will have:
|
|
68
|
+
68 * // {
|
|
69
|
+
69 * // article_id: { type: 'id', belongsTo: 'articles', as: 'article', search: true },
|
|
70
|
+
70 * // tag_id: { type: 'id', belongsTo: 'tags', as: 'tag', search: true },
|
|
71
|
+
71 * // display_order: { type: 'number' }
|
|
72
|
+
72 * // }
|
|
73
|
+
73 * // This enables efficient many-to-many filtering!
|
|
74
|
+
74 *
|
|
75
|
+
75 * @example
|
|
76
|
+
76 * // Input: Schema with dependent getters
|
|
77
|
+
77 * const schema = {
|
|
78
|
+
78 * first_name: { type: 'string' },
|
|
79
|
+
79 * last_name: { type: 'string' },
|
|
80
|
+
80 * full_name: {
|
|
81
|
+
81 * type: 'string',
|
|
82
|
+
82 * getter: (record) => `${record.first_name} ${record.last_name}`,
|
|
83
|
+
83 * runGetterAfter: ['first_name', 'last_name'] // Dependencies
|
|
84
|
+
84 * },
|
|
85
|
+
85 * email: {
|
|
86
|
+
86 * type: 'string',
|
|
87
|
+
87 * setter: (value) => value.toLowerCase().trim()
|
|
88
|
+
88 * }
|
|
89
|
+
89 * };
|
|
90
|
+
90 *
|
|
91
|
+
91 * // Output includes dependency-sorted fields:
|
|
92
|
+
92 * // {
|
|
93
|
+
93 * // fieldGetters: { full_name: { getter: [Function], runGetterAfter: [...] } },
|
|
94
|
+
94 * // sortedGetterFields: ['full_name'], // Topologically sorted
|
|
95
|
+
95 * // fieldSetters: { email: { setter: [Function], runSetterAfter: [] } },
|
|
96
|
+
96 * // sortedSetterFields: ['email']
|
|
97
|
+
97 * // }
|
|
98
|
+
98 *
|
|
99
|
+
99 * @description
|
|
100
|
+
100 * Used by:
|
|
101
|
+
101 * - Every REST method calls this before any operations
|
|
102
|
+
102 * - Compilation happens once per scope (cached)
|
|
103
|
+
103 *
|
|
104
|
+
104 * Purpose:
|
|
105
|
+
105 * - Separates computed fields from database fields
|
|
106
|
+
106 * - Adds missing types to belongsTo relationships
|
|
107
|
+
107 * - Auto-detects pivot tables for many-to-many
|
|
108
|
+
108 * - Generates search schemas from field markers
|
|
109
|
+
109 * - Validates getter/setter dependencies
|
|
110
|
+
110 * - Provides hooks for schema enrichment
|
|
111
|
+
111 * - Caches everything for performance
|
|
112
|
+
112 *
|
|
113
|
+
113 * Data flow:
|
|
114
|
+
114 * 1. Extracts computed fields to separate object
|
|
115
|
+
115 * 2. Adds type:'id' to belongsTo fields
|
|
116
|
+
116 * 3. Detects pivot tables (2+ foreign keys, 40%+ of fields)
|
|
117
|
+
117 * 4. Runs schema:enrich hook for plugins
|
|
118
|
+
118 * 5. Creates json-rest-schema validation instance
|
|
119
|
+
119 * 6. Generates searchSchema from search:true fields
|
|
120
|
+
120 * 7. Runs searchSchema:enrich hook
|
|
121
|
+
121 * 8. Extracts and topologically sorts getters/setters
|
|
122
|
+
122 * 9. Caches all results in scope.vars.schemaInfo
|
|
123
|
+
123 */
|
|
124
|
+
124 export async function compileSchemas (scope, deps) {
|
|
125
|
+
125 // Extract scopeName from context
|
|
126
|
+
126 const { context, runHooks } = deps
|
|
127
|
+
127 const scopeName = context.scopeName
|
|
128
|
+
128
|
|
129
|
+
129 // Get raw schema
|
|
130
|
+
130 const rawFields = scope.scopeOptions?.schema || {}
|
|
131
|
+
131
|
|
132
|
+
132 // Extract computed fields from schema and build enriched schema
|
|
133
|
+
133 const computedFields = {}
|
|
134
|
+
134 const enrichedFields = {}
|
|
135
|
+
135
|
|
136
|
+
136 for (const [fieldName, fieldDef] of Object.entries(rawFields)) {
|
|
137
|
+
137 if (fieldDef.computed === true) {
|
|
138
|
+
138 // Extract computed field - copy entire definition
|
|
139
|
+
139 computedFields[fieldName] = { ...fieldDef }
|
|
140
|
+
140
|
|
141
|
+
141 // Validate computed field
|
|
142
|
+
142 if (!fieldDef.type) {
|
|
143
|
+
143 throw new Error(`Computed field '${fieldName}' in scope '${scopeName}' must have a type`)
|
|
144
|
+
144 }
|
|
145
|
+
145
|
|
146
|
+
146 if (fieldDef.compute && typeof fieldDef.compute !== 'function') {
|
|
147
|
+
147 throw new Error(`Computed field '${fieldName}' in scope '${scopeName}' has invalid compute function`)
|
|
148
|
+
148 }
|
|
149
|
+
149
|
|
150
|
+
150 // Don't include computed fields in the validation schema
|
|
151
|
+
151 } else {
|
|
152
|
+
152 enrichedFields[fieldName] = { ...fieldDef }
|
|
153
|
+
153 }
|
|
154
|
+
154 }
|
|
155
|
+
155
|
|
156
|
+
156 // Default enrichment: Add type to belongsTo fields
|
|
157
|
+
157 for (const [fieldName, fieldDef] of Object.entries(enrichedFields)) {
|
|
158
|
+
158 if (fieldDef.belongsTo && !fieldDef.type) {
|
|
159
|
+
159 fieldDef.type = 'id'
|
|
160
|
+
160 }
|
|
161
|
+
161 }
|
|
162
|
+
162
|
|
163
|
+
163 // ADD LOGIC TO MAKE FIELDS SEARCHABLE HERE
|
|
164
|
+
164 // Auto-detect pivot tables and make their foreign key fields searchable
|
|
165
|
+
165 // This is critical for many-to-many relationship operations which need to filter pivot records
|
|
166
|
+
166 const fields = Object.entries(enrichedFields)
|
|
167
|
+
167 const belongsToFields = fields.filter(([_, def]) => def.belongsTo)
|
|
168
|
+
168
|
|
169
|
+
169 // If 2+ belongsTo fields and they make up 40%+ of non-id fields, likely a pivot table
|
|
170
|
+
170 const nonIdFields = fields.filter(([name, _]) => name !== 'id')
|
|
171
|
+
171 const isProbablyPivot = belongsToFields.length >= 2 &&
|
|
172
|
+
172 (nonIdFields.length === 0 || belongsToFields.length / nonIdFields.length >= 0.4)
|
|
173
|
+
173
|
|
174
|
+
174 if (isProbablyPivot) {
|
|
175
|
+
175 // Make all belongsTo fields searchable for pivot operations
|
|
176
|
+
176 for (const [fieldName, fieldDef] of belongsToFields) {
|
|
177
|
+
177 if (!fieldDef.search) {
|
|
178
|
+
178 fieldDef.search = true
|
|
179
|
+
179 }
|
|
180
|
+
180 }
|
|
181
|
+
181 }
|
|
182
|
+
182
|
|
183
|
+
183 // Hook: schema:enrich
|
|
184
|
+
184 const schemaContext = {
|
|
185
|
+
185 fields: enrichedFields, // Mutable
|
|
186
|
+
186 originalFields: rawFields, // Read-only
|
|
187
|
+
187 scopeName,
|
|
188
|
+
188 scopeOptions: scope.scopeOptions || {}
|
|
189
|
+
189 }
|
|
190
|
+
190 await runHooks('schema:enrich', schemaContext)
|
|
191
|
+
191
|
|
192
|
+
192 // Create schema object
|
|
193
|
+
193 const schemaObject = createSchema(schemaContext.fields)
|
|
194
|
+
194
|
|
195
|
+
195 // Generate searchSchema by merging explicit searchSchema with fields marked search:true.
|
|
196
|
+
196 // This allows two ways to define searchable fields: either mark fields with search:true
|
|
197
|
+
197 // in the main schema, or provide an explicit searchSchema with more control over filtering.
|
|
198
|
+
198 // The explicit searchSchema takes precedence when there are conflicts - it can override
|
|
199
|
+
199 // fields marked with search:true. Fields with search:true that are NOT in the explicit
|
|
200
|
+
200 // searchSchema will be added automatically.
|
|
201
|
+
201 // Example: title: {search: true} auto-generates a searchable field with sensible defaults,
|
|
202
|
+
202 // while searchSchema can specify filterOperator: 'contains' or complex join configurations.
|
|
203
|
+
203 const rawSearchFields = generateSearchSchemaFromSchema(
|
|
204
|
+
204 schemaContext.fields,
|
|
205
|
+
205 scope.scopeOptions.searchSchema
|
|
206
|
+
206 )
|
|
207
|
+
207
|
|
208
|
+
208 if (rawSearchFields) {
|
|
209
|
+
209 // Mark all search fields as indexed for database optimization.
|
|
210
|
+
210 // This ensures that any field used for filtering will have a database index created,
|
|
211
|
+
211 // dramatically improving query performance. Without indexes, filtering large tables
|
|
212
|
+
212 // would require full table scans. The storage plugin uses these hints to create indexes.
|
|
213
|
+
213 // Example: A status field marked for search gets indexed: true, enabling efficient
|
|
214
|
+
214 // WHERE status = 'published' queries that can use index lookups instead of scanning all rows.
|
|
215
|
+
215 ensureSearchFieldsAreIndexed(rawSearchFields)
|
|
216
|
+
216
|
|
217
|
+
217 // Hook: searchSchema:enrich
|
|
218
|
+
218 const searchSchemaContext = {
|
|
219
|
+
219 fields: schemaContext.fields, // Mutable
|
|
220
|
+
220 originalFields: rawSearchFields, // Read-only enriched schema
|
|
221
|
+
221 scopeName
|
|
222
|
+
222 }
|
|
223
|
+
223 await runHooks('searchSchema:enrich', searchSchemaContext)
|
|
224
|
+
224
|
|
225
|
+
225 // Create searchSchema object
|
|
226
|
+
226 var searchSchemaObject = createSchema(rawSearchFields)
|
|
227
|
+
227 } else {
|
|
228
|
+
228 var searchSchemaObject = createSchema({})
|
|
229
|
+
229 }
|
|
230
|
+
230
|
|
231
|
+
231 // Build schemaRelationships including polymorphic fields from schema
|
|
232
|
+
232 const schemaRelationships = { ...(scope.scopeOptions.relationships || {}) }
|
|
233
|
+
233
|
|
234
|
+
234 // Validate belongsTo fields
|
|
235
|
+
235 for (const [fieldName, fieldDef] of Object.entries(schemaContext.fields)) {
|
|
236
|
+
236 // Validate that belongsTo fields have 'as' property
|
|
237
|
+
237 if (fieldDef.belongsTo && !fieldDef.as) {
|
|
238
|
+
238 throw new Error(
|
|
239
|
+
239 `Field '${fieldName}' in resource '${scopeName}' has belongsTo: '${fieldDef.belongsTo}' but is missing the required 'as' property. ` +
|
|
240
|
+
240 'The \'as\' property defines the relationship name used in JSON:API payloads. ' +
|
|
241
|
+
241 `Example: ${fieldName}: { type: 'number', belongsTo: '${fieldDef.belongsTo}', as: '${fieldName.replace(/_id$/, '')}' }`
|
|
242
|
+
242 )
|
|
243
|
+
243 }
|
|
244
|
+
244 }
|
|
245
|
+
245
|
|
246
|
+
246 // Extract and validate getter definitions
|
|
247
|
+
247 const fieldGetters = {}
|
|
248
|
+
248 const getterFields = []
|
|
249
|
+
249
|
|
250
|
+
250 for (const [fieldName, fieldDef] of Object.entries(schemaContext.fields)) {
|
|
251
|
+
251 if (fieldDef.getter && typeof fieldDef.getter === 'function') {
|
|
252
|
+
252 fieldGetters[fieldName] = {
|
|
253
|
+
253 getter: fieldDef.getter,
|
|
254
|
+
254 runGetterAfter: fieldDef.runGetterAfter || [],
|
|
255
|
+
255 fieldDef
|
|
256
|
+
256 }
|
|
257
|
+
257 getterFields.push(fieldName)
|
|
258
|
+
258
|
|
259
|
+
259 // Validate dependencies exist
|
|
260
|
+
260 if (fieldDef.runGetterAfter && Array.isArray(fieldDef.runGetterAfter)) {
|
|
261
|
+
261 for (const dep of fieldDef.runGetterAfter) {
|
|
262
|
+
262 if (!schemaContext.fields[dep]) {
|
|
263
|
+
263 throw new Error(
|
|
264
|
+
264 `Field '${fieldName}' in resource '${scopeName}' has getter dependency '${dep}' that does not exist in schema`
|
|
265
|
+
265 )
|
|
266
|
+
266 }
|
|
267
|
+
267 }
|
|
268
|
+
268 }
|
|
269
|
+
269 }
|
|
270
|
+
270 }
|
|
271
|
+
271
|
|
272
|
+
272 // Sort getters by dependencies
|
|
273
|
+
273 let sortedGetterFields = []
|
|
274
|
+
274 if (Object.keys(fieldGetters).length > 0) {
|
|
275
|
+
275 try {
|
|
276
|
+
276 sortedGetterFields = sortFieldsByDependencies(fieldGetters, 'runGetterAfter')
|
|
277
|
+
277 } catch (error) {
|
|
278
|
+
278 throw new Error(`Invalid getter dependencies in ${scopeName}: ${error.message}`)
|
|
279
|
+
279 }
|
|
280
|
+
280 }
|
|
281
|
+
281
|
|
282
|
+
282 // Extract and validate setter definitions
|
|
283
|
+
283 const fieldSetters = {}
|
|
284
|
+
284 const setterFields = []
|
|
285
|
+
285
|
|
286
|
+
286 for (const [fieldName, fieldDef] of Object.entries(schemaContext.fields)) {
|
|
287
|
+
287 if (fieldDef.setter && typeof fieldDef.setter === 'function') {
|
|
288
|
+
288 fieldSetters[fieldName] = {
|
|
289
|
+
289 setter: fieldDef.setter,
|
|
290
|
+
290 runSetterAfter: fieldDef.runSetterAfter || [],
|
|
291
|
+
291 fieldDef
|
|
292
|
+
292 }
|
|
293
|
+
293 setterFields.push(fieldName)
|
|
294
|
+
294
|
|
295
|
+
295 // Validate dependencies exist
|
|
296
|
+
296 if (fieldDef.runSetterAfter && Array.isArray(fieldDef.runSetterAfter)) {
|
|
297
|
+
297 for (const dep of fieldDef.runSetterAfter) {
|
|
298
|
+
298 if (!schemaContext.fields[dep]) {
|
|
299
|
+
299 throw new Error(
|
|
300
|
+
300 `Field '${fieldName}' in resource '${scopeName}' has setter dependency '${dep}' that does not exist in schema`
|
|
301
|
+
301 )
|
|
302
|
+
302 }
|
|
303
|
+
303 }
|
|
304
|
+
304 }
|
|
305
|
+
305 }
|
|
306
|
+
306 }
|
|
307
|
+
307
|
|
308
|
+
308 // Sort setters by dependencies
|
|
309
|
+
309 let sortedSetterFields = []
|
|
310
|
+
310 if (Object.keys(fieldSetters).length > 0) {
|
|
311
|
+
311 try {
|
|
312
|
+
312 sortedSetterFields = sortFieldsByDependencies(fieldSetters, 'runSetterAfter')
|
|
313
|
+
313 } catch (error) {
|
|
314
|
+
314 throw new Error(`Invalid setter dependencies in ${scopeName}: ${error.message}`)
|
|
315
|
+
315 }
|
|
316
|
+
316 }
|
|
317
|
+
317
|
|
318
|
+
318 // Cache everything
|
|
319
|
+
319 scope.vars.schemaInfo = {
|
|
320
|
+
320
|
|
321
|
+
321 schemaInstance: schemaObject,
|
|
322
|
+
322 schemaStructure: schemaObject.structure,
|
|
323
|
+
323
|
|
324
|
+
324 searchSchemaInstance: searchSchemaObject,
|
|
325
|
+
325 searchSchemaStructure: searchSchemaObject.structure,
|
|
326
|
+
326
|
|
327
|
+
327 computed: computedFields,
|
|
328
|
+
328 schemaRelationships,
|
|
329
|
+
329 tableName: scope.scopeOptions.tableName || scopeName,
|
|
330
|
+
330 idProperty: scope.scopeOptions.idProperty || scope.vars.idProperty,
|
|
331
|
+
331 fieldGetters,
|
|
332
|
+
332 sortedGetterFields,
|
|
333
|
+
333 fieldSetters,
|
|
334
|
+
334 sortedSetterFields
|
|
335
|
+
335 }
|
|
336
|
+
336 }
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
1 /**
|
|
2
|
+
2 * Parses URL query strings into JSON:API compliant parameter objects
|
|
3
|
+
3 *
|
|
4
|
+
4 * @param {string} queryString - The query string part of URL (without ?)
|
|
5
|
+
5 * @returns {object} Parsed query parameters in JSON:API format
|
|
6
|
+
6 *
|
|
7
|
+
7 * @example
|
|
8
|
+
8 * // Input: Basic include and sort
|
|
9
|
+
9 * const query = parseJsonApiQuery('include=author&sort=-created_at');
|
|
10
|
+
10 *
|
|
11
|
+
11 * // Output: Parsed into arrays
|
|
12
|
+
12 * // {
|
|
13
|
+
13 * // include: ['author'], // Split by comma
|
|
14
|
+
14 * // fields: {},
|
|
15
|
+
15 * // filters: {},
|
|
16
|
+
16 * // sort: ['-created_at'], // Split by comma
|
|
17
|
+
17 * // page: {}
|
|
18
|
+
18 * // }
|
|
19
|
+
19 *
|
|
20
|
+
20 * @example
|
|
21
|
+
21 * // Input: Filters with bracket notation
|
|
22
|
+
22 * const query = parseJsonApiQuery('filter[status]=published&filter[author_id]=123');
|
|
23
|
+
23 *
|
|
24
|
+
24 * // Output: Extracted filter keys
|
|
25
|
+
25 * // {
|
|
26
|
+
26 * // include: [],
|
|
27
|
+
27 * // fields: {},
|
|
28
|
+
28 * // filters: {
|
|
29
|
+
29 * // status: 'published', // filter[status] → filters.status
|
|
30
|
+
30 * // author_id: '123' // Values kept as strings
|
|
31
|
+
31 * // },
|
|
32
|
+
32 * // sort: [],
|
|
33
|
+
33 * // page: {}
|
|
34
|
+
34 * // }
|
|
35
|
+
35 *
|
|
36
|
+
36 * @example
|
|
37
|
+
37 * // Input: Sparse fieldsets
|
|
38
|
+
38 * const query = parseJsonApiQuery('fields[articles]=title,body&fields[users]=name');
|
|
39
|
+
39 *
|
|
40
|
+
40 * // Output: Fields kept as comma-separated strings
|
|
41
|
+
41 * // {
|
|
42
|
+
42 * // include: [],
|
|
43
|
+
43 * // fields: {
|
|
44
|
+
44 * // articles: 'title,body', // NOT split into array
|
|
45
|
+
45 * // users: 'name' // Validation expects strings
|
|
46
|
+
46 * // },
|
|
47
|
+
47 * // filters: {},
|
|
48
|
+
48 * // sort: [],
|
|
49
|
+
49 * // page: {}
|
|
50
|
+
50 * // }
|
|
51
|
+
51 *
|
|
52
|
+
52 * @example
|
|
53
|
+
53 * // Input: Pagination with numeric conversion
|
|
54
|
+
54 * const query = parseJsonApiQuery('page[size]=20&page[number]=3&page[cursor]=abc123');
|
|
55
|
+
55 *
|
|
56
|
+
56 * // Output: Numbers converted, strings preserved
|
|
57
|
+
57 * // {
|
|
58
|
+
58 * // include: [],
|
|
59
|
+
59 * // fields: {},
|
|
60
|
+
60 * // filters: {},
|
|
61
|
+
61 * // sort: [],
|
|
62
|
+
62 * // page: {
|
|
63
|
+
63 * // size: 20, // "20" → 20 (numeric)
|
|
64
|
+
64 * // number: 3, // "3" → 3 (numeric)
|
|
65
|
+
65 * // cursor: 'abc123' // Non-numeric stays string
|
|
66
|
+
66 * // }
|
|
67
|
+
67 * // }
|
|
68
|
+
68 *
|
|
69
|
+
69 * @example
|
|
70
|
+
70 * // Input: Complex real-world query
|
|
71
|
+
71 * const query = parseJsonApiQuery(
|
|
72
|
+
72 * 'include=author,comments.user&' +
|
|
73
|
+
73 * 'filter[status]=published&' +
|
|
74
|
+
74 * 'filter[created_after]=2024-01-01&' +
|
|
75
|
+
75 * 'fields[articles]=title,summary&' +
|
|
76
|
+
76 * 'sort=-created_at,title&' +
|
|
77
|
+
77 * 'page[size]=10'
|
|
78
|
+
78 * );
|
|
79
|
+
79 *
|
|
80
|
+
80 * // Output: All parameters properly categorized
|
|
81
|
+
81 * // {
|
|
82
|
+
82 * // include: ['author', 'comments.user'],
|
|
83
|
+
83 * // fields: { articles: 'title,summary' },
|
|
84
|
+
84 * // filters: {
|
|
85
|
+
85 * // status: 'published',
|
|
86
|
+
86 * // created_after: '2024-01-01'
|
|
87
|
+
87 * // },
|
|
88
|
+
88 * // sort: ['-created_at', 'title'],
|
|
89
|
+
89 * // page: { size: 10 }
|
|
90
|
+
90 * // }
|
|
91
|
+
91 *
|
|
92
|
+
92 * @description
|
|
93
|
+
93 * Used by:
|
|
94
|
+
94 * - express-plugin parses req.query with this
|
|
95
|
+
95 * - http-plugin parses URL query strings with this
|
|
96
|
+
96 * - websocket-plugin parses message query parameters with this
|
|
97
|
+
97 *
|
|
98
|
+
98 * Purpose:
|
|
99
|
+
99 * - Provides uniform JSON:API query parsing across all transports
|
|
100
|
+
100 * - Handles bracket notation without regex complexity
|
|
101
|
+
101 * - Converts data types appropriately (numbers for pagination)
|
|
102
|
+
102 * - Ignores non-JSON:API parameters gracefully
|
|
103
|
+
103 * - Returns consistent structure even for empty input
|
|
104
|
+
104 *
|
|
105
|
+
105 * Data flow:
|
|
106
|
+
106 * 1. Uses URLSearchParams for reliable parsing
|
|
107
|
+
107 * 2. Categorizes parameters by their prefix pattern
|
|
108
|
+
108 * 3. Extracts bracketed keys (filter[x] → x)
|
|
109
|
+
109 * 4. Splits comma-separated values for include/sort
|
|
110
|
+
110 * 5. Converts numeric strings for pagination
|
|
111
|
+
111 * 6. Returns normalized structure for REST API
|
|
112
|
+
112 */
|
|
113
|
+
113 export function parseJsonApiQuery (queryString) {
|
|
114
|
+
114 if (!queryString) {
|
|
115
|
+
115 return {
|
|
116
|
+
116 include: [],
|
|
117
|
+
117 fields: {},
|
|
118
|
+
118 filters: {},
|
|
119
|
+
119 sort: [],
|
|
120
|
+
120 page: {}
|
|
121
|
+
121 }
|
|
122
|
+
122 }
|
|
123
|
+
123
|
|
124
|
+
124 const params = new URLSearchParams(queryString)
|
|
125
|
+
125 const result = {
|
|
126
|
+
126 include: [],
|
|
127
|
+
127 fields: {},
|
|
128
|
+
128 filters: {},
|
|
129
|
+
129 sort: [],
|
|
130
|
+
130 page: {}
|
|
131
|
+
131 }
|
|
132
|
+
132
|
|
133
|
+
133 for (const [key, value] of params) {
|
|
134
|
+
134 if (key === 'include') {
|
|
135
|
+
135 // Parse include (comma-separated string to array)
|
|
136
|
+
136 result.include = value.split(',').map(s => s.trim()).filter(s => s.length > 0)
|
|
137
|
+
137 } else if (key === 'sort') {
|
|
138
|
+
138 // Parse sort (comma-separated string to array)
|
|
139
|
+
139 result.sort = value.split(',').map(s => s.trim()).filter(s => s.length > 0)
|
|
140
|
+
140 } else if (key.startsWith('filter[') && key.endsWith(']')) {
|
|
141
|
+
141 // Parse filter[key] = value into filters: { key: value }
|
|
142
|
+
142 const filterKey = key.slice(7, -1) // Remove 'filter[' and ']'
|
|
143
|
+
143 if (filterKey) {
|
|
144
|
+
144 result.filters[filterKey] = value
|
|
145
|
+
145 }
|
|
146
|
+
146 } else if (key.startsWith('fields[') && key.endsWith(']')) {
|
|
147
|
+
147 // Parse fields[type] = fields into fields: { type: "field1,field2" }
|
|
148
|
+
148 // Keep as comma-separated string to match REST API validation expectations
|
|
149
|
+
149 const fieldType = key.slice(7, -1) // Remove 'fields[' and ']'
|
|
150
|
+
150 if (fieldType) {
|
|
151
|
+
151 result.fields[fieldType] = value
|
|
152
|
+
152 }
|
|
153
|
+
153 } else if (key.startsWith('page[') && key.endsWith(']')) {
|
|
154
|
+
154 // Parse page[size] = 10 into page: { size: 10 }
|
|
155
|
+
155 const pageKey = key.slice(5, -1) // Remove 'page[' and ']'
|
|
156
|
+
156 if (pageKey) {
|
|
157
|
+
157 // Convert to number if it's a valid number, otherwise keep as string
|
|
158
|
+
158 result.page[pageKey] = isNaN(value) ? value : parseInt(value, 10)
|
|
159
|
+
159 }
|
|
160
|
+
160 }
|
|
161
|
+
161 // Ignore other query parameters that don't match JSON:API patterns
|
|
162
|
+
162 }
|
|
163
|
+
163
|
|
164
|
+
164 return result
|
|
165
|
+
165 }
|