@kernhq/module-quire 0.7.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +93 -55
  2. package/dist/{server → client}/formula.d.ts +6 -0
  3. package/dist/client/formula.d.ts.map +1 -0
  4. package/dist/{server → client}/formula.js +6 -0
  5. package/dist/client/formula.js.map +1 -0
  6. package/dist/contract/properties.d.ts +26 -0
  7. package/dist/contract/properties.d.ts.map +1 -1
  8. package/dist/contract/properties.js +24 -0
  9. package/dist/contract/properties.js.map +1 -1
  10. package/dist/contract/router.d.ts +334 -0
  11. package/dist/contract/router.d.ts.map +1 -1
  12. package/dist/contract/router.js +43 -1
  13. package/dist/contract/router.js.map +1 -1
  14. package/dist/server/_impl.d.ts +341 -0
  15. package/dist/server/_impl.d.ts.map +1 -1
  16. package/dist/server/_impl.js +79 -10
  17. package/dist/server/_impl.js.map +1 -1
  18. package/dist/server/document.d.ts +13 -0
  19. package/dist/server/document.d.ts.map +1 -0
  20. package/dist/server/document.js +58 -0
  21. package/dist/server/document.js.map +1 -0
  22. package/dist/server/index.d.ts.map +1 -1
  23. package/dist/server/index.js +26 -1
  24. package/dist/server/index.js.map +1 -1
  25. package/dist/server/render.d.ts +53 -0
  26. package/dist/server/render.d.ts.map +1 -0
  27. package/dist/server/render.js +358 -0
  28. package/dist/server/render.js.map +1 -0
  29. package/dist/server/services/databases.d.ts +73 -1
  30. package/dist/server/services/databases.d.ts.map +1 -1
  31. package/dist/server/services/databases.js +167 -10
  32. package/dist/server/services/databases.js.map +1 -1
  33. package/dist/server/services/pages.d.ts.map +1 -1
  34. package/dist/server/services/pages.js +11 -1
  35. package/dist/server/services/pages.js.map +1 -1
  36. package/dist/server/services/query.d.ts.map +1 -1
  37. package/dist/server/services/query.js +93 -37
  38. package/dist/server/services/query.js.map +1 -1
  39. package/dist/server/services/versions.d.ts.map +1 -1
  40. package/dist/server/services/versions.js +13 -1
  41. package/dist/server/services/versions.js.map +1 -1
  42. package/migrations/0006_rank_collation.sql +14 -0
  43. package/migrations/meta/_journal.json +7 -0
  44. package/package.json +12 -9
  45. package/src/client/components/PageEditor.svelte +7 -0
  46. package/src/client/components/PageTreeRow.svelte +1 -1
  47. package/src/client/core-api.ts +38 -0
  48. package/src/client/database/BoardView.svelte +354 -0
  49. package/src/client/database/CalendarView.svelte +346 -0
  50. package/src/client/database/DatabaseView.svelte +785 -0
  51. package/src/client/database/FilterMenu.svelte +281 -0
  52. package/src/client/database/FilterValue.svelte +176 -0
  53. package/src/client/database/GalleryView.svelte +177 -0
  54. package/src/client/database/ListView.svelte +108 -0
  55. package/src/client/database/OptionChip.svelte +38 -0
  56. package/src/client/database/PropertyDialog.svelte +460 -0
  57. package/src/client/database/PropertyMenu.svelte +162 -0
  58. package/src/client/database/RowPanel.svelte +157 -0
  59. package/src/client/database/SortMenu.svelte +199 -0
  60. package/src/client/database/TableView.svelte +388 -0
  61. package/src/client/database/ViewDialog.svelte +229 -0
  62. package/src/client/database/cells/Cell.svelte +103 -0
  63. package/src/client/database/cells/CheckboxCell.svelte +33 -0
  64. package/src/client/database/cells/ComputedCell.svelte +100 -0
  65. package/src/client/database/cells/DateCell.svelte +97 -0
  66. package/src/client/database/cells/LinkCell.svelte +143 -0
  67. package/src/client/database/cells/NumberCell.svelte +131 -0
  68. package/src/client/database/cells/PersonCell.svelte +116 -0
  69. package/src/client/database/cells/RelationCell.svelte +222 -0
  70. package/src/client/database/cells/SelectCell.svelte +133 -0
  71. package/src/client/database/cells/TextCell.svelte +85 -0
  72. package/src/client/database/colours.ts +27 -0
  73. package/src/client/database/property-types.test.ts +64 -0
  74. package/src/client/database/property-types.ts +294 -0
  75. package/src/client/database/view-config.test.ts +148 -0
  76. package/src/client/database/view-config.ts +102 -0
  77. package/src/client/formula.test.ts +139 -0
  78. package/src/client/formula.ts +430 -0
  79. package/src/client/i18n.ts +1176 -0
  80. package/src/client/index.ts +31 -0
  81. package/src/client/mock.ts +511 -1
  82. package/src/client/pages/PageView.svelte +47 -17
  83. package/src/client/pages/SpacePage.svelte +8 -2
  84. package/src/client/query.ts +17 -4
  85. package/src/contract/properties.ts +28 -0
  86. package/src/contract/router.ts +46 -0
  87. package/dist/server/formula.d.ts.map +0 -1
  88. package/dist/server/formula.js.map +0 -1
@@ -0,0 +1,430 @@
1
+ /**
2
+ * The formula language.
3
+ *
4
+ * A hand-written Pratt parser to a typed AST, evaluated by walking it. **Never `eval`, never `new
5
+ * Function`** — a formula is text a workspace member types, it is evaluated on the server, and
6
+ * handing that to the JavaScript engine is arbitrary code execution with the database connection
7
+ * already open.
8
+ *
9
+ * The language is deliberately small: numbers, strings, booleans, dates, `prop("Name")`, the usual
10
+ * operators, and a fixed function table. Anything it cannot express is a column somebody can fill
11
+ * in by hand, which is a better outcome than a language nobody can predict the cost of.
12
+ *
13
+ * It lives under `src/client` because that is the directory this package publishes as **source**,
14
+ * and the property editor has to tell somebody their expression is wrong while they are typing it
15
+ * — a round trip per keystroke is not a validator. The client only ever *parses*: `evaluateFormula`
16
+ * is exported for the server, which stays the only thing that runs one against a row. `rank.ts` is
17
+ * here for the same reason and the server imports that too.
18
+ */
19
+
20
+ export type FormulaValue = number | string | boolean | Date | null
21
+
22
+ export type Ast =
23
+ | { kind: 'literal'; value: FormulaValue }
24
+ | { kind: 'prop'; name: string }
25
+ | { kind: 'unary'; op: '-' | 'not'; operand: Ast }
26
+ | { kind: 'binary'; op: string; left: Ast; right: Ast }
27
+ | { kind: 'call'; name: string; args: Ast[] }
28
+ /**
29
+ * Deliberately **not** named `then`/`else`.
30
+ *
31
+ * An object with a `then` property is *thenable*: `await` and `Promise.resolve()` call it as a
32
+ * promise. This node's `then` would be an AST node rather than a function, so the moment one of
33
+ * these is returned from an `async` function the runtime stops treating it as data — silently,
34
+ * with no error where the mistake is. `consequent`/`alternate` is the standard naming and costs
35
+ * nothing.
36
+ */
37
+ | { kind: 'if'; cond: Ast; consequent: Ast; alternate: Ast }
38
+
39
+ export class FormulaError extends Error {}
40
+
41
+ /** ---- lexer ---- */
42
+
43
+ type Token =
44
+ | { t: 'num'; v: number }
45
+ | { t: 'str'; v: string }
46
+ | { t: 'name'; v: string }
47
+ | { t: 'op'; v: string }
48
+ | { t: 'end' }
49
+
50
+ const OPERATORS = [
51
+ '>=',
52
+ '<=',
53
+ '!=',
54
+ '==',
55
+ '&&',
56
+ '||',
57
+ '+',
58
+ '-',
59
+ '*',
60
+ '/',
61
+ '%',
62
+ '^',
63
+ '>',
64
+ '<',
65
+ '=',
66
+ '(',
67
+ ')',
68
+ ',',
69
+ ]
70
+
71
+ function lex(input: string): Token[] {
72
+ const out: Token[] = []
73
+ let i = 0
74
+ while (i < input.length) {
75
+ const c = input[i] as string
76
+ if (/\s/.test(c)) {
77
+ i++
78
+ continue
79
+ }
80
+ if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(input[i + 1] ?? ''))) {
81
+ let j = i
82
+ while (j < input.length && /[0-9.]/.test(input[j] as string)) j++
83
+ const raw = input.slice(i, j)
84
+ const v = Number(raw)
85
+ if (Number.isNaN(v)) throw new FormulaError(`Not a number: ${raw}`)
86
+ out.push({ t: 'num', v })
87
+ i = j
88
+ continue
89
+ }
90
+ if (c === '"' || c === "'") {
91
+ let j = i + 1
92
+ let v = ''
93
+ while (j < input.length && input[j] !== c) {
94
+ if (input[j] === '\\' && j + 1 < input.length) {
95
+ v += input[j + 1]
96
+ j += 2
97
+ continue
98
+ }
99
+ v += input[j]
100
+ j++
101
+ }
102
+ if (j >= input.length) throw new FormulaError('Unterminated string')
103
+ out.push({ t: 'str', v })
104
+ i = j + 1
105
+ continue
106
+ }
107
+ if (/[A-Za-z_]/.test(c)) {
108
+ let j = i
109
+ while (j < input.length && /[A-Za-z0-9_]/.test(input[j] as string)) j++
110
+ out.push({ t: 'name', v: input.slice(i, j) })
111
+ i = j
112
+ continue
113
+ }
114
+ const op = OPERATORS.find((o) => input.startsWith(o, i))
115
+ if (!op) throw new FormulaError(`Unexpected character: ${c}`)
116
+ out.push({ t: 'op', v: op })
117
+ i += op.length
118
+ }
119
+ out.push({ t: 'end' })
120
+ return out
121
+ }
122
+
123
+ /** ---- parser ---- */
124
+
125
+ /** Binding power per infix operator. Higher binds tighter. */
126
+ const BINDING: Record<string, number> = {
127
+ '||': 1,
128
+ '&&': 2,
129
+ '==': 3,
130
+ '=': 3,
131
+ '!=': 3,
132
+ '>': 4,
133
+ '<': 4,
134
+ '>=': 4,
135
+ '<=': 4,
136
+ '+': 5,
137
+ '-': 5,
138
+ '*': 6,
139
+ '/': 6,
140
+ '%': 6,
141
+ '^': 7,
142
+ }
143
+
144
+ export function parseFormula(input: string): Ast {
145
+ const tokens = lex(input)
146
+ let pos = 0
147
+ const peek = () => tokens[pos] as Token
148
+ const next = () => tokens[pos++] as Token
149
+
150
+ function parseExpression(minBinding = 0): Ast {
151
+ let left = parsePrefix()
152
+ for (;;) {
153
+ const token = peek()
154
+ if (token.t !== 'op') break
155
+ const binding = BINDING[token.v]
156
+ if (binding === undefined || binding < minBinding) break
157
+ next()
158
+ // `^` is right-associative; everything else groups to the left.
159
+ const right = parseExpression(token.v === '^' ? binding : binding + 1)
160
+ left = { kind: 'binary', op: token.v === '=' ? '==' : token.v, left, right }
161
+ }
162
+ return left
163
+ }
164
+
165
+ function parsePrefix(): Ast {
166
+ const token = next()
167
+ if (token.t === 'num' || token.t === 'str') return { kind: 'literal', value: token.v }
168
+ if (token.t === 'op' && token.v === '-') return { kind: 'unary', op: '-', operand: parseExpression(6) }
169
+ if (token.t === 'op' && token.v === '(') {
170
+ const inner = parseExpression()
171
+ expect(')')
172
+ return inner
173
+ }
174
+ if (token.t === 'name') {
175
+ const name = token.v.toLowerCase()
176
+ const after = peek()
177
+ if (after.t !== 'op' || after.v !== '(') {
178
+ if (name === 'true') return { kind: 'literal', value: true }
179
+ if (name === 'false') return { kind: 'literal', value: false }
180
+ if (name === 'null' || name === 'empty') return { kind: 'literal', value: null }
181
+ throw new FormulaError(`Unknown name: ${token.v}`)
182
+ }
183
+ next()
184
+ const args: Ast[] = []
185
+ if (!(peek().t === 'op' && (peek() as { v: string }).v === ')')) {
186
+ for (;;) {
187
+ args.push(parseExpression())
188
+ const sep = peek()
189
+ if (sep.t === 'op' && sep.v === ',') {
190
+ next()
191
+ continue
192
+ }
193
+ break
194
+ }
195
+ }
196
+ expect(')')
197
+ if (name === 'not')
198
+ return { kind: 'unary', op: 'not', operand: args[0] ?? { kind: 'literal', value: null } }
199
+ if (name === 'if') {
200
+ if (args.length !== 3) throw new FormulaError('if() takes a condition and two results')
201
+ return { kind: 'if', cond: args[0]!, consequent: args[1]!, alternate: args[2]! }
202
+ }
203
+ if (name === 'prop') {
204
+ const arg = args[0]
205
+ if (!arg || arg.kind !== 'literal' || typeof arg.value !== 'string')
206
+ throw new FormulaError('prop() takes the name of a property, in quotes')
207
+ return { kind: 'prop', name: arg.value }
208
+ }
209
+ const fn = lookupFunction(name)
210
+ if (!fn) throw new FormulaError(`Unknown function: ${token.v}`)
211
+ return { kind: 'call', name: fn, args }
212
+ }
213
+ throw new FormulaError('Unexpected end of formula')
214
+ }
215
+
216
+ function expect(op: string) {
217
+ const token = next()
218
+ if (token.t !== 'op' || token.v !== op) throw new FormulaError(`Expected ${op}`)
219
+ }
220
+
221
+ const ast = parseExpression()
222
+ if (peek().t !== 'end') throw new FormulaError('Unexpected trailing input')
223
+ return ast
224
+ }
225
+
226
+ /** ---- evaluator ---- */
227
+
228
+ const num = (v: FormulaValue): number => {
229
+ if (typeof v === 'number') return v
230
+ if (typeof v === 'boolean') return v ? 1 : 0
231
+ if (v instanceof Date) return v.getTime()
232
+ if (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v))) return Number(v)
233
+ return 0
234
+ }
235
+ const str = (v: FormulaValue): string => {
236
+ if (v === null) return ''
237
+ if (v instanceof Date) return v.toISOString()
238
+ return String(v)
239
+ }
240
+ const bool = (v: FormulaValue): boolean => {
241
+ if (typeof v === 'boolean') return v
242
+ if (typeof v === 'number') return v !== 0
243
+ if (typeof v === 'string') return v !== ''
244
+ return v !== null
245
+ }
246
+
247
+ /**
248
+ * The function table.
249
+ *
250
+ * Looked up case-insensitively — `dateBetween`, `datebetween` and `DATEBETWEEN` are the same
251
+ * function, because a formula is typed by a person and nobody should have to remember which of
252
+ * these is a name and which is a typo. The names are written in camelCase here for readability and
253
+ * normalised on lookup; keying the object directly by the lowercased name would have made every
254
+ * camelCase function silently unreachable.
255
+ */
256
+ const FUNCTIONS: Record<string, (args: FormulaValue[]) => FormulaValue> = {
257
+ // text
258
+ concat: (a) => a.map(str).join(''),
259
+ join: (a) =>
260
+ a
261
+ .slice(1)
262
+ .map(str)
263
+ .join(str(a[0] ?? '')),
264
+ length: (a) => str(a[0] ?? null).length,
265
+ lower: (a) => str(a[0] ?? null).toLowerCase(),
266
+ upper: (a) => str(a[0] ?? null).toUpperCase(),
267
+ trim: (a) => str(a[0] ?? null).trim(),
268
+ contains: (a) => str(a[0] ?? null).includes(str(a[1] ?? null)),
269
+ replace: (a) =>
270
+ str(a[0] ?? null)
271
+ .split(str(a[1] ?? null))
272
+ .join(str(a[2] ?? null)),
273
+ slice: (a) => str(a[0] ?? null).slice(num(a[1] ?? 0), a[2] === undefined ? undefined : num(a[2])),
274
+ format: (a) => str(a[0] ?? null),
275
+ // number
276
+ abs: (a) => Math.abs(num(a[0] ?? 0)),
277
+ round: (a) => {
278
+ const p = 10 ** num(a[1] ?? 0)
279
+ return Math.round(num(a[0] ?? 0) * p) / p
280
+ },
281
+ floor: (a) => Math.floor(num(a[0] ?? 0)),
282
+ ceil: (a) => Math.ceil(num(a[0] ?? 0)),
283
+ sqrt: (a) => Math.sqrt(num(a[0] ?? 0)),
284
+ min: (a) => (a.length ? Math.min(...a.map(num)) : 0),
285
+ max: (a) => (a.length ? Math.max(...a.map(num)) : 0),
286
+ sum: (a) => a.reduce<number>((t, v) => t + num(v), 0),
287
+ average: (a) => (a.length ? a.reduce<number>((t, v) => t + num(v), 0) / a.length : 0),
288
+ toNumber: (a) => num(a[0] ?? null),
289
+ // logic
290
+ and: (a) => a.every(bool),
291
+ or: (a) => a.some(bool),
292
+ equal: (a) => str(a[0] ?? null) === str(a[1] ?? null),
293
+ // dates
294
+ now: () => new Date(),
295
+ today: () => {
296
+ const d = new Date()
297
+ d.setHours(0, 0, 0, 0)
298
+ return d
299
+ },
300
+ year: (a) => asDate(a[0] ?? null)?.getFullYear() ?? 0,
301
+ month: (a) => (asDate(a[0] ?? null)?.getMonth() ?? -1) + 1,
302
+ day: (a) => asDate(a[0] ?? null)?.getDate() ?? 0,
303
+ /** whole days between two dates; negative when the second is earlier */
304
+ dateBetween: (a) => {
305
+ const from = asDate(a[0] ?? null)
306
+ const to = asDate(a[1] ?? null)
307
+ if (!from || !to) return 0
308
+ return Math.round((to.getTime() - from.getTime()) / 86_400_000)
309
+ },
310
+ dateAdd: (a) => {
311
+ const d = asDate(a[0] ?? null)
312
+ if (!d) return null
313
+ return new Date(d.getTime() + num(a[1] ?? 0) * 86_400_000)
314
+ },
315
+ // general
316
+ isEmpty: (a) => {
317
+ const v = a[0] ?? null
318
+ return v === null || v === '' || (Array.isArray(v) && v.length === 0)
319
+ },
320
+ }
321
+
322
+ /** The canonical name of a function, whatever case it was typed in. */
323
+ const FUNCTION_NAMES = new Map<string, string>()
324
+ function lookupFunction(name: string): string | null {
325
+ if (FUNCTION_NAMES.size === 0) {
326
+ for (const key of Object.keys(FUNCTIONS)) FUNCTION_NAMES.set(key.toLowerCase(), key)
327
+ }
328
+ return FUNCTION_NAMES.get(name.toLowerCase()) ?? null
329
+ }
330
+
331
+ function asDate(v: FormulaValue): Date | null {
332
+ if (v instanceof Date) return v
333
+ if (typeof v === 'string') {
334
+ const d = new Date(v)
335
+ return Number.isNaN(d.getTime()) ? null : d
336
+ }
337
+ if (typeof v === 'number') return new Date(v)
338
+ return null
339
+ }
340
+
341
+ export interface FormulaContext {
342
+ /** the value of a column by *name*, because that is what somebody types in a formula */
343
+ prop(name: string): FormulaValue
344
+ }
345
+
346
+ /** How deep a formula may nest before it is refused, so a pathological one cannot blow the stack. */
347
+ const MAX_DEPTH = 64
348
+
349
+ export function evaluateFormula(ast: Ast, ctx: FormulaContext, depth = 0): FormulaValue {
350
+ if (depth > MAX_DEPTH) throw new FormulaError('This formula nests too deeply')
351
+ const evaluate = (node: Ast) => evaluateFormula(node, ctx, depth + 1)
352
+
353
+ switch (ast.kind) {
354
+ case 'literal':
355
+ return ast.value
356
+ case 'prop':
357
+ return ctx.prop(ast.name)
358
+ case 'unary':
359
+ return ast.op === '-' ? -num(evaluate(ast.operand)) : !bool(evaluate(ast.operand))
360
+ case 'if':
361
+ return bool(evaluate(ast.cond)) ? evaluate(ast.consequent) : evaluate(ast.alternate)
362
+ case 'call': {
363
+ const fn = FUNCTIONS[ast.name]
364
+ if (!fn) throw new FormulaError(`Unknown function: ${ast.name}`)
365
+ return fn(ast.args.map(evaluate))
366
+ }
367
+ case 'binary': {
368
+ // Short-circuit before evaluating the right side, so `false && prop("x")` never touches x.
369
+ if (ast.op === '&&') return bool(evaluate(ast.left)) ? bool(evaluate(ast.right)) : false
370
+ if (ast.op === '||') return bool(evaluate(ast.left)) ? true : bool(evaluate(ast.right))
371
+ const l = evaluate(ast.left)
372
+ const r = evaluate(ast.right)
373
+ switch (ast.op) {
374
+ case '+':
375
+ // `+` concatenates when either side is text, which is what somebody writing a label wants.
376
+ return typeof l === 'string' || typeof r === 'string' ? str(l) + str(r) : num(l) + num(r)
377
+ case '-':
378
+ return num(l) - num(r)
379
+ case '*':
380
+ return num(l) * num(r)
381
+ case '/':
382
+ // Dividing by nothing is a blank cell, not Infinity and not a crashed table.
383
+ return num(r) === 0 ? null : num(l) / num(r)
384
+ case '%':
385
+ return num(r) === 0 ? null : num(l) % num(r)
386
+ case '^':
387
+ return num(l) ** num(r)
388
+ case '==':
389
+ return str(l) === str(r)
390
+ case '!=':
391
+ return str(l) !== str(r)
392
+ case '>':
393
+ return num(l) > num(r)
394
+ case '<':
395
+ return num(l) < num(r)
396
+ case '>=':
397
+ return num(l) >= num(r)
398
+ case '<=':
399
+ return num(l) <= num(r)
400
+ default:
401
+ throw new FormulaError(`Unknown operator: ${ast.op}`)
402
+ }
403
+ }
404
+ }
405
+ }
406
+
407
+ /** Which columns a formula reads, so a change to one of them can recompute exactly what depends on it. */
408
+ export function formulaDependencies(ast: Ast, out = new Set<string>()): Set<string> {
409
+ switch (ast.kind) {
410
+ case 'prop':
411
+ out.add(ast.name)
412
+ break
413
+ case 'unary':
414
+ formulaDependencies(ast.operand, out)
415
+ break
416
+ case 'binary':
417
+ formulaDependencies(ast.left, out)
418
+ formulaDependencies(ast.right, out)
419
+ break
420
+ case 'if':
421
+ formulaDependencies(ast.cond, out)
422
+ formulaDependencies(ast.consequent, out)
423
+ formulaDependencies(ast.alternate, out)
424
+ break
425
+ case 'call':
426
+ for (const arg of ast.args) formulaDependencies(arg, out)
427
+ break
428
+ }
429
+ return out
430
+ }