@silverbulletmd/silverbullet 2.4.1 → 2.5.3

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