@silverbulletmd/silverbullet 2.4.2 → 2.6.1

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 (97) hide show
  1. package/README.md +19 -4
  2. package/client/asset_bundle/bundle.ts +3 -9
  3. package/client/data/datastore.ts +4 -5
  4. package/client/markdown_parser/constants.ts +5 -4
  5. package/client/plugos/hooks/code_widget.ts +3 -8
  6. package/client/plugos/hooks/command.ts +8 -8
  7. package/client/plugos/hooks/document_editor.ts +10 -15
  8. package/client/plugos/hooks/event.ts +33 -36
  9. package/client/plugos/hooks/mq.ts +17 -17
  10. package/client/plugos/hooks/plug_namespace.ts +3 -8
  11. package/client/plugos/hooks/slash_command.ts +13 -28
  12. package/client/plugos/hooks/syscall.ts +3 -3
  13. package/client/plugos/manifest_cache.ts +22 -15
  14. package/client/plugos/plug.ts +2 -6
  15. package/client/plugos/plug_compile.ts +79 -78
  16. package/client/plugos/protocol.ts +28 -28
  17. package/client/plugos/proxy_fetch.ts +7 -6
  18. package/client/plugos/sandboxes/web_worker_sandbox.ts +1 -1
  19. package/client/plugos/sandboxes/worker_sandbox.ts +18 -18
  20. package/client/plugos/syscalls/asset.ts +1 -3
  21. package/client/plugos/syscalls/code_widget.ts +1 -3
  22. package/client/plugos/syscalls/config.ts +1 -5
  23. package/client/plugos/syscalls/datastore.ts +1 -1
  24. package/client/plugos/syscalls/editor.ts +72 -69
  25. package/client/plugos/syscalls/event.ts +9 -12
  26. package/client/plugos/syscalls/fetch.ts +31 -23
  27. package/client/plugos/syscalls/index.ts +10 -1
  28. package/client/plugos/syscalls/jsonschema.ts +72 -32
  29. package/client/plugos/syscalls/language.ts +9 -5
  30. package/client/plugos/syscalls/markdown.ts +29 -7
  31. package/client/plugos/syscalls/mq.ts +4 -12
  32. package/client/plugos/syscalls/service_registry.ts +1 -4
  33. package/client/plugos/syscalls/shell.ts +2 -5
  34. package/client/plugos/syscalls/space.ts +1 -1
  35. package/client/plugos/syscalls/sync.ts +69 -60
  36. package/client/plugos/syscalls/system.ts +2 -3
  37. package/client/plugos/system.ts +6 -12
  38. package/client/plugos/worker_runtime.ts +12 -33
  39. package/client/space_lua/aggregates.ts +782 -0
  40. package/client/space_lua/ast.ts +42 -8
  41. package/client/space_lua/ast_narrow.ts +4 -2
  42. package/client/space_lua/eval.ts +886 -575
  43. package/client/space_lua/labels.ts +7 -12
  44. package/client/space_lua/liq_null.ts +6 -0
  45. package/client/space_lua/numeric.ts +5 -8
  46. package/client/space_lua/parse.ts +346 -120
  47. package/client/space_lua/query_collection.ts +926 -82
  48. package/client/space_lua/query_env.ts +26 -0
  49. package/client/space_lua/render_lua_markdown.ts +369 -0
  50. package/client/space_lua/rp.ts +5 -4
  51. package/client/space_lua/runtime.ts +288 -155
  52. package/client/space_lua/stdlib/format.ts +53 -39
  53. package/client/space_lua/stdlib/js.ts +3 -7
  54. package/client/space_lua/stdlib/load.ts +1 -3
  55. package/client/space_lua/stdlib/math.ts +84 -58
  56. package/client/space_lua/stdlib/net.ts +27 -17
  57. package/client/space_lua/stdlib/os.ts +81 -85
  58. package/client/space_lua/stdlib/pattern.ts +695 -0
  59. package/client/space_lua/stdlib/prng.ts +148 -0
  60. package/client/space_lua/stdlib/space_lua.ts +17 -23
  61. package/client/space_lua/stdlib/string.ts +102 -190
  62. package/client/space_lua/stdlib/string_pack.ts +490 -0
  63. package/client/space_lua/stdlib/table.ts +76 -16
  64. package/client/space_lua/stdlib.ts +53 -39
  65. package/client/space_lua/tonumber.ts +82 -42
  66. package/client/space_lua/util.ts +53 -15
  67. package/dist/plug-compile.js +55 -98
  68. package/package.json +27 -20
  69. package/plug-api/constants.ts +0 -32
  70. package/plug-api/lib/async.ts +20 -7
  71. package/plug-api/lib/crypto.ts +16 -17
  72. package/plug-api/lib/dates.ts +15 -7
  73. package/plug-api/lib/json.ts +11 -5
  74. package/plug-api/lib/limited_map.ts +1 -1
  75. package/plug-api/lib/native_fetch.ts +2 -0
  76. package/plug-api/lib/ref.ts +23 -23
  77. package/plug-api/lib/resolve.ts +7 -11
  78. package/plug-api/lib/tags.ts +13 -4
  79. package/plug-api/lib/transclusion.ts +10 -21
  80. package/plug-api/lib/tree.ts +165 -45
  81. package/plug-api/lib/yaml.ts +35 -25
  82. package/plug-api/syscalls/asset.ts +1 -1
  83. package/plug-api/syscalls/config.ts +1 -4
  84. package/plug-api/syscalls/editor.ts +15 -15
  85. package/plug-api/syscalls/jsonschema.ts +1 -3
  86. package/plug-api/syscalls/lua.ts +3 -9
  87. package/plug-api/syscalls/mq.ts +1 -4
  88. package/plug-api/syscalls/shell.ts +4 -1
  89. package/plug-api/syscalls/space.ts +3 -10
  90. package/plug-api/syscalls/system.ts +1 -4
  91. package/plug-api/syscalls/yaml.ts +2 -6
  92. package/plug-api/system_mock.ts +0 -1
  93. package/plug-api/types/client.ts +16 -1
  94. package/plug-api/types/event.ts +6 -4
  95. package/plug-api/types/manifest.ts +8 -9
  96. package/plugs/builtin_plugs.ts +2 -2
  97. package/client/plugos/sandboxes/deno_worker_sandbox.ts +0 -6
@@ -1,7 +1,34 @@
1
- import type { LuaExpression } from "./ast.ts";
2
- import { LuaEnv, luaGet, luaKeys, LuaStackFrame, LuaTable } from "./runtime.ts";
3
- import { evalExpression } from "./eval.ts";
4
- import { asyncQuickSort } from "./util.ts";
1
+ import type {
2
+ LuaAggregateCallExpression,
3
+ LuaBinaryExpression,
4
+ LuaDynamicField,
5
+ LuaExpression,
6
+ LuaExpressionField,
7
+ LuaFilteredCallExpression,
8
+ LuaFunctionBody,
9
+ LuaFunctionCallExpression,
10
+ LuaParenthesizedExpression,
11
+ LuaPropField,
12
+ LuaUnaryExpression,
13
+ } from "./ast.ts";
14
+
15
+ import {
16
+ jsToLuaValue,
17
+ luaCall,
18
+ LuaEnv,
19
+ LuaFunction,
20
+ luaGet,
21
+ luaKeys,
22
+ LuaRuntimeError,
23
+ LuaStackFrame,
24
+ LuaTable,
25
+ luaTruthy,
26
+ type LuaValue,
27
+ singleResult,
28
+ } from "./runtime.ts";
29
+ import { isSqlNull, LIQ_NULL } from "./liq_null.ts";
30
+ import { evalExpression, luaOp } from "./eval.ts";
31
+ import { asyncMergeSort } from "./util.ts";
5
32
  import type { DataStore } from "../data/datastore.ts";
6
33
  import type { KvPrimitives } from "../data/kv_primitives.ts";
7
34
 
@@ -9,29 +36,125 @@ import type { QueryCollationConfig } from "../../plug-api/types/config.ts";
9
36
 
10
37
  import type { KvKey } from "../../plug-api/types/datastore.ts";
11
38
 
12
- export function buildItemEnv(
39
+ import { executeAggregate, getAggregateSpec } from "./aggregates.ts";
40
+ import { Config } from "../config.ts";
41
+
42
+ // Implicit single group map key (aggregates without `group by`)
43
+ const IMPLICIT_GROUP_KEY: unique symbol = Symbol("implicit-group");
44
+
45
+ // Build environment for post-`group by` clauses. Injects `key` and `group`
46
+ // as top-level variables. Unpacks first group item fields and group-by key
47
+ // fields as locals so that bare field access works after grouping.
48
+ function buildGroupItemEnv(
49
+ objectVariable: string | undefined,
50
+ groupByNames: string[] | undefined,
51
+ item: any,
52
+ parentGlobals: LuaEnv,
53
+ sf: LuaStackFrame,
54
+ ): LuaEnv {
55
+ const itemEnv = new LuaEnv(parentGlobals);
56
+ itemEnv.setLocal("_", item);
57
+ if (item instanceof LuaTable) {
58
+ const keyVal = item.rawGet("key");
59
+ const groupVal = item.rawGet("group");
60
+ const firstItem =
61
+ groupVal instanceof LuaTable ? groupVal.rawGet(1) : undefined;
62
+
63
+ if (firstItem) {
64
+ for (const k of luaKeys(firstItem)) {
65
+ if (typeof k !== "string") continue;
66
+ itemEnv.setLocal(k, luaGet(firstItem, k, sf.astCtx ?? null, sf));
67
+ }
68
+ }
69
+
70
+ if (objectVariable) {
71
+ itemEnv.setLocal(objectVariable, firstItem ?? item);
72
+ }
73
+ if (keyVal !== undefined) {
74
+ itemEnv.setLocal("key", keyVal);
75
+ }
76
+ if (groupVal !== undefined) {
77
+ itemEnv.setLocal("group", groupVal);
78
+ }
79
+
80
+ // Unpack named fields from multi-key LuaTable keys
81
+ if (keyVal instanceof LuaTable) {
82
+ for (const k of luaKeys(keyVal)) {
83
+ if (typeof k !== "string") continue;
84
+ itemEnv.setLocal(k, luaGet(keyVal, k, sf.astCtx ?? null, sf));
85
+ }
86
+ }
87
+
88
+ // Bind all `group by` aliases/names to their key values. For
89
+ // single key bind the name to the scalar `keyVal`. For multi-key
90
+ // bind each name to the field from the key table.
91
+ if (groupByNames && groupByNames.length > 0) {
92
+ if (!(keyVal instanceof LuaTable)) {
93
+ // Bind all names to scalar
94
+ for (const gbn of groupByNames) {
95
+ itemEnv.setLocal(gbn, keyVal);
96
+ }
97
+ } else {
98
+ // Ensure every alias is bound even if `luaKeys` missed it
99
+ for (const gbn of groupByNames) {
100
+ const v = keyVal.rawGet(gbn);
101
+ if (v !== undefined) {
102
+ itemEnv.setLocal(gbn, v);
103
+ }
104
+ }
105
+ }
106
+ }
107
+ }
108
+ return itemEnv;
109
+ }
110
+
111
+ /**
112
+ * Build an environment for evaluating per-item expressions in queries.
113
+ *
114
+ * When `objectVariable` is NOT set: item fields are unpacked as locals
115
+ * and shadow any globals. The item is also bound to `_`.
116
+ *
117
+ * When `objectVariable` IS set: only the object variable is bound.
118
+ * Item fields are NOT unpacked - the user opted into qualified access
119
+ * (e.g. `p.name`) and bare names must resolve from the parent env.
120
+ */
121
+ function buildItemEnvLocal(
13
122
  objectVariable: string | undefined,
14
123
  item: any,
15
124
  env: LuaEnv,
16
125
  sf: LuaStackFrame,
17
126
  ): LuaEnv {
18
127
  const itemEnv = new LuaEnv(env);
19
- if (!objectVariable) {
20
- // Inject all item keys as variables
21
- for (const key of luaKeys(item)) {
22
- itemEnv.setLocal(key, luaGet(item, key, sf.astCtx ?? null, sf));
23
- }
24
- // As well as _
25
- itemEnv.setLocal("_", item);
26
- } else {
128
+ if (objectVariable) {
27
129
  itemEnv.setLocal(objectVariable, item);
130
+ } else {
131
+ // Unpack item fields as locals so unqualified access works
132
+ itemEnv.setLocal("_", item);
133
+ if (item instanceof LuaTable) {
134
+ for (const key of luaKeys(item)) {
135
+ itemEnv.setLocal(key, luaGet(item, key, sf.astCtx ?? null, sf));
136
+ }
137
+ } else if (typeof item === "object" && item !== null) {
138
+ for (const key of luaKeys(item)) {
139
+ itemEnv.setLocal(key, luaGet(item, key, sf.astCtx ?? null, sf));
140
+ }
141
+ }
28
142
  }
29
143
  return itemEnv;
30
144
  }
31
145
 
146
+ export { buildItemEnvLocal as buildItemEnv };
147
+
32
148
  export type LuaOrderBy = {
33
149
  expr: LuaExpression;
34
150
  desc: boolean;
151
+ nulls?: "first" | "last";
152
+ using?: string | LuaFunctionBody;
153
+ };
154
+
155
+ export type LuaGroupByEntry = {
156
+ expr: LuaExpression;
157
+ alias?: string;
35
158
  };
36
159
 
37
160
  /**
@@ -51,6 +174,10 @@ export type LuaCollectionQuery = {
51
174
  offset?: number;
52
175
  // Whether to return only distinct values
53
176
  distinct?: boolean;
177
+ // The group by entries evaluated with Lua
178
+ groupBy?: LuaGroupByEntry[];
179
+ // The having expression evaluated with Lua
180
+ having?: LuaExpression;
54
181
  };
55
182
 
56
183
  export interface LuaQueryCollection {
@@ -58,6 +185,7 @@ export interface LuaQueryCollection {
58
185
  query: LuaCollectionQuery,
59
186
  env: LuaEnv,
60
187
  sf: LuaStackFrame,
188
+ config?: Config,
61
189
  ): Promise<any[]>;
62
190
  }
63
191
 
@@ -65,38 +193,506 @@ export interface LuaQueryCollection {
65
193
  * Implements a query collection for a regular JavaScript array
66
194
  */
67
195
  export class ArrayQueryCollection<T> implements LuaQueryCollection {
68
- constructor(private readonly array: T[]) {
69
- }
70
-
196
+ constructor(private readonly array: T[]) {}
71
197
  query(
72
198
  query: LuaCollectionQuery,
73
199
  env: LuaEnv,
74
200
  sf: LuaStackFrame,
75
- collation?: QueryCollationConfig,
201
+ config?: Config,
76
202
  ): Promise<any[]> {
77
- return applyQuery(this.array, query, env, sf, collation);
203
+ return applyQuery(this.array, query, env, sf, config);
204
+ }
205
+ }
206
+
207
+ // Wrap any object, array, or LuaQueryCollection as a queryable collection
208
+ export function toCollection(obj: any): LuaQueryCollection {
209
+ if (
210
+ obj instanceof ArrayQueryCollection ||
211
+ obj instanceof DataStoreQueryCollection
212
+ ) {
213
+ return obj;
214
+ }
215
+ if (Array.isArray(obj)) {
216
+ return new ArrayQueryCollection(obj);
217
+ }
218
+ return new ArrayQueryCollection([obj]);
219
+ }
220
+
221
+ function containsAggregate(expr: LuaExpression, config?: Config): boolean {
222
+ switch (expr.type) {
223
+ case "FilteredCall": {
224
+ const fc = (expr as LuaFilteredCallExpression).call;
225
+ if (
226
+ fc.prefix.type === "Variable" &&
227
+ getAggregateSpec(fc.prefix.name, config)
228
+ ) {
229
+ return true;
230
+ }
231
+ return (
232
+ containsAggregate(fc, config) ||
233
+ containsAggregate((expr as LuaFilteredCallExpression).filter, config)
234
+ );
235
+ }
236
+ case "AggregateCall": {
237
+ const ac = expr as LuaAggregateCallExpression;
238
+ const fc = ac.call;
239
+ if (
240
+ fc.prefix.type === "Variable" &&
241
+ getAggregateSpec(fc.prefix.name, config)
242
+ ) {
243
+ return true;
244
+ }
245
+ return containsAggregate(fc, config);
246
+ }
247
+ case "FunctionCall": {
248
+ const fc = expr as LuaFunctionCallExpression;
249
+ if (
250
+ fc.prefix.type === "Variable" &&
251
+ getAggregateSpec(fc.prefix.name, config)
252
+ ) {
253
+ return true;
254
+ }
255
+ return fc.args.some((a) => containsAggregate(a, config));
256
+ }
257
+ case "Binary": {
258
+ const bin = expr as LuaBinaryExpression;
259
+ return (
260
+ containsAggregate(bin.left, config) ||
261
+ containsAggregate(bin.right, config)
262
+ );
263
+ }
264
+ case "Unary": {
265
+ const un = expr as LuaUnaryExpression;
266
+ return containsAggregate(un.argument, config);
267
+ }
268
+ case "Parenthesized": {
269
+ const p = expr as LuaParenthesizedExpression;
270
+ return containsAggregate(p.expression, config);
271
+ }
272
+ case "TableConstructor":
273
+ return expr.fields.some((f) => {
274
+ switch (f.type) {
275
+ case "PropField":
276
+ return containsAggregate((f as LuaPropField).value, config);
277
+ case "DynamicField": {
278
+ const df = f as LuaDynamicField;
279
+ return (
280
+ containsAggregate(df.key, config) ||
281
+ containsAggregate(df.value, config)
282
+ );
283
+ }
284
+ case "ExpressionField":
285
+ return containsAggregate((f as LuaExpressionField).value, config);
286
+ default:
287
+ return false;
288
+ }
289
+ });
290
+ default:
291
+ return false;
292
+ }
293
+ }
294
+
295
+ // Wrap a value for select result tables so that the column key survives
296
+ // in the `LuaTable`
297
+ function selectVal(v: LuaValue): LuaValue {
298
+ return v === null || v === undefined ? LIQ_NULL : v;
299
+ }
300
+
301
+ /**
302
+ * Evaluate an expression in aggregate-aware mode.
303
+ *
304
+ * When a FunctionCall matches a registered aggregate name, the aggregate
305
+ * protocol is executed instead of normal call semantics. All other
306
+ * expressions fall through to normal evalExpression.
307
+ */
308
+ export async function evalExpressionWithAggregates(
309
+ expr: LuaExpression,
310
+ env: LuaEnv,
311
+ sf: LuaStackFrame,
312
+ groupItems: LuaTable,
313
+ objectVariable: string | undefined,
314
+ outerEnv: LuaEnv,
315
+ config: Config,
316
+ ): Promise<LuaValue> {
317
+ if (!containsAggregate(expr, config)) {
318
+ return evalExpression(expr, env, sf);
319
+ }
320
+ const recurse = (e: LuaExpression) =>
321
+ evalExpressionWithAggregates(
322
+ e,
323
+ env,
324
+ sf,
325
+ groupItems,
326
+ objectVariable,
327
+ outerEnv,
328
+ config,
329
+ );
330
+
331
+ if (expr.type === "FilteredCall") {
332
+ const filtered = expr as LuaFilteredCallExpression;
333
+ const fc = filtered.call;
334
+ if (fc.prefix.type === "Variable") {
335
+ const name = fc.prefix.name;
336
+ const spec = getAggregateSpec(name, config);
337
+ if (spec) {
338
+ const valueExpr = fc.args.length > 0 ? fc.args[0] : null;
339
+ const extraArgExprs = fc.args.length > 1 ? fc.args.slice(1) : [];
340
+ return executeAggregate(
341
+ spec,
342
+ groupItems,
343
+ valueExpr,
344
+ extraArgExprs,
345
+ objectVariable,
346
+ outerEnv,
347
+ sf,
348
+ evalExpression,
349
+ config,
350
+ filtered.filter,
351
+ fc.orderBy,
352
+ );
353
+ }
354
+ }
355
+
356
+ return evalExpression(expr, env, sf);
357
+ }
358
+
359
+ if (expr.type === "FunctionCall") {
360
+ const fc = expr as LuaFunctionCallExpression;
361
+ if (fc.prefix.type === "Variable") {
362
+ const name = fc.prefix.name;
363
+ const spec = getAggregateSpec(name, config);
364
+ if (spec) {
365
+ const valueExpr = fc.args.length > 0 ? fc.args[0] : null;
366
+ const extraArgExprs = fc.args.length > 1 ? fc.args.slice(1) : [];
367
+ return executeAggregate(
368
+ spec,
369
+ groupItems,
370
+ valueExpr,
371
+ extraArgExprs,
372
+ objectVariable,
373
+ outerEnv,
374
+ sf,
375
+ evalExpression,
376
+ config,
377
+ undefined,
378
+ fc.orderBy,
379
+ );
380
+ }
381
+ }
382
+ }
383
+ if (expr.type === "TableConstructor") {
384
+ const table = new LuaTable();
385
+ let nextArrayIndex = 1;
386
+ for (const field of expr.fields) {
387
+ switch (field.type) {
388
+ case "PropField": {
389
+ const pf = field as LuaPropField;
390
+ const value = await recurse(pf.value);
391
+ void table.set(pf.key, selectVal(value), sf);
392
+ break;
393
+ }
394
+ case "DynamicField": {
395
+ const df = field as LuaDynamicField;
396
+ const key = await evalExpression(df.key, env, sf);
397
+ const value = await recurse(df.value);
398
+ void table.set(key, selectVal(value), sf);
399
+ break;
400
+ }
401
+ case "ExpressionField": {
402
+ const ef = field as LuaExpressionField;
403
+ const value = await recurse(ef.value);
404
+ table.rawSetArrayIndex(nextArrayIndex, selectVal(value));
405
+ nextArrayIndex++;
406
+ break;
407
+ }
408
+ }
409
+ }
410
+ return table;
411
+ }
412
+ if (expr.type === "Binary") {
413
+ const bin = expr as LuaBinaryExpression;
414
+ if (bin.operator === "and") {
415
+ const left = singleResult(await recurse(bin.left));
416
+ if (!luaTruthy(left)) return left;
417
+ return singleResult(await recurse(bin.right));
418
+ }
419
+ if (bin.operator === "or") {
420
+ const left = singleResult(await recurse(bin.left));
421
+ if (luaTruthy(left)) return left;
422
+ return singleResult(await recurse(bin.right));
423
+ }
424
+ const left = singleResult(await recurse(bin.left));
425
+ const right = singleResult(await recurse(bin.right));
426
+ return luaOp(bin.operator, left, right, undefined, undefined, expr.ctx, sf);
427
+ }
428
+ if (expr.type === "Unary") {
429
+ const un = expr as LuaUnaryExpression;
430
+ const arg = singleResult(await recurse(un.argument));
431
+ switch (un.operator) {
432
+ case "-":
433
+ return typeof arg === "number"
434
+ ? -arg
435
+ : luaOp("-", 0, arg, undefined, undefined, expr.ctx, sf);
436
+ case "not":
437
+ return !luaTruthy(arg);
438
+ case "#":
439
+ return evalExpression(expr, env, sf);
440
+ case "~":
441
+ if (typeof arg === "number") return ~arg;
442
+ throw new Error("attempt to perform bitwise operation on a non-number");
443
+ default:
444
+ return evalExpression(expr, env, sf);
445
+ }
446
+ }
447
+ if (expr.type === "Parenthesized") {
448
+ const paren = expr as LuaParenthesizedExpression;
449
+ return singleResult(await recurse(paren.expression));
450
+ }
451
+ return evalExpression(expr, env, sf);
452
+ }
453
+
454
+ /**
455
+ * Collect the canonical key order from an array of select results.
456
+ * Finds the first LuaTable that has the maximum number of string keys
457
+ * and returns its keys in insertion order. This represents the
458
+ * "complete" column set in the order the user wrote in `select { ... }`.
459
+ */
460
+ function collectCanonicalKeyOrder(results: any[]): string[] | null {
461
+ let best: string[] | null = null;
462
+ for (const item of results) {
463
+ if (item instanceof LuaTable) {
464
+ const keys = luaKeys(item).filter(
465
+ (k): k is string => typeof k === "string",
466
+ );
467
+ if (!best || keys.length > best.length) {
468
+ best = keys;
469
+ }
470
+ }
471
+ }
472
+ return best;
473
+ }
474
+
475
+ function normalizeSelectResults(results: any[]): any[] {
476
+ if (results.length === 0) return results;
477
+ const canonicalKeys = collectCanonicalKeyOrder(results);
478
+ if (!canonicalKeys || canonicalKeys.length === 0) return results;
479
+ for (let i = 0; i < results.length; i++) {
480
+ const item = results[i];
481
+ if (!(item instanceof LuaTable)) continue;
482
+ let needsRebuild = false;
483
+ for (const k of canonicalKeys) {
484
+ const v = item.rawGet(k);
485
+ if (v === undefined || v === null) {
486
+ needsRebuild = true;
487
+ break;
488
+ }
489
+ }
490
+ if (!needsRebuild) continue;
491
+ const rebuilt = new LuaTable();
492
+ for (const k of canonicalKeys) {
493
+ const v = item.rawGet(k);
494
+ void rebuilt.rawSet(k, v === undefined || v === null ? LIQ_NULL : v);
495
+ }
496
+ for (const k of luaKeys(item)) {
497
+ if (typeof k !== "string") {
498
+ void rebuilt.rawSet(k, item.rawGet(k));
499
+ }
500
+ }
501
+ results[i] = rebuilt;
78
502
  }
503
+ return results;
504
+ }
505
+
506
+ function resolveUsing(
507
+ using: string | LuaFunctionBody | undefined,
508
+ env: LuaEnv,
509
+ _sf: LuaStackFrame,
510
+ ): LuaValue | null {
511
+ if (using === undefined) return null;
512
+ if (typeof using === "string") {
513
+ return env.get(using) ?? null;
514
+ }
515
+ return new LuaFunction(using, env);
516
+ }
517
+
518
+ // Compare values using a custom comparator with SWO violation detection
519
+ async function usingCompare(
520
+ luaCmp: LuaValue,
521
+ aVal: any,
522
+ bVal: any,
523
+ originalA: number,
524
+ originalB: number,
525
+ desc: boolean,
526
+ sf: LuaStackFrame,
527
+ violated: boolean[],
528
+ keyIdx: number,
529
+ ): Promise<number> {
530
+ const res = luaTruthy(
531
+ singleResult(await luaCall(luaCmp, [aVal, bVal], sf.astCtx ?? {}, sf)),
532
+ );
533
+ const reverseRes = luaTruthy(
534
+ singleResult(await luaCall(luaCmp, [bVal, aVal], sf.astCtx ?? {}, sf)),
535
+ );
536
+
537
+ // both true means SWO violation
538
+ if (res && reverseRes) {
539
+ violated[keyIdx] = true;
540
+ return originalA < originalB ? -1 : 1;
541
+ }
542
+
543
+ if (res) return desc ? 1 : -1;
544
+ if (reverseRes) return desc ? -1 : 1;
545
+ return 0;
79
546
  }
80
547
 
81
548
  /**
82
- * Applies a given query (where, order by, limit etc.) to a set of results
549
+ * Pre-compute all sort keys for each result item (Schwartzian transform)
550
+ * and evaluate each `order by` expression exactly once per item
83
551
  */
552
+ async function precomputeSortKeys(
553
+ results: any[],
554
+ orderBy: LuaOrderBy[],
555
+ mkEnv: (
556
+ ov: string | undefined,
557
+ item: any,
558
+ e: LuaEnv,
559
+ s: LuaStackFrame,
560
+ ) => LuaEnv,
561
+ objectVariable: string | undefined,
562
+ env: LuaEnv,
563
+ sf: LuaStackFrame,
564
+ grouped: boolean,
565
+ selectResults: any[] | undefined,
566
+ config: Config,
567
+ ): Promise<any[][]> {
568
+ const allKeys: any[][] = new Array(results.length);
569
+ for (let i = 0; i < results.length; i++) {
570
+ const item = results[i];
571
+ const itemEnv = mkEnv(objectVariable, item, env, sf);
572
+ if (selectResults) {
573
+ const row = selectResults[i];
574
+ if (row) {
575
+ for (const k of luaKeys(row)) {
576
+ const v = luaGet(row, k, sf.astCtx ?? null, sf);
577
+ itemEnv.setLocal(k, isSqlNull(v) ? null : v);
578
+ }
579
+ }
580
+ }
581
+ const keys: any[] = new Array(orderBy.length);
582
+ for (let j = 0; j < orderBy.length; j++) {
583
+ if (grouped) {
584
+ const groupTable = (item as LuaTable).rawGet("group");
585
+ keys[j] = await evalExpressionWithAggregates(
586
+ orderBy[j].expr,
587
+ itemEnv,
588
+ sf,
589
+ groupTable,
590
+ objectVariable,
591
+ env,
592
+ config,
593
+ );
594
+ } else {
595
+ keys[j] = await evalExpression(orderBy[j].expr, itemEnv, sf);
596
+ }
597
+ }
598
+ allKeys[i] = keys;
599
+ }
600
+ return allKeys;
601
+ }
602
+
603
+ /**
604
+ * Compare two items by their pre-computed sort keys without Lua
605
+ * expressions evaluation.
606
+ */
607
+ async function sortKeyCompare(
608
+ a: { val: any; idx: number },
609
+ b: { val: any; idx: number },
610
+ orderBy: LuaOrderBy[],
611
+ aKeys: any[],
612
+ bKeys: any[],
613
+ collation: QueryCollationConfig | undefined,
614
+ collator: Intl.Collator,
615
+ resolvedUsing: (LuaValue | null)[],
616
+ violated: boolean[],
617
+ sf: LuaStackFrame,
618
+ ): Promise<number> {
619
+ for (let idx = 0; idx < orderBy.length; idx++) {
620
+ const { desc, nulls } = orderBy[idx];
621
+ const aVal = aKeys[idx];
622
+ const bVal = bKeys[idx];
623
+
624
+ // Handle nulls positioning
625
+ const aIsNull = aVal === null || aVal === undefined || isSqlNull(aVal);
626
+ const bIsNull = bVal === null || bVal === undefined || isSqlNull(bVal);
627
+ if (aIsNull || bIsNull) {
628
+ if (aIsNull && bIsNull) continue;
629
+ // Default: nulls last for asc, nulls first for desc
630
+ const nullsLast = nulls === "last" || (nulls === undefined && !desc);
631
+ if (aIsNull) return nullsLast ? 1 : -1;
632
+ return nullsLast ? -1 : 1;
633
+ }
634
+
635
+ const usingFn = resolvedUsing[idx];
636
+ if (usingFn) {
637
+ const cmp = await usingCompare(
638
+ usingFn,
639
+ aVal,
640
+ bVal,
641
+ a.idx,
642
+ b.idx,
643
+ desc,
644
+ sf,
645
+ violated,
646
+ idx,
647
+ );
648
+ if (cmp !== 0) return cmp;
649
+ } else if (
650
+ collation?.enabled &&
651
+ typeof aVal === "string" &&
652
+ typeof bVal === "string"
653
+ ) {
654
+ const order = collator.compare(aVal, bVal);
655
+ if (order !== 0) {
656
+ return desc ? -order : order;
657
+ }
658
+ } else if (aVal < bVal) {
659
+ return desc ? 1 : -1;
660
+ } else if (aVal > bVal) {
661
+ return desc ? -1 : 1;
662
+ }
663
+ }
664
+ return 0;
665
+ }
666
+
667
+ // Build a select-result table from a non-aggregate select expression
668
+ async function evalSelectExpression(
669
+ selectExpr: LuaExpression,
670
+ itemEnv: LuaEnv,
671
+ sf: LuaStackFrame,
672
+ ): Promise<LuaValue> {
673
+ const result = await evalExpression(selectExpr, itemEnv, sf);
674
+ if (!(result instanceof LuaTable)) return result;
675
+ for (const k of luaKeys(result)) {
676
+ const v = result.rawGet(k);
677
+ if (v === null || v === undefined) {
678
+ void result.rawSet(k, LIQ_NULL);
679
+ }
680
+ }
681
+ return result;
682
+ }
683
+
84
684
  export async function applyQuery(
85
685
  results: any[],
86
686
  query: LuaCollectionQuery,
87
687
  env: LuaEnv,
88
688
  sf: LuaStackFrame,
89
- collation?: QueryCollationConfig,
689
+ config: Config = new Config(),
90
690
  ): Promise<any[]> {
91
- // Shallow copy to avoid updating underlying data structures
92
691
  results = results.slice();
93
-
94
- // Filter results based on `where` clause first
95
692
  if (query.where) {
96
693
  const filteredResults = [];
97
694
  for (const value of results) {
98
- // Enrich value
99
- const itemEnv = buildItemEnv(query.objectVariable, value, env, sf);
695
+ const itemEnv = buildItemEnvLocal(query.objectVariable, value, env, sf);
100
696
  if (await evalExpression(query.where, itemEnv, sf)) {
101
697
  filteredResults.push(value);
102
698
  }
@@ -104,76 +700,286 @@ export async function applyQuery(
104
700
  results = filteredResults;
105
701
  }
106
702
 
107
- // Apply `order by` next
108
- if (query.orderBy) {
109
- // Retrieve from config API if not passed
110
- if (collation === undefined) {
111
- // @ts-ignore: Hack to access client via the browser
112
- const config = globalThis.client.config; // HACK: Shouldn't be using client here directly
703
+ // Implicit single group
704
+ if (
705
+ !query.groupBy &&
706
+ ((query.select && containsAggregate(query.select, config)) ||
707
+ (query.having && containsAggregate(query.having, config)))
708
+ ) {
709
+ query = { ...query, groupBy: [] };
710
+ }
113
711
 
114
- collation = config.get("queryCollation", {});
115
- }
116
- // Both arguments are optional, so passing undefined is fine
117
- const collator = Intl.Collator(collation?.locale, collation?.options);
712
+ const grouped = !!query.groupBy;
713
+
714
+ // Collect group-by key names for unpacking into the post-group environment.
715
+ let groupByNames: string[] | undefined;
716
+
717
+ if (query.groupBy) {
718
+ // Extract expressions and names from `group by` entries
719
+ const groupByEntries = query.groupBy;
720
+
721
+ // Derive canonical name (explicit alias first, or from expression)
722
+ groupByNames = groupByEntries
723
+ .map((entry) => {
724
+ if (entry.alias) return entry.alias;
725
+ if (entry.expr.type === "Variable") return entry.expr.name;
726
+ if (entry.expr.type === "PropertyAccess") return entry.expr.property;
727
+ return undefined as unknown as string;
728
+ })
729
+ .filter(Boolean);
730
+
731
+ const groups = new Map<string | symbol, { key: any; items: any[] }>();
732
+
733
+ for (const item of results) {
734
+ const itemEnv = buildItemEnvLocal(query.objectVariable, item, env, sf);
735
+
736
+ const keyParts: any[] = [];
737
+ const keyRecord: Record<string, any> = {};
738
+
739
+ for (let ei = 0; ei < groupByEntries.length; ei++) {
740
+ const entry = groupByEntries[ei];
741
+ const v = await evalExpression(entry.expr, itemEnv, sf);
742
+ keyParts.push(v);
743
+ // Use alias if provided, or from expression
744
+ const name =
745
+ entry.alias ??
746
+ (entry.expr.type === "Variable" ? entry.expr.name : undefined) ??
747
+ (entry.expr.type === "PropertyAccess"
748
+ ? entry.expr.property
749
+ : undefined);
750
+ if (name) {
751
+ keyRecord[name] = v;
752
+ }
753
+ }
118
754
 
119
- results = await asyncQuickSort(results, async (a, b) => {
120
- // Compare each orderBy clause until we find a difference
121
- for (const { expr, desc } of query.orderBy!) {
122
- const aEnv = buildItemEnv(query.objectVariable, a, env, sf);
123
- const bEnv = buildItemEnv(query.objectVariable, b, env, sf);
124
-
125
- const aVal = await evalExpression(expr, aEnv, sf);
126
- const bVal = await evalExpression(expr, bEnv, sf);
127
-
128
- if (
129
- collation?.enabled &&
130
- typeof aVal === "string" &&
131
- typeof bVal === "string"
132
- ) {
133
- const order = collator.compare(aVal, bVal);
134
- if (order != 0) {
135
- return desc ? -order : order;
755
+ // Implicit single group uses a symbol key
756
+ const compositeKey: string | symbol =
757
+ keyParts.length === 0
758
+ ? IMPLICIT_GROUP_KEY
759
+ : keyParts.length === 1
760
+ ? generateKey(keyParts[0])
761
+ : JSON.stringify(keyParts.map(generateKey));
762
+ let entry = groups.get(compositeKey);
763
+ if (!entry) {
764
+ let keyVal: any;
765
+ if (keyParts.length === 0) {
766
+ // Implicit single group — key is `nil`
767
+ keyVal = null;
768
+ } else if (keyParts.length === 1) {
769
+ keyVal = keyParts[0];
770
+ } else {
771
+ const kt = new LuaTable();
772
+ // Always populate array indices from keyParts
773
+ for (let i = 0; i < keyParts.length; i++) {
774
+ kt.rawSetArrayIndex(i + 1, keyParts[i]);
775
+ }
776
+ // Additionally set named fields for Variable/PropertyAccess exprs
777
+ for (const name in keyRecord) {
778
+ void kt.rawSet(name, keyRecord[name]);
136
779
  }
137
- } else if (aVal < bVal) {
138
- return desc ? 1 : -1;
139
- } else if (aVal > bVal) {
140
- return desc ? -1 : 1;
780
+ keyVal = kt;
141
781
  }
142
- // If equal, continue to next orderBy clause
782
+ entry = {
783
+ key: keyVal,
784
+ items: [],
785
+ };
786
+ groups.set(compositeKey, entry);
143
787
  }
144
- return 0; // All orderBy clauses were equal
145
- });
788
+ entry.items.push(item);
789
+ }
790
+
791
+ results = [];
792
+ for (const { key, items } of groups.values()) {
793
+ const groupTable = new LuaTable();
794
+ for (let i = 0; i < items.length; i++) {
795
+ const item = items[i];
796
+ groupTable.rawSetArrayIndex(
797
+ i + 1,
798
+ item instanceof LuaTable || typeof item !== "object" || item === null
799
+ ? item
800
+ : jsToLuaValue(item),
801
+ );
802
+ }
803
+ const row = new LuaTable();
804
+ void row.rawSet("key", key);
805
+ void row.rawSet("group", groupTable);
806
+ results.push(row);
807
+ }
146
808
  }
147
809
 
148
- // Apply the select
149
- if (query.select) {
150
- const newResult = [];
810
+ if (query.having) {
811
+ const filteredResults = [];
812
+ for (const value of results) {
813
+ let condResult;
814
+ if (grouped) {
815
+ const itemEnv = buildGroupItemEnv(
816
+ query.objectVariable,
817
+ groupByNames,
818
+ value,
819
+ env,
820
+ sf,
821
+ );
822
+ const groupTable = (value as LuaTable).rawGet("group");
823
+ condResult = await evalExpressionWithAggregates(
824
+ query.having,
825
+ itemEnv,
826
+ sf,
827
+ groupTable,
828
+ query.objectVariable,
829
+ env,
830
+ config,
831
+ );
832
+ } else {
833
+ const itemEnv = buildItemEnvLocal(query.objectVariable, value, env, sf);
834
+ condResult = await evalExpression(query.having, itemEnv, sf);
835
+ }
836
+ if (condResult) {
837
+ filteredResults.push(value);
838
+ }
839
+ }
840
+ results = filteredResults;
841
+ }
842
+
843
+ const mkEnv = grouped
844
+ ? (ov: string | undefined, item: any, e: LuaEnv, s: LuaStackFrame) =>
845
+ buildGroupItemEnv(ov, groupByNames, item, e, s)
846
+ : buildItemEnvLocal;
847
+
848
+ let selectResults: any[] | undefined;
849
+
850
+ // Pre-compute select for grouped + ordered queries
851
+ if (grouped && query.select && query.orderBy) {
852
+ const selectExpr = query.select;
853
+ selectResults = [];
151
854
  for (const item of results) {
152
- const itemEnv = buildItemEnv(query.objectVariable, item, env, sf);
153
- newResult.push(await evalExpression(query.select, itemEnv, sf));
855
+ const itemEnv = mkEnv(query.objectVariable, item, env, sf);
856
+ const groupTable = (item as LuaTable).rawGet("group");
857
+ const selected = await evalExpressionWithAggregates(
858
+ selectExpr,
859
+ itemEnv,
860
+ sf,
861
+ groupTable,
862
+ query.objectVariable,
863
+ env,
864
+ config,
865
+ );
866
+ selectResults.push(selected);
867
+ }
868
+ selectResults = normalizeSelectResults(selectResults);
869
+ }
870
+
871
+ if (query.orderBy) {
872
+ const collation = config.get<QueryCollationConfig>("queryCollation", {});
873
+ const collator = Intl.Collator(collation?.locale, collation?.options);
874
+
875
+ const resolvedUsing: (LuaValue | null)[] = [];
876
+ const violated: boolean[] = [];
877
+ for (const ob of query.orderBy) {
878
+ resolvedUsing.push(resolveUsing(ob.using, env, sf));
879
+ violated.push(false);
880
+ }
881
+
882
+ // Decorate: pre-compute all sort keys once (Schwartzian transform)
883
+ const sortKeys = await precomputeSortKeys(
884
+ results,
885
+ query.orderBy,
886
+ mkEnv,
887
+ query.objectVariable,
888
+ env,
889
+ sf,
890
+ grouped,
891
+ selectResults,
892
+ config,
893
+ );
894
+
895
+ // Tag each result with its original index for stable sorting
896
+ const tagged = results.map((val, idx) => ({ val, idx }));
897
+
898
+ // Sort: compare cached keys only, no Lua eval in comparator
899
+ await asyncMergeSort(tagged, (a, b) =>
900
+ sortKeyCompare(
901
+ a,
902
+ b,
903
+ query.orderBy!,
904
+ sortKeys[a.idx],
905
+ sortKeys[b.idx],
906
+ collation,
907
+ collator,
908
+ resolvedUsing,
909
+ violated,
910
+ sf,
911
+ ),
912
+ );
913
+
914
+ // Check for SWO violations in comparators
915
+ for (let i = 0; i < violated.length; i++) {
916
+ if (violated[i]) {
917
+ throw new LuaRuntimeError(
918
+ `order by #${
919
+ i + 1
920
+ }: 'using' comparator violates strict weak ordering`,
921
+ sf,
922
+ );
923
+ }
924
+ }
925
+
926
+ if (selectResults) {
927
+ const reorderedResults: any[] = new Array(tagged.length);
928
+ const reorderedSelect: any[] = new Array(tagged.length);
929
+ for (let i = 0; i < tagged.length; i++) {
930
+ reorderedResults[i] = tagged[i].val;
931
+ reorderedSelect[i] = selectResults[tagged[i].idx];
932
+ }
933
+ results = reorderedResults;
934
+ selectResults = reorderedSelect;
935
+ } else {
936
+ results = tagged.map((t) => t.val);
937
+ }
938
+ }
939
+
940
+ if (query.select) {
941
+ const selectExpr = query.select;
942
+ if (selectResults) {
943
+ results = selectResults;
944
+ } else {
945
+ const newResult = [];
946
+ for (const item of results) {
947
+ const itemEnv = mkEnv(query.objectVariable, item, env, sf);
948
+ if (grouped) {
949
+ const groupTable = (item as LuaTable).rawGet("group");
950
+ newResult.push(
951
+ await evalExpressionWithAggregates(
952
+ selectExpr,
953
+ itemEnv,
954
+ sf,
955
+ groupTable,
956
+ query.objectVariable,
957
+ env,
958
+ config,
959
+ ),
960
+ );
961
+ } else {
962
+ newResult.push(await evalSelectExpression(query.select, itemEnv, sf));
963
+ }
964
+ }
965
+ results = newResult;
154
966
  }
155
- results = newResult;
967
+ results = normalizeSelectResults(results);
156
968
  }
157
969
 
158
- // Apply distinct filter (after select to filter on selected values)
159
970
  if (query.distinct) {
160
971
  const seen = new Set();
161
972
  const distinctResult = [];
162
-
163
973
  for (const item of results) {
164
- // For non-primitive values, we use a JSON string as the key for comparison
165
974
  const key = generateKey(item);
166
-
167
975
  if (!seen.has(key)) {
168
976
  seen.add(key);
169
977
  distinctResult.push(item);
170
978
  }
171
979
  }
172
-
173
980
  results = distinctResult;
174
981
  }
175
982
 
176
- // Apply the limit and offset
177
983
  if (query.limit !== undefined && query.offset !== undefined) {
178
984
  results = results.slice(query.offset, query.offset + query.limit);
179
985
  } else if (query.limit !== undefined) {
@@ -192,41 +998,79 @@ export async function queryLua<T = any>(
192
998
  env: LuaEnv,
193
999
  sf: LuaStackFrame = LuaStackFrame.lostFrame,
194
1000
  enricher?: (key: KvKey, item: any) => any,
1001
+ config?: Config,
195
1002
  ): Promise<T[]> {
196
1003
  const results: T[] = [];
197
- // Accumulate all results into an array
198
- for await (
199
- let { key, value } of kv.query({ prefix })
200
- ) {
1004
+ for await (let { key, value } of kv.query({ prefix })) {
201
1005
  if (enricher) {
202
1006
  value = enricher(key, value);
203
1007
  }
204
1008
  results.push(value);
205
1009
  }
206
-
207
- return applyQuery(results, query, env, sf);
1010
+ return applyQuery(results, query, env, sf, config);
208
1011
  }
209
1012
 
210
1013
  function generateKey(value: any) {
1014
+ if (isSqlNull(value)) {
1015
+ return "__SQL_NULL__";
1016
+ }
211
1017
  if (value instanceof LuaTable) {
212
- return JSON.stringify(value.toJS());
1018
+ return JSON.stringify(luaTableToJSWithNulls(value));
213
1019
  }
214
1020
  return typeof value === "object" && value !== null
215
1021
  ? JSON.stringify(value)
216
1022
  : value;
217
1023
  }
1024
+
1025
+ function luaTableToJSWithNulls(
1026
+ table: LuaTable,
1027
+ sf = LuaStackFrame.lostFrame,
1028
+ ): any {
1029
+ if (table.length > 0) {
1030
+ const arr: any[] = [];
1031
+ for (let i = 1; i <= table.length; i++) {
1032
+ const v = table.rawGet(i);
1033
+ arr.push(
1034
+ isSqlNull(v)
1035
+ ? "__SQL_NULL__"
1036
+ : v instanceof LuaTable
1037
+ ? luaTableToJSWithNulls(v, sf)
1038
+ : v,
1039
+ );
1040
+ }
1041
+ return arr;
1042
+ }
1043
+ const obj: Record<string, any> = {};
1044
+ for (const key of luaKeys(table)) {
1045
+ const v = table.rawGet(key);
1046
+ obj[key] = isSqlNull(v)
1047
+ ? "__SQL_NULL__"
1048
+ : v instanceof LuaTable
1049
+ ? luaTableToJSWithNulls(v, sf)
1050
+ : v;
1051
+ }
1052
+ return obj;
1053
+ }
1054
+
218
1055
  export class DataStoreQueryCollection implements LuaQueryCollection {
219
1056
  constructor(
220
1057
  private readonly dataStore: DataStore,
221
1058
  readonly prefix: string[],
222
- ) {
223
- }
224
-
1059
+ ) {}
225
1060
  query(
226
1061
  query: LuaCollectionQuery,
227
1062
  env: LuaEnv,
228
1063
  sf: LuaStackFrame,
1064
+ config?: Config,
229
1065
  ): Promise<any[]> {
230
- return queryLua(this.dataStore.kv, this.prefix, query, env, sf);
1066
+ return queryLua(
1067
+ this.dataStore.kv,
1068
+ this.prefix,
1069
+ query,
1070
+ env,
1071
+ sf,
1072
+ undefined,
1073
+ config,
1074
+ );
231
1075
  }
232
1076
  }