@silverbulletmd/silverbullet 2.10.0 → 2.12.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 (105) hide show
  1. package/README.md +6 -6
  2. package/client/asset_bundle/bundle.ts +0 -1
  3. package/client/config.ts +343 -0
  4. package/client/data/kv_primitives.ts +2 -2
  5. package/client/markdown_parser/constants.ts +5 -1
  6. package/client/plugos/hooks/command.ts +8 -6
  7. package/client/plugos/hooks/event.ts +60 -24
  8. package/client/plugos/hooks/mq.ts +0 -3
  9. package/client/plugos/hooks/slash_command.ts +3 -20
  10. package/client/plugos/hooks/syscall.ts +0 -4
  11. package/client/plugos/manifest_cache.ts +2 -34
  12. package/client/plugos/plug.ts +0 -3
  13. package/client/plugos/plug_compile.ts +3 -13
  14. package/client/plugos/sandboxes/sandbox.ts +2 -2
  15. package/client/plugos/sandboxes/worker_sandbox.ts +19 -9
  16. package/client/plugos/syscalls/capture.ts +104 -0
  17. package/client/plugos/syscalls/editor.ts +197 -56
  18. package/client/plugos/syscalls/event.ts +12 -9
  19. package/client/plugos/syscalls/fetch.ts +0 -4
  20. package/client/plugos/syscalls/icon.ts +46 -0
  21. package/client/plugos/syscalls/index.ts +33 -13
  22. package/client/plugos/syscalls/jsonschema.ts +0 -1
  23. package/client/plugos/syscalls/mq.ts +0 -1
  24. package/client/plugos/syscalls/navigator.ts +138 -0
  25. package/client/plugos/syscalls/search.ts +44 -0
  26. package/client/plugos/syscalls/shell.ts +0 -1
  27. package/client/plugos/syscalls/space.ts +250 -7
  28. package/client/plugos/syscalls/sync.ts +70 -4
  29. package/client/plugos/syscalls/system.ts +39 -0
  30. package/client/plugos/system.ts +9 -7
  31. package/client/plugos/types.ts +6 -6
  32. package/client/plugos/worker_runtime.ts +0 -2
  33. package/client/space_lua/aggregates.ts +6 -22
  34. package/client/space_lua/ast.ts +0 -5
  35. package/client/space_lua/ast_narrow.ts +0 -5
  36. package/client/space_lua/budget.ts +113 -0
  37. package/client/space_lua/budget_ui.ts +49 -0
  38. package/client/space_lua/eval.ts +113 -88
  39. package/client/space_lua/labels.ts +0 -4
  40. package/client/space_lua/numeric.ts +2 -7
  41. package/client/space_lua/parse.ts +0 -17
  42. package/client/space_lua/quarantine.ts +127 -0
  43. package/client/space_lua/query_collection.ts +18 -51
  44. package/client/space_lua/query_env.ts +0 -2
  45. package/client/space_lua/render_lua_markdown.ts +2 -7
  46. package/client/space_lua/render_widget.ts +18 -1
  47. package/client/space_lua/runtime.ts +64 -77
  48. package/client/space_lua/stdlib/format.ts +0 -12
  49. package/client/space_lua/stdlib/js.ts +0 -2
  50. package/client/space_lua/stdlib/load.ts +0 -1
  51. package/client/space_lua/stdlib/math.ts +0 -7
  52. package/client/space_lua/stdlib/net.ts +0 -4
  53. package/client/space_lua/stdlib/os.ts +0 -10
  54. package/client/space_lua/stdlib/pattern.ts +18 -23
  55. package/client/space_lua/stdlib/space_lua.ts +1 -2
  56. package/client/space_lua/stdlib/string_pack.ts +4 -7
  57. package/client/space_lua/stdlib/table.ts +0 -5
  58. package/client/space_lua/stdlib.ts +18 -31
  59. package/client/space_lua/tonumber.ts +0 -9
  60. package/dist/plug-compile.js +3 -4
  61. package/package.json +52 -41
  62. package/plug-api/lib/async.ts +1 -6
  63. package/plug-api/lib/collation.ts +22 -0
  64. package/plug-api/lib/crypto.ts +16 -15
  65. package/plug-api/lib/dates.ts +33 -0
  66. package/plug-api/lib/fuzzy.ts +282 -0
  67. package/plug-api/lib/json.ts +0 -6
  68. package/plug-api/lib/limited_map.ts +0 -2
  69. package/plug-api/lib/link_write.ts +27 -0
  70. package/plug-api/lib/ref.ts +28 -5
  71. package/plug-api/lib/resolve.ts +1 -9
  72. package/plug-api/lib/resolve_path.ts +290 -0
  73. package/plug-api/lib/shortcut.ts +15 -0
  74. package/plug-api/lib/tags.ts +0 -3
  75. package/plug-api/lib/transclusion.ts +28 -1
  76. package/plug-api/lib/tree.ts +0 -5
  77. package/plug-api/lib/yaml.ts +5 -44
  78. package/plug-api/syscall.ts +1 -5
  79. package/plug-api/syscalls/config.ts +2 -2
  80. package/plug-api/syscalls/editor.ts +75 -213
  81. package/plug-api/syscalls/icon.ts +18 -0
  82. package/plug-api/syscalls/index.ts +22 -4
  83. package/plug-api/syscalls/search.ts +11 -0
  84. package/plug-api/syscalls/space.ts +122 -5
  85. package/plug-api/syscalls/sync.ts +31 -2
  86. package/plug-api/syscalls/system.ts +46 -0
  87. package/plug-api/syscalls.ts +2 -0
  88. package/plug-api/system_mock.ts +13 -0
  89. package/plug-api/types/client.ts +20 -4
  90. package/plug-api/types/datastore.ts +11 -2
  91. package/plug-api/types/index.ts +10 -0
  92. package/plug-api/types/manifest.ts +7 -0
  93. package/plug-api/types/profile.ts +13 -0
  94. package/plug-api/types/revisions.ts +67 -0
  95. package/plug-api/ui/description.ts +64 -0
  96. package/plug-api/ui/hover.ts +69 -0
  97. package/plug-api/ui/index.ts +58 -2
  98. package/plug-api/ui/scroll.ts +30 -0
  99. package/plug-api/ui/tree_model.ts +285 -0
  100. package/plug-api/ui/tree_types.ts +67 -0
  101. package/plug-api/ui/use_fit_collapse.ts +67 -0
  102. package/plugs/builtin_plugs.ts +0 -1
  103. package/plugs/index/types.ts +9 -0
  104. package/plug-api/lib/memory_cache.ts +0 -21
  105. /package/plug-api/{ui → lib}/panel_styles.ts +0 -0
@@ -1,8 +1,13 @@
1
- import type { SysCallMapping } from "../system.ts";
2
1
  import type { Client } from "../../client.ts";
2
+ import type { SysCallMapping } from "../system.ts";
3
3
 
4
4
  export function syncSyscalls(client: Client): SysCallMapping {
5
5
  const syncTimeoutMs = 30000;
6
+ // Deferring indexing only pays off while the sync engine is actually
7
+ // delivering files. Once it has gone this long without reporting progress
8
+ // there is nothing left to yield to, and continuing to defer would stall
9
+ // the initial index forever.
10
+ const syncStallMs = 5000;
6
11
 
7
12
  function waitForServiceWorkerActivation(path?: string): Promise<any> {
8
13
  return new Promise<any>((resolve, reject) => {
@@ -60,6 +65,20 @@ export function syncSyscalls(client: Client): SysCallMapping {
60
65
  }
61
66
 
62
67
  return {
68
+ "sync.isEnabled": {
69
+ callback: (): boolean => {
70
+ return !client.bootConfig.disableServiceWorker;
71
+ },
72
+ description:
73
+ "Whether client Sync is enabled. False when the service worker is off.",
74
+ returns: [
75
+ {
76
+ type: "boolean",
77
+ description:
78
+ "False when SB_DISABLE_SERVICE_WORKER is set or the browser has no service worker.",
79
+ },
80
+ ],
81
+ },
63
82
  "sync.hasInitialSyncCompleted": {
64
83
  callback: (): boolean => {
65
84
  return client.fullSyncCompleted;
@@ -70,14 +89,47 @@ export function syncSyscalls(client: Client): SysCallMapping {
70
89
  { type: "boolean", description: "Whether initial sync is complete." },
71
90
  ],
72
91
  },
92
+ "sync.areFilesReadyToIndex": {
93
+ callback: (_ctx, paths: string[]): boolean[] => {
94
+ const allReady =
95
+ !!client.bootConfig.disableServiceWorker ||
96
+ client.fullSyncCompleted ||
97
+ client.fullIndexCompleted ||
98
+ (client.serverPingMs !== undefined && client.serverPingMs < 25) ||
99
+ Date.now() - client.lastSyncProgressAt > syncStallMs;
100
+ return paths.map((path) => allReady || client.syncedPaths.has(path));
101
+ },
102
+ description:
103
+ "For each file, whether indexing it now would read it locally or cheaply (true), or race the initial sync and expensively re-download it (false).",
104
+ parameters: [
105
+ {
106
+ name: "paths",
107
+ type: "string[]",
108
+ description: "Space-relative file paths.",
109
+ },
110
+ ],
111
+ returns: [
112
+ {
113
+ type: "boolean[]",
114
+ description: "Per-path readiness, in input order.",
115
+ },
116
+ ],
117
+ },
73
118
  "sync.performFileSync": {
74
- callback: async (_ctx, path: string): Promise<void> => {
119
+ callback: async (
120
+ _ctx,
121
+ path: string,
122
+ remoteLastModified?: number,
123
+ remoteRevisionHash?: string,
124
+ ): Promise<void> => {
75
125
  await client.postServiceWorkerMessage({
76
126
  type: "perform-file-sync",
77
127
  path,
128
+ remoteLastModified,
129
+ remoteRevisionHash,
78
130
  });
79
131
  // postServiceWorkerMessage returns silently if no SW, so only wait if SW is active
80
- const registration = await navigator.serviceWorker.getRegistration();
132
+ const registration = await navigator.serviceWorker?.getRegistration();
81
133
  if (registration?.active) {
82
134
  return waitForServiceWorkerActivation(path);
83
135
  }
@@ -90,13 +142,27 @@ export function syncSyscalls(client: Client): SysCallMapping {
90
142
  type: "string",
91
143
  description: "Space-relative file path.",
92
144
  },
145
+ {
146
+ name: "remoteLastModified",
147
+ type: "number",
148
+ optional: true,
149
+ description:
150
+ "lastModified of the remote change event, used for echo suppression.",
151
+ },
152
+ {
153
+ name: "remoteRevisionHash",
154
+ type: "string",
155
+ optional: true,
156
+ description:
157
+ "Content revision the remote change event reported, which tells a same-millisecond change from an echo.",
158
+ },
93
159
  ],
94
160
  examples: [{ code: 'sync.performFileSync("notes/important.md")' }],
95
161
  },
96
162
  "sync.performSpaceSync": {
97
163
  callback: async (): Promise<number> => {
98
164
  await client.postServiceWorkerMessage({ type: "perform-space-sync" });
99
- const registration = await navigator.serviceWorker.getRegistration();
165
+ const registration = await navigator.serviceWorker?.getRegistration();
100
166
  if (registration?.active) {
101
167
  return waitForServiceWorkerActivation();
102
168
  }
@@ -3,6 +3,11 @@ import type { Client } from "../../client.ts";
3
3
  import { version as publicVersion } from "../../../version.json";
4
4
  import type { CommandDef } from "@silverbulletmd/silverbullet/type/manifest";
5
5
  import type { SyscallMeta } from "@silverbulletmd/silverbullet/type/index";
6
+ import { loadAccounts, loadProfile } from "../../accounts.ts";
7
+ import type {
8
+ Account,
9
+ ClientProfile,
10
+ } from "../../../plug-api/types/profile.ts";
6
11
 
7
12
  export function systemSyscalls(
8
13
  client: Client,
@@ -86,6 +91,7 @@ export function systemSyscalls(
86
91
  hide: def.hide,
87
92
  requireMode: def.requireMode,
88
93
  requireEditor: def.requireEditor,
94
+ requireServiceWorker: def.requireServiceWorker,
89
95
  menu: def.menu,
90
96
  menuMac: def.menuMac,
91
97
  menuWindows: def.menuWindows,
@@ -97,6 +103,27 @@ export function systemSyscalls(
97
103
  description:
98
104
  "Returns a map of every currently available command definition.",
99
105
  },
106
+ "system.listPaletteCommands": {
107
+ callback: () => client.listPaletteCommands(),
108
+ description: `Returns the commands the command palette would show right now: context- and mode-filtered, hidden ones dropped, each with its prettified key hint and the time it was last run on this client.`,
109
+ signatures: ["system.listPaletteCommands()"],
110
+ },
111
+ "system.runPaletteCommand": {
112
+ callback: async (_ctx, name: string) => {
113
+ await client.registerCommandRun(name);
114
+ try {
115
+ return await client.runCommandByName(name);
116
+ } catch (e: any) {
117
+ client.reportError(e, "Command invocation");
118
+ return false;
119
+ }
120
+ },
121
+ description: `Runs a command as if it had been picked from the command palette: records it as the most recently run, then invokes it and returns its result. A command that throws is reported to the user and answered as false, which in this protocol means "do not move focus on my behalf".`,
122
+ parameters: [
123
+ { name: "name", type: "string", description: "Command name." },
124
+ ],
125
+ returns: [{ description: "Whatever the command returned." }],
126
+ },
100
127
  "system.listSyscalls": {
101
128
  callback: (): SyscallMeta[] => {
102
129
  const syscalls: SyscallMeta[] = [];
@@ -226,6 +253,18 @@ export function systemSyscalls(
226
253
  callback: () => publicVersion,
227
254
  description: "Returns the running SilverBullet version.",
228
255
  },
256
+ "system.getProfile": {
257
+ callback: (): Promise<ClientProfile> => loadProfile(client),
258
+ description:
259
+ 'The current user\'s identity. `username` is "me" when the server has no account for this session.',
260
+ signatures: ["system.getProfile()"],
261
+ },
262
+ "system.listAccounts": {
263
+ callback: (): Promise<Account[]> => loadAccounts(client),
264
+ description:
265
+ "Every account with access to this space, with the current user marked.",
266
+ signatures: ["system.listAccounts()"],
267
+ },
229
268
  "system.getConfig": {
230
269
  callback: (_ctx, key: string, defaultValue: any = undefined) =>
231
270
  client.config.get(key, defaultValue),
@@ -2,16 +2,16 @@ import type { Hook } from "./types.ts";
2
2
  import { EventEmitter } from "./event.ts";
3
3
  import type { SandboxFactory } from "./sandboxes/sandbox.ts";
4
4
  import { Plug } from "./plug.ts";
5
- import { InMemoryManifestCache, type ManifestCache } from "./manifest_cache.ts";
5
+ import type { ManifestCache } from "./manifest_cache.ts";
6
6
  import {
7
7
  builtinPlugNames,
8
8
  builtinPlugPaths,
9
9
  } from "../../plugs/builtin_plugs.ts";
10
10
  import type { LuaFunctionDocumentation } from "../../plug-api/types/index.ts";
11
11
 
12
- export interface SysCallMapping {
12
+ export type SysCallMapping = {
13
13
  [key: string]: SyscallSignature | SyscallDefinition;
14
- }
14
+ };
15
15
 
16
16
  export type SystemEvents<HookT> = {
17
17
  plugLoaded: (plug: Plug<HookT>) => void | Promise<void>;
@@ -55,7 +55,12 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
55
55
  ) {
56
56
  super();
57
57
  if (!options.manifestCache) {
58
- options.manifestCache = new InMemoryManifestCache();
58
+ options.manifestCache = {
59
+ async getManifest(plug) {
60
+ await plug.sandbox.init();
61
+ return plug.sandbox.manifest!;
62
+ },
63
+ };
59
64
  }
60
65
  }
61
66
 
@@ -99,7 +104,6 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
99
104
  * @param args an array of arguments to pass to the function
100
105
  */
101
106
  invokeFunction(name: string, args: any[]): Promise<any> {
102
- // Some sanity type checks
103
107
  if (typeof name !== "string") {
104
108
  throw new Error(
105
109
  `invokeFunction: function name should be a string, got ${typeof name}`,
@@ -112,7 +116,6 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
112
116
  }
113
117
  const [plugName, functionName] = name.split(".");
114
118
  if (!functionName) {
115
- // Sanity check
116
119
  throw new Error(`Missing function name: ${name}`);
117
120
  }
118
121
  const plug = this.loadedPlugs.get(plugName);
@@ -169,7 +172,6 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
169
172
  throw new Error("Plug tried to overwrite internal plug");
170
173
  }
171
174
 
172
- // Validate the manifest
173
175
  let errors: string[] = [];
174
176
  for (const feature of this.enabledHooks) {
175
177
  errors = [...errors, ...feature.validateManifest(manifest)];
@@ -4,7 +4,7 @@ import type { AssetJson } from "../asset_bundle/bundle.ts";
4
4
  /** The generic top level of a plug manifest file.
5
5
  * Defines plug metadata and functions.
6
6
  */
7
- export interface Manifest<HookT> {
7
+ export type Manifest<HookT> = {
8
8
  /** The plug's name. Typically this is the name of the manifest file, without the file extension. */
9
9
  name: string;
10
10
 
@@ -42,7 +42,7 @@ export interface Manifest<HookT> {
42
42
  * which runs esbuild in bundle mode on `in` and writes the result to `out`.
43
43
  */
44
44
  build?: BuildStep[];
45
- }
45
+ };
46
46
 
47
47
  /** A pre-asset-bundling transformation, e.g. bundling a TypeScript entry point
48
48
  * into a single JS file that is then loaded from plug assets.
@@ -52,14 +52,14 @@ export interface Manifest<HookT> {
52
52
  * - `sass`: compile an SCSS/SASS file at `in` to CSS at `out`.
53
53
  * - `copy`: copy `in` verbatim to `out`.
54
54
  */
55
- export interface BuildStep {
55
+ export type BuildStep = {
56
56
  /** Transformation type. Defaults to "esbuild" when omitted. */
57
57
  type?: "esbuild" | "sass" | "copy";
58
58
  /** Input path relative to the manifest. */
59
59
  in: string;
60
60
  /** Output path relative to the manifest. */
61
61
  out: string;
62
- }
62
+ };
63
63
 
64
64
  /** Associates hooks with a function. This is the generic base structure, that identifies the function. Hooks are defined by the type parameter. */
65
65
  export type FunctionDef<HookT> = {
@@ -78,8 +78,8 @@ export type FunctionDef<HookT> = {
78
78
  env?: string;
79
79
  } & HookT;
80
80
 
81
- export interface Hook<HookT> {
81
+ export type Hook<HookT> = {
82
82
  validateManifest(manifest: Manifest<HookT>): string[];
83
83
 
84
84
  apply(system: System<HookT>): void;
85
- }
85
+ };
@@ -112,7 +112,6 @@ export function setupMessageListener(
112
112
  }
113
113
  })().catch(console.error);
114
114
  });
115
- // Signal initialization with manifest
116
115
  workerPostMessage({
117
116
  type: "manifest",
118
117
  manifest,
@@ -139,7 +138,6 @@ export async function sandboxFetch(
139
138
 
140
139
  globalThis.nativeFetch = globalThis.fetch;
141
140
 
142
- // Monkey patch fetch()
143
141
  export function monkeyPatchFetch() {
144
142
  // @ts-expect-error: monkey patching fetch
145
143
  globalThis.fetch = async (
@@ -24,13 +24,13 @@ import type { Config } from "../config.ts";
24
24
  import { coerceToNumber, isTaggedFloat } from "./numeric.ts";
25
25
  import YAML from "js-yaml";
26
26
 
27
- export interface AggregateSpec {
27
+ export type AggregateSpec = {
28
28
  name: string;
29
29
  description?: string;
30
30
  initialize: LuaValue; // ILuaFunction
31
31
  iterate: LuaValue; // ILuaFunction
32
32
  finish?: LuaValue; // ILuaFunction | undefined
33
- }
33
+ };
34
34
 
35
35
  // Helper to build an ILuaFunction from a plain function. Equivalent to
36
36
  // LuaBuiltinFunction but without referencing the class.
@@ -54,11 +54,11 @@ function unboxValue(value: LuaValue): LuaValue {
54
54
  }
55
55
 
56
56
  // Welford's online algorithm (for variance and standard deviation)
57
- interface WelfordState {
57
+ type WelfordState = {
58
58
  n: number;
59
59
  mean: number;
60
60
  m2: number;
61
- }
61
+ };
62
62
 
63
63
  function welfordInit(): WelfordState {
64
64
  return { n: 0, mean: 0, m2: 0 };
@@ -112,7 +112,6 @@ function covarIterate(state: CovarState, x: any, y: any): CovarState {
112
112
  return state;
113
113
  }
114
114
 
115
- // Quantile interpolation methods
116
115
  type QuantileMethod =
117
116
  | "linear" // percentile_cont
118
117
  | "lower" // percentile_disc
@@ -120,13 +119,12 @@ type QuantileMethod =
120
119
  | "nearest"
121
120
  | "midpoint";
122
121
 
123
- interface QuantileState {
122
+ type QuantileState = {
124
123
  values: number[];
125
124
  q: number;
126
125
  method: QuantileMethod;
127
- }
126
+ };
128
127
 
129
- // Default method based on aggregate invocation name
130
128
  const quantileNameDefaults: Record<string, QuantileMethod> = {
131
129
  percentile_cont: "linear",
132
130
  percentile_disc: "lower",
@@ -159,7 +157,6 @@ function quantileFinish(state: QuantileState): number | null {
159
157
  }
160
158
  }
161
159
 
162
- // Shared spec — branching on `ctx.name` for the default method
163
160
  function makeQuantileSpec(name: string, description: string): AggregateSpec {
164
161
  return {
165
162
  name,
@@ -187,9 +184,7 @@ function makeQuantileSpec(name: string, description: string): AggregateSpec {
187
184
  };
188
185
  }
189
186
 
190
- // Built-in aggregate specs
191
187
  const builtinAggregates: Record<string, AggregateSpec> = {
192
- // General purpose
193
188
  count: {
194
189
  name: "count",
195
190
  description:
@@ -299,7 +294,6 @@ const builtinAggregates: Record<string, AggregateSpec> = {
299
294
  return value;
300
295
  }),
301
296
  },
302
- // Collection and format
303
297
  array_agg: {
304
298
  name: "array_agg",
305
299
  description: "Input values concatenated into an array",
@@ -365,7 +359,6 @@ const builtinAggregates: Record<string, AggregateSpec> = {
365
359
  return JSON.stringify(state);
366
360
  }),
367
361
  },
368
- // Bitwise and boolean
369
362
  bit_and: {
370
363
  name: "bit_and",
371
364
  description: "Bitwise AND of all non-null input values",
@@ -447,7 +440,6 @@ const builtinAggregates: Record<string, AggregateSpec> = {
447
440
  return state.hasValue ? state.result : null;
448
441
  }),
449
442
  },
450
- // Statistical
451
443
  stddev_pop: {
452
444
  name: "stddev_pop",
453
445
  description: "Population standard deviation of non-null inputs",
@@ -558,7 +550,6 @@ const builtinAggregates: Record<string, AggregateSpec> = {
558
550
  return state.bestCount > 0 ? state.best : null;
559
551
  }),
560
552
  },
561
- // Quantile and percentile
562
553
  quantile: makeQuantileSpec(
563
554
  "quantile",
564
555
  "Quantile of ordered set of non-null inputs; arguments: value, quantile (0-1), interpolation ('lower', 'higher', 'nearest', 'midpoint' and default: 'linear')",
@@ -617,14 +608,12 @@ export function getAggregateSpec(
617
608
  const spec: any = config.get(`aggregates.${current}`, null);
618
609
  if (!spec) break;
619
610
 
620
- // Check for alias redirect
621
611
  const alias = spec instanceof LuaTable ? spec.rawGet("alias") : spec.alias;
622
612
  if (typeof alias === "string") {
623
613
  current = alias;
624
614
  continue;
625
615
  }
626
616
 
627
- // Full definition in config
628
617
  let candidate: AggregateSpec | null = null;
629
618
  if (spec instanceof LuaTable) {
630
619
  const init = spec.rawGet("initialize");
@@ -700,16 +689,13 @@ export async function executeAggregate(
700
689
  }
701
690
  }
702
691
 
703
- // Initialize
704
692
  let state = await luaCall(spec.initialize, [ctx, ...extraArgs], noCtx, sf);
705
693
 
706
- // Collect filtered items
707
694
  const filteredItems: LuaValue[] = [];
708
695
  const len = items.length;
709
696
  for (let i = 1; i <= len; i++) {
710
697
  const item = items.rawGet(i);
711
698
 
712
- // Filter
713
699
  if (filterExpr) {
714
700
  const filterEnv = buildItemEnv(objectVariable, item, env, sf);
715
701
  const filterResult = await evalExprFn(filterExpr, filterEnv, sf);
@@ -747,7 +733,6 @@ export async function executeAggregate(
747
733
  });
748
734
  }
749
735
 
750
- // Iterate
751
736
  for (const item of filteredItems) {
752
737
  const itemEnv = buildItemEnv(objectVariable, item, env, sf);
753
738
  let value: LuaValue;
@@ -773,7 +758,6 @@ export async function executeAggregate(
773
758
  );
774
759
  }
775
760
 
776
- // Finish
777
761
  if (spec.finish) {
778
762
  state = await luaCall(spec.finish, [state, ctx, ...extraArgs], noCtx, sf);
779
763
  }
@@ -38,7 +38,6 @@ export type LuaComment = {
38
38
  ctx: ASTCtx;
39
39
  };
40
40
 
41
- // STATEMENTS
42
41
  export type LuaReturnStatement = {
43
42
  type: "Return";
44
43
  expressions: LuaExpression[];
@@ -177,7 +176,6 @@ export type LuaFunctionCallStatement = {
177
176
  call: LuaFunctionCallExpression;
178
177
  } & ASTContext;
179
178
 
180
- // EXPRESSIONS
181
179
  export type LuaExpression =
182
180
  | LuaNilLiteral
183
181
  | LuaBooleanLiteral
@@ -294,21 +292,18 @@ export type LuaFunctionDefinition = {
294
292
  body: LuaFunctionBody;
295
293
  } & ASTContext;
296
294
 
297
- // Aggregate with per-row filter
298
295
  export type LuaFilteredCallExpression = {
299
296
  type: "FilteredCall";
300
297
  call: LuaFunctionCallExpression;
301
298
  filter: LuaExpression;
302
299
  } & ASTContext;
303
300
 
304
- // Aggregate call with intra-aggregate order by
305
301
  export type LuaAggregateCallExpression = {
306
302
  type: "AggregateCall";
307
303
  call: LuaFunctionCallExpression;
308
304
  orderBy: LuaOrderBy[];
309
305
  } & ASTContext;
310
306
 
311
- // Query stuff
312
307
  export type LuaQueryExpression = {
313
308
  type: "Query";
314
309
  clauses: LuaQueryClause[];
@@ -2,13 +2,11 @@
2
2
 
3
3
  import type { ASTCtx, LuaExpression, LuaLValue, LuaStatement } from "./ast.ts";
4
4
 
5
- // Extract by `type` discriminant
6
5
  type NarrowByType<
7
6
  U,
8
7
  K extends U extends { type: infer T } ? T : never,
9
8
  > = Extract<U, { type: K }>;
10
9
 
11
- // Expressions
12
10
  export const asStringExpr = (e: LuaExpression) =>
13
11
  e as NarrowByType<LuaExpression, "String">;
14
12
  export const asNumberExpr = (e: LuaExpression) =>
@@ -38,7 +36,6 @@ export const asFunctionDef = (e: LuaExpression) =>
38
36
  export const asQueryExpr = (e: LuaExpression) =>
39
37
  e as NarrowByType<LuaExpression, "Query">;
40
38
 
41
- // L-values
42
39
  export const asLValueVariable = (l: LuaLValue) =>
43
40
  l as Extract<LuaLValue, { type: "Variable" }>;
44
41
  export const asLValueTableAccess = (l: LuaLValue) =>
@@ -46,7 +43,6 @@ export const asLValueTableAccess = (l: LuaLValue) =>
46
43
  export const asLValuePropertyAccess = (l: LuaLValue) =>
47
44
  l as Extract<LuaLValue, { type: "PropertyAccess" }>;
48
45
 
49
- // Statements
50
46
  export const asAssignment = (s: LuaStatement) =>
51
47
  s as NarrowByType<LuaStatement, "Assignment">;
52
48
  export const asLocal = (s: LuaStatement) =>
@@ -79,5 +75,4 @@ export const asGoto = (s: LuaStatement) =>
79
75
  export const asSemicolon = (s: LuaStatement) =>
80
76
  s as NarrowByType<LuaStatement, "Semicolon">;
81
77
 
82
- // Pull ctx with a single shape
83
78
  export const ctxOf = (node: { ctx: ASTCtx }): ASTCtx => node.ctx;
@@ -0,0 +1,113 @@
1
+ export const BUDGET_TICKS = 5000;
2
+ export const YIELD_AFTER_MS = 500;
3
+ export const YIELD_EVERY_MS = 50;
4
+ export const YIELD_COUNT_LIMIT = 200;
5
+ export const BUSY_LIMIT_DEFAULT_MS = 2000;
6
+ export const BUSY_LIMIT_COMMAND_MS = 10000;
7
+ export const KEEP_GOING_MS = 10000;
8
+
9
+ export class LuaBudgetStopped extends Error {
10
+ constructor() {
11
+ super("Script stopped by the user");
12
+ this.name = "LuaBudgetStopped";
13
+ }
14
+ }
15
+
16
+ export type LuaBudget = {
17
+ ticks: number;
18
+ lastCheck: number;
19
+ lastYield: number;
20
+ busyMs: number;
21
+ yieldCount: number;
22
+ awaited: boolean;
23
+ stopped: boolean;
24
+ /** Set once the invocation this budget was created for has completed. */
25
+ finished: boolean;
26
+ limitReported: boolean;
27
+ busyLimitMs: number;
28
+ yieldAfterMs: number;
29
+ onLimit: ((b: LuaBudget) => void) | undefined;
30
+ now: () => number;
31
+ doYield: () => Promise<void>;
32
+ };
33
+
34
+ export function macrotaskYield(): Promise<void> {
35
+ // Do NOT use scheduler.yield() here: it resumes at a *higher* priority than
36
+ // ordinary tasks, so it never lets timers/input/rendering run and defeats
37
+ // the whole point of yielding. MessageChannel posts a genuine macrotask.
38
+ if (typeof MessageChannel === "function") {
39
+ return new Promise<void>((resolve) => {
40
+ const ch = new MessageChannel();
41
+ ch.port1.onmessage = () => {
42
+ ch.port1.close();
43
+ resolve();
44
+ };
45
+ ch.port2.postMessage(undefined);
46
+ });
47
+ }
48
+ return new Promise<void>((resolve) => setTimeout(resolve, 0));
49
+ }
50
+
51
+ export function makeLuaBudget(
52
+ opts: Partial<
53
+ Pick<
54
+ LuaBudget,
55
+ "busyLimitMs" | "yieldAfterMs" | "onLimit" | "now" | "doYield"
56
+ >
57
+ > = {},
58
+ ): LuaBudget {
59
+ const now = opts.now ?? (() => performance.now());
60
+ return {
61
+ ticks: BUDGET_TICKS,
62
+ lastCheck: now(),
63
+ lastYield: now(),
64
+ busyMs: 0,
65
+ yieldCount: 0,
66
+ awaited: false,
67
+ stopped: false,
68
+ finished: false,
69
+ limitReported: false,
70
+ busyLimitMs: opts.busyLimitMs ?? BUSY_LIMIT_DEFAULT_MS,
71
+ yieldAfterMs: opts.yieldAfterMs ?? YIELD_AFTER_MS,
72
+ onLimit: opts.onLimit,
73
+ now,
74
+ doYield: opts.doYield ?? macrotaskYield,
75
+ };
76
+ }
77
+
78
+ export function budgetTick(b: LuaBudget): Promise<void> | undefined {
79
+ if (b.stopped) {
80
+ throw new LuaBudgetStopped();
81
+ }
82
+ b.ticks = BUDGET_TICKS;
83
+
84
+ const now = b.now();
85
+ const sinceCheck = now - b.lastCheck;
86
+ b.lastCheck = now;
87
+ if (b.awaited) {
88
+ b.awaited = false;
89
+ } else {
90
+ b.busyMs += sinceCheck;
91
+ }
92
+
93
+ if (
94
+ !b.limitReported &&
95
+ (b.busyMs >= b.busyLimitMs || b.yieldCount >= YIELD_COUNT_LIMIT)
96
+ ) {
97
+ b.limitReported = true;
98
+ b.onLimit?.(b);
99
+ }
100
+
101
+ const threshold = b.yieldCount === 0 ? b.yieldAfterMs : YIELD_EVERY_MS;
102
+ if (now - b.lastYield < threshold) {
103
+ return undefined;
104
+ }
105
+ b.lastYield = now;
106
+ b.yieldCount++;
107
+ // The event-loop turn spent yielding (rendering, sync, other scripts) must
108
+ // not be billed to this script's busyMs at the next tick, so re-read the
109
+ // clock on resume rather than trusting the pre-yield lastCheck.
110
+ return b.doYield().then(() => {
111
+ b.lastCheck = b.now();
112
+ });
113
+ }
@@ -0,0 +1,49 @@
1
+ import { KEEP_GOING_MS, type LuaBudget } from "./budget.ts";
2
+
3
+ export type BudgetNotifier = {
4
+ flashNotification(
5
+ message: string,
6
+ type?: string,
7
+ options?: {
8
+ timeout?: number;
9
+ actions?: { name: string; run: () => void }[];
10
+ },
11
+ ): void;
12
+ };
13
+
14
+ export function offerStopNotification(
15
+ ui: BudgetNotifier,
16
+ message: string,
17
+ budget: LuaBudget,
18
+ onStop?: () => void,
19
+ ): void {
20
+ ui.flashNotification(message, "warning", {
21
+ timeout: 0,
22
+ actions: [
23
+ {
24
+ name: "Stop",
25
+ run: () => {
26
+ // The script may have finished running (this toast is persistent)
27
+ // between the limit tripping and the user clicking Stop; don't
28
+ // quarantine a script that isn't running anymore.
29
+ if (budget.finished) {
30
+ return;
31
+ }
32
+ budget.stopped = true;
33
+ onStop?.();
34
+ },
35
+ },
36
+ {
37
+ name: "Keep going",
38
+ run: () => {
39
+ budget.busyLimitMs = budget.busyMs + KEEP_GOING_MS;
40
+ // Resetting to 0 would restore the 500ms first-yield threshold,
41
+ // making the tab unresponsive again right after the user asked
42
+ // to continue.
43
+ budget.yieldCount = 1;
44
+ budget.limitReported = false;
45
+ },
46
+ },
47
+ ],
48
+ });
49
+ }