axiodb 22.3.1 → 22.4.2
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/electron/electron-builder.json +58 -0
- package/electron/main/main.cts +585 -0
- package/electron/main/preload.cts +51 -0
- package/electron/package-lock.json +8123 -0
- package/electron/package.json +52 -0
- package/electron/public/AXioDB.png +0 -0
- package/electron/resources/after-install.sh +45 -0
- package/electron/resources/after-remove.sh +29 -0
- package/electron/resources/icon.png +0 -0
- package/electron/resources/icons/128x128.png +0 -0
- package/electron/resources/icons/16x16.png +0 -0
- package/electron/resources/icons/24x24.png +0 -0
- package/electron/resources/icons/256x256.png +0 -0
- package/electron/resources/icons/32x32.png +0 -0
- package/electron/resources/icons/48x48.png +0 -0
- package/electron/resources/icons/512x512.png +0 -0
- package/electron/resources/icons/64x64.png +0 -0
- package/electron/src/App.jsx +128 -0
- package/electron/src/api/authApi.js +83 -0
- package/electron/src/api/client.js +102 -0
- package/electron/src/assets/AXioDB.png +0 -0
- package/electron/src/components/auth/CreateRoleModal.jsx +189 -0
- package/electron/src/components/auth/CreateUserModal.jsx +131 -0
- package/electron/src/components/auth/ForcePasswordChangeModal.jsx +132 -0
- package/electron/src/components/auth/ProtectedRoute.jsx +42 -0
- package/electron/src/components/auth/ResetPasswordModal.jsx +90 -0
- package/electron/src/components/auth/UserAvatarMenu.jsx +103 -0
- package/electron/src/components/collection/CreateCollectionModal.jsx +116 -0
- package/electron/src/components/collection/DeleteCollectionModal.jsx +85 -0
- package/electron/src/components/collection/SchemaViewModal.jsx +369 -0
- package/electron/src/components/dashboard/CollectionsChart.jsx +94 -0
- package/electron/src/components/dashboard/DatabaseTreeView.jsx +130 -0
- package/electron/src/components/dashboard/InMemoryCacheCard.jsx +60 -0
- package/electron/src/components/dashboard/StorageDonut.jsx +76 -0
- package/electron/src/components/dashboard/StorageUsageCard.jsx +59 -0
- package/electron/src/components/dashboard/TotalCollectionsCard.jsx +47 -0
- package/electron/src/components/dashboard/TotalDatabasesCard.jsx +43 -0
- package/electron/src/components/dashboard/TotalDocumentsCard.jsx +44 -0
- package/electron/src/components/database/CreateDatabaseModal.jsx +109 -0
- package/electron/src/components/database/DeleteDatabaseModal.jsx +97 -0
- package/electron/src/components/query/CodeEditor.jsx +343 -0
- package/electron/src/components/query/ObjectEditor.jsx +115 -0
- package/electron/src/components/query/ObjectView.jsx +44 -0
- package/electron/src/components/query/QueryEditor.jsx +32 -0
- package/electron/src/components/query/queryLanguage.js +755 -0
- package/electron/src/components/ui/Button.jsx +58 -0
- package/electron/src/components/ui/Card.jsx +29 -0
- package/electron/src/components/ui/ErrorBoundary.jsx +108 -0
- package/electron/src/components/ui/Feedback.jsx +66 -0
- package/electron/src/components/ui/Field.jsx +65 -0
- package/electron/src/components/ui/MetricCard.jsx +104 -0
- package/electron/src/components/ui/Modal.jsx +119 -0
- package/electron/src/components/ui/Page.jsx +40 -0
- package/electron/src/config/key.js +11 -0
- package/electron/src/index.css +181 -0
- package/electron/src/index.html +13 -0
- package/electron/src/layout/Sidebar.jsx +478 -0
- package/electron/src/layout/StatusBar.jsx +70 -0
- package/electron/src/layout/Titlebar.jsx +128 -0
- package/electron/src/main.jsx +42 -0
- package/electron/src/pages/ConnectionHub.jsx +464 -0
- package/electron/src/pages/Dashboard.jsx +128 -0
- package/electron/src/pages/Documents.jsx +823 -0
- package/electron/src/pages/Import.jsx +527 -0
- package/electron/src/pages/UserManagement.jsx +294 -0
- package/electron/src/pages/Welcome.jsx +157 -0
- package/electron/src/store/authStore.js +30 -0
- package/electron/src/store/connectionStore.js +103 -0
- package/electron/src/store/dbStore.js +165 -0
- package/electron/src/store/store.js +8 -0
- package/electron/src/utils/format.js +39 -0
- package/electron/vite.config.js +28 -0
- package/lib/Services/Indexation.operation.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The little language the Query console understands:
|
|
3
|
+
*
|
|
4
|
+
* <Collection>.query({ name: 'Ankan' }).exec()
|
|
5
|
+
* <Collection>.aggregate([{ $match: {} }]).exec()
|
|
6
|
+
*
|
|
7
|
+
* `.exec()` is required because the real API is chainable and nothing runs without it -
|
|
8
|
+
* `collection.query({})` on its own just builds a chain object. Accepting it here without
|
|
9
|
+
* the terminal call would teach a syntax that does nothing in a .js file.
|
|
10
|
+
*
|
|
11
|
+
* Those are the only two shapes the Dashboard HTTP API exposes for reading
|
|
12
|
+
* (`POST /api/operation/all/by-query/` and `POST /api/operation/aggregate/`), so they are
|
|
13
|
+
* the only two offered. `.Limit()`, `.Skip()` and `.Sort()` exist on the embedded library
|
|
14
|
+
* but the REST layer fixes them itself, so suggesting them here would be a lie.
|
|
15
|
+
*
|
|
16
|
+
* The argument is a JavaScript object literal, not JSON - unquoted keys, bare `$gt`, and
|
|
17
|
+
* single quotes all work, so what you type here is what you would type in a .js file using
|
|
18
|
+
* the package. `parseLiteral` below turns it into a real value, which the caller serialises
|
|
19
|
+
* to JSON for the wire. It is a hand-written recursive-descent parser rather than `eval` or
|
|
20
|
+
* `new Function`: this string comes from a text box, and handing that to the engine would be
|
|
21
|
+
* arbitrary code execution in the dashboard.
|
|
22
|
+
*
|
|
23
|
+
* Everything below is syntax only - no document data is ever inspected or suggested.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Methods offered after `<Collection>.` */
|
|
27
|
+
export const METHODS = [
|
|
28
|
+
{
|
|
29
|
+
label: 'query',
|
|
30
|
+
detail: '(filter) → documents',
|
|
31
|
+
doc: 'Find documents matching a MongoDB-style filter object. `{}` returns everything.',
|
|
32
|
+
insert: 'query({}).exec()',
|
|
33
|
+
caretOffset: 7
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
label: 'aggregate',
|
|
37
|
+
detail: '(pipeline) → documents',
|
|
38
|
+
doc: 'Run an aggregation pipeline. The pipeline is an array and must start with a $match stage.',
|
|
39
|
+
insert: 'aggregate([{ $match: {} }]).exec()',
|
|
40
|
+
caretOffset: 22
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/** Terminal call. Offered after the closing `)` of query()/aggregate(). */
|
|
45
|
+
export const TERMINALS = [
|
|
46
|
+
{
|
|
47
|
+
label: 'exec',
|
|
48
|
+
detail: '() → Promise<result>',
|
|
49
|
+
doc: 'Runs the chain. The API is lazy - nothing touches the database until exec() is called.',
|
|
50
|
+
insert: 'exec()'
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
/** Operators accepted by the query engine (source/utility/Searcher.utils.ts). */
|
|
55
|
+
export const QUERY_OPERATORS = [
|
|
56
|
+
{ label: '$eq', detail: 'equals', doc: 'Matches values equal to the given value.', insert: '$eq: ' },
|
|
57
|
+
{ label: '$ne', detail: 'not equal', doc: 'Matches values not equal to the given value.', insert: '$ne: ' },
|
|
58
|
+
{ label: '$gt', detail: 'greater than', doc: 'Matches values greater than the given value.', insert: '$gt: ' },
|
|
59
|
+
{ label: '$gte', detail: 'greater or equal', doc: 'Matches values greater than or equal to the given value.', insert: '$gte: ' },
|
|
60
|
+
{ label: '$lt', detail: 'less than', doc: 'Matches values less than the given value.', insert: '$lt: ' },
|
|
61
|
+
{ label: '$lte', detail: 'less or equal', doc: 'Matches values less than or equal to the given value.', insert: '$lte: ' },
|
|
62
|
+
{ label: '$in', detail: 'in array', doc: 'Matches any value present in the given array.', insert: '$in: []' },
|
|
63
|
+
{ label: '$nin', detail: 'not in array', doc: 'Matches values absent from the given array.', insert: '$nin: []' },
|
|
64
|
+
{ label: '$all', detail: 'contains all', doc: 'Matches arrays containing every listed element.', insert: '$all: []' },
|
|
65
|
+
{ label: '$size', detail: 'array length', doc: 'Matches arrays with exactly this many elements.', insert: '$size: 0' },
|
|
66
|
+
{ label: '$exists', detail: 'field present', doc: 'Matches documents where the field does (true) or does not (false) exist.', insert: '$exists: true' },
|
|
67
|
+
{ label: '$type', detail: 'value type', doc: 'Matches values of the given JavaScript type, e.g. "string" or "number".', insert: "$type: 'string'" },
|
|
68
|
+
{ label: '$regex', detail: 'pattern match', doc: 'Matches strings against a regular expression. Pair with $options for flags.', insert: "$regex: ''" },
|
|
69
|
+
{ label: '$options', detail: 'regex flags', doc: 'Flags for a sibling $regex, e.g. "i" for case-insensitive.', insert: "$options: 'i'" },
|
|
70
|
+
{ label: '$elemMatch', detail: 'array element match', doc: 'Matches arrays with at least one element satisfying every listed condition.', insert: '$elemMatch: {}' },
|
|
71
|
+
{ label: '$not', detail: 'negate', doc: 'Inverts the enclosed condition.', insert: '$not: {}' },
|
|
72
|
+
{ label: '$and', detail: 'all of', doc: 'Every condition in the array must match.', insert: '$and: []' },
|
|
73
|
+
{ label: '$or', detail: 'any of', doc: 'At least one condition in the array must match.', insert: '$or: []' },
|
|
74
|
+
{ label: '$nor', detail: 'none of', doc: 'No condition in the array may match.', insert: '$nor: []' }
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
/** Pipeline stages accepted by the aggregation engine (source/Services/Aggregation). */
|
|
78
|
+
export const AGGREGATION_STAGES = [
|
|
79
|
+
{ label: '$match', detail: 'filter stage', doc: 'Filters documents. Supports $and, $or, $nor, $not, $exists, $elemMatch, $all, $size, $type, $mod.', insert: '$match: {}' },
|
|
80
|
+
{ label: '$group', detail: 'group stage', doc: 'Groups by _id. Accumulators: $sum, $avg, $min, $max, $first, $last, $push, $addToSet, $stdDevPop, $stdDevSamp.', insert: "$group: { _id: '$field' }" },
|
|
81
|
+
{ label: '$sort', detail: 'order stage', doc: 'Orders documents. Multi-field support. 1 ascending, -1 descending.', insert: '$sort: {}' },
|
|
82
|
+
{ label: '$project', detail: 'shape stage', doc: 'Include (1), exclude (0), or compute fields via expressions.', insert: '$project: {}' },
|
|
83
|
+
{ label: '$limit', detail: 'cap stage', doc: 'Keeps at most N documents.', insert: '$limit: 10' },
|
|
84
|
+
{ label: '$skip', detail: 'offset stage', doc: 'Discards the first N documents.', insert: '$skip: 0' },
|
|
85
|
+
{ label: '$unwind', detail: 'flatten stage', doc: 'Expands an array field. Supports includeArrayIndex and preserveNullAndEmptyArrays.', insert: "$unwind: '$field'" },
|
|
86
|
+
{ label: '$addFields', detail: 'add fields stage', doc: 'Adds computed fields via expressions.', insert: '$addFields: {}' },
|
|
87
|
+
{ label: '$set', detail: 'alias for $addFields', doc: 'Alias for $addFields.', insert: '$set: {}' },
|
|
88
|
+
{ label: '$unset', detail: 'remove fields', doc: 'Removes specified fields.', insert: "$unset: ['field']" },
|
|
89
|
+
{ label: '$lookup', detail: 'join stage', doc: 'Cross-collection join. Equality: {from, localField, foreignField, as}. Pipeline: {from, let, pipeline, as}.', insert: "$lookup: { from: 'Collection', localField: 'id', foreignField: 'id', as: 'joined' }" },
|
|
90
|
+
{ label: '$facet', detail: 'multi-pipeline', doc: 'Runs multiple sub-pipelines in parallel on the same input.', insert: '$facet: {}' },
|
|
91
|
+
{ label: '$bucket', detail: 'bucket stage', doc: 'Groups documents into buckets defined by boundaries.', insert: '$bucket: { groupBy: "$field", boundaries: [] }' },
|
|
92
|
+
{ label: '$bucketAuto', detail: 'auto bucket', doc: 'Automatically distributes documents into N buckets.', insert: '$bucketAuto: { groupBy: "$field", buckets: 5 }' },
|
|
93
|
+
{ label: '$count', detail: 'count stage', doc: 'Counts documents and assigns to a named field.', insert: "$count: 'total'" },
|
|
94
|
+
{ label: '$sortByCount', detail: 'group + sort', doc: 'Groups by expression, sorts by count descending.', insert: "$sortByCount: '$field'" },
|
|
95
|
+
{ label: '$sample', detail: 'random sample', doc: 'Returns N random documents.', insert: '$sample: { size: 10 }' },
|
|
96
|
+
{ label: '$replaceRoot', detail: 'replace doc', doc: 'Replaces document with a subdocument.', insert: '$replaceRoot: { newRoot: "$sub" }' },
|
|
97
|
+
{ label: '$replaceWith', detail: 'alias for $replaceRoot', doc: 'Alias for $replaceRoot.', insert: '$replaceWith: "$sub"' },
|
|
98
|
+
{ label: '$sum', detail: 'accumulator', doc: 'Inside $group: totals a field, or counts with a literal 1.', insert: '$sum: 1' },
|
|
99
|
+
{ label: '$avg', detail: 'accumulator', doc: 'Inside $group: averages a numeric field.', insert: "$avg: '$field'" },
|
|
100
|
+
{ label: '$min', detail: 'accumulator', doc: 'Inside $group: minimum value.', insert: "$min: '$field'" },
|
|
101
|
+
{ label: '$max', detail: 'accumulator', doc: 'Inside $group: maximum value.', insert: "$max: '$field'" },
|
|
102
|
+
{ label: '$first', detail: 'accumulator', doc: 'Inside $group: first value in the group.', insert: "$first: '$field'" },
|
|
103
|
+
{ label: '$last', detail: 'accumulator', doc: 'Inside $group: last value in the group.', insert: "$last: '$field'" },
|
|
104
|
+
{ label: '$push', detail: 'accumulator', doc: 'Inside $group: collects values into an array.', insert: "$push: '$field'" },
|
|
105
|
+
{ label: '$addToSet', detail: 'accumulator', doc: 'Inside $group: collects unique values into an array.', insert: "$addToSet: '$field'" }
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
const QUERY_OPERATOR_NAMES = new Set(QUERY_OPERATORS.map((o) => o.label))
|
|
109
|
+
const AGGREGATION_NAMES = new Set(AGGREGATION_STAGES.map((s) => s.label))
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Splits source into coloured tokens. Single left-to-right pass, no lookbehind - a string
|
|
113
|
+
* is re-classified as a key once we see the `:` that follows it.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} text
|
|
116
|
+
* @returns {Array<{type: string, value: string, start: number}>}
|
|
117
|
+
*/
|
|
118
|
+
export function tokenize (text) {
|
|
119
|
+
const tokens = []
|
|
120
|
+
let i = 0
|
|
121
|
+
|
|
122
|
+
while (i < text.length) {
|
|
123
|
+
const char = text[i]
|
|
124
|
+
|
|
125
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
126
|
+
let end = i + 1
|
|
127
|
+
while (end < text.length && (text[end] !== char || text[end - 1] === '\\')) end++
|
|
128
|
+
const value = text.slice(i, Math.min(end + 1, text.length))
|
|
129
|
+
const inner = value.slice(1, -1)
|
|
130
|
+
tokens.push({
|
|
131
|
+
type: inner.startsWith('$') ? 'operator' : 'string',
|
|
132
|
+
value,
|
|
133
|
+
start: i
|
|
134
|
+
})
|
|
135
|
+
i = end + 1
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (/\s/.test(char)) {
|
|
140
|
+
let end = i
|
|
141
|
+
while (end < text.length && /\s/.test(text[end])) end++
|
|
142
|
+
tokens.push({ type: 'space', value: text.slice(i, end), start: i })
|
|
143
|
+
i = end
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (/[0-9-]/.test(char) && /[0-9]/.test(text[i + 1] ?? char)) {
|
|
148
|
+
let end = i
|
|
149
|
+
while (end < text.length && /[0-9.eE+-]/.test(text[end])) end++
|
|
150
|
+
tokens.push({ type: 'number', value: text.slice(i, end), start: i })
|
|
151
|
+
i = end
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (/[A-Za-z_$]/.test(char)) {
|
|
156
|
+
let end = i
|
|
157
|
+
while (end < text.length && /[A-Za-z0-9_$]/.test(text[end])) end++
|
|
158
|
+
const word = text.slice(i, end)
|
|
159
|
+
const isLiteral = word === 'true' || word === 'false' || word === 'null' || word === 'undefined'
|
|
160
|
+
const isMethod = text[end] === '('
|
|
161
|
+
tokens.push({
|
|
162
|
+
type: isLiteral
|
|
163
|
+
? 'literal'
|
|
164
|
+
: isMethod
|
|
165
|
+
? 'method'
|
|
166
|
+
: word.startsWith('$') ? 'operator' : 'identifier',
|
|
167
|
+
value: word,
|
|
168
|
+
start: i
|
|
169
|
+
})
|
|
170
|
+
i = end
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
tokens.push({ type: 'punctuation', value: char, start: i })
|
|
175
|
+
i++
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Anything immediately followed by `:` is a property key, not a value. `$operator` keys
|
|
179
|
+
// keep their own colour - that distinction is the whole point of highlighting them.
|
|
180
|
+
for (let t = 0; t < tokens.length; t++) {
|
|
181
|
+
if (tokens[t].type !== 'string' && tokens[t].type !== 'identifier') continue
|
|
182
|
+
let next = t + 1
|
|
183
|
+
while (next < tokens.length && tokens[next].type === 'space') next++
|
|
184
|
+
if (tokens[next]?.value === ':') tokens[t].type = 'key'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return tokens
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Index of the `)` closing the `(` at `open`, or -1. Counts depth and skips quoted
|
|
192
|
+
* sections, so a paren inside a string value cannot close the call.
|
|
193
|
+
*/
|
|
194
|
+
function findMatchingParen (text, open) {
|
|
195
|
+
let depth = 0
|
|
196
|
+
let quote = null
|
|
197
|
+
|
|
198
|
+
for (let i = open; i < text.length; i++) {
|
|
199
|
+
const char = text[i]
|
|
200
|
+
|
|
201
|
+
if (quote) {
|
|
202
|
+
if (char === '\\') i++
|
|
203
|
+
else if (char === quote) quote = null
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (char === '"' || char === "'" || char === '`') { quote = char; continue }
|
|
208
|
+
if (char === '(') depth++
|
|
209
|
+
else if (char === ')') {
|
|
210
|
+
depth--
|
|
211
|
+
if (depth === 0) return i
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return -1
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Pulls `<Collection>.<method>(<args>)` - and the trailing `.exec()` if present - out of
|
|
220
|
+
* the source.
|
|
221
|
+
*
|
|
222
|
+
* @returns {{collection: string, method: string, args: string, argsStart: number,
|
|
223
|
+
* hasExec: boolean, tail: string, tailStart: number} | null}
|
|
224
|
+
*/
|
|
225
|
+
export function parseExpression (text) {
|
|
226
|
+
const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(/.exec(text)
|
|
227
|
+
if (!match) return null
|
|
228
|
+
|
|
229
|
+
const open = match[0].length - 1
|
|
230
|
+
const close = findMatchingParen(text, open)
|
|
231
|
+
if (close === -1) return null
|
|
232
|
+
|
|
233
|
+
const tail = text.slice(close + 1)
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
collection: match[1],
|
|
237
|
+
method: match[2],
|
|
238
|
+
args: text.slice(open + 1, close),
|
|
239
|
+
argsStart: open + 1,
|
|
240
|
+
hasExec: /^\s*\.\s*exec\s*\(\s*\)\s*;?\s*$/.test(tail),
|
|
241
|
+
tail,
|
|
242
|
+
tailStart: close + 1
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The method being called, without requiring the call to be closed yet.
|
|
248
|
+
*
|
|
249
|
+
* {@link parseExpression} needs a matching `)` and so returns null for everything the user is
|
|
250
|
+
* still typing - which is exactly when suggestions matter. This only needs the opening paren,
|
|
251
|
+
* so `Coll.aggregate([{ $ma` still resolves to `aggregate`.
|
|
252
|
+
*
|
|
253
|
+
* @returns {string | null}
|
|
254
|
+
*/
|
|
255
|
+
function detectMethod (text) {
|
|
256
|
+
return /^\s*[A-Za-z_][A-Za-z0-9_]*\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(/.exec(text)?.[1] ?? null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Thrown by {@link parseLiteral} with the offset of the offending character. */
|
|
260
|
+
export class LiteralSyntaxError extends Error {
|
|
261
|
+
constructor (message, offset) {
|
|
262
|
+
super(message)
|
|
263
|
+
this.name = 'LiteralSyntaxError'
|
|
264
|
+
this.offset = offset
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Parses a JavaScript object-literal subset into a real value: objects, arrays, strings
|
|
270
|
+
* (single, double, or backtick without interpolation), numbers, true/false/null, unquoted
|
|
271
|
+
* and `$`-prefixed keys, and trailing commas.
|
|
272
|
+
*
|
|
273
|
+
* Deliberately hand-written - `eval`/`new Function` on text box contents would be arbitrary
|
|
274
|
+
* code execution. Nothing here can call out, only build plain data.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} text
|
|
277
|
+
* @returns {*} the parsed value
|
|
278
|
+
* @throws {LiteralSyntaxError} on malformed input, carrying the character offset
|
|
279
|
+
*/
|
|
280
|
+
export function parseLiteral (text) {
|
|
281
|
+
let pos = 0
|
|
282
|
+
|
|
283
|
+
const skipSpace = () => {
|
|
284
|
+
while (pos < text.length && /\s/.test(text[pos])) pos++
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const fail = (message) => {
|
|
288
|
+
throw new LiteralSyntaxError(message, Math.min(pos, Math.max(text.length - 1, 0)))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const parseString = () => {
|
|
292
|
+
const quote = text[pos]
|
|
293
|
+
pos++
|
|
294
|
+
let out = ''
|
|
295
|
+
while (pos < text.length && text[pos] !== quote) {
|
|
296
|
+
if (text[pos] === '\\') {
|
|
297
|
+
const escapes = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', 0: '\0' }
|
|
298
|
+
const next = text[pos + 1]
|
|
299
|
+
out += escapes[next] ?? next
|
|
300
|
+
pos += 2
|
|
301
|
+
continue
|
|
302
|
+
}
|
|
303
|
+
out += text[pos]
|
|
304
|
+
pos++
|
|
305
|
+
}
|
|
306
|
+
if (pos >= text.length) fail('Unterminated string')
|
|
307
|
+
pos++
|
|
308
|
+
return out
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const parseNumber = () => {
|
|
312
|
+
const start = pos
|
|
313
|
+
if (text[pos] === '+' || text[pos] === '-') pos++
|
|
314
|
+
while (pos < text.length && /[0-9.eE+-]/.test(text[pos])) pos++
|
|
315
|
+
const raw = text.slice(start, pos)
|
|
316
|
+
const value = Number(raw)
|
|
317
|
+
if (Number.isNaN(value)) fail(`Invalid number "${raw}"`)
|
|
318
|
+
return value
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Object keys: quoted, or a bare identifier that may start with `$` or `_`. */
|
|
322
|
+
const parseKey = () => {
|
|
323
|
+
if (text[pos] === '"' || text[pos] === "'" || text[pos] === '`') return parseString()
|
|
324
|
+
const start = pos
|
|
325
|
+
while (pos < text.length && /[A-Za-z0-9_$]/.test(text[pos])) pos++
|
|
326
|
+
if (pos === start) fail(`Expected a property name but found "${text[pos] ?? 'end of input'}"`)
|
|
327
|
+
return text.slice(start, pos)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const parseObject = () => {
|
|
331
|
+
pos++ // {
|
|
332
|
+
const out = {}
|
|
333
|
+
skipSpace()
|
|
334
|
+
if (text[pos] === '}') { pos++; return out }
|
|
335
|
+
|
|
336
|
+
for (;;) {
|
|
337
|
+
skipSpace()
|
|
338
|
+
if (text[pos] === '}') { pos++; return out } // trailing comma
|
|
339
|
+
const key = parseKey()
|
|
340
|
+
skipSpace()
|
|
341
|
+
if (text[pos] !== ':') fail(`Expected ":" after property "${key}"`)
|
|
342
|
+
pos++
|
|
343
|
+
skipSpace()
|
|
344
|
+
out[key] = parseValue()
|
|
345
|
+
skipSpace()
|
|
346
|
+
if (text[pos] === ',') { pos++; continue }
|
|
347
|
+
if (text[pos] === '}') { pos++; return out }
|
|
348
|
+
fail('Expected "," or "}"')
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const parseArray = () => {
|
|
353
|
+
pos++ // [
|
|
354
|
+
const out = []
|
|
355
|
+
skipSpace()
|
|
356
|
+
if (text[pos] === ']') { pos++; return out }
|
|
357
|
+
|
|
358
|
+
for (;;) {
|
|
359
|
+
skipSpace()
|
|
360
|
+
if (text[pos] === ']') { pos++; return out } // trailing comma
|
|
361
|
+
out.push(parseValue())
|
|
362
|
+
skipSpace()
|
|
363
|
+
if (text[pos] === ',') { pos++; continue }
|
|
364
|
+
if (text[pos] === ']') { pos++; return out }
|
|
365
|
+
fail('Expected "," or "]"')
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const parseValue = () => {
|
|
370
|
+
skipSpace()
|
|
371
|
+
if (pos >= text.length) fail('Unexpected end of input')
|
|
372
|
+
|
|
373
|
+
const char = text[pos]
|
|
374
|
+
if (char === '{') return parseObject()
|
|
375
|
+
if (char === '[') return parseArray()
|
|
376
|
+
if (char === '"' || char === "'" || char === '`') return parseString()
|
|
377
|
+
if (/[0-9+-]/.test(char)) return parseNumber()
|
|
378
|
+
|
|
379
|
+
if (text.startsWith('true', pos)) { pos += 4; return true }
|
|
380
|
+
if (text.startsWith('false', pos)) { pos += 5; return false }
|
|
381
|
+
if (text.startsWith('null', pos)) { pos += 4; return null }
|
|
382
|
+
if (text.startsWith('undefined', pos)) { pos += 9; return undefined }
|
|
383
|
+
|
|
384
|
+
fail(`Unexpected "${char}"`)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
skipSpace()
|
|
388
|
+
if (pos >= text.length) throw new LiteralSyntaxError('Expected a value', 0)
|
|
389
|
+
const value = parseValue()
|
|
390
|
+
skipSpace()
|
|
391
|
+
if (pos < text.length) fail(`Unexpected "${text[pos]}" after the value`)
|
|
392
|
+
return value
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Every `$word` used as a property *key*, with its offset - the basis for flagging unknown
|
|
397
|
+
* operators. Only keys count: `{ _id: '$city' }` uses `$city` as a field reference, and
|
|
398
|
+
* flagging that as an unknown operator would be wrong.
|
|
399
|
+
*/
|
|
400
|
+
function collectOperators (tokens) {
|
|
401
|
+
const operators = []
|
|
402
|
+
|
|
403
|
+
for (let t = 0; t < tokens.length; t++) {
|
|
404
|
+
if (tokens[t].type !== 'operator') continue
|
|
405
|
+
let next = t + 1
|
|
406
|
+
while (next < tokens.length && tokens[next].type === 'space') next++
|
|
407
|
+
if (tokens[next]?.value !== ':') continue
|
|
408
|
+
|
|
409
|
+
const raw = tokens[t].value
|
|
410
|
+
const quoted = raw.startsWith('"') || raw.startsWith("'") || raw.startsWith('`')
|
|
411
|
+
operators.push({
|
|
412
|
+
name: quoted ? raw.slice(2, -1) : raw.slice(1),
|
|
413
|
+
start: tokens[t].start,
|
|
414
|
+
end: tokens[t].start + raw.length
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return operators
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Validates the expression and returns VS Code-style diagnostics.
|
|
423
|
+
*
|
|
424
|
+
* @param {string} text - Editor contents.
|
|
425
|
+
* @param {string} collectionName - The collection this console is bound to.
|
|
426
|
+
* @returns {Array<{start: number, end: number, message: string, severity: 'error'|'warning'}>}
|
|
427
|
+
*/
|
|
428
|
+
export function validate (text, collectionName) {
|
|
429
|
+
const diagnostics = []
|
|
430
|
+
const trimmed = text.trim()
|
|
431
|
+
if (!trimmed) return diagnostics
|
|
432
|
+
|
|
433
|
+
const parsed = parseExpression(text)
|
|
434
|
+
if (!parsed) {
|
|
435
|
+
diagnostics.push({
|
|
436
|
+
start: 0,
|
|
437
|
+
end: text.length,
|
|
438
|
+
message: `Expected ${collectionName}.query({ ... }).exec() or ${collectionName}.aggregate([ ... ]).exec()`,
|
|
439
|
+
severity: 'error'
|
|
440
|
+
})
|
|
441
|
+
return diagnostics
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (!parsed.hasExec) {
|
|
445
|
+
const empty = parsed.tail.trim().length === 0
|
|
446
|
+
diagnostics.push({
|
|
447
|
+
start: parsed.tailStart,
|
|
448
|
+
end: empty ? text.length : parsed.tailStart + parsed.tail.length,
|
|
449
|
+
message: empty
|
|
450
|
+
? 'Missing .exec() - the chain is lazy, so nothing runs without it.'
|
|
451
|
+
: `Only .exec() can follow ${parsed.method}() here. Ordering and paging are set by the dashboard.`,
|
|
452
|
+
severity: 'error'
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const nameStart = text.indexOf(parsed.collection)
|
|
457
|
+
if (parsed.collection !== collectionName) {
|
|
458
|
+
diagnostics.push({
|
|
459
|
+
start: nameStart,
|
|
460
|
+
end: nameStart + parsed.collection.length,
|
|
461
|
+
message: `Unknown collection "${parsed.collection}". This console is bound to "${collectionName}".`,
|
|
462
|
+
severity: 'error'
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const method = METHODS.find((m) => m.label === parsed.method)
|
|
467
|
+
if (!method) {
|
|
468
|
+
const methodStart = text.indexOf(parsed.method, nameStart + parsed.collection.length)
|
|
469
|
+
diagnostics.push({
|
|
470
|
+
start: methodStart,
|
|
471
|
+
end: methodStart + parsed.method.length,
|
|
472
|
+
message: `"${parsed.method}" is not available here. Use ${METHODS.map((m) => m.label).join(' or ')}.`,
|
|
473
|
+
severity: 'error'
|
|
474
|
+
})
|
|
475
|
+
return diagnostics
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let value
|
|
479
|
+
try {
|
|
480
|
+
value = parseLiteral(parsed.args)
|
|
481
|
+
} catch (error) {
|
|
482
|
+
const start = parsed.argsStart + (error.offset ?? 0)
|
|
483
|
+
diagnostics.push({
|
|
484
|
+
start: Math.min(start, Math.max(text.length - 1, 0)),
|
|
485
|
+
end: Math.min(start + 1, text.length),
|
|
486
|
+
message: error.message,
|
|
487
|
+
severity: 'error'
|
|
488
|
+
})
|
|
489
|
+
return diagnostics
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const tokens = tokenize(parsed.args)
|
|
493
|
+
const known = parsed.method === 'aggregate'
|
|
494
|
+
? new Set([...AGGREGATION_NAMES, ...QUERY_OPERATOR_NAMES])
|
|
495
|
+
: QUERY_OPERATOR_NAMES
|
|
496
|
+
|
|
497
|
+
for (const operator of collectOperators(tokens)) {
|
|
498
|
+
// `$field` references (e.g. "$age" inside $group/$unwind) are values, not operators.
|
|
499
|
+
if (known.has(`$${operator.name}`)) continue
|
|
500
|
+
if (parsed.method === 'aggregate') continue
|
|
501
|
+
diagnostics.push({
|
|
502
|
+
start: parsed.argsStart + operator.start,
|
|
503
|
+
end: parsed.argsStart + operator.end,
|
|
504
|
+
message: `Unknown operator "$${operator.name}".`,
|
|
505
|
+
severity: 'warning'
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (parsed.method === 'query') {
|
|
510
|
+
if (Array.isArray(value) || typeof value !== 'object' || value === null) {
|
|
511
|
+
diagnostics.push({
|
|
512
|
+
start: parsed.argsStart,
|
|
513
|
+
end: parsed.argsStart + parsed.args.length,
|
|
514
|
+
message: 'query() takes a filter object, for example { "age": { "$gt": 25 } }.',
|
|
515
|
+
severity: 'error'
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
return diagnostics
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// aggregate() - the engine throws unless the pipeline is an array starting with $match.
|
|
522
|
+
if (!Array.isArray(value)) {
|
|
523
|
+
diagnostics.push({
|
|
524
|
+
start: parsed.argsStart,
|
|
525
|
+
end: parsed.argsStart + parsed.args.length,
|
|
526
|
+
message: 'aggregate() takes an array of pipeline stages.',
|
|
527
|
+
severity: 'error'
|
|
528
|
+
})
|
|
529
|
+
return diagnostics
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (value.length === 0) {
|
|
533
|
+
diagnostics.push({
|
|
534
|
+
start: parsed.argsStart,
|
|
535
|
+
end: parsed.argsStart + parsed.args.length,
|
|
536
|
+
message: 'Pipeline is empty. The first stage must be $match.',
|
|
537
|
+
severity: 'error'
|
|
538
|
+
})
|
|
539
|
+
return diagnostics
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (!Object.prototype.hasOwnProperty.call(value[0] ?? {}, '$match')) {
|
|
543
|
+
const firstStage = parsed.args.indexOf('{')
|
|
544
|
+
const stageStart = parsed.argsStart + (firstStage === -1 ? 0 : firstStage)
|
|
545
|
+
const firstKey = tokens.find((token) => token.type === 'operator')
|
|
546
|
+
diagnostics.push({
|
|
547
|
+
start: firstKey ? parsed.argsStart + firstKey.start : stageStart,
|
|
548
|
+
end: firstKey ? parsed.argsStart + firstKey.start + firstKey.value.length : stageStart + 1,
|
|
549
|
+
message: 'Pipeline must have a $match stage at top. Use [{ $match: {} }, ...] to start from every document.',
|
|
550
|
+
severity: 'error'
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return diagnostics
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Decides what to offer at the caret.
|
|
559
|
+
*
|
|
560
|
+
* @param {string} text - Editor contents.
|
|
561
|
+
* @param {number} caret - Caret offset.
|
|
562
|
+
* @param {string} collectionName
|
|
563
|
+
* @returns {{items: Array<object>, replaceFrom: number, prefix: string}}
|
|
564
|
+
*/
|
|
565
|
+
export function getSuggestions (text, caret, collectionName, fields = []) {
|
|
566
|
+
const before = text.slice(0, caret)
|
|
567
|
+
|
|
568
|
+
// `).` - the terminal call.
|
|
569
|
+
const terminalContext = /\)\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)?$/.exec(before)
|
|
570
|
+
if (terminalContext) {
|
|
571
|
+
const prefix = terminalContext[1] ?? ''
|
|
572
|
+
return {
|
|
573
|
+
items: TERMINALS.filter((t) => t.label.startsWith(prefix)),
|
|
574
|
+
replaceFrom: caret - prefix.length,
|
|
575
|
+
prefix
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// `<Collection>.` or a partly-typed method name after it.
|
|
580
|
+
const methodContext = new RegExp(`${collectionName}\\s*\\.\\s*([A-Za-z_][A-Za-z0-9_]*)?$`).exec(before)
|
|
581
|
+
if (methodContext) {
|
|
582
|
+
const prefix = methodContext[1] ?? ''
|
|
583
|
+
return {
|
|
584
|
+
items: METHODS.filter((m) => m.label.startsWith(prefix)),
|
|
585
|
+
replaceFrom: caret - prefix.length,
|
|
586
|
+
prefix
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// A `$operator`, typed either bare or already inside quotes.
|
|
591
|
+
const operatorContext = /"?(\$[A-Za-z]*)$/.exec(before)
|
|
592
|
+
if (operatorContext) {
|
|
593
|
+
const prefix = operatorContext[1]
|
|
594
|
+
const pool = detectMethod(text) === 'aggregate'
|
|
595
|
+
? [...AGGREGATION_STAGES, ...QUERY_OPERATORS]
|
|
596
|
+
: QUERY_OPERATORS
|
|
597
|
+
const quoted = before.endsWith(prefix) && before[before.length - prefix.length - 1] === '"'
|
|
598
|
+
return {
|
|
599
|
+
items: pool
|
|
600
|
+
.filter((item) => item.label.startsWith(prefix))
|
|
601
|
+
.map((item) => (quoted ? { ...item, insert: item.label } : item)),
|
|
602
|
+
replaceFrom: caret - prefix.length - (quoted ? 1 : 0),
|
|
603
|
+
prefix
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Property identifier (e.g. typing a field name inside an object)
|
|
608
|
+
const idContext = /([A-Za-z_][A-Za-z0-9_]*)$/.exec(before)
|
|
609
|
+
if (idContext && fields && fields.length > 0) {
|
|
610
|
+
const prefix = idContext[1]
|
|
611
|
+
const matches = fields
|
|
612
|
+
.filter((f) => f.startsWith(prefix))
|
|
613
|
+
.map((f) => ({
|
|
614
|
+
label: f,
|
|
615
|
+
detail: 'field',
|
|
616
|
+
doc: `Collection field "${f}"`,
|
|
617
|
+
insert: `${f}: `
|
|
618
|
+
}))
|
|
619
|
+
if (matches.length > 0) {
|
|
620
|
+
return {
|
|
621
|
+
items: matches,
|
|
622
|
+
replaceFrom: caret - prefix.length,
|
|
623
|
+
prefix
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return { items: [], replaceFrom: caret, prefix: '' }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Ctrl+Space with no prefix: offer everything that makes sense at the caret. */
|
|
632
|
+
export function getAllSuggestions (text, caret, collectionName, fields = []) {
|
|
633
|
+
const explicit = getSuggestions(text, caret, collectionName, fields)
|
|
634
|
+
if (explicit.items.length > 0) return explicit
|
|
635
|
+
|
|
636
|
+
const method = detectMethod(text)
|
|
637
|
+
if (!method) return { items: METHODS, replaceFrom: caret, prefix: '' }
|
|
638
|
+
|
|
639
|
+
const items = method === 'aggregate'
|
|
640
|
+
? [...AGGREGATION_STAGES, ...QUERY_OPERATORS]
|
|
641
|
+
: [...QUERY_OPERATORS]
|
|
642
|
+
|
|
643
|
+
if (fields && fields.length > 0) {
|
|
644
|
+
fields.forEach((f) => {
|
|
645
|
+
items.push({
|
|
646
|
+
label: f,
|
|
647
|
+
detail: 'field',
|
|
648
|
+
doc: `Collection field "${f}"`,
|
|
649
|
+
insert: `${f}: `
|
|
650
|
+
})
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
items,
|
|
656
|
+
replaceFrom: caret,
|
|
657
|
+
prefix: ''
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Bare `{ key: ... }` is legal only for a valid JS identifier; everything else needs quotes. */
|
|
662
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Renders a value as a JavaScript object literal - the inverse of {@link parseLiteral}.
|
|
666
|
+
*
|
|
667
|
+
* Unquoted keys where legal and single-quoted strings, so what the editor shows is what you
|
|
668
|
+
* would type in a .js file. `JSON.stringify` cannot do this: it always quotes keys and always
|
|
669
|
+
* uses double quotes.
|
|
670
|
+
*
|
|
671
|
+
* @param {*} value
|
|
672
|
+
* @param {number} [indent] - spaces per level
|
|
673
|
+
* @returns {string}
|
|
674
|
+
*/
|
|
675
|
+
export function formatLiteral (value, indent = 2) {
|
|
676
|
+
const pad = (depth) => ' '.repeat(indent * depth)
|
|
677
|
+
|
|
678
|
+
const render = (node, depth) => {
|
|
679
|
+
if (node === null) return 'null'
|
|
680
|
+
if (node === undefined) return 'undefined'
|
|
681
|
+
|
|
682
|
+
const type = typeof node
|
|
683
|
+
if (type === 'number' || type === 'boolean') return String(node)
|
|
684
|
+
if (type === 'string') {
|
|
685
|
+
// Prefer single quotes; fall back to double when the value itself contains one.
|
|
686
|
+
const escaped = node.replace(/\\/g, '\\\\').replace(/\n/g, '\\n')
|
|
687
|
+
return escaped.includes("'")
|
|
688
|
+
? `"${escaped.replace(/"/g, '\\"')}"`
|
|
689
|
+
: `'${escaped}'`
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (Array.isArray(node)) {
|
|
693
|
+
if (node.length === 0) return '[]'
|
|
694
|
+
const items = node.map((item) => `${pad(depth + 1)}${render(item, depth + 1)}`)
|
|
695
|
+
return `[\n${items.join(',\n')}\n${pad(depth)}]`
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (type === 'object') {
|
|
699
|
+
const entries = Object.entries(node)
|
|
700
|
+
if (entries.length === 0) return '{}'
|
|
701
|
+
const rendered = entries.map(([key, item]) => {
|
|
702
|
+
const safeKey = IDENTIFIER.test(key) ? key : `'${key}'`
|
|
703
|
+
return `${pad(depth + 1)}${safeKey}: ${render(item, depth + 1)}`
|
|
704
|
+
})
|
|
705
|
+
return `{\n${rendered.join(',\n')}\n${pad(depth)}}`
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return String(node)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return render(value, 0)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Validates a standalone document literal - what the insert/update editors need.
|
|
716
|
+
* Returns the same diagnostic shape {@link validate} produces.
|
|
717
|
+
*
|
|
718
|
+
* @param {string} text
|
|
719
|
+
* @returns {Array<{start: number, end: number, message: string, severity: 'error'|'warning'}>}
|
|
720
|
+
*/
|
|
721
|
+
export function validateDocument (text) {
|
|
722
|
+
if (!text.trim()) {
|
|
723
|
+
return [{ start: 0, end: 0, message: 'Document is empty.', severity: 'error' }]
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
let value
|
|
727
|
+
try {
|
|
728
|
+
value = parseLiteral(text)
|
|
729
|
+
} catch (error) {
|
|
730
|
+
const start = Math.min(error.offset ?? 0, Math.max(text.length - 1, 0))
|
|
731
|
+
return [{ start, end: start + 1, message: error.message, severity: 'error' }]
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (Array.isArray(value) || typeof value !== 'object' || value === null) {
|
|
735
|
+
return [{
|
|
736
|
+
start: 0,
|
|
737
|
+
end: text.length,
|
|
738
|
+
message: 'A document must be an object, for example { name: \'Ankan\' }.',
|
|
739
|
+
severity: 'error'
|
|
740
|
+
}]
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// documentId/updatedAt are assigned by AxioDB; sending them back is a silent no-op at best.
|
|
744
|
+
return ['documentId', 'updatedAt']
|
|
745
|
+
.filter((field) => Object.prototype.hasOwnProperty.call(value, field))
|
|
746
|
+
.map((field) => {
|
|
747
|
+
const at = Math.max(text.indexOf(field), 0)
|
|
748
|
+
return {
|
|
749
|
+
start: at,
|
|
750
|
+
end: at + field.length,
|
|
751
|
+
message: `"${field}" is managed by AxioDB and is ignored here.`,
|
|
752
|
+
severity: 'warning'
|
|
753
|
+
}
|
|
754
|
+
})
|
|
755
|
+
}
|