@repogo/plugins 0.1.42 → 0.1.43

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.
package/README.md CHANGED
@@ -7,7 +7,7 @@ phone draw its data live and run its methods when you tap.
7
7
 
8
8
  - [Quick start](#quick-start)
9
9
  - [Where plugins live](#where-plugins-live)
10
- - [Methods](#methods): queries, mutations, streams, cached queries
10
+ - [Methods](#methods): reads, writes, destructive methods, streams, cached reads
11
11
  - [Data](#data): namespaces, collections, kv, streams, buckets, uploads
12
12
  - [Workflows](#workflows)
13
13
  - [Crons](#crons)
@@ -21,22 +21,22 @@ phone draw its data live and run its methods when you tap.
21
21
  ## Quick start
22
22
 
23
23
  ```ts
24
- // .repogo/plugins/notes/app.ts
24
+ // ~/.repogo/plugins/notes/app.ts
25
25
  import { definePlugin, method, z } from "@repogo/plugins";
26
26
 
27
27
  export default definePlugin({
28
- description: "Notes for this project",
29
- namespace: "myproject.notes",
28
+ description: "Notes for any project",
29
+ namespace: "notes",
30
30
  collections: { notes: z.object({ text: z.string(), done: z.boolean().default(false) }) },
31
31
  methods: {
32
32
  open: method(z.object({}), async (_, ctx) => ctx.collections.notes.list({ where: { done: false } }), {
33
- kind: "query",
33
+ effect: "read",
34
34
  description: "Notes not done yet, newest first",
35
35
  }),
36
36
  clear: method(z.object({}), async (_, ctx) => {
37
37
  for (const note of await ctx.collections.notes.list({ limit: 500 })) await ctx.collections.notes.delete(note.id);
38
38
  return null;
39
- }, { confirm: "Delete every note?" }),
39
+ }, { effect: "destructive", confirm: "Delete every note?" }),
40
40
  },
41
41
  });
42
42
  ```
@@ -46,9 +46,9 @@ a minute; at once when something calls it), and it gets:
46
46
 
47
47
  - `notes.open` and `notes.clear`, the methods it wrote;
48
48
  - `notes.notes.list`, `.get`, `.create`, `.update` and `.delete`, generated for its collection;
49
- - a `data.db` in `~/.repogo/plugin-data/myproject.notes/`.
49
+ - a `data.db` in `~/.repogo/plugin-data/notes/`.
50
50
 
51
- Add the package to `.repogo/plugins/package.json` (`bun add @repogo/plugins`) for
51
+ Add the package to `~/.repogo/plugins/package.json` (`bun add @repogo/plugins`) for
52
52
  types and editor help. The host runs plugins with its own copy of this package,
53
53
  the version it shipped with, so a plugin always matches its host. Plugins run on
54
54
  [Bun](https://bun.sh); install it on the environment.
@@ -57,30 +57,39 @@ the version it shipped with, so a plugin always matches its host. Plugins run on
57
57
 
58
58
  | Folder | For | `ctx.root` |
59
59
  | --- | --- | --- |
60
- | `<project>/.repogo/plugins/<name>/app.ts` | One project | The project's checkout |
61
- | `~/.repogo/plugins/<name>/app.ts` | Every project (Vercel, Gmail, builds) | The project that called it |
60
+ | `~/.repogo/plugins/<name>/app.ts` | Every project | The project that called it |
62
61
 
63
- A plugin's name is its folder's: lowercase letters, digits and hyphens. A
64
- project plugin hides an environment one of the same name. Each folder's plugins
65
- run together in one Worker, so a plugin's blocking code stalls the others in its
66
- folder: use async APIs.
62
+ Plugins live only on the environment, in folders the user put there. A project
63
+ can't ship plugin code: the host never imports a repo's `.repogo/plugins`, so
64
+ cloning or opening a project never runs anything in it. A plugin about one
65
+ project (its keys, its database, its plans) is still an environment plugin: it
66
+ reads that project from `ctx.root`, and says so instead of acting when it's
67
+ called from a project it doesn't fit.
68
+
69
+ A project ships no pages either: the app loads `.ui` and `.html` pages only
70
+ from `~/.repogo/plugins`, so a cloned repo can't put its own page, and the
71
+ script an `.html` page runs, in front of the user. A page about one project
72
+ lives beside its plugin and takes the project as state (`?project=…`).
73
+
74
+ A plugin's name is its folder's: lowercase letters, digits and hyphens. The
75
+ environment's plugins run together in one Worker, so a plugin's blocking code
76
+ stalls the others: use async APIs.
67
77
 
68
78
  Pages beside an environment plugin (`pages/*.ui`, or `pages/*.md`) open from
69
79
  any project as `~/.repogo/plugins/<name>/pages/<page>.ui`. A
70
80
  `.ui` page is card markup with no fence; a `.md` page is writing with cards.
71
81
 
72
- In the full iOS app, `~/.repogo/plugins/plugin.ui` supplies a shared Plugins
73
- tab. A project’s `.repogo/plugin.ui` overrides the entire page for that
74
- project; entries are not merged. If neither exists, the tab is absent.
75
- Both are plain card markup without a code fence. Read and preserve existing
82
+ In the full iOS app, `~/.repogo/plugins/plugin.ui` is the Plugins tab in
83
+ every project; a project's `.repogo/plugin.ui` is never read. If it doesn't
84
+ exist, the tab is absent. It is plain card markup without a code fence. Read and preserve existing
76
85
  entries, and link to plugin pages with `page:` actions.
77
86
 
78
- A user-facing plugin needs both its saved page and a link from the applicable `plugin.ui`. Saving a file in `.repogo/tabs` or naming it `index.ui` does not register a project tab. Plugin discovery can list a page without a navigation entry; verify the effective `plugin.ui` links to the existing page.
87
+ A user-facing plugin needs both its saved page and a link from `~/.repogo/plugins/plugin.ui`. Saving a file in a pages folder or naming it `index.ui` does not add it to the tab. Plugin discovery can list a page without a navigation entry; verify `plugin.ui` links to the existing page.
79
88
 
80
89
  ## Methods
81
90
 
82
91
  ```ts
83
- method(input, run, { kind?, description?, output?, confirm? })
92
+ method(input, run, { effect?, description?, output?, confirm? })
84
93
  stream(input, async function* run, { description? })
85
94
  ```
86
95
 
@@ -88,14 +97,18 @@ stream(input, async function* run, { description? })
88
97
  filled in before `run` sees it. `run(input, ctx)` returns the result: anything
89
98
  JSON can carry.
90
99
 
91
- | Kind | Does | Who runs it |
100
+ | Effect | Does | Who runs it |
92
101
  | --- | --- | --- |
93
- | `query` | Only reads; writing throws | Cards on their own, and again whenever data it read changes |
94
- | `mutation` (the default) | Reads and writes | Only when someone acts: a tap, an agent, a cron, other code |
95
- | `stream` | Only reads; yields items as they happen | A card while it shows it |
96
-
97
- Mark every method that only reads as `kind: "query"`: that's what lets a card
98
- show it and keep it current. Give each a `description`; agents read it to choose
102
+ | `read` | Only reads; writing throws | Cards on their own, and again whenever data it read changes |
103
+ | `write` (the default) | Reads and writes | Only when someone acts: a tap, an agent, a cron, other code |
104
+ | `destructive` | Writes what can't be undone; needs a `confirm` | Only after the user answers its question, on a tap |
105
+ | `stream()` | A read that yields items as they happen | A card while it shows it |
106
+
107
+ Mark every method that only reads `effect: "read"`: that's what lets a card
108
+ show it and keep it current. Mark what can't be undone (a delete, a send, a
109
+ deploy) `effect: "destructive"` with a `confirm` question; a destructive method
110
+ without one doesn't load. A method with a `confirm` never runs from an agent's
111
+ `plugin_call` or a cron: the agent draws a button, and the user's tap asks. Give each a `description`; agents read it to choose
99
112
  a method. `output`, a zod schema of the result, tells agents and cards its
100
113
  field names.
101
114
 
@@ -108,7 +121,7 @@ or the voice agent reads the whole of one.
108
121
  ```ts
109
122
  open: method(z.object({ limit: z.number().int().min(1).max(100).default(20) }), async ({ limit }, ctx) =>
110
123
  (await ctx.collections.notes.list({ where: { done: false }, limit })).map(({ id, text }) => ({ id, text: text.slice(0, 200) })),
111
- { kind: "query", description: "Notes not done yet, newest first" }),
124
+ { effect: "read", description: "Notes not done yet, newest first" }),
112
125
  ```
113
126
 
114
127
  A **stream** sends each `yield` to the card as it happens, for as long as the
@@ -142,7 +155,7 @@ projects: cached(z.object({ limit: z.number().int().min(1).max(200).default(100)
142
155
 
143
156
  - Answers are kept per method, input and project (`ctx.root`). `shared: true`
144
157
  keeps one for every project, for answers that don't depend on it.
145
- - `run` is read-only, like a query's, and has `timeout` (a minute unless it says).
158
+ - `run` is read-only, like any read's, and has `timeout` (a minute unless it says).
146
159
  - A background check that fails keeps the saved answer, goes to the plugin's
147
160
  logs, and is tried again after another `fresh`.
148
161
  - Answers live in the namespace's `data.db`, so a plugin with a cached method
@@ -501,7 +514,7 @@ itself. Each line names a plugin, one of its methods and a schedule:
501
514
  | Field | What |
502
515
  | --- | --- |
503
516
  | `plugin`, `method` | The plugin's folder name and one of its methods, called with `{}` as a tap would |
504
- | `project` | The project whose `.repogo/plugins` holds it, an absolute path or one starting `~/`; left out, `~/.repogo/plugins` |
517
+ | `project` | The project the run gets as `ctx.root`, an absolute path or one starting `~/`; left out, the home folder. The plugin is always the environment's |
505
518
  | `schedule` | A cron: five fields (minute hour day-of-month month day-of-week), or six with seconds first (`*/5 * * * * *`); each `*`, a number, a range, a list or a step |
506
519
  | `timezone` | Defaults to the environment's |
507
520
  | `enabled` | `false` keeps the line without running it |
@@ -520,9 +533,9 @@ isn't valid JSON pauses every line until it's fixed. Each line also shows
520
533
  has the same for a plugin's own, and a query reading them re-runs around each run.
521
534
 
522
535
  Schedule something only when the user asks for it. A cron's `ctx.root` is its
523
- project, or the home folder for a plugin in `~/.repogo/plugins`. Plugins in one
524
- folder share a thread: code that blocks it holds up every other cron there, and
525
- the host's log names the crons that were running when it happened.
536
+ line's project, or the home folder without one. Plugins share a thread: code
537
+ that blocks it holds up every other cron, and the host's log names the crons
538
+ that were running when it happened.
526
539
 
527
540
  ## Logging
528
541
 
@@ -593,7 +606,7 @@ ends it with that state, left up for 15 minutes:
593
606
  ```ts
594
607
  deploy: method(z.object({ project: z.string() }), async ({ project }, ctx) => {
595
608
  const steps = ["Build", "Test", "Deploy", "Verify"];
596
- const page = `.repogo/tabs/deploys.ui?project=${project}`;
609
+ const page = `~/.repogo/plugins/deploys/pages/index.ui?project=${project}`;
597
610
  const show = (current: number, body: string) =>
598
611
  ctx.activity({ id: `deploy:${project}`, title: `Deploying ${project}`, body, page,
599
612
  symbol: "shippingbox", progress: { kind: "steps", steps, current } });
@@ -633,10 +646,10 @@ or CLI of its own. It answers the tool's structured content, else its text
633
646
  customers: method(z.object({}), async (_, ctx) => {
634
647
  const list = await ctx.mcp("stripe", "stripe_api_read", { stripe_api_operation_id: "GetCustomers", /* … */ });
635
648
  return list.data.map((c) => ({ id: c.id, email: c.email }));
636
- }, { kind: "query" }),
649
+ }, { effect: "read" }),
637
650
  ```
638
651
 
639
- A query may run only the server's read-only tools, as a card's `Data` may. A
652
+ A read may run only the server's read-only tools, as a card's `Data` may. A
640
653
  tool marked destructive runs only from a method with a `confirm`, or one
641
654
  called from it with `ctx.call`: the user's answer to that question is its
642
655
  yes. A refusal throws `FORBIDDEN`; a tool's own error throws `INTERNAL` with
@@ -667,17 +680,17 @@ Events run in memory: none is retried, and a handler that throws is logged.
667
680
  ## How cards and agents reach a plugin
668
681
 
669
682
  **Agents** use two tools. `plugins` lists every plugin with a line per method
670
- (its kind, whether it asks first, and its input's fields, optional ones ending
683
+ (its effect, whether it asks first, and its input's fields, optional ones ending
671
684
  in `?`), and every line of `~/.repogo/crons.json` with how it's going.
672
685
  `plugins` with `plugin` set answers that plugin in full: each method's input
673
686
  and output JSON Schema and confirm, its `storage` (each bucket's and stream's
674
687
  folder), the pages that show its methods, and the cron lines naming it.
675
688
  `plugin_call` runs one. Inspect a method's schema before naming it in a card.
676
- Verify query fields with a read-only call. Do not execute mutations just to
689
+ Verify a read's fields with a read-only call. Do not execute writes just to
677
690
  check a card; run them only when the user's task calls for that action.
678
691
 
679
692
  **Cards** name methods as `plugin:<plugin>.<method>`. A `Data` element runs a
680
- query, or follows a stream, and redraws when its data changes. `Button`,
693
+ read, or follows a stream, and redraws when its data changes. `Button`,
681
694
  `Item` and `Swipe` actions call methods; on iOS, `Toggle` can call one on a
682
695
  user flip. `Upload` calls a plugin method with staged files. Params must match
683
696
  the discovered input schema. These fragments assume `q` and `note` are supplied
@@ -701,10 +714,10 @@ by the enclosing card:
701
714
  ### Saving from a switch
702
715
 
703
716
  Prefer SDK storage for plugin-owned data. A storage declaration generates
704
- methods; ordinary saves do not need a custom mutation. For example:
717
+ methods; ordinary saves do not need a custom write method. For example:
705
718
 
706
719
  ```ts
707
- // .repogo/plugins/preferences/app.ts
720
+ // ~/.repogo/plugins/preferences/app.ts
708
721
  import { definePlugin, z } from "@repogo/plugins";
709
722
 
710
723
  export default definePlugin({
@@ -746,9 +759,10 @@ schema answers `VALIDATION` with a message per field. Anything else thrown is
746
759
  `INTERNAL` with its message.
747
760
 
748
761
  A method's `confirm` is asked before a card's tap runs it. An agent's
749
- `plugin_call` gets it back as a `CONFIRM` error until it calls again with
750
- `confirmed: true`, once the user agrees. Ordinary `ctx.call` from plugin code skips
751
- it; an unattended workflow refuses confirmation-gated methods. Give one to anything that can't be undone. `{{field}}` in the question is
762
+ `plugin_call` never runs it: it gets the question back as a `CONFIRM` error and
763
+ draws a button, and the user's tap asks. Ordinary `ctx.call` from plugin code skips
764
+ it; an unattended workflow refuses confirmation-gated methods. A `destructive`
765
+ method must have one. `{{field}}` in the question is
752
766
  filled from the call's input, so it can say what it acts on:
753
767
  `confirm: "Restart {{machine}}?"`.
754
768
 
package/dist/context.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { Bucket } from "./buckets";
2
2
  import type { Collection } from "./collections";
3
3
  import type { Kv } from "./kv";
4
4
  import type { Stream } from "./streams";
5
- import type { HTTPAccess, MethodKind } from "./define";
5
+ import type { Effect, HTTPAccess } from "./define";
6
6
  import type { Duration } from "./duration";
7
7
  /** An alert for the user's phones: what it says, and the page a tap opens. */
8
8
  export type Notice = {
@@ -74,7 +74,9 @@ export type Activity = {
74
74
  export type ManifestMethod = {
75
75
  /** `<plugin>.<method>`, the name `ctx.call` takes. */
76
76
  name: string;
77
- kind: MethodKind;
77
+ effect: Effect;
78
+ /** A read that sends items as they happen. */
79
+ stream: boolean;
78
80
  description: string | null;
79
81
  /** What a card asks before running it; null runs on tap. */
80
82
  confirm: string | null;
@@ -113,13 +115,15 @@ export type ManifestStorage = {
113
115
  };
114
116
  /**
115
117
  * One schedule the host gives a Worker, from a line of `~/.repogo/crons.json`: call
116
- * `<plugin>.<method>` with `{}` when `schedule` comes due in `timezone` (the environment's).
118
+ * `<plugin>.<method>` with `{}` when `schedule` comes due in `timezone` (the environment's),
119
+ * with `root` (the line's project) as `ctx.root`, else the Worker's own.
117
120
  */
118
121
  export type CronEntry = {
119
122
  plugin: string;
120
123
  method: string;
121
124
  schedule: string;
122
125
  timezone?: string;
126
+ root?: string;
123
127
  };
124
128
  /** A schedule and how it's going; `error` says why it couldn't be set, and then nothing runs. */
125
129
  export type CronStatus = ManifestCron & {
@@ -181,7 +185,7 @@ export type Context = {
181
185
  * environment's), with their methods: what `call` can reach.
182
186
  */
183
187
  plugins: () => Manifest["plugins"];
184
- /** Aborts when a stream's last watcher leaves; a query or mutation's never does. */
188
+ /** Aborts when a stream's last watcher leaves; a read or write's never does. */
185
189
  signal: AbortSignal;
186
190
  /** Sends an alert to every phone paired with this environment. */
187
191
  notify: (notice: Notice) => void;
package/dist/define.d.ts CHANGED
@@ -11,26 +11,28 @@ export type StreamDefinition = z.ZodType | {
11
11
  retain: Retain;
12
12
  };
13
13
  /**
14
- * A query only reads, so cards may run it on their own and again when its data
15
- * changes. A mutation writes, and runs only when the user acts. A stream only
16
- * reads too, and sends items as they happen for as long as someone watches.
17
- * Unmarked methods are mutations: repeating one is never assumed safe.
14
+ * What a method does. `read` only reads, so cards may run it on their own and
15
+ * again when its data changes. `write` changes data, and runs only when the
16
+ * user acts. `destructive` can't be undone, and must ask first (`confirm`).
17
+ * Unmarked methods write: repeating one is never assumed safe.
18
18
  */
19
- export type MethodKind = "query" | "mutation" | "stream";
19
+ export type Effect = "read" | "write" | "destructive";
20
20
  export type Method<I extends z.ZodType = z.ZodType, O extends z.ZodType = z.ZodType> = {
21
21
  input: I;
22
22
  /** What it returns; the agent and cards read field names from it. */
23
23
  output?: O;
24
- kind: MethodKind;
24
+ effect: Effect;
25
+ /** Set by `stream`: a read that sends items as they happen. */
26
+ stream?: true;
25
27
  description?: string;
26
28
  /**
27
- * For what can't be undone: the question a card's button asks before it
28
- * runs, such as "Delete every item?". Methods without one run on tap.
29
+ * The question asked before it runs, such as "Delete {{name}}?", filled from
30
+ * its input; required for a destructive method. Methods without one run on tap.
29
31
  */
30
32
  confirm?: string;
31
33
  /** Who may call it at the environment's own address; unset, no one. */
32
34
  http?: HTTPAccess;
33
- /** Set by `cached`: the query answers from what it saved, and checks again when that's old. */
35
+ /** Set by `cached`: the read answers from what it saved, and checks again when that's old. */
34
36
  cache?: CacheOptions;
35
37
  run: (input: z.infer<I>, ctx: Context) => unknown | Promise<unknown>;
36
38
  };
@@ -67,22 +69,27 @@ export type PluginDefinition = {
67
69
  private?: string[];
68
70
  };
69
71
  export declare function definePlugin<T extends PluginDefinition>(plugin: T): T;
70
- export declare function method<I extends z.ZodType, O extends z.ZodType = z.ZodType>(input: I, run: (input: z.infer<I>, ctx: Context) => unknown | Promise<unknown>, options?: {
71
- kind?: MethodKind;
72
+ type MethodOptions<O extends z.ZodType> = {
72
73
  output?: O;
73
74
  description?: string;
74
- confirm?: string;
75
75
  http?: true | {
76
76
  auth: HTTPAccess;
77
77
  };
78
- }): Method<I, O>;
78
+ } & ({
79
+ effect: "destructive";
80
+ confirm: string;
81
+ } | {
82
+ effect?: "read" | "write";
83
+ confirm?: string;
84
+ });
85
+ export declare function method<I extends z.ZodType, O extends z.ZodType = z.ZodType>(input: I, run: (input: z.infer<I>, ctx: Context) => unknown | Promise<unknown>, options?: MethodOptions<O>): Method<I, O>;
79
86
  /**
80
- * A query that answers at once from what it saved last time, for answers that
87
+ * A read that answers at once from what it saved last time, for answers that
81
88
  * are slow to get: a CLI's list, another service's API. When the saved answer
82
89
  * is older than `fresh`, `run` goes again in the background, and cards showing
83
90
  * it redraw only if the answer changed. With nothing saved, the first call
84
91
  * waits for `run`. Each project keeps its own answers unless `shared`.
85
- * `run` is read-only, like a query's; it has `timeout` (a minute unless it says).
92
+ * `run` is read-only, like any read's; it has `timeout` (a minute unless it says).
86
93
  */
87
94
  export declare function cached<I extends z.ZodType, O extends z.ZodType = z.ZodType>(input: I, run: (input: z.infer<I>, ctx: Context) => unknown | Promise<unknown>, options: {
88
95
  fresh: Duration;
@@ -95,8 +102,9 @@ export declare function cached<I extends z.ZodType, O extends z.ZodType = z.ZodT
95
102
  * A method that sends items as they happen: a log tail, a build's output. Each
96
103
  * `yield` is one item; a card shows them as a list that grows. It runs while
97
104
  * someone watches and `ctx.signal` aborts when the last one leaves, so pass it
98
- * on (`Bun.spawn(cmd, { signal: ctx.signal })`). Like a query, it may not write.
105
+ * on (`Bun.spawn(cmd, { signal: ctx.signal })`). Like any read, it may not write.
99
106
  */
100
107
  export declare function stream<I extends z.ZodType>(input: I, run: (input: z.infer<I>, ctx: Context) => AsyncIterable<unknown>, options?: {
101
108
  description?: string;
102
109
  }): Method<I>;
110
+ export {};
@@ -1,10 +1,11 @@
1
1
  import { z } from "zod";
2
2
  import type { Bucket } from "./buckets";
3
- import type { MethodKind } from "./define";
3
+ import type { Effect } from "./define";
4
4
  import type { Kv } from "./kv";
5
5
  import type { Stream } from "./streams";
6
6
  export type Generated = {
7
- kind: MethodKind;
7
+ effect: Effect;
8
+ stream?: true;
8
9
  description: string;
9
10
  input: z.ZodType;
10
11
  output?: z.ZodType;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { cached, definePlugin, method, stream } from "./define";
2
2
  export { upload, type UploadedFile } from "./uploads";
3
- export type { PluginDefinition, Method, MethodKind, HTTPAccess, StreamDefinition } from "./define";
3
+ export type { PluginDefinition, Method, Effect, HTTPAccess, StreamDefinition } from "./define";
4
4
  export type { Activity, ActivityProgress, ActivityTimer, ActivityTint, AgentOptions, AgentReply, Caller, Context, CronEntry, CronStatus, Manifest, ManifestCron, ManifestMethod, ManifestStorage } from "./context";
5
5
  export type { LogLevel, LogRecord } from "./logs";
6
6
  export type { BucketFile, FileStatus, PutFile } from "./buckets";
package/dist/index.js CHANGED
@@ -47,10 +47,12 @@ function definePlugin(plugin) {
47
47
  return plugin;
48
48
  }
49
49
  function method(input, run, options = {}) {
50
+ if ("kind" in options)
51
+ throw new Error('method: kind is now effect: "read", "write" or "destructive"');
50
52
  return {
51
53
  input,
52
54
  run,
53
- kind: options.kind ?? "mutation",
55
+ effect: options.effect ?? "write",
54
56
  output: options.output,
55
57
  description: options.description,
56
58
  confirm: options.confirm,
@@ -61,14 +63,14 @@ function cached(input, run, options) {
61
63
  return {
62
64
  input,
63
65
  run,
64
- kind: "query",
66
+ effect: "read",
65
67
  output: options.output,
66
68
  description: options.description,
67
69
  cache: { fresh: parseDuration(options.fresh), shared: options.shared ?? false, timeout: parseDuration(options.timeout ?? "1m") }
68
70
  };
69
71
  }
70
72
  function stream(input, run, options = {}) {
71
- return { input, run, kind: "stream", description: options.description };
73
+ return { input, run, effect: "read", stream: true, description: options.description };
72
74
  }
73
75
  // src/uploads.ts
74
76
  import { AsyncLocalStorage } from "async_hooks";
package/dist/runtime.d.ts CHANGED
@@ -2,14 +2,15 @@ import { z } from "zod";
2
2
  import { type Schedule } from "./cron";
3
3
  import type { Caller, CronEntry, CronStatus, Manifest, Notice } from "./context";
4
4
  export type { Manifest, ManifestCron, ManifestMethod } from "./context";
5
- import type { HTTPAccess, PluginDefinition, MethodKind } from "./define";
5
+ import type { Effect, HTTPAccess, PluginDefinition } from "./define";
6
6
  import { EventBus } from "./events";
7
7
  import type { WorkflowBridge } from "./workflow/contracts";
8
8
  import { type UploadedFile } from "./uploads";
9
9
  export type RegisteredMethod = {
10
10
  name: string;
11
11
  plugin: string;
12
- kind: MethodKind;
12
+ effect: Effect;
13
+ stream?: true;
13
14
  description?: string;
14
15
  confirm?: string;
15
16
  http?: HTTPAccess;
@@ -35,9 +36,11 @@ type NoticeListener = (notice: SentNotice) => void;
35
36
  export type HostCaller = (plugin: string, method: string, params: unknown) => Promise<unknown>;
36
37
  /** A schedule as the runtime keeps it: read once, with its last run. */
37
38
  type CronJob = {
38
- /** The plugin, method, schedule and zone: an entry the host sends again keeps its job, and its state. */
39
+ /** The plugin, method, schedule, zone and root: an entry the host sends again keeps its job, and its state. */
39
40
  signature: string;
40
41
  plugin: string;
42
+ /** The project the run gets as `ctx.root`; none keeps the Worker's own. */
43
+ root?: string;
41
44
  /** `<plugin>.<method>`. */
42
45
  method: string;
43
46
  schedule: string;
@@ -98,9 +101,9 @@ export declare class Runtime {
98
101
  caller: Caller;
99
102
  }): Promise<unknown>;
100
103
  /**
101
- * Calls a method and reports what it read and wrote. `expect: "query"` is how
104
+ * Calls a method and reports what it read and wrote. `expect: "read"` is how
102
105
  * a card runs a method on its own: it refuses anything that might write, and
103
- * so does everything the query calls.
106
+ * so does everything the read calls.
104
107
  */
105
108
  /**
106
109
  * `confirmed` is false for a tap the user hasn't answered; `ctx.call` leaves it out.
@@ -108,7 +111,7 @@ export declare class Runtime {
108
111
  * pass on to the code the method calls.
109
112
  */
110
113
  call(name: string, input?: unknown, options?: {
111
- expect?: MethodKind;
114
+ expect?: "read";
112
115
  confirmed?: boolean;
113
116
  root?: string;
114
117
  caller?: Caller;
package/dist/testing.js CHANGED
@@ -91,6 +91,7 @@ function carryable(value) {
91
91
 
92
92
  // src/runtime.ts
93
93
  import { AsyncLocalStorage as AsyncLocalStorage3 } from "async_hooks";
94
+ import { existsSync } from "fs";
94
95
  import { mkdir as mkdir3, readdir as readdir3 } from "fs/promises";
95
96
  import { join as join3 } from "path";
96
97
  import { z as z5 } from "zod";
@@ -155,7 +156,7 @@ function recordRead(tag) {
155
156
  }
156
157
  function assertWritable(tag) {
157
158
  if (current.getStore()?.readOnly) {
158
- throw new MethodError("FORBIDDEN", `A query can't change data (it tried to write ${tag}).`);
159
+ throw new MethodError("FORBIDDEN", `A read can't change data (it tried to write ${tag}).`);
159
160
  }
160
161
  }
161
162
  function recordWrite(tag) {
@@ -953,28 +954,28 @@ function kvMethods(name, kv, schema) {
953
954
  const entry = z.object({ key: z.string(), value: schema, expiresAt: z.string().nullable(), createdAt: z.string(), updatedAt: z.string() });
954
955
  return {
955
956
  get: {
956
- kind: "query",
957
+ effect: "read",
957
958
  description: `Get one ${name} entry by key, or null`,
958
959
  input: z.object({ key }),
959
960
  output: entry.nullable(),
960
961
  run: ({ key: key2 }) => kv.entry(key2)
961
962
  },
962
963
  list: {
963
- kind: "query",
964
+ effect: "read",
964
965
  description: `List ${name} entries in key order, optionally by prefix`,
965
966
  input: z.object({ prefix: z.string().optional(), limit }),
966
967
  output: z.array(entry),
967
968
  run: (input) => kv.list(input)
968
969
  },
969
970
  set: {
970
- kind: "mutation",
971
+ effect: "write",
971
972
  description: `Set a ${name} key; ttl ("5m", "1h", "7d" or ms) makes it expire`,
972
973
  input: z.object({ key, value: schema, ttl }),
973
974
  output: entry,
974
975
  run: ({ key: key2, value, ttl: ttl2 }) => kv.set(key2, value, { ttl: ttl2 })
975
976
  },
976
977
  delete: {
977
- kind: "mutation",
978
+ effect: "destructive",
978
979
  description: `Delete a ${name} key`,
979
980
  input: z.object({ key }),
980
981
  output: z.null(),
@@ -988,41 +989,42 @@ function streamMethods(name, stream, schema) {
988
989
  const line = z.object({ id: z.string(), key: z.string(), offset: z.number(), at: z.string(), data: schema });
989
990
  return {
990
991
  tail: {
991
- kind: "stream",
992
+ effect: "read",
993
+ stream: true,
992
994
  description: `Follow a ${name} key live: its last lines (100 unless last says), then each new one, until it ends`,
993
995
  input: z.object({ key, from: z.number().int().min(0).optional(), last: z.number().int().positive().max(500).optional() }),
994
996
  run: ({ key: key2, from, last }, signal) => stream.follow(key2, { from, last, signal })
995
997
  },
996
998
  read: {
997
- kind: "query",
999
+ effect: "read",
998
1000
  description: `Read a ${name} key's lines from an offset`,
999
1001
  input: z.object({ key, from: z.number().int().min(0).optional(), limit }),
1000
1002
  output: z.array(line),
1001
1003
  run: ({ key: key2, ...options }) => stream.read(key2, options)
1002
1004
  },
1003
1005
  keys: {
1004
- kind: "query",
1006
+ effect: "read",
1005
1007
  description: `List ${name} keys, most recently written first`,
1006
1008
  input: z.object({ limit }),
1007
1009
  output: z.array(z.object({ key: z.string(), lines: z.number(), bytes: z.number(), endedAt: z.string().nullable(), updatedAt: z.string() })),
1008
1010
  run: (input) => stream.keys(input)
1009
1011
  },
1010
1012
  append: {
1011
- kind: "mutation",
1013
+ effect: "write",
1012
1014
  description: `Append a line to a ${name} key`,
1013
1015
  input: z.object({ key, data: schema }),
1014
1016
  output: line,
1015
1017
  run: ({ key: key2, data }) => stream.append(key2, data)
1016
1018
  },
1017
1019
  end: {
1018
- kind: "mutation",
1020
+ effect: "write",
1019
1021
  description: `End a ${name} key: followers stop and appends fail`,
1020
1022
  input: z.object({ key }),
1021
1023
  output: z.null(),
1022
1024
  run: async ({ key: key2 }) => (await stream.end(key2), null)
1023
1025
  },
1024
1026
  delete: {
1025
- kind: "mutation",
1027
+ effect: "destructive",
1026
1028
  description: `Delete a ${name} key and its lines`,
1027
1029
  input: z.object({ key }),
1028
1030
  output: z.null(),
@@ -1051,28 +1053,28 @@ function bucketMethods(name, bucket, schema) {
1051
1053
  const revision = z.number().int().optional();
1052
1054
  return {
1053
1055
  list: {
1054
- kind: "query",
1056
+ effect: "read",
1055
1057
  description: `List ${name} files, newest first, optionally in one folder or with one status`,
1056
1058
  input: z.object({ folder: z.string().optional(), status: status.optional(), limit }),
1057
1059
  output: z.array(file),
1058
1060
  run: (input) => bucket.list(input)
1059
1061
  },
1060
1062
  search: {
1061
- kind: "query",
1063
+ effect: "read",
1062
1064
  description: `Search ${name} files by name, folder and meta`,
1063
1065
  input: z.object({ query: z.string(), limit }),
1064
1066
  output: z.array(file),
1065
1067
  run: ({ query, limit: limit2 }) => bucket.search(query, { limit: limit2 })
1066
1068
  },
1067
1069
  get: {
1068
- kind: "query",
1070
+ effect: "read",
1069
1071
  description: `Get one ${name} file by id, or null`,
1070
1072
  input: z.object({ id }),
1071
1073
  output: file.nullable(),
1072
1074
  run: ({ id: id2 }) => bucket.get(id2)
1073
1075
  },
1074
1076
  put: {
1075
- kind: "mutation",
1077
+ effect: "write",
1076
1078
  description: `Add a file to ${name}: its name, and text or an absolute path to copy`,
1077
1079
  input: z.object({
1078
1080
  name: z.string(),
@@ -1089,21 +1091,21 @@ function bucketMethods(name, bucket, schema) {
1089
1091
  }
1090
1092
  },
1091
1093
  update: {
1092
- kind: "mutation",
1094
+ effect: "write",
1093
1095
  description: `Change fields of a ${name} file's meta; null removes one`,
1094
1096
  input: z.object({ id, meta: z.record(z.string(), z.unknown()), revision }),
1095
1097
  output: file,
1096
1098
  run: ({ id: id2, meta, revision: revision2 }) => bucket.update(id2, meta, { revision: revision2 })
1097
1099
  },
1098
1100
  move: {
1099
- kind: "mutation",
1101
+ effect: "write",
1100
1102
  description: `Rename a ${name} file or move it to another folder`,
1101
1103
  input: z.object({ id, name: z.string().optional(), folder: z.string().optional(), revision }),
1102
1104
  output: file,
1103
1105
  run: ({ id: id2, name: name2, folder, revision: revision2 }) => bucket.move(id2, { name: name2, folder }, { revision: revision2 })
1104
1106
  },
1105
1107
  delete: {
1106
- kind: "mutation",
1108
+ effect: "destructive",
1107
1109
  description: `Delete a ${name} file and its meta`,
1108
1110
  input: z.object({ id }),
1109
1111
  output: z.null(),
@@ -1524,29 +1526,29 @@ function workflowMethods(plugin, namespace, name, definition, ctx, bridge, direc
1524
1526
  return bridge({ operation, dir, plugin, namespace, workflow: name, root: ctx.root, caller: ctx.caller, input, settings: workflowSettings(definition) });
1525
1527
  };
1526
1528
  const id = z4.object({ id: z4.string().uuid() }).strict();
1527
- const spec = (method, kind, input, description, confirm, output) => ({
1529
+ const spec = (method, effect, input, description, confirm, output) => ({
1528
1530
  name: `${plugin}.${name}.${method}`,
1529
1531
  plugin,
1530
- kind,
1532
+ effect,
1531
1533
  input,
1532
1534
  description,
1533
1535
  confirm,
1534
1536
  output,
1535
1537
  run: async (input2, raw) => {
1536
- if (kind === "query")
1538
+ if (effect === "read")
1537
1539
  recordRead(workflowTag(namespace));
1538
1540
  const request = method === "start" ? { ...input2, input: raw.input } : input2;
1539
1541
  return invoke(method, request);
1540
1542
  }
1541
1543
  });
1542
1544
  return [
1543
- spec("list", "query", z4.object({ limit: z4.number().int().min(1).max(100).default(20), status: z4.string().optional(), cursor: z4.string().optional() }).strict(), "List workflow runs", undefined, z4.object({ items: z4.array(RunSummarySchema), cursor: z4.string().nullable() })),
1544
- spec("get", "query", id, "Get one workflow run"),
1545
- spec("start", "mutation", z4.object({ input: definition.input, key: z4.string().min(1).max(200).optional() }).strict(), definition.description ?? "Start a durable workflow", definition.confirm, RunSummarySchema),
1546
- spec("retry", "mutation", id, "Retry a safely failed query step", undefined, RunSummarySchema),
1547
- spec("restart", "mutation", id, "Start a run that needs attention over from the beginning, on the current code", undefined, RunSummarySchema),
1548
- spec("cancel", "mutation", id, "Request cancellation of a workflow", undefined, RunSummarySchema),
1549
- spec("cancelAll", "mutation", z4.object({ status: z4.string().optional() }).strict(), "Cancel every unfinished run, or only those in one status", undefined, z4.object({ cancelled: z4.number().int() }))
1545
+ spec("list", "read", z4.object({ limit: z4.number().int().min(1).max(100).default(20), status: z4.string().optional(), cursor: z4.string().optional() }).strict(), "List workflow runs", undefined, z4.object({ items: z4.array(RunSummarySchema), cursor: z4.string().nullable() })),
1546
+ spec("get", "read", id, "Get one workflow run"),
1547
+ spec("start", "write", z4.object({ input: definition.input, key: z4.string().min(1).max(200).optional() }).strict(), definition.description ?? "Start a durable workflow", definition.confirm, RunSummarySchema),
1548
+ spec("retry", "write", id, "Retry a safely failed query step", undefined, RunSummarySchema),
1549
+ spec("restart", "write", id, "Start a run that needs attention over from the beginning, on the current code", undefined, RunSummarySchema),
1550
+ spec("cancel", "write", id, "Request cancellation of a workflow", undefined, RunSummarySchema),
1551
+ spec("cancelAll", "write", z4.object({ status: z4.string().optional() }).strict(), "Cancel every unfinished run, or only those in one status", undefined, z4.object({ cancelled: z4.number().int() }))
1550
1552
  ];
1551
1553
  }
1552
1554
  function workflowSettings(definition) {
@@ -1606,6 +1608,8 @@ class Runtime {
1606
1608
  continue;
1607
1609
  if (only && !only.includes(entry.name))
1608
1610
  continue;
1611
+ if (!existsSync(join3(pluginsDir, entry.name, "app.ts")))
1612
+ continue;
1609
1613
  const module = await logContext.run({ plugin: entry.name }, () => import(join3(pluginsDir, entry.name, "app.ts")));
1610
1614
  await this.add(entry.name, module.default);
1611
1615
  }
@@ -1731,7 +1735,8 @@ class Runtime {
1731
1735
  this.register({
1732
1736
  name: fullName,
1733
1737
  plugin: name,
1734
- kind: method.kind,
1738
+ effect: method.effect,
1739
+ stream: method.stream,
1735
1740
  description: method.description,
1736
1741
  confirm: method.confirm,
1737
1742
  http: method.http,
@@ -1787,17 +1792,17 @@ class Runtime {
1787
1792
  return runWith(where, () => this.call(name, input, options));
1788
1793
  if (where !== logContext.getStore())
1789
1794
  return logContext.run(where, () => this.call(name, input, options));
1790
- if ((options.expect === "query" || currentAccess()?.readOnly) && method.kind === "mutation") {
1795
+ if ((options.expect === "read" || currentAccess()?.readOnly) && method.effect !== "read") {
1791
1796
  throw new MethodError("FORBIDDEN", `${name} changes data, so it only runs when the user acts.`);
1792
1797
  }
1793
1798
  const raw = input ?? {};
1794
1799
  const parsed = method.input.parse(raw);
1795
1800
  if (method.confirm && (options.confirmed === false || workflowScope.getStore()))
1796
1801
  throw new MethodError("CONFIRM", fillConfirm(method.confirm, parsed));
1797
- if (method.kind === "stream")
1802
+ if (method.stream)
1798
1803
  return { result: await this.sample(name, input), reads: [], invalidates: [] };
1799
1804
  const confirmed = this.callConfirmed.getStore() ?? false;
1800
- const run = await tracked(method.kind === "query", (tags) => this.notifyInvalidated(tags), () => this.callConfirmed.run(confirmed || method.confirm !== undefined, async () => {
1805
+ const run = await tracked(method.effect === "read", (tags) => this.notifyInvalidated(tags), () => this.callConfirmed.run(confirmed || method.confirm !== undefined, async () => {
1801
1806
  const result = await method.run(parsed, raw);
1802
1807
  await this.events.emit(name, { input: parsed, result });
1803
1808
  return result;
@@ -1816,8 +1821,8 @@ class Runtime {
1816
1821
  const method = this.methods.get(name);
1817
1822
  if (!method)
1818
1823
  throw new MethodError("NOT_FOUND", `There is no method ${name}.`);
1819
- if (method.kind !== "stream")
1820
- throw new MethodError("VALIDATION", `${name} is a ${method.kind}, not a stream.`);
1824
+ if (!method.stream)
1825
+ throw new MethodError("VALIDATION", `${name} is not a stream.`);
1821
1826
  const raw = input ?? {};
1822
1827
  const parsed = method.input.parse(raw);
1823
1828
  const root = options.root ?? this.callRoot.getStore() ?? this.root;
@@ -1872,7 +1877,7 @@ class Runtime {
1872
1877
  const statuses = [];
1873
1878
  for (const entry of entries) {
1874
1879
  const timezone = entry.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
1875
- const signature = `${entry.plugin}.${entry.method} ${entry.schedule} ${timezone}`;
1880
+ const signature = `${entry.plugin}.${entry.method} ${entry.schedule} ${timezone} ${entry.root ?? ""}`;
1876
1881
  try {
1877
1882
  const job = kept.get(signature) ?? this.cronJob(entry, timezone, signature);
1878
1883
  jobs.push(job);
@@ -1904,8 +1909,8 @@ class Runtime {
1904
1909
  const method = this.methods.get(name);
1905
1910
  if (!method || method.plugin !== entry.plugin)
1906
1911
  throw new Error(`${entry.method} isn't one of ${entry.plugin}'s methods`);
1907
- if (method.kind === "stream")
1908
- throw new Error(`${name} is a stream; a cron runs a query or a mutation`);
1912
+ if (method.stream)
1913
+ throw new Error(`${name} is a stream; a cron runs a read or a write`);
1909
1914
  if (method.confirm)
1910
1915
  throw new Error(`${name} asks before it runs, so nothing may run it on a schedule`);
1911
1916
  if (!method.input.safeParse({}).success)
@@ -1922,6 +1927,7 @@ class Runtime {
1922
1927
  return {
1923
1928
  signature,
1924
1929
  plugin: entry.plugin,
1930
+ root: entry.root,
1925
1931
  method: name,
1926
1932
  schedule: entry.schedule,
1927
1933
  timezone,
@@ -1946,7 +1952,7 @@ class Runtime {
1946
1952
  job.running = true;
1947
1953
  job.lastRunAt = now;
1948
1954
  const startedAt = Date.now();
1949
- started.push(this.call(job.method, {}, { caller: "cron" }).then(() => {
1955
+ started.push(this.call(job.method, {}, { caller: "cron", root: job.root }).then(() => {
1950
1956
  job.lastError = null;
1951
1957
  }, (error) => {
1952
1958
  job.lastError = toCallError(error).message;
@@ -2007,7 +2013,8 @@ class Runtime {
2007
2013
  description: plugin.description ?? null,
2008
2014
  methods: [...this.methods.values()].filter((method) => method.plugin === name).map((method) => ({
2009
2015
  name: method.name,
2010
- kind: method.kind,
2016
+ effect: method.effect,
2017
+ stream: method.stream ?? false,
2011
2018
  description: method.description ?? null,
2012
2019
  confirm: method.confirm ?? null,
2013
2020
  http: method.http ?? null,
@@ -2083,6 +2090,9 @@ class Runtime {
2083
2090
  register(method) {
2084
2091
  if (this.methods.has(method.name))
2085
2092
  throw new Error(`${method.name} is defined twice; rename the method or the data`);
2093
+ if (method.effect === "destructive" && !method.confirm) {
2094
+ throw new Error(`${method.name} is destructive, so it needs a confirm question, such as { confirm: "Delete {{id}}?" }`);
2095
+ }
2086
2096
  this.methods.set(method.name, method);
2087
2097
  }
2088
2098
  registerGenerated(plugin, name, methods) {
@@ -2099,26 +2109,26 @@ class Runtime {
2099
2109
  const prefix = `${plugin}.${name}`;
2100
2110
  const id = z5.object({ id: z5.string() });
2101
2111
  const record2 = schema.extend({ id: z5.string(), createdAt: z5.string(), updatedAt: z5.string() });
2102
- const crud = (method, kind, input, output, description, run, confirm) => this.register({
2112
+ const crud = (method, effect, input, output, description, run, confirm) => this.register({
2103
2113
  name: `${prefix}.${method}`,
2104
2114
  plugin,
2105
- kind,
2115
+ effect,
2106
2116
  description,
2107
2117
  confirm,
2108
2118
  input,
2109
2119
  output,
2110
2120
  run: async (value, raw) => run(value, raw)
2111
2121
  });
2112
- crud("list", "query", z5.object({
2122
+ crud("list", "read", z5.object({
2113
2123
  limit: z5.number().int().positive().max(500).optional(),
2114
2124
  offset: z5.number().int().min(0).optional(),
2115
2125
  orderBy: z5.string().optional(),
2116
2126
  order: z5.enum(["asc", "desc"]).optional()
2117
2127
  }), z5.array(record2), `List ${name} records, newest first unless orderBy says`, (input) => collection.list(input));
2118
- crud("get", "query", id, record2.nullable(), `Get one ${name} record by id, or null`, (input) => collection.get(input.id));
2119
- crud("create", "mutation", schema, record2, `Create a ${name} record`, (input) => collection.create(input));
2120
- crud("update", "mutation", id.extend(schema.partial().shape), record2, `Update fields on a ${name} record`, ({ id: id2, ...fields }, raw) => collection.update(id2, Object.fromEntries(Object.entries(fields).filter(([key]) => Object.hasOwn(raw ?? {}, key)))));
2121
- crud("delete", "mutation", id, z5.null(), `Delete a ${name} record by id`, async (input) => {
2128
+ crud("get", "read", id, record2.nullable(), `Get one ${name} record by id, or null`, (input) => collection.get(input.id));
2129
+ crud("create", "write", schema, record2, `Create a ${name} record`, (input) => collection.create(input));
2130
+ crud("update", "write", id.extend(schema.partial().shape), record2, `Update fields on a ${name} record`, ({ id: id2, ...fields }, raw) => collection.update(id2, Object.fromEntries(Object.entries(fields).filter(([key]) => Object.hasOwn(raw ?? {}, key)))));
2131
+ crud("delete", "destructive", id, z5.null(), `Delete a ${name} record by id`, async (input) => {
2122
2132
  await collection.delete(input.id);
2123
2133
  return null;
2124
2134
  }, "Delete this record?");
@@ -2147,7 +2157,7 @@ function cronState(job) {
2147
2157
  function mcpFailure(server, tool, error) {
2148
2158
  switch (error.code) {
2149
2159
  case "NOT_READ_ONLY":
2150
- return new MethodError("FORBIDDEN", `${server}.${tool} changes data, so it runs from a mutation, not a query.`);
2160
+ return new MethodError("FORBIDDEN", `${server}.${tool} changes data, so it runs from a write, not a read.`);
2151
2161
  case "CONFIRM":
2152
2162
  return new MethodError("FORBIDDEN", `${server}.${tool} may destroy data, so it runs only from a method with a confirm.`);
2153
2163
  }
@@ -47,10 +47,12 @@ function definePlugin(plugin) {
47
47
  return plugin;
48
48
  }
49
49
  function method(input, run, options = {}) {
50
+ if ("kind" in options)
51
+ throw new Error('method: kind is now effect: "read", "write" or "destructive"');
50
52
  return {
51
53
  input,
52
54
  run,
53
- kind: options.kind ?? "mutation",
55
+ effect: options.effect ?? "write",
54
56
  output: options.output,
55
57
  description: options.description,
56
58
  confirm: options.confirm,
@@ -61,14 +63,14 @@ function cached(input, run, options) {
61
63
  return {
62
64
  input,
63
65
  run,
64
- kind: "query",
66
+ effect: "read",
65
67
  output: options.output,
66
68
  description: options.description,
67
69
  cache: { fresh: parseDuration(options.fresh), shared: options.shared ?? false, timeout: parseDuration(options.timeout ?? "1m") }
68
70
  };
69
71
  }
70
72
  function stream(input, run, options = {}) {
71
- return { input, run, kind: "stream", description: options.description };
73
+ return { input, run, effect: "read", stream: true, description: options.description };
72
74
  }
73
75
  // src/uploads.ts
74
76
  import { AsyncLocalStorage } from "async_hooks";
@@ -19669,6 +19671,7 @@ function carryable(value) {
19669
19671
 
19670
19672
  // src/runtime.ts
19671
19673
  import { AsyncLocalStorage as AsyncLocalStorage5 } from "async_hooks";
19674
+ import { existsSync } from "fs";
19672
19675
  import { mkdir as mkdir3, readdir as readdir3 } from "fs/promises";
19673
19676
  import { join as join3 } from "path";
19674
19677
 
@@ -19732,7 +19735,7 @@ function recordRead(tag) {
19732
19735
  }
19733
19736
  function assertWritable(tag) {
19734
19737
  if (current.getStore()?.readOnly) {
19735
- throw new MethodError("FORBIDDEN", `A query can't change data (it tried to write ${tag}).`);
19738
+ throw new MethodError("FORBIDDEN", `A read can't change data (it tried to write ${tag}).`);
19736
19739
  }
19737
19740
  }
19738
19741
  function recordWrite(tag) {
@@ -20529,28 +20532,28 @@ function kvMethods(name, kv, schema) {
20529
20532
  const entry = exports_external.object({ key: exports_external.string(), value: schema, expiresAt: exports_external.string().nullable(), createdAt: exports_external.string(), updatedAt: exports_external.string() });
20530
20533
  return {
20531
20534
  get: {
20532
- kind: "query",
20535
+ effect: "read",
20533
20536
  description: `Get one ${name} entry by key, or null`,
20534
20537
  input: exports_external.object({ key }),
20535
20538
  output: entry.nullable(),
20536
20539
  run: ({ key: key2 }) => kv.entry(key2)
20537
20540
  },
20538
20541
  list: {
20539
- kind: "query",
20542
+ effect: "read",
20540
20543
  description: `List ${name} entries in key order, optionally by prefix`,
20541
20544
  input: exports_external.object({ prefix: exports_external.string().optional(), limit }),
20542
20545
  output: exports_external.array(entry),
20543
20546
  run: (input2) => kv.list(input2)
20544
20547
  },
20545
20548
  set: {
20546
- kind: "mutation",
20549
+ effect: "write",
20547
20550
  description: `Set a ${name} key; ttl ("5m", "1h", "7d" or ms) makes it expire`,
20548
20551
  input: exports_external.object({ key, value: schema, ttl }),
20549
20552
  output: entry,
20550
20553
  run: ({ key: key2, value, ttl: ttl2 }) => kv.set(key2, value, { ttl: ttl2 })
20551
20554
  },
20552
20555
  delete: {
20553
- kind: "mutation",
20556
+ effect: "destructive",
20554
20557
  description: `Delete a ${name} key`,
20555
20558
  input: exports_external.object({ key }),
20556
20559
  output: exports_external.null(),
@@ -20564,41 +20567,42 @@ function streamMethods(name, stream2, schema) {
20564
20567
  const line = exports_external.object({ id: exports_external.string(), key: exports_external.string(), offset: exports_external.number(), at: exports_external.string(), data: schema });
20565
20568
  return {
20566
20569
  tail: {
20567
- kind: "stream",
20570
+ effect: "read",
20571
+ stream: true,
20568
20572
  description: `Follow a ${name} key live: its last lines (100 unless last says), then each new one, until it ends`,
20569
20573
  input: exports_external.object({ key, from: exports_external.number().int().min(0).optional(), last: exports_external.number().int().positive().max(500).optional() }),
20570
20574
  run: ({ key: key2, from, last }, signal) => stream2.follow(key2, { from, last, signal })
20571
20575
  },
20572
20576
  read: {
20573
- kind: "query",
20577
+ effect: "read",
20574
20578
  description: `Read a ${name} key's lines from an offset`,
20575
20579
  input: exports_external.object({ key, from: exports_external.number().int().min(0).optional(), limit }),
20576
20580
  output: exports_external.array(line),
20577
20581
  run: ({ key: key2, ...options }) => stream2.read(key2, options)
20578
20582
  },
20579
20583
  keys: {
20580
- kind: "query",
20584
+ effect: "read",
20581
20585
  description: `List ${name} keys, most recently written first`,
20582
20586
  input: exports_external.object({ limit }),
20583
20587
  output: exports_external.array(exports_external.object({ key: exports_external.string(), lines: exports_external.number(), bytes: exports_external.number(), endedAt: exports_external.string().nullable(), updatedAt: exports_external.string() })),
20584
20588
  run: (input2) => stream2.keys(input2)
20585
20589
  },
20586
20590
  append: {
20587
- kind: "mutation",
20591
+ effect: "write",
20588
20592
  description: `Append a line to a ${name} key`,
20589
20593
  input: exports_external.object({ key, data: schema }),
20590
20594
  output: line,
20591
20595
  run: ({ key: key2, data }) => stream2.append(key2, data)
20592
20596
  },
20593
20597
  end: {
20594
- kind: "mutation",
20598
+ effect: "write",
20595
20599
  description: `End a ${name} key: followers stop and appends fail`,
20596
20600
  input: exports_external.object({ key }),
20597
20601
  output: exports_external.null(),
20598
20602
  run: async ({ key: key2 }) => (await stream2.end(key2), null)
20599
20603
  },
20600
20604
  delete: {
20601
- kind: "mutation",
20605
+ effect: "destructive",
20602
20606
  description: `Delete a ${name} key and its lines`,
20603
20607
  input: exports_external.object({ key }),
20604
20608
  output: exports_external.null(),
@@ -20627,28 +20631,28 @@ function bucketMethods(name, bucket, schema) {
20627
20631
  const revision = exports_external.number().int().optional();
20628
20632
  return {
20629
20633
  list: {
20630
- kind: "query",
20634
+ effect: "read",
20631
20635
  description: `List ${name} files, newest first, optionally in one folder or with one status`,
20632
20636
  input: exports_external.object({ folder: exports_external.string().optional(), status: status.optional(), limit }),
20633
20637
  output: exports_external.array(file2),
20634
20638
  run: (input2) => bucket.list(input2)
20635
20639
  },
20636
20640
  search: {
20637
- kind: "query",
20641
+ effect: "read",
20638
20642
  description: `Search ${name} files by name, folder and meta`,
20639
20643
  input: exports_external.object({ query: exports_external.string(), limit }),
20640
20644
  output: exports_external.array(file2),
20641
20645
  run: ({ query, limit: limit2 }) => bucket.search(query, { limit: limit2 })
20642
20646
  },
20643
20647
  get: {
20644
- kind: "query",
20648
+ effect: "read",
20645
20649
  description: `Get one ${name} file by id, or null`,
20646
20650
  input: exports_external.object({ id }),
20647
20651
  output: file2.nullable(),
20648
20652
  run: ({ id: id2 }) => bucket.get(id2)
20649
20653
  },
20650
20654
  put: {
20651
- kind: "mutation",
20655
+ effect: "write",
20652
20656
  description: `Add a file to ${name}: its name, and text or an absolute path to copy`,
20653
20657
  input: exports_external.object({
20654
20658
  name: exports_external.string(),
@@ -20665,21 +20669,21 @@ function bucketMethods(name, bucket, schema) {
20665
20669
  }
20666
20670
  },
20667
20671
  update: {
20668
- kind: "mutation",
20672
+ effect: "write",
20669
20673
  description: `Change fields of a ${name} file's meta; null removes one`,
20670
20674
  input: exports_external.object({ id, meta: exports_external.record(exports_external.string(), exports_external.unknown()), revision }),
20671
20675
  output: file2,
20672
20676
  run: ({ id: id2, meta: meta3, revision: revision2 }) => bucket.update(id2, meta3, { revision: revision2 })
20673
20677
  },
20674
20678
  move: {
20675
- kind: "mutation",
20679
+ effect: "write",
20676
20680
  description: `Rename a ${name} file or move it to another folder`,
20677
20681
  input: exports_external.object({ id, name: exports_external.string().optional(), folder: exports_external.string().optional(), revision }),
20678
20682
  output: file2,
20679
20683
  run: ({ id: id2, name: name2, folder, revision: revision2 }) => bucket.move(id2, { name: name2, folder }, { revision: revision2 })
20680
20684
  },
20681
20685
  delete: {
20682
- kind: "mutation",
20686
+ effect: "destructive",
20683
20687
  description: `Delete a ${name} file and its meta`,
20684
20688
  input: exports_external.object({ id }),
20685
20689
  output: exports_external.null(),
@@ -21115,29 +21119,29 @@ function workflowMethods(plugin, namespace, name, definition, ctx, bridge, direc
21115
21119
  return bridge({ operation, dir, plugin, namespace, workflow: name, root: ctx.root, caller: ctx.caller, input: input2, settings: workflowSettings(definition) });
21116
21120
  };
21117
21121
  const id = exports_external.object({ id: exports_external.string().uuid() }).strict();
21118
- const spec = (method2, kind, input2, description, confirm, output2) => ({
21122
+ const spec = (method2, effect, input2, description, confirm, output2) => ({
21119
21123
  name: `${plugin}.${name}.${method2}`,
21120
21124
  plugin,
21121
- kind,
21125
+ effect,
21122
21126
  input: input2,
21123
21127
  description,
21124
21128
  confirm,
21125
21129
  output: output2,
21126
21130
  run: async (input3, raw) => {
21127
- if (kind === "query")
21131
+ if (effect === "read")
21128
21132
  recordRead(workflowTag(namespace));
21129
21133
  const request = method2 === "start" ? { ...input3, input: raw.input } : input3;
21130
21134
  return invoke(method2, request);
21131
21135
  }
21132
21136
  });
21133
21137
  return [
21134
- spec("list", "query", exports_external.object({ limit: exports_external.number().int().min(1).max(100).default(20), status: exports_external.string().optional(), cursor: exports_external.string().optional() }).strict(), "List workflow runs", undefined, exports_external.object({ items: exports_external.array(RunSummarySchema), cursor: exports_external.string().nullable() })),
21135
- spec("get", "query", id, "Get one workflow run"),
21136
- spec("start", "mutation", exports_external.object({ input: definition.input, key: exports_external.string().min(1).max(200).optional() }).strict(), definition.description ?? "Start a durable workflow", definition.confirm, RunSummarySchema),
21137
- spec("retry", "mutation", id, "Retry a safely failed query step", undefined, RunSummarySchema),
21138
- spec("restart", "mutation", id, "Start a run that needs attention over from the beginning, on the current code", undefined, RunSummarySchema),
21139
- spec("cancel", "mutation", id, "Request cancellation of a workflow", undefined, RunSummarySchema),
21140
- spec("cancelAll", "mutation", exports_external.object({ status: exports_external.string().optional() }).strict(), "Cancel every unfinished run, or only those in one status", undefined, exports_external.object({ cancelled: exports_external.number().int() }))
21138
+ spec("list", "read", exports_external.object({ limit: exports_external.number().int().min(1).max(100).default(20), status: exports_external.string().optional(), cursor: exports_external.string().optional() }).strict(), "List workflow runs", undefined, exports_external.object({ items: exports_external.array(RunSummarySchema), cursor: exports_external.string().nullable() })),
21139
+ spec("get", "read", id, "Get one workflow run"),
21140
+ spec("start", "write", exports_external.object({ input: definition.input, key: exports_external.string().min(1).max(200).optional() }).strict(), definition.description ?? "Start a durable workflow", definition.confirm, RunSummarySchema),
21141
+ spec("retry", "write", id, "Retry a safely failed query step", undefined, RunSummarySchema),
21142
+ spec("restart", "write", id, "Start a run that needs attention over from the beginning, on the current code", undefined, RunSummarySchema),
21143
+ spec("cancel", "write", id, "Request cancellation of a workflow", undefined, RunSummarySchema),
21144
+ spec("cancelAll", "write", exports_external.object({ status: exports_external.string().optional() }).strict(), "Cancel every unfinished run, or only those in one status", undefined, exports_external.object({ cancelled: exports_external.number().int() }))
21141
21145
  ];
21142
21146
  }
21143
21147
  function workflowSettings(definition) {
@@ -21197,6 +21201,8 @@ class Runtime {
21197
21201
  continue;
21198
21202
  if (only && !only.includes(entry.name))
21199
21203
  continue;
21204
+ if (!existsSync(join3(pluginsDir, entry.name, "app.ts")))
21205
+ continue;
21200
21206
  const module = await logContext.run({ plugin: entry.name }, () => import(join3(pluginsDir, entry.name, "app.ts")));
21201
21207
  await this.add(entry.name, module.default);
21202
21208
  }
@@ -21322,7 +21328,8 @@ class Runtime {
21322
21328
  this.register({
21323
21329
  name: fullName,
21324
21330
  plugin: name,
21325
- kind: method2.kind,
21331
+ effect: method2.effect,
21332
+ stream: method2.stream,
21326
21333
  description: method2.description,
21327
21334
  confirm: method2.confirm,
21328
21335
  http: method2.http,
@@ -21378,17 +21385,17 @@ class Runtime {
21378
21385
  return runWith(where, () => this.call(name, input2, options));
21379
21386
  if (where !== logContext.getStore())
21380
21387
  return logContext.run(where, () => this.call(name, input2, options));
21381
- if ((options.expect === "query" || currentAccess()?.readOnly) && method2.kind === "mutation") {
21388
+ if ((options.expect === "read" || currentAccess()?.readOnly) && method2.effect !== "read") {
21382
21389
  throw new MethodError("FORBIDDEN", `${name} changes data, so it only runs when the user acts.`);
21383
21390
  }
21384
21391
  const raw = input2 ?? {};
21385
21392
  const parsed = method2.input.parse(raw);
21386
21393
  if (method2.confirm && (options.confirmed === false || workflowScope.getStore()))
21387
21394
  throw new MethodError("CONFIRM", fillConfirm(method2.confirm, parsed));
21388
- if (method2.kind === "stream")
21395
+ if (method2.stream)
21389
21396
  return { result: await this.sample(name, input2), reads: [], invalidates: [] };
21390
21397
  const confirmed = this.callConfirmed.getStore() ?? false;
21391
- const run = await tracked(method2.kind === "query", (tags) => this.notifyInvalidated(tags), () => this.callConfirmed.run(confirmed || method2.confirm !== undefined, async () => {
21398
+ const run = await tracked(method2.effect === "read", (tags) => this.notifyInvalidated(tags), () => this.callConfirmed.run(confirmed || method2.confirm !== undefined, async () => {
21392
21399
  const result = await method2.run(parsed, raw);
21393
21400
  await this.events.emit(name, { input: parsed, result });
21394
21401
  return result;
@@ -21407,8 +21414,8 @@ class Runtime {
21407
21414
  const method2 = this.methods.get(name);
21408
21415
  if (!method2)
21409
21416
  throw new MethodError("NOT_FOUND", `There is no method ${name}.`);
21410
- if (method2.kind !== "stream")
21411
- throw new MethodError("VALIDATION", `${name} is a ${method2.kind}, not a stream.`);
21417
+ if (!method2.stream)
21418
+ throw new MethodError("VALIDATION", `${name} is not a stream.`);
21412
21419
  const raw = input2 ?? {};
21413
21420
  const parsed = method2.input.parse(raw);
21414
21421
  const root = options.root ?? this.callRoot.getStore() ?? this.root;
@@ -21463,7 +21470,7 @@ class Runtime {
21463
21470
  const statuses = [];
21464
21471
  for (const entry of entries) {
21465
21472
  const timezone = entry.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
21466
- const signature = `${entry.plugin}.${entry.method} ${entry.schedule} ${timezone}`;
21473
+ const signature = `${entry.plugin}.${entry.method} ${entry.schedule} ${timezone} ${entry.root ?? ""}`;
21467
21474
  try {
21468
21475
  const job = kept.get(signature) ?? this.cronJob(entry, timezone, signature);
21469
21476
  jobs.push(job);
@@ -21495,8 +21502,8 @@ class Runtime {
21495
21502
  const method2 = this.methods.get(name);
21496
21503
  if (!method2 || method2.plugin !== entry.plugin)
21497
21504
  throw new Error(`${entry.method} isn't one of ${entry.plugin}'s methods`);
21498
- if (method2.kind === "stream")
21499
- throw new Error(`${name} is a stream; a cron runs a query or a mutation`);
21505
+ if (method2.stream)
21506
+ throw new Error(`${name} is a stream; a cron runs a read or a write`);
21500
21507
  if (method2.confirm)
21501
21508
  throw new Error(`${name} asks before it runs, so nothing may run it on a schedule`);
21502
21509
  if (!method2.input.safeParse({}).success)
@@ -21513,6 +21520,7 @@ class Runtime {
21513
21520
  return {
21514
21521
  signature,
21515
21522
  plugin: entry.plugin,
21523
+ root: entry.root,
21516
21524
  method: name,
21517
21525
  schedule: entry.schedule,
21518
21526
  timezone,
@@ -21537,7 +21545,7 @@ class Runtime {
21537
21545
  job.running = true;
21538
21546
  job.lastRunAt = now;
21539
21547
  const startedAt = Date.now();
21540
- started.push(this.call(job.method, {}, { caller: "cron" }).then(() => {
21548
+ started.push(this.call(job.method, {}, { caller: "cron", root: job.root }).then(() => {
21541
21549
  job.lastError = null;
21542
21550
  }, (error62) => {
21543
21551
  job.lastError = toCallError(error62).message;
@@ -21598,7 +21606,8 @@ class Runtime {
21598
21606
  description: plugin.description ?? null,
21599
21607
  methods: [...this.methods.values()].filter((method2) => method2.plugin === name).map((method2) => ({
21600
21608
  name: method2.name,
21601
- kind: method2.kind,
21609
+ effect: method2.effect,
21610
+ stream: method2.stream ?? false,
21602
21611
  description: method2.description ?? null,
21603
21612
  confirm: method2.confirm ?? null,
21604
21613
  http: method2.http ?? null,
@@ -21674,6 +21683,9 @@ class Runtime {
21674
21683
  register(method2) {
21675
21684
  if (this.methods.has(method2.name))
21676
21685
  throw new Error(`${method2.name} is defined twice; rename the method or the data`);
21686
+ if (method2.effect === "destructive" && !method2.confirm) {
21687
+ throw new Error(`${method2.name} is destructive, so it needs a confirm question, such as { confirm: "Delete {{id}}?" }`);
21688
+ }
21677
21689
  this.methods.set(method2.name, method2);
21678
21690
  }
21679
21691
  registerGenerated(plugin, name, methods) {
@@ -21690,26 +21702,26 @@ class Runtime {
21690
21702
  const prefix = `${plugin}.${name}`;
21691
21703
  const id = exports_external.object({ id: exports_external.string() });
21692
21704
  const record3 = schema.extend({ id: exports_external.string(), createdAt: exports_external.string(), updatedAt: exports_external.string() });
21693
- const crud = (method2, kind, input2, output2, description, run, confirm) => this.register({
21705
+ const crud = (method2, effect, input2, output2, description, run, confirm) => this.register({
21694
21706
  name: `${prefix}.${method2}`,
21695
21707
  plugin,
21696
- kind,
21708
+ effect,
21697
21709
  description,
21698
21710
  confirm,
21699
21711
  input: input2,
21700
21712
  output: output2,
21701
21713
  run: async (value, raw) => run(value, raw)
21702
21714
  });
21703
- crud("list", "query", exports_external.object({
21715
+ crud("list", "read", exports_external.object({
21704
21716
  limit: exports_external.number().int().positive().max(500).optional(),
21705
21717
  offset: exports_external.number().int().min(0).optional(),
21706
21718
  orderBy: exports_external.string().optional(),
21707
21719
  order: exports_external.enum(["asc", "desc"]).optional()
21708
21720
  }), exports_external.array(record3), `List ${name} records, newest first unless orderBy says`, (input2) => collection.list(input2));
21709
- crud("get", "query", id, record3.nullable(), `Get one ${name} record by id, or null`, (input2) => collection.get(input2.id));
21710
- crud("create", "mutation", schema, record3, `Create a ${name} record`, (input2) => collection.create(input2));
21711
- crud("update", "mutation", id.extend(schema.partial().shape), record3, `Update fields on a ${name} record`, ({ id: id2, ...fields }, raw) => collection.update(id2, Object.fromEntries(Object.entries(fields).filter(([key]) => Object.hasOwn(raw ?? {}, key)))));
21712
- crud("delete", "mutation", id, exports_external.null(), `Delete a ${name} record by id`, async (input2) => {
21721
+ crud("get", "read", id, record3.nullable(), `Get one ${name} record by id, or null`, (input2) => collection.get(input2.id));
21722
+ crud("create", "write", schema, record3, `Create a ${name} record`, (input2) => collection.create(input2));
21723
+ crud("update", "write", id.extend(schema.partial().shape), record3, `Update fields on a ${name} record`, ({ id: id2, ...fields }, raw) => collection.update(id2, Object.fromEntries(Object.entries(fields).filter(([key]) => Object.hasOwn(raw ?? {}, key)))));
21724
+ crud("delete", "destructive", id, exports_external.null(), `Delete a ${name} record by id`, async (input2) => {
21713
21725
  await collection.delete(input2.id);
21714
21726
  return null;
21715
21727
  }, "Delete this record?");
@@ -21738,7 +21750,7 @@ function cronState(job) {
21738
21750
  function mcpFailure(server, tool, error62) {
21739
21751
  switch (error62.code) {
21740
21752
  case "NOT_READ_ONLY":
21741
- return new MethodError("FORBIDDEN", `${server}.${tool} changes data, so it runs from a mutation, not a query.`);
21753
+ return new MethodError("FORBIDDEN", `${server}.${tool} changes data, so it runs from a write, not a read.`);
21742
21754
  case "CONFIRM":
21743
21755
  return new MethodError("FORBIDDEN", `${server}.${tool} may destroy data, so it runs only from a method with a confirm.`);
21744
21756
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repogo/plugins",
3
- "version": "0.1.42",
3
+ "version": "0.1.43",
4
4
  "description": "Write RepoGo plugins: collections and methods over SQLite that agents and cards call",
5
5
  "type": "module",
6
6
  "exports": {