axiodb 22.2.2 → 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.
Files changed (74) hide show
  1. package/electron/electron-builder.json +58 -0
  2. package/electron/main/main.cts +585 -0
  3. package/electron/main/preload.cts +51 -0
  4. package/electron/package-lock.json +8123 -0
  5. package/electron/package.json +52 -0
  6. package/electron/public/AXioDB.png +0 -0
  7. package/electron/resources/after-install.sh +45 -0
  8. package/electron/resources/after-remove.sh +29 -0
  9. package/electron/resources/icon.png +0 -0
  10. package/electron/resources/icons/128x128.png +0 -0
  11. package/electron/resources/icons/16x16.png +0 -0
  12. package/electron/resources/icons/24x24.png +0 -0
  13. package/electron/resources/icons/256x256.png +0 -0
  14. package/electron/resources/icons/32x32.png +0 -0
  15. package/electron/resources/icons/48x48.png +0 -0
  16. package/electron/resources/icons/512x512.png +0 -0
  17. package/electron/resources/icons/64x64.png +0 -0
  18. package/electron/src/App.jsx +128 -0
  19. package/electron/src/api/authApi.js +83 -0
  20. package/electron/src/api/client.js +102 -0
  21. package/electron/src/assets/AXioDB.png +0 -0
  22. package/electron/src/components/auth/CreateRoleModal.jsx +189 -0
  23. package/electron/src/components/auth/CreateUserModal.jsx +131 -0
  24. package/electron/src/components/auth/ForcePasswordChangeModal.jsx +132 -0
  25. package/electron/src/components/auth/ProtectedRoute.jsx +42 -0
  26. package/electron/src/components/auth/ResetPasswordModal.jsx +90 -0
  27. package/electron/src/components/auth/UserAvatarMenu.jsx +103 -0
  28. package/electron/src/components/collection/CreateCollectionModal.jsx +116 -0
  29. package/electron/src/components/collection/DeleteCollectionModal.jsx +85 -0
  30. package/electron/src/components/collection/SchemaViewModal.jsx +369 -0
  31. package/electron/src/components/dashboard/CollectionsChart.jsx +94 -0
  32. package/electron/src/components/dashboard/DatabaseTreeView.jsx +130 -0
  33. package/electron/src/components/dashboard/InMemoryCacheCard.jsx +60 -0
  34. package/electron/src/components/dashboard/StorageDonut.jsx +76 -0
  35. package/electron/src/components/dashboard/StorageUsageCard.jsx +59 -0
  36. package/electron/src/components/dashboard/TotalCollectionsCard.jsx +47 -0
  37. package/electron/src/components/dashboard/TotalDatabasesCard.jsx +43 -0
  38. package/electron/src/components/dashboard/TotalDocumentsCard.jsx +44 -0
  39. package/electron/src/components/database/CreateDatabaseModal.jsx +109 -0
  40. package/electron/src/components/database/DeleteDatabaseModal.jsx +97 -0
  41. package/electron/src/components/query/CodeEditor.jsx +343 -0
  42. package/electron/src/components/query/ObjectEditor.jsx +115 -0
  43. package/electron/src/components/query/ObjectView.jsx +44 -0
  44. package/electron/src/components/query/QueryEditor.jsx +32 -0
  45. package/electron/src/components/query/queryLanguage.js +755 -0
  46. package/electron/src/components/ui/Button.jsx +58 -0
  47. package/electron/src/components/ui/Card.jsx +29 -0
  48. package/electron/src/components/ui/ErrorBoundary.jsx +108 -0
  49. package/electron/src/components/ui/Feedback.jsx +66 -0
  50. package/electron/src/components/ui/Field.jsx +65 -0
  51. package/electron/src/components/ui/MetricCard.jsx +104 -0
  52. package/electron/src/components/ui/Modal.jsx +119 -0
  53. package/electron/src/components/ui/Page.jsx +40 -0
  54. package/electron/src/config/key.js +11 -0
  55. package/electron/src/index.css +181 -0
  56. package/electron/src/index.html +13 -0
  57. package/electron/src/layout/Sidebar.jsx +478 -0
  58. package/electron/src/layout/StatusBar.jsx +70 -0
  59. package/electron/src/layout/Titlebar.jsx +128 -0
  60. package/electron/src/main.jsx +42 -0
  61. package/electron/src/pages/ConnectionHub.jsx +464 -0
  62. package/electron/src/pages/Dashboard.jsx +128 -0
  63. package/electron/src/pages/Documents.jsx +823 -0
  64. package/electron/src/pages/Import.jsx +527 -0
  65. package/electron/src/pages/UserManagement.jsx +294 -0
  66. package/electron/src/pages/Welcome.jsx +157 -0
  67. package/electron/src/store/authStore.js +30 -0
  68. package/electron/src/store/connectionStore.js +103 -0
  69. package/electron/src/store/dbStore.js +165 -0
  70. package/electron/src/store/store.js +8 -0
  71. package/electron/src/utils/format.js +39 -0
  72. package/electron/vite.config.js +28 -0
  73. package/lib/Services/Indexation.operation.js +1 -1
  74. 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
+ }